diff --git a/.gitignore b/.gitignore index 93f75b637..4df74955f 100644 --- a/.gitignore +++ b/.gitignore @@ -54,7 +54,6 @@ id_ed25519 # Development symlinks (created per-worktree, not committed) .claude -.sisyphus packages/web/agent-orchestrator.yaml # Local agent orchestrator config (may contain secrets) @@ -68,4 +67,3 @@ agent-orchestrator.yaml # OS-specific files .DS_Store Thumbs.db -.gstack/ diff --git a/.gitignore-template b/.gitignore-template deleted file mode 100644 index 3cc2a555a..000000000 --- a/.gitignore-template +++ /dev/null @@ -1,5 +0,0 @@ -# Agent configuration and tracking (personal, not for repo) -# Add these to .gitignore for ALL projects with agent configs - -.claude/ -.opencode/ diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 44eac42f8..4832c23b2 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -549,6 +549,138 @@ describe("API Routes", () => { enrichSpy.mockRestore(); vi.useRealTimers(); }); + + it("uses cache-first PR enrichment with live fallback for terminal PR states", async () => { + const terminalLifecycle = createInitialCanonicalLifecycle("worker", new Date()); + terminalLifecycle.session.state = "terminated"; + terminalLifecycle.session.reason = "user_killed"; + terminalLifecycle.session.terminatedAt = terminalLifecycle.session.lastTransitionAt; + terminalLifecycle.runtime.state = "exited"; + terminalLifecycle.runtime.reason = "process_exited"; + terminalLifecycle.pr.state = "merged"; + terminalLifecycle.pr.reason = "merged"; + + const sessionsWithPRs = [ + makeSession({ + id: "worker-live", + status: "pr_open", + activity: "idle", + pr: { + number: 201, + url: "https://github.com/acme/my-app/pull/201", + title: "Live PR", + owner: "acme", + repo: "my-app", + branch: "feat/live-pr", + baseBranch: "main", + isDraft: false, + }, + }), + makeSession({ + id: "worker-killed", + status: "killed", + activity: "exited", + lifecycle: terminalLifecycle, + pr: { + number: 202, + url: "https://github.com/acme/my-app/pull/202", + title: "Terminal PR", + owner: "acme", + repo: "my-app", + branch: "feat/terminal-pr", + baseBranch: "main", + isDraft: false, + }, + }), + ]; + (mockSessionManager.listCached as ReturnType).mockResolvedValue(sessionsWithPRs); + + const metadataSpy = vi + .spyOn(serialize, "enrichSessionsMetadata") + .mockResolvedValue(undefined); + + const enrichSpy = vi + .spyOn(serialize, "enrichSessionPR") + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + + const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions")); + + expect(res.status).toBe(200); + expect(enrichSpy).toHaveBeenCalledTimes(3); + expect(enrichSpy.mock.calls[0]).toEqual([ + expect.objectContaining({ id: "worker-live" }), + expect.anything(), + sessionsWithPRs[0]!.pr, + ]); + expect(enrichSpy.mock.calls[1]).toEqual([ + expect.objectContaining({ id: "worker-killed" }), + expect.anything(), + sessionsWithPRs[1]!.pr, + { cacheOnly: true }, + ]); + expect(enrichSpy.mock.calls[2]).toEqual([ + expect.objectContaining({ id: "worker-killed" }), + expect.anything(), + sessionsWithPRs[1]!.pr, + ]); + + metadataSpy.mockRestore(); + enrichSpy.mockRestore(); + }); + + it("keeps live PR refreshes for killed sessions whose PR is still open", async () => { + const runtimeTerminalLifecycle = createInitialCanonicalLifecycle("worker", new Date()); + runtimeTerminalLifecycle.session.state = "terminated"; + runtimeTerminalLifecycle.session.reason = "user_killed"; + runtimeTerminalLifecycle.session.terminatedAt = runtimeTerminalLifecycle.session.lastTransitionAt; + runtimeTerminalLifecycle.runtime.state = "missing"; + runtimeTerminalLifecycle.runtime.reason = "process_missing"; + runtimeTerminalLifecycle.pr.state = "open"; + runtimeTerminalLifecycle.pr.reason = "in_progress"; + + const sessionWithOpenPR = [ + makeSession({ + id: "worker-open-pr", + status: "killed", + activity: "exited", + lifecycle: runtimeTerminalLifecycle, + pr: { + number: 203, + url: "https://github.com/acme/my-app/pull/203", + title: "Open PR on killed runtime", + owner: "acme", + repo: "my-app", + branch: "feat/open-pr-runtime-dead", + baseBranch: "main", + isDraft: false, + }, + }), + ]; + (mockSessionManager.listCached as ReturnType).mockResolvedValue(sessionWithOpenPR); + + const metadataSpy = vi + .spyOn(serialize, "enrichSessionsMetadata") + .mockResolvedValue(undefined); + + const enrichSpy = vi + .spyOn(serialize, "enrichSessionPR") + .mockResolvedValue(true); + + const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions")); + + expect(res.status).toBe(200); + expect(enrichSpy).toHaveBeenCalledTimes(1); + expect(enrichSpy.mock.calls[0]).toEqual([ + expect.objectContaining({ id: "worker-open-pr" }), + expect.anything(), + sessionWithOpenPR[0]!.pr, + ]); + + metadataSpy.mockRestore(); + enrichSpy.mockRestore(); + }); }); describe("GET /api/runtime/terminal", () => { diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index bc06843f8..b1e7fd688 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -17,6 +17,11 @@ const METADATA_ENRICH_TIMEOUT_MS = 3_000; const PR_ENRICH_TIMEOUT_MS = 4_000; const PER_PR_ENRICH_TIMEOUT_MS = 1_500; +function hasTerminalPRState(session: Parameters[0]): boolean { + const prState = session.lifecycle?.pr.state; + return prState === "merged" || prState === "closed"; +} + function compareOrchestratorRecency(a: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }, b: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }): number { return ( (b.lastActivityAt?.getTime() ?? 0) - (a.lastActivityAt?.getTime() ?? 0) || @@ -151,7 +156,8 @@ export async function GET(request: Request) { for (let i = 0; i < workerSessions.length; i++) { const core = workerSessions[i]; - if (!core?.pr) continue; + const pr = core?.pr; + if (!pr) continue; const project = resolveProject(core, config.projects); const scm = getSCM(registry, project); @@ -159,7 +165,14 @@ export async function GET(request: Request) { prEnrichPromises.push( settlesWithin( - enrichSessionPR(dashboardSessions[i], scm, core.pr), + hasTerminalPRState(core) + ? enrichSessionPR(dashboardSessions[i], scm, pr, { cacheOnly: true }).then( + (cached) => + cached + ? true + : enrichSessionPR(dashboardSessions[i], scm, pr), + ) + : enrichSessionPR(dashboardSessions[i], scm, pr), PER_PR_ENRICH_TIMEOUT_MS, ), ); diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 331c1bb28..9b8975bfe 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -7103,6 +7103,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { display: inline-flex; align-items: center; gap: 4px; + flex-wrap: wrap; } /* Project name + pills: inline on desktop, stacked column on mobile */ @@ -7180,4 +7181,35 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { padding: 1px 5px; max-width: 90px; } + .topbar-session-pills .topbar-zone-pill { + font-size: 9px; + padding: 1px 5px; + gap: 3px; + } + /* Hide zone pill labels on mobile — count + color is enough */ + .topbar-session-pills .topbar-zone-pill__label { + display: none; + } } + +/* Orchestrator agent-zone pills — compact topbar variant. Replaces the + stacked status strip that used to sit above the terminal. */ +.topbar-zone-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + color: var(--color-text-secondary); +} +.topbar-zone-pill__value { font-weight: 600; font-variant-numeric: tabular-nums; } +.topbar-zone-pill__label { opacity: 0.85; } +.topbar-zone-pill--merge { background: color-mix(in srgb, var(--color-status-ready) 12%, transparent); color: var(--color-status-ready); } +.topbar-zone-pill--respond { background: color-mix(in srgb, var(--color-status-error) 12%, transparent); color: var(--color-status-error); } +.topbar-zone-pill--review { background: color-mix(in srgb, var(--color-accent-orange) 12%, transparent); color: var(--color-accent-orange); } +.topbar-zone-pill--working { background: color-mix(in srgb, var(--color-accent-blue) 12%, transparent); color: var(--color-accent-blue); } +.topbar-zone-pill--pending { background: color-mix(in srgb, var(--color-status-attention) 12%, transparent); color: var(--color-status-attention); } +.topbar-zone-pill--done { background: color-mix(in srgb, var(--color-text-tertiary) 14%, transparent); color: var(--color-text-tertiary); } diff --git a/packages/web/src/components/DirectTerminal.tsx b/packages/web/src/components/DirectTerminal.tsx index d02e008f4..9f74c7daf 100644 --- a/packages/web/src/components/DirectTerminal.tsx +++ b/packages/web/src/components/DirectTerminal.tsx @@ -192,6 +192,7 @@ export function DirectTerminal({ const [fontSize, setFontSize] = useState(getStoredFontSize()); const followOutputRef = useRef(true); const [followOutput, setFollowOutput] = useState(true); + const programmaticScrollRef = useRef(false); // Update URL when fullscreen changes useEffect(() => { @@ -385,16 +386,16 @@ export function DirectTerminal({ fontsFace!.addEventListener("loadingdone", handleFontsLoadingDone); } - // Grab viewport element for manual follow-output scroll - const viewport = terminal.element?.querySelector(".xterm-viewport") ?? null; - - // Attach touch scroll for mobile — disable follow-output while user is scrolling + // Attach touch scroll for mobile — disable follow-output while user is scrolling. + // Note: onScrollTowardLatest intentionally does NOT hide the button. In normal + // buffer, terminal.onScroll fires after scrollLines() and decides based on real + // position. In alternate buffer (tmux), there's no way to detect when the user + // has truly returned to the live tail, so the button stays visible until clicked. // eslint-disable-next-line @typescript-eslint/no-explicit-any const cleanupTouchScroll = attachTouchScroll(terminal as any, (data) => { writeTerminal(sessionId, data); }, { onScrollAway: () => { followOutputRef.current = false; setFollowOutput(false); }, - onScrollTowardLatest: () => { followOutputRef.current = true; setFollowOutput(true); }, }); // Set up ResizeObserver to handle flex layout changes @@ -487,31 +488,31 @@ export function DirectTerminal({ } } else { terminal.write(data); - if (followOutputRef.current && viewport) { - programmaticScroll = true; - viewport.scrollTop = viewport.scrollHeight; + if (followOutputRef.current) { + programmaticScrollRef.current = true; + terminal.scrollToBottom(); } } }); - // Track whether our own write()-driven scrollTop change triggered this event - let programmaticScroll = false; - const handleViewportScroll = () => { - if (!viewport) return; - if (programmaticScroll) { - programmaticScroll = false; + // Use xterm's onScroll event (fires with new viewportY) instead of a DOM + // scroll listener — xterm v6 may update scrollTop via RAF, making DOM + // "scroll" events unreliable for detecting user-initiated scrolls. + const scrollDisposable = terminal.onScroll(() => { + if (programmaticScrollRef.current) { + programmaticScrollRef.current = false; return; } - const distFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; - if (distFromBottom < 24) { + const buf = terminal.buffer.active; + const atBottom = buf.viewportY + terminal.rows >= buf.length; + if (atBottom) { followOutputRef.current = true; setFollowOutput(true); } else { followOutputRef.current = false; setFollowOutput(false); } - }; - viewport?.addEventListener("scroll", handleViewportScroll, { passive: true }); + }); // Handle window resize const handleResize = () => { @@ -542,7 +543,7 @@ export function DirectTerminal({ if (fontsListenerAttached && fontsFace) { fontsFace.removeEventListener("loadingdone", handleFontsLoadingDone); } - viewport?.removeEventListener("scroll", handleViewportScroll); + scrollDisposable.dispose(); inputDisposable?.dispose(); inputDisposable = null; unsubscribe?.(); @@ -924,13 +925,21 @@ export function DirectTerminal({