Merge remote-tracking branch 'origin/main' into feat/windows-platform-adapter
This commit is contained in:
commit
2e9eaa4f8f
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
# Agent configuration and tracking (personal, not for repo)
|
||||
# Add these to .gitignore for ALL projects with agent configs
|
||||
|
||||
.claude/
|
||||
.opencode/
|
||||
|
|
@ -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<typeof vi.fn>).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<typeof vi.fn>).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", () => {
|
||||
|
|
|
|||
|
|
@ -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<typeof isTerminalSession>[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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>(".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({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
followOutputRef.current = true;
|
||||
setFollowOutput(true);
|
||||
const t = terminalInstance.current;
|
||||
if (t) {
|
||||
const vp = t.element?.querySelector<HTMLElement>(".xterm-viewport");
|
||||
if (vp) vp.scrollTop = vp.scrollHeight;
|
||||
if (t.buffer.active.type === "normal") {
|
||||
// Normal buffer: scrollback exists in xterm, use its API.
|
||||
programmaticScrollRef.current = true;
|
||||
t.scrollToBottom();
|
||||
} else {
|
||||
// Alternate buffer (tmux/vim): xterm has no scrollback to scroll
|
||||
// to. The user is in tmux copy-mode (entered by attachTouchScroll
|
||||
// on swipe). Send 'q' to exit copy-mode and return to live tail.
|
||||
writeTerminal(sessionId, "q");
|
||||
}
|
||||
}
|
||||
followOutputRef.current = true;
|
||||
setFollowOutput(true);
|
||||
}}
|
||||
className="absolute bottom-3 right-3 z-20 flex h-8 w-8 items-center justify-center rounded-full border border-[var(--color-border-default)] bg-[var(--color-bg-elevated)] text-[var(--color-text-primary)] shadow-md active:scale-95"
|
||||
aria-label="Jump to latest"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from "react";
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
|
||||
import {
|
||||
|
|
@ -91,112 +91,32 @@ function normalizeActivityLabelForClass(activityLabel: string): string {
|
|||
return activityLabel.toLowerCase().replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
function OrchestratorTopStrip({
|
||||
headline,
|
||||
crumbId,
|
||||
activityLabel,
|
||||
activityColor,
|
||||
branch,
|
||||
pr,
|
||||
crumbHref,
|
||||
crumbLabel,
|
||||
rightSlot,
|
||||
}: {
|
||||
headline: string;
|
||||
crumbId: string;
|
||||
activityLabel: string;
|
||||
activityColor: string;
|
||||
branch: string | null;
|
||||
pr: DashboardPR | null;
|
||||
crumbHref: string;
|
||||
crumbLabel: string;
|
||||
rightSlot?: ReactNode;
|
||||
}) {
|
||||
/**
|
||||
* Compact agent-zone breakdown for orchestrator sessions, rendered inline in
|
||||
* the topbar pill row. Replaces the previous stacked status strip that sat
|
||||
* above the terminal.
|
||||
*/
|
||||
function OrchestratorZonePills({ zones }: { zones: OrchestratorZones }) {
|
||||
const stats: Array<{ value: number; label: string; toneClass: string }> = [
|
||||
{ value: zones.merge, label: "merge", toneClass: "topbar-zone-pill--merge" },
|
||||
{ value: zones.respond, label: "respond", toneClass: "topbar-zone-pill--respond" },
|
||||
{ value: zones.review, label: "review", toneClass: "topbar-zone-pill--review" },
|
||||
{ value: zones.working, label: "working", toneClass: "topbar-zone-pill--working" },
|
||||
{ value: zones.pending, label: "pending", toneClass: "topbar-zone-pill--pending" },
|
||||
{ value: zones.done, label: "done", toneClass: "topbar-zone-pill--done" },
|
||||
].filter((s) => s.value > 0);
|
||||
|
||||
if (stats.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="session-detail-top-strip">
|
||||
{/* Breadcrumbs */}
|
||||
<div className="session-detail-crumbs">
|
||||
<a
|
||||
href={crumbHref}
|
||||
className="session-detail-crumb-back"
|
||||
>
|
||||
<svg
|
||||
className="h-3 w-3 opacity-60"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
{crumbLabel}
|
||||
</a>
|
||||
<span className="session-detail-crumb-sep">/</span>
|
||||
<span className="session-detail-crumb-id">{crumbId}</span>
|
||||
<span className="session-detail-mode-badge">orchestrator</span>
|
||||
</div>
|
||||
|
||||
{/* Identity strip */}
|
||||
<div className="session-detail-identity">
|
||||
<div className="session-detail-identity__info">
|
||||
<h1 className="session-detail-identity__title">
|
||||
{headline}
|
||||
</h1>
|
||||
<div className="session-detail-identity__pills">
|
||||
<div
|
||||
className="session-detail-status-pill"
|
||||
>
|
||||
<span
|
||||
className="session-detail-status-pill__dot"
|
||||
style={{ background: activityColor }}
|
||||
/>
|
||||
<span className="session-detail-status-pill__label">
|
||||
{activityLabel}
|
||||
</span>
|
||||
</div>
|
||||
{branch ? (
|
||||
pr ? (
|
||||
<a
|
||||
href={buildGitHubBranchUrl(pr)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="session-detail-link-pill session-detail-link-pill--branch session-detail-link-pill--branch-link hover:no-underline"
|
||||
>
|
||||
{branch}
|
||||
</a>
|
||||
) : (
|
||||
<span className="session-detail-link-pill session-detail-link-pill--branch">
|
||||
{branch}
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
{pr ? (
|
||||
<a
|
||||
href={pr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="session-detail-link-pill session-detail-link-pill--pr hover:no-underline"
|
||||
>
|
||||
PR #{pr.number}
|
||||
</a>
|
||||
) : null}
|
||||
{pr && (pr.additions > 0 || pr.deletions > 0) ? (
|
||||
<span className="session-detail-link-pill session-detail-link-pill--diff">
|
||||
<span className="session-detail-diff--add">+{pr.additions}</span>
|
||||
{" "}
|
||||
<span className="session-detail-diff--del">-{pr.deletions}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rightSlot ? (
|
||||
<div className="session-detail-identity__actions session-detail-identity__actions--custom">
|
||||
{rightSlot}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -222,147 +142,6 @@ async function askAgentToFix(
|
|||
}
|
||||
}
|
||||
|
||||
// ── Orchestrator status strip ─────────────────────────────────────────
|
||||
|
||||
function _OrchestratorStatusStrip({
|
||||
zones,
|
||||
createdAt,
|
||||
headline,
|
||||
activityLabel,
|
||||
activityColor,
|
||||
branch,
|
||||
pr,
|
||||
crumbHref,
|
||||
crumbLabel,
|
||||
}: {
|
||||
zones: OrchestratorZones;
|
||||
createdAt: string;
|
||||
headline: string;
|
||||
activityLabel: string;
|
||||
activityColor: string;
|
||||
branch: string | null;
|
||||
pr: DashboardPR | null;
|
||||
crumbHref: string;
|
||||
crumbLabel: string;
|
||||
}) {
|
||||
const [uptime, setUptime] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const compute = () => {
|
||||
const diff = Date.now() - new Date(createdAt).getTime();
|
||||
const h = Math.floor(diff / 3_600_000);
|
||||
const m = Math.floor((diff % 3_600_000) / 60_000);
|
||||
setUptime(h > 0 ? `${h}h ${m}m` : `${m}m`);
|
||||
};
|
||||
compute();
|
||||
const id = setInterval(compute, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [createdAt]);
|
||||
|
||||
const stats: Array<{ value: number; label: string; color: string; bg: string }> = [
|
||||
{
|
||||
value: zones.merge,
|
||||
label: "merge-ready",
|
||||
color: "var(--color-status-ready)",
|
||||
bg: "color-mix(in srgb, var(--color-status-ready) 10%, transparent)",
|
||||
},
|
||||
{
|
||||
value: zones.respond,
|
||||
label: "responding",
|
||||
color: "var(--color-status-error)",
|
||||
bg: "color-mix(in srgb, var(--color-status-error) 10%, transparent)",
|
||||
},
|
||||
{
|
||||
value: zones.review,
|
||||
label: "review",
|
||||
color: "var(--color-accent-orange)",
|
||||
bg: "color-mix(in srgb, var(--color-accent-orange) 10%, transparent)",
|
||||
},
|
||||
{
|
||||
value: zones.working,
|
||||
label: "working",
|
||||
color: "var(--color-accent-blue)",
|
||||
bg: "color-mix(in srgb, var(--color-accent-blue) 10%, transparent)",
|
||||
},
|
||||
{
|
||||
value: zones.pending,
|
||||
label: "pending",
|
||||
color: "var(--color-status-attention)",
|
||||
bg: "color-mix(in srgb, var(--color-status-attention) 10%, transparent)",
|
||||
},
|
||||
{
|
||||
value: zones.done,
|
||||
label: "done",
|
||||
color: "var(--color-text-tertiary)",
|
||||
bg: "color-mix(in srgb, var(--color-text-tertiary) 14%, transparent)",
|
||||
},
|
||||
].filter((s) => s.value > 0);
|
||||
|
||||
const total =
|
||||
zones.merge + zones.respond + zones.review + zones.working + zones.pending + zones.done;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1180px] px-5 pt-5 lg:px-8">
|
||||
<OrchestratorTopStrip
|
||||
headline={headline}
|
||||
crumbId={headline}
|
||||
activityLabel={activityLabel}
|
||||
activityColor={activityColor}
|
||||
branch={branch}
|
||||
pr={pr}
|
||||
crumbHref={crumbHref}
|
||||
crumbLabel={crumbLabel}
|
||||
rightSlot={
|
||||
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
|
||||
<div className="flex items-baseline gap-1.5 mr-2">
|
||||
<span className="text-[22px] font-bold leading-none tabular-nums text-[var(--color-text-primary)]">
|
||||
{total}
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)]">agents</span>
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-[var(--color-border-subtle)] mr-1" />
|
||||
|
||||
{/* Per-zone pills */}
|
||||
{stats.length > 0 ? (
|
||||
stats.map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1"
|
||||
style={{ background: s.bg }}
|
||||
>
|
||||
<span
|
||||
className="text-[15px] font-bold leading-none tabular-nums"
|
||||
style={{ color: s.color }}
|
||||
>
|
||||
{s.value}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: s.color, opacity: 0.8 }}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[12px] text-[var(--color-text-tertiary)]">
|
||||
no active agents
|
||||
</span>
|
||||
)}
|
||||
|
||||
{uptime && (
|
||||
<span className="ml-auto font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
|
||||
up {uptime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────
|
||||
|
||||
export function SessionDetail({
|
||||
|
|
@ -404,8 +183,6 @@ export function SessionDetail({
|
|||
? `/exit\nopencode --session ${opencodeSessionId}\n`
|
||||
: undefined;
|
||||
const dashboardHref = session.projectId ? projectDashboardPath(session.projectId) : "/";
|
||||
const crumbHref = dashboardHref;
|
||||
const crumbLabel = "Dashboard";
|
||||
|
||||
const handleKill = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -525,44 +302,42 @@ export function SessionDetail({
|
|||
{showHeaderProjectLabel && (
|
||||
<span className="dashboard-app-header__project">{headerProjectLabel}</span>
|
||||
)}
|
||||
{!isOrchestrator && (
|
||||
<span className="dashboard-app-header__session-id topbar-mobile-only">
|
||||
{session.id}
|
||||
</span>
|
||||
<span className="dashboard-app-header__session-id topbar-mobile-only">
|
||||
{session.id}
|
||||
</span>
|
||||
{isOrchestrator && (
|
||||
<span className="session-detail-mode-badge">orchestrator</span>
|
||||
)}
|
||||
</div>
|
||||
{!isOrchestrator && (
|
||||
<div className="topbar-session-pills">
|
||||
<div className={cn("topbar-status-pill", `topbar-status-pill--${normalizeActivityLabelForClass(activity.label)}`)}>
|
||||
<span className="topbar-status-pill__dot" style={{ background: activity.color }} />
|
||||
<span className="topbar-status-pill__label">{activity.label}</span>
|
||||
</div>
|
||||
{session.branch ? (
|
||||
pr ? (
|
||||
<a
|
||||
href={buildGitHubBranchUrl(pr)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="topbar-branch-pill topbar-branch-pill--link"
|
||||
>
|
||||
{session.branch}
|
||||
</a>
|
||||
) : (
|
||||
<span className="topbar-branch-pill">{session.branch}</span>
|
||||
)
|
||||
) : null}
|
||||
<div className="topbar-session-pills">
|
||||
<div className={cn("topbar-status-pill", `topbar-status-pill--${normalizeActivityLabelForClass(activity.label)}`)}>
|
||||
<span className="topbar-status-pill__dot" style={{ background: activity.color }} />
|
||||
<span className="topbar-status-pill__label">{activity.label}</span>
|
||||
</div>
|
||||
)}
|
||||
{session.branch ? (
|
||||
pr ? (
|
||||
<a
|
||||
href={buildGitHubBranchUrl(pr)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="topbar-branch-pill topbar-branch-pill--link"
|
||||
>
|
||||
{session.branch}
|
||||
</a>
|
||||
) : (
|
||||
<span className="topbar-branch-pill">{session.branch}</span>
|
||||
)
|
||||
) : null}
|
||||
{isOrchestrator && orchestratorZones ? (
|
||||
<OrchestratorZonePills zones={orchestratorZones} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* Desktop-only session title + session id.
|
||||
On mobile the session id lives next to the project name (above). */}
|
||||
{!isOrchestrator && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
<div className="dashboard-app-header__spacer" />
|
||||
<div className="dashboard-app-header__actions">
|
||||
{pr ? (
|
||||
|
|
@ -626,7 +401,7 @@ export function SessionDetail({
|
|||
{!isOrchestrator && orchestratorHref ? (
|
||||
<a
|
||||
href={orchestratorHref}
|
||||
className="dashboard-app-btn dashboard-app-btn--amber topbar-desktop-only"
|
||||
className="dashboard-app-btn dashboard-app-btn--amber"
|
||||
aria-label="Orchestrator"
|
||||
>
|
||||
<svg
|
||||
|
|
@ -675,21 +450,6 @@ export function SessionDetail({
|
|||
|
||||
<div className="dashboard-main dashboard-main--desktop">
|
||||
<main className="session-detail-page flex-1 min-h-0 flex flex-col bg-[var(--color-bg-base)]">
|
||||
{/* Orchestrator status strip — rendered above terminal only on orchestrator pages */}
|
||||
{isOrchestrator && orchestratorZones && (
|
||||
<_OrchestratorStatusStrip
|
||||
zones={orchestratorZones}
|
||||
createdAt={session.createdAt}
|
||||
headline={headline}
|
||||
activityLabel={activity.label}
|
||||
activityColor={activity.color}
|
||||
branch={session.branch}
|
||||
pr={pr}
|
||||
crumbHref={crumbHref}
|
||||
crumbLabel={crumbLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Terminal — fills all remaining height */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{!showTerminal ? (
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
dataDir: ~/.agent-orchestrator
|
||||
worktreeDir: ~/.worktrees
|
||||
port: 3000
|
||||
defaults:
|
||||
runtime: tmux
|
||||
agent: claude-code
|
||||
workspace: worktree
|
||||
notifiers:
|
||||
- desktop
|
||||
projects:
|
||||
ao-35:
|
||||
repo: ComposioHQ/agent-orchestrator
|
||||
path: /Users/equinox/.worktrees/ao/ao/ao-35
|
||||
defaultBranch: feat/seamless-onboarding
|
||||
agentRules: >-
|
||||
Always run tests before pushing.
|
||||
|
||||
Use conventional commits (feat:, fix:, chore:, docs:, refactor:, test:).
|
||||
|
||||
Link issue numbers in commit messages.
|
||||
|
||||
Write clear commit messages that explain WHY, not just WHAT.
|
||||
|
||||
|
||||
Use modern JavaScript (ES6+).
|
||||
|
||||
Prefer const over let, never use var.
|
||||
|
||||
Use async/await over raw Promises.
|
||||
|
||||
Use template literals for string interpolation.
|
||||
|
||||
|
||||
This is a pnpm monorepo with workspaces.
|
||||
|
||||
Run commands from root with pnpm -r (recursive) or from specific package
|
||||
directories.
|
||||
|
||||
Before pushing: pnpm build && pnpm typecheck && pnpm lint && pnpm test.
|
||||
|
||||
Always build packages before running dependent packages.
|
||||
|
||||
|
||||
Before pushing, run these commands:
|
||||
|
||||
- pnpm build (build all packages)
|
||||
|
||||
- pnpm typecheck (type check all packages)
|
||||
|
||||
- pnpm lint (or pnpm lint:fix to auto-fix)
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
dataDir: ~/.agent-orchestrator
|
||||
worktreeDir: ~/.worktrees
|
||||
port: 3000
|
||||
defaults:
|
||||
runtime: tmux
|
||||
agent: claude-code
|
||||
workspace: worktree
|
||||
notifiers:
|
||||
- desktop
|
||||
projects:
|
||||
ao-35:
|
||||
repo: ComposioHQ/agent-orchestrator
|
||||
path: /Users/equinox/.worktrees/ao/ao/ao-35
|
||||
defaultBranch: feat/seamless-onboarding
|
||||
agentRules: >-
|
||||
Always run tests before pushing.
|
||||
|
||||
Use conventional commits (feat:, fix:, chore:, docs:, refactor:, test:).
|
||||
|
||||
Link issue numbers in commit messages.
|
||||
|
||||
Write clear commit messages that explain WHY, not just WHAT.
|
||||
|
||||
|
||||
Use TypeScript strict mode.
|
||||
|
||||
Use ESM modules with .js extensions in imports (e.g., import { foo } from
|
||||
"./bar.js").
|
||||
|
||||
Use node: prefix for built-in modules (e.g., import { readFile } from
|
||||
"node:fs").
|
||||
|
||||
Prefer const over let, never use var.
|
||||
|
||||
Use type imports for type-only imports: import type { Foo } from
|
||||
"./bar.js".
|
||||
|
||||
No any types - use unknown with type guards instead.
|
||||
|
||||
|
||||
This is a pnpm monorepo with workspaces.
|
||||
|
||||
Run commands from root with pnpm -r (recursive) or from specific package
|
||||
directories.
|
||||
|
||||
Before pushing: pnpm build && pnpm typecheck && pnpm lint && pnpm test.
|
||||
|
||||
Always build packages before running dependent packages.
|
||||
|
||||
|
||||
Before pushing, run these commands:
|
||||
|
||||
- pnpm build (build all packages)
|
||||
|
||||
- pnpm typecheck (type check all packages)
|
||||
|
||||
- pnpm lint (or pnpm lint:fix to auto-fix)
|
||||
Loading…
Reference in New Issue