diff --git a/infra/worktree-recovery-state.mjs b/infra/worktree-recovery-state.mjs index df55d4a2f..bb78bd046 100644 --- a/infra/worktree-recovery-state.mjs +++ b/infra/worktree-recovery-state.mjs @@ -96,10 +96,7 @@ function buildNextWorktreeRecoveryState(currentState, event) { if (normalizedEvent.outcome === "healthy_noop") { return { ...nextState, - health: - state.health === "recovered" - ? "recovered" - : (state.failureStreak > 0 ? state.health : "healthy"), + health: state.health === "healthy" ? "healthy" : state.health, lastHealthyAt: normalizedEvent.timestamp, }; } diff --git a/tests/tui/fixtures.mjs b/tests/tui/fixtures.mjs index 8d3fd5eea..194b6a470 100644 --- a/tests/tui/fixtures.mjs +++ b/tests/tui/fixtures.mjs @@ -66,6 +66,20 @@ export const sessionDetailFixture = { ok: true, session: { ...sessionsFixture[0], + branch: "task/cfd631a87666-feat-tui-session-detail-modal-full-session-drill", + provider: "openai", + model: "gpt-5.4", + tokensIn: 2300, + tokensOut: 1100, + runtimeMs: 30000, + turns: Array.from({ length: 18 }, (_, index) => ({ + id: `turn-${index + 1}`, + number: index + 1, + timestamp: `2026-03-23T00:00:${String(index).padStart(2, "0")}.000Z`, + tokenDelta: 50 + index, + durationMs: 1000 + (index * 250), + lastToolCall: index % 2 === 0 ? "shell.exec" : "assistant.message", + })), messages: [ { timestamp: "2026-03-23T00:00:00.000Z", role: "user", content: "Start debugging" }, { timestamp: "2026-03-23T00:00:05.000Z", role: "assistant", content: "Loaded the relevant logs" }, @@ -77,9 +91,15 @@ export const sessionDiffFixture = { ok: true, summary: "2 files changed", diff: { + formatted: [ + "diff --git a/src/app.mjs b/src/app.mjs", + "--- a/src/app.mjs", + "+++ b/src/app.mjs", + ...Array.from({ length: 45 }, (_, index) => index % 3 === 0 ? `+added line ${index}` : index % 3 === 1 ? `-removed line ${index}` : ` context line ${index}`), + ].join("\\n"), files: [ - { filename: "src/app.mjs", additions: 8, deletions: 2 }, - { filename: "tests/app.test.mjs", additions: 4, deletions: 0 }, + { filename: "src/app.mjs", additions: 8, deletions: 2, patch: "+added line\n-removed line\n context" }, + { filename: "tests/app.test.mjs", additions: 4, deletions: 0, patch: "+test line" }, ], }, }; @@ -130,3 +150,4 @@ export function createMockWsClient() { }, }; } + diff --git a/tests/tui/screens.test.mjs b/tests/tui/screens.test.mjs index d74dc79f8..f8562ba4f 100644 --- a/tests/tui/screens.test.mjs +++ b/tests/tui/screens.test.mjs @@ -119,6 +119,117 @@ describe("tui screen rendering", () => { await view.unmount(); }); + it("opens a session detail modal on enter and renders timeline, diff, and logs", async () => { + const bridge = createMockBridge(); + const view = await renderInk( + React.createElement(AgentsScreen, { + wsBridge: bridge, + host: "127.0.0.1", + port: 3080, + sessions: sessionsFixture, + stats: monitorStatsFixture, + }), + { columns: 220, rows: 60 }, + ); + + await waitFor(() => view.text().includes("Backoff queue (1)")); + await view.press("`r", 80); + await waitFor(() => view.text().includes("Session Detail")); + + expect(view.text()).toContain("Task ID"); + expect(view.text()).toContain("Turn Timeline"); + expect(view.text()).toContain("Latest Diff"); + expect(view.text()).toContain("Stdout"); + expect(view.text()).toContain("[S]teer"); + + await view.unmount(); + }); + + it("sends a steer message from session detail and shows confirmation", async () => { + const bridge = createMockBridge(); + const view = await renderInk( + React.createElement(AgentsScreen, { + wsBridge: bridge, + host: "127.0.0.1", + port: 3080, + sessions: sessionsFixture, + stats: monitorStatsFixture, + }), + { columns: 220, rows: 60 }, + ); + + await waitFor(() => view.text().includes("Backoff queue (1)")); + await view.press("`r", 80); + await waitFor(() => view.text().includes("Session Detail")); + + await view.press("s", 40); + await waitFor(() => view.text().includes("Steer message:")); + await view.press("Please continue with focused logging", 50); + await view.press("`r", 100); + + await waitFor(() => view.text().includes("Steer sent ✓")); + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/sessions/session-active-1/message?workspace=all"), + expect.objectContaining({ method: "POST" }), + ); + + await view.unmount(); + }); + + it("scrolls the turn timeline independently inside session detail", async () => { + const bridge = createMockBridge(); + const view = await renderInk( + React.createElement(AgentsScreen, { + wsBridge: bridge, + host: "127.0.0.1", + port: 3080, + sessions: sessionsFixture, + stats: monitorStatsFixture, + }), + { columns: 220, rows: 60 }, + ); + + await waitFor(() => view.text().includes("Backoff queue (1)")); + await view.press("`r", 80); + await waitFor(() => view.text().includes("| 2026-03-23 00:00:00.000 |")); + expect(view.text()).not.toContain("| 2026-03-23 00:00:17.000 |"); + + await view.press("\u001b[6~", 80); + await waitFor(() => view.text().includes("| 2026-03-23 00:00:17.000 |")); + + await view.unmount(); + }); + + it("streams live stdout into the right panel while detail is open", async () => { + const bridge = createMockBridge(); + const view = await renderInk( + React.createElement(AgentsScreen, { + wsBridge: bridge, + host: "127.0.0.1", + port: 3080, + sessions: sessionsFixture, + stats: monitorStatsFixture, + }), + { columns: 220, rows: 60 }, + ); + + await waitFor(() => view.text().includes("Backoff queue (1)")); + await view.press("`r", 80); + await waitFor(() => view.text().includes("Session Detail")); + + bridge.emit("logs:stream", { + logType: "stdout", + raw: "Steer message accepted by running session", + line: "Steer message accepted by running session", + level: "info", + timestamp: "2026-03-23T00:00:31.000Z", + filePath: "", + }); + + await waitFor(() => view.text().includes("00:00:31")); + await view.unmount(); + }); + it("navigates app tabs with numeric key input", async () => { const bridge = createMockBridge(); const view = await renderInk( @@ -180,6 +291,7 @@ describe("tui screen rendering", () => { await view.press("?"); await view.press(" ", 40); + await waitFor(() => view.text().includes("? Help")); await view.unmount(); }); @@ -216,8 +328,3 @@ describe("tui screen rendering", () => { await view.unmount(); }); }); - - - - - diff --git a/tui/screens/agents.mjs b/tui/screens/agents.mjs index 730852714..b49bd313b 100644 --- a/tui/screens/agents.mjs +++ b/tui/screens/agents.mjs @@ -17,6 +17,10 @@ import { const html = htm.bind(React.createElement); const FIXED_TABLE_WIDTH = 2 + 8 + 12 + 8 + 10 + 12 + 14 + 7; +const DETAIL_POLL_MS = 1000; +const MAX_DIFF_LINES = 40; +const MAX_LOG_LINES = 20; +const PAGE_SCROLL = 8; function pad(text, width, align = "left") { const value = String(text || ""); @@ -53,6 +57,23 @@ async function fetchJson(host, port, path, init) { return payload; } +function formatTimestamp(value) { + if (!value) return "-"; + return String(value).replace("T", " ").replace("Z", ""); +} + +function formatDuration(ms) { + const value = Number(ms || 0); + if (!Number.isFinite(value) || value <= 0) return "0s"; + const totalSeconds = Math.round(value / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours) return `${hours}h ${minutes}m`; + if (minutes) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + function summarizeDiff(diffPayload) { const diff = diffPayload?.diff || {}; const files = Array.isArray(diff.files) ? diff.files : []; @@ -63,6 +84,26 @@ function summarizeDiff(diffPayload) { additions: Number(file.additions || 0), deletions: Number(file.deletions || 0), })), + lines: collectDiffLines(diffPayload), + }; +} + +function collectDiffLines(diffPayload) { + const candidates = []; + if (typeof diffPayload?.diff?.formatted === "string") candidates.push(diffPayload.diff.formatted); + if (typeof diffPayload?.summary === "string" && diffPayload.summary.includes("\n")) candidates.push(diffPayload.summary); + const filePatches = Array.isArray(diffPayload?.diff?.files) + ? diffPayload.diff.files.map((file) => file.patch).filter(Boolean) + : []; + candidates.push(...filePatches); + const source = candidates.find((value) => String(value || "").trim()) || ""; + const allLines = String(source).split(/\r?\n/).filter((line) => line.length); + if (allLines.length <= MAX_DIFF_LINES) { + return { omitted: 0, visible: allLines }; + } + return { + omitted: allLines.length - MAX_DIFF_LINES, + visible: allLines.slice(-MAX_DIFF_LINES), }; } @@ -71,34 +112,170 @@ function sessionMessagesToLogLines(sessionPayload) { ? sessionPayload.session.messages : []; return messages.slice(-40).map((message) => { - const ts = String(message.timestamp || "").replace("T", " ").replace("Z", ""); + const ts = formatTimestamp(message.timestamp); const role = String(message.role || message.type || "event").padEnd(10, " "); const content = String(message.content || "").replace(/\s+/g, " ").trim(); return `${ts} ${role} ${content}`; }); } +function streamPayloadToLogLine(payload) { + if (!payload) return ""; + const timestamp = formatTimestamp(payload.timestamp || payload.time || payload.createdAt); + const level = String(payload.level || payload.stream || payload.type || "log").padEnd(7, " "); + const message = String( + payload.line + || payload.raw + || payload.message + || payload.content + || payload.text + || payload.stdout + || payload.stderr + || "", + ).replace(/\s+/g, " ").trim(); + return `${timestamp} ${level} ${message}`.trimEnd(); +} + +function readSession(sessionPayload) { + return sessionPayload?.session || sessionPayload || {}; +} + +function deriveTurnTimeline(sessionPayload) { + const session = readSession(sessionPayload); + const turns = Array.isArray(session.turns) ? session.turns : []; + if (turns.length) { + return turns.map((turn, index) => ({ + key: turn.id || `turn-${index}`, + number: Number(turn.number || index + 1), + timestamp: formatTimestamp(turn.timestamp || turn.startedAt || turn.createdAt), + tokenDelta: Number(turn.tokenDelta || turn.tokens || turn.outputTokens || 0), + duration: formatDuration(turn.durationMs || turn.elapsedMs), + eventType: String(turn.lastToolCall || turn.eventType || turn.type || "turn"), + })); + } + const messages = Array.isArray(session.messages) ? session.messages : []; + return messages.map((message, index) => ({ + key: `${message.timestamp || index}-${message.role || message.type || "event"}`, + number: index + 1, + timestamp: formatTimestamp(message.timestamp), + tokenDelta: Number(message.tokenDelta || message.tokens || 0), + duration: formatDuration(message.durationMs || 0), + eventType: String(message.lastToolCall || message.role || message.type || "event"), + })); +} + function detailLines(sessionPayload) { - const session = sessionPayload?.session || sessionPayload || {}; + const session = readSession(sessionPayload); + const metadata = session.metadata || {}; + const tokenIn = Number(session.tokensIn ?? metadata.tokensIn ?? 0); + const tokenOut = Number(session.tokensOut ?? metadata.tokensOut ?? 0); + const runtimeMs = Number(session.elapsedMs || session.runtimeMs || 0); return [ - `ID ${session.id || "-"}`, - `Status ${session.status || "-"}`, - `Title ${session.title || session.taskTitle || "-"}`, - `Type ${session.type || "-"}`, - `Workspace ${session.metadata?.workspaceId || session.workspaceId || "-"}`, - `Path ${session.metadata?.workspaceDir || session.workspaceDir || "-"}`, - `Model ${session.metadata?.model || session.model || "-"}`, - `Agent ${session.metadata?.agent || session.agent || "-"}`, - `Turns ${session.turnCount || 0}`, - `Messages ${Array.isArray(session.messages) ? session.messages.length : 0}`, + `Task ID ${session.taskId || "-"}`, + `Session UUID ${session.id || "-"}`, + `Model ${metadata.model || session.model || "-"}`, + `Provider ${metadata.provider || session.provider || "-"}`, + `Branch ${metadata.branch || session.branch || "-"}`, + `Start time ${formatTimestamp(session.createdAt || session.startedAt)}`, + `Total runtime ${formatDuration(runtimeMs)}`, + `Turn count ${session.turnCount || deriveTurnTimeline(sessionPayload).length}`, + `Token split in ${tokenIn} / out ${tokenOut}`, ]; } +function sliceWindow(items, offset, size) { + return items.slice(offset, offset + size); +} + +function clampOffset(next, size, visible) { + return Math.max(0, Math.min(next, Math.max(0, size - visible))); +} + +function SessionDetail({ + sessionPayload, + diffView, + logLines, + timelineOffset, + visibleTimelineRows, + steerMode, + steerValue, + terminalColumns, +}) { + const metadataLines = detailLines(sessionPayload); + const timeline = deriveTurnTimeline(sessionPayload); + const visibleTurns = sliceWindow(timeline, timelineOffset, visibleTimelineRows); + const rightPanel = terminalColumns >= 160; + const diffLines = diffView?.lines?.visible || []; + const omitted = Number(diffView?.lines?.omitted || 0); + + return html` + <${Box} position="relative" flexDirection="column" paddingY=${1}> + <${Text} bold>Session Detail + <${Box} marginTop=${1} flexDirection=${rightPanel ? "row" : "column"}> + <${Box} flexDirection="column" width=${rightPanel ? Math.max(100, terminalColumns - 70) : undefined} flexGrow=${1}> + <${Text} bold>Metadata + ${metadataLines.map((line) => html`<${Text} key=${line}>${line}`) } + + <${Box} marginTop=${1} flexDirection="column" borderStyle="single" paddingX=${1}> + <${Text} bold>Turn Timeline + <${Text} dimColor>turn | timestamp | Δtokens | duration | event + ${visibleTurns.length + ? visibleTurns.map((turn) => html` + <${Text} key=${turn.key}> + ${pad(turn.number, 4, "right")} | ${pad(turn.timestamp, 23)} | ${pad(turn.tokenDelta, 7, "right")} | ${pad(turn.duration, 8)} | ${turn.eventType} + + `) + : html`<${Text} dimColor>No turns yet`} + <${Text} dimColor>↑/↓ scroll PgUp/PgDn jump + + + <${Box} marginTop=${1} flexDirection="column" borderStyle="single" paddingX=${1}> + <${Text} bold>Latest Diff + <${Text}>${diffView?.summary || "(loading diff...)"} + ${omitted ? html`<${Text} dimColor>… ${omitted} lines omitted` : null} + ${diffLines.length + ? diffLines.map((line, index) => html` + <${Text} + key=${`${index}-${line}`} + color=${line.startsWith("+") && !line.startsWith("+++") ? "green" : line.startsWith("-") && !line.startsWith("---") ? "red" : undefined} + > + ${line} + + `) + : html`<${Text} dimColor>No diff lines available`} + + + + ${rightPanel + ? html` + <${Box} marginLeft=${1} flexDirection="column" width=${44} borderStyle="single" paddingX=${1}> + <${Text} bold>Stdout + ${(logLines || []).slice(-MAX_LOG_LINES).map((line, index) => html` + <${Text} key=${index} wrap="truncate-end">${line} + `)} + ${!(logLines || []).length ? html`<${Text} dimColor>No stdout yet` : null} + + ` + : null} + + <${Box} marginTop=${1} flexDirection="column"> + <${Text} dimColor>[S]teer [F]orce new thread [K]ill [Esc] close modal + ${steerMode + ? html` + <${Text}>Steer message: ${steerValue || ""} + ` + : null} + + + `; +} + export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080, sessions, stats = null, onFooterHintsChange }) { const resolvedHost = wsBridge?.host || host; const resolvedPort = wsBridge?.port || port; const { stdout } = useStdout(); const liveSessionsRef = React.useRef([]); + const detailPollRef = React.useRef(null); const [entries, setEntries] = React.useState([]); const [retryQueue, setRetryQueue] = React.useState({ count: 0, items: [] }); const [selectedId, setSelectedId] = React.useState(""); @@ -109,6 +286,13 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080 const [confirmKill, setConfirmKill] = React.useState(false); const [statusLine, setStatusLine] = React.useState(""); const [clockMs, setClockMs] = React.useState(Date.now()); + const [timelineOffset, setTimelineOffset] = React.useState(0); + const [steerMode, setSteerMode] = React.useState(false); + const [steerValue, setSteerValue] = React.useState(""); + + const terminalColumns = stdout?.columns || 120; + const terminalRows = stdout?.rows || 40; + const visibleTimelineRows = Math.max(6, Math.min(14, terminalRows - 20)); const selectedSession = React.useMemo( () => entries.find((entry) => entry.id === selectedId)?.session || entries[0]?.session || null, @@ -135,6 +319,42 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080 }); }, []); + React.useEffect(() => { + const intervalId = setInterval(() => { + const now = Date.now(); + setClockMs(now); + setEntries((previous) => { + const nextEntries = reconcileSessionEntries(previous, liveSessionsRef.current, now); + setSelectedId((current) => { + if (current && nextEntries.some((entry) => entry.id === current)) return current; + return nextEntries[0]?.id || ""; + }); + return nextEntries; + }); + }, 1000); + + return () => { + clearInterval(intervalId); + }; + }, []); + const clearDetailPoll = React.useCallback(() => { + if (detailPollRef.current) { + clearInterval(detailPollRef.current); + detailPollRef.current = null; + } + }, []); + + const closeModal = React.useCallback(() => { + clearDetailPoll(); + setDetailView(null); + setLogLines([]); + setDiffView(null); + setConfirmKill(false); + setTimelineOffset(0); + setSteerMode(false); + setSteerValue(""); + }, [clearDetailPoll]); + const refreshData = React.useCallback(async () => { try { const [sessionsPayload, retryPayload] = await Promise.all([ @@ -150,7 +370,6 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080 } }, [applyRetryQueue, applySessionSnapshot, resolvedHost, resolvedPort]); - React.useEffect(() => { applySessionSnapshot(sessions, Date.now()); }, [applySessionSnapshot, sessions]); @@ -160,57 +379,6 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080 applyRetryQueue(stats.retryQueue); } }, [applyRetryQueue, stats]); - const moveSelection = React.useCallback((direction) => { - if (!entries.length) return; - const currentIndex = Math.max(0, entries.findIndex((entry) => entry.id === selectedId)); - const nextIndex = (currentIndex + direction + entries.length) % entries.length; - setSelectedId(entries[nextIndex]?.id || ""); - }, [entries, selectedId]); - - const openDetail = React.useCallback(async () => { - if (!selectedSession) return; - setDetailView(detailLines(selectedSession)); - setLogLines([]); - setDiffView(null); - setConfirmKill(false); - }, [selectedSession]); - - const loadLogs = React.useCallback(async () => { - if (!selectedSession?.id) return; - try { - const payload = await fetchJson(resolvedHost, resolvedPort, `/api/sessions/${encodeURIComponent(selectedSession.id)}?workspace=all`); - setLogLines(sessionMessagesToLogLines(payload)); - setDetailView(null); - setDiffView(null); - setStatusLine(`Loaded logs for ${describeSelection(selectedSession)}`); - } catch (error) { - setStatusLine(error.message || String(error)); - } - }, [resolvedHost, resolvedPort, selectedSession]); - - const loadDiff = React.useCallback(async () => { - if (!selectedSession?.id) return; - try { - const payload = await fetchJson(resolvedHost, resolvedPort, `/api/sessions/${encodeURIComponent(selectedSession.id)}/diff?workspace=all`); - setDiffView(summarizeDiff(payload)); - setDetailView(null); - setLogLines([]); - setStatusLine(`Loaded diff for ${describeSelection(selectedSession)}`); - } catch (error) { - setStatusLine(error.message || String(error)); - } - }, [resolvedHost, resolvedPort, selectedSession]); - - const runAction = React.useCallback(async (action) => { - if (!selectedSession?.id) return; - try { - await fetchJson(resolvedHost, resolvedPort, sessionActionPath(selectedSession.id, action), { method: "POST" }); - setStatusLine(`${action} requested for ${describeSelection(selectedSession)}`); - setConfirmKill(false); - } catch (error) { - setStatusLine(error.message || String(error)); - } - }, [resolvedHost, resolvedPort, selectedSession]); React.useEffect(() => { let active = true; @@ -218,7 +386,14 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080 const intervalId = setInterval(() => { const now = Date.now(); setClockMs(now); - setEntries((previous) => reconcileSessionEntries(previous, liveSessionsRef.current, now)); + setEntries((previous) => { + const nextEntries = reconcileSessionEntries(previous, liveSessionsRef.current, now); + setSelectedId((current) => { + if (current && nextEntries.some((entry) => entry.id === current)) return current; + return nextEntries[0]?.id || ""; + }); + return nextEntries; + }); }, 1000); return () => { active = false; @@ -233,12 +408,12 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080 if (!wsBridge || typeof wsBridge.on !== "function") return undefined; const handlers = [ wsBridge.on("sessions:update", (payload) => { - const sessions = Array.isArray(payload?.sessions) + const nextSessions = Array.isArray(payload?.sessions) ? payload.sessions : Array.isArray(payload) ? payload : []; - applySessionSnapshot(sessions, Date.now()); + applySessionSnapshot(nextSessions, Date.now()); }), wsBridge.on("session:event", (payload) => { const session = payload?.session; @@ -251,33 +426,180 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080 else nextSessions.unshift(session); applySessionSnapshot(nextSessions, Date.now()); - if (selectedId === session.id) { - setDetailView(detailLines(payload)); - const hasMessages = Array.isArray(payload?.session?.messages); - const isMessageEvent = payload?.event?.kind === "message"; - if (hasMessages || isMessageEvent) { - setLogLines(sessionMessagesToLogLines(payload)); - } + if (String(detailView?.session?.id || "") !== String(session.id)) return; + setDetailView(payload); + if (Array.isArray(payload?.session?.messages) || payload?.event?.kind === "message") { + setLogLines(sessionMessagesToLogLines(payload).slice(-MAX_LOG_LINES)); } }), wsBridge.on("retry:update", applyRetryQueue), wsBridge.on("retry-queue-updated", applyRetryQueue), + wsBridge.on("logs:stream", (payload) => { + const line = streamPayloadToLogLine(payload); + if (!line) return; + setLogLines((current) => [...current.slice(-(MAX_LOG_LINES - 1)), line]); + }), ]; - return () => { handlers.forEach((unsubscribe) => { if (typeof unsubscribe === "function") unsubscribe(); }); }; - }, [applyRetryQueue, applySessionSnapshot, wsBridge]); + }, [applyRetryQueue, applySessionSnapshot, detailView, wsBridge]); + + React.useEffect(() => () => clearDetailPoll(), [clearDetailPoll]); + + const moveSelection = React.useCallback((delta) => { + if (!entries.length) return; + const index = entries.findIndex((entry) => entry.id === selectedSession?.id); + const nextIndex = index === -1 ? 0 : (index + delta + entries.length) % entries.length; + setSelectedId(entries[nextIndex]?.id || ""); + }, [entries, selectedSession]); + + const loadLogs = React.useCallback(async () => { + if (!selectedSession?.id) return; + try { + const payload = await fetchJson(resolvedHost, resolvedPort, `/api/sessions/${encodeURIComponent(selectedSession.id)}?workspace=all`); + setLogLines(sessionMessagesToLogLines(payload)); + setStatusLine(`Loaded logs for ${describeSelection(selectedSession)}`); + } catch (error) { + setStatusLine(error.message || String(error)); + } + }, [resolvedHost, resolvedPort, selectedSession]); + + const loadDiff = React.useCallback(async () => { + if (!selectedSession?.id) return; + try { + const payload = await fetchJson(resolvedHost, resolvedPort, `/api/sessions/${encodeURIComponent(selectedSession.id)}/diff?workspace=all`); + setDiffView(summarizeDiff(payload)); + setStatusLine(`Loaded diff for ${describeSelection(selectedSession)}`); + } catch (error) { + setStatusLine(error.message || String(error)); + } + }, [resolvedHost, resolvedPort, selectedSession]); + + const loadDetail = React.useCallback(async () => { + if (!selectedSession?.id) return; + try { + const payload = await fetchJson(resolvedHost, resolvedPort, `/api/sessions/${encodeURIComponent(selectedSession.id)}?workspace=all`); + setDetailView(payload); + setStatusLine(""); + } catch (error) { + setStatusLine(error.message || String(error)); + } + }, [resolvedHost, resolvedPort, selectedSession]); + + const openDetail = React.useCallback(async () => { + if (!selectedSession?.id) return; + setTimelineOffset(0); + setDetailView({ session: selectedSession }); + setLogLines([]); + setDiffView({ summary: "(loading diff...)", files: [], lines: { omitted: 0, visible: [] } }); + await Promise.all([loadDetail(), loadDiff()]); + clearDetailPoll(); + detailPollRef.current = setInterval(() => { + void loadDetail(); + }, DETAIL_POLL_MS); + }, [clearDetailPoll, loadDetail, loadDiff, selectedSession]); + + const runAction = React.useCallback(async (action) => { + if (!selectedSession?.id) return; + try { + await fetchJson(resolvedHost, resolvedPort, sessionActionPath(selectedSession.id, action), { method: "POST" }); + setStatusLine(`${action} sent to ${describeSelection(selectedSession)}`); + if (action === "kill") setConfirmKill(false); + await refreshData(); + } catch (error) { + setStatusLine(error.message || String(error)); + } + }, [refreshData, resolvedHost, resolvedPort, selectedSession]); + + const sendSteer = React.useCallback(async () => { + if (!selectedSession?.id || !steerValue.trim()) return; + try { + await fetchJson( + resolvedHost, + resolvedPort, + `/api/sessions/${encodeURIComponent(selectedSession.id)}/message?workspace=all`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: steerValue.trim() }), + }, + ); + setStatusLine("Steer sent ✓"); + setSteerMode(false); + setSteerValue(""); + await loadDetail(); + } catch (error) { + setStatusLine(error.message || String(error)); + } + }, [loadDetail, resolvedHost, resolvedPort, selectedSession, steerValue]); useInput((input, key) => { if (confirmKill) { if (input === "y" || input === "Y") { void runAction("kill"); + } else if (key.escape || input === "n" || input === "N" || key.return) { + setConfirmKill(false); + } + return; + } + + if (steerMode) { + if (key.escape) { + setSteerMode(false); + setSteerValue(""); + return; + } + if (key.return || input === "`r") { + void sendSteer(); + return; + } + if (key.backspace || key.delete) { + setSteerValue((current) => current.slice(0, -1)); + return; + } + if (input) { + setSteerValue((current) => current + input); + } + return; + } + + if (detailView) { + const timeline = deriveTurnTimeline(detailView); + if (key.escape) { + closeModal(); + return; + } + if (key.upArrow) { + setTimelineOffset((current) => clampOffset(current - 1, timeline.length, visibleTimelineRows)); + return; + } + if (key.downArrow) { + setTimelineOffset((current) => clampOffset(current + 1, timeline.length, visibleTimelineRows)); + return; + } + if (key.pageUp) { + setTimelineOffset((current) => clampOffset(current - PAGE_SCROLL, timeline.length, visibleTimelineRows)); + return; + } + if (key.pageDown) { + setTimelineOffset((current) => clampOffset(current + PAGE_SCROLL, timeline.length, visibleTimelineRows)); + return; + } + if (input === "s" || input === "S") { + setSteerMode(true); + setSteerValue(""); return; } - setConfirmKill(false); + if (input === "f" || input === "F") { + void runAction("force-new-thread"); + return; + } + if (input === "k" || input === "K") { + setConfirmKill(true); + } return; } @@ -289,7 +611,7 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080 moveSelection(1); return; } - if (key.return) { + if (key.return || input === "`r") { void openDetail(); return; } @@ -325,13 +647,13 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080 return; } if (key.escape) { - setDetailView(null); - setLogLines([]); - setDiffView(null); - setConfirmKill(false); + closeModal(); } }); + const eventWidth = Math.max(12, terminalColumns - FIXED_TABLE_WIDTH); + const backoffMessageWidth = Math.max(20, terminalColumns - 34); + React.useEffect(() => { if (typeof onFooterHintsChange !== "function") return; onFooterHintsChange(getFooterHints("agents", { @@ -342,114 +664,118 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080 })); }, [confirmKill, detailView, diffView, logLines.length, onFooterHintsChange]); - const eventWidth = Math.max(12, (stdout?.columns || 120) - FIXED_TABLE_WIDTH); - const backoffMessageWidth = Math.max(20, (stdout?.columns || 120) - 34); return html` <${Box} flexDirection="column" paddingY=${1}> - <${Box} borderStyle="single" paddingX=${1}> - ${renderCell("", 2, {})} - ${renderCell("ID", 8, { dimColor: true })} - ${renderCell("STAGE", 12, { dimColor: true })} - ${renderCell("PID", 8, { dimColor: true })} - ${renderCell("AGE/TURN", 10, { dimColor: true })} - ${renderCell("TOKENS", 12, { dimColor: true })} - ${renderCell("SESSION", 14, { dimColor: true })} - ${renderCell("EVENT", eventWidth, { dimColor: true })} - - ${(entries.length ? entries : [{ id: "empty", session: null }]).map((entry) => { - if (!entry.session) { - return html` - <${Box} key="empty" paddingX=${1}> - <${Text} dimColor>No sessions - - `; - } - const row = projectSessionRow(entry.session, clockMs, eventWidth); - const selected = entry.id === selectedSession?.id; - return html` - <${Box} key=${entry.id} paddingX=${1}> - ${renderCell(row.statusDot, 2, { - color: row.statusColor, - inverse: selected, - dimColor: row.isDimmed || entry.isRetained, - })} - ${renderCell(row.idText, 8, { inverse: selected, dimColor: row.isDimmed || entry.isRetained })} - ${renderCell(row.stageText, 12, { - inverse: selected, - color: row.statusColor, - dimColor: row.isDimmed || entry.isRetained, - })} - ${renderCell(row.pidText, 8, { inverse: selected, dimColor: row.isDimmed || entry.isRetained })} - ${renderCell(row.ageTurnText, 10, { inverse: selected, dimColor: row.isDimmed || entry.isRetained })} - ${renderCell(row.tokensText, 12, { inverse: selected, dimColor: row.isDimmed || entry.isRetained })} - ${renderCell(row.sessionText, 14, { inverse: selected, dimColor: row.isDimmed || entry.isRetained })} - ${renderCell(row.eventText, eventWidth, { inverse: selected, dimColor: row.isDimmed || entry.isRetained })} - - `; - })} - - <${Box} marginTop=${1} flexDirection="column" borderStyle="single" paddingX=${1}> - <${Text} bold> - Backoff queue (${retryQueue.count || 0}) ${showBackoff ? "[B to collapse]" : "[B to expand]"} - - ${showBackoff - ? (retryQueue.items || []).slice(0, 6).map((item, index) => html` - <${Text} key=${item.taskId || item.id || index} wrap="truncate-end"> - ${pad(String(item.taskTitle || item.taskId || item.id || `item-${index}`), 16)} - ${pad(formatRetryQueueCountdown(item, clockMs), 16)} - ${pad(String(item.lastError || item.error || item.reason || "-"), backoffMessageWidth)} - - `) - : null} - ${showBackoff && !(retryQueue.items || []).length - ? html`<${Text} dimColor>No tasks cooling down` - : null} - - - ${confirmKill && selectedSession - ? html` - <${Box} marginTop=${1}> - <${Text} color="red">Kill ${describeSelection(selectedSession)}? [y/N] - - ` - : null} - ${detailView ? html` - <${Box} marginTop=${1} flexDirection="column" borderStyle="single" paddingX=${1}> - <${Text} bold>Detail - ${detailView.map((line) => html`<${Text} key=${line}>${line}`) } + <${SessionDetail} + sessionPayload=${detailView} + diffView=${diffView} + logLines=${logLines} + timelineOffset=${timelineOffset} + visibleTimelineRows=${visibleTimelineRows} + steerMode=${steerMode} + steerValue=${steerValue} + terminalColumns=${terminalColumns} + />` + : html` + <${Box} borderStyle="single" paddingX=${1}> + ${renderCell("", 2, {})} + ${renderCell("ID", 8, { dimColor: true })} + ${renderCell("STAGE", 12, { dimColor: true })} + ${renderCell("PID", 8, { dimColor: true })} + ${renderCell("AGE/TURN", 10, { dimColor: true })} + ${renderCell("TOKENS", 12, { dimColor: true })} + ${renderCell("SESSION", 14, { dimColor: true })} + ${renderCell("EVENT", eventWidth, { dimColor: true })} - ` - : null} - - ${logLines.length - ? html` - <${Box} marginTop=${1} flexDirection="column" borderStyle="single" paddingX=${1}> - <${Text} bold>Logs - ${logLines.map((line, index) => html` - <${Text} key=${index} wrap="truncate-end">${line} - `)} - - ` - : null} + ${(entries.length ? entries : [{ id: "empty", session: null }]).map((entry) => { + if (!entry.session) { + return html` + <${Box} key="empty" paddingX=${1}> + <${Text} dimColor>No sessions + + `; + } + const row = projectSessionRow(entry.session, clockMs, eventWidth); + const selected = entry.id === selectedSession?.id; + return html` + <${Box} key=${entry.id} paddingX=${1}> + ${renderCell(row.statusDot, 2, { + color: row.statusColor, + inverse: selected, + dimColor: row.isDimmed || entry.isRetained, + })} + ${renderCell(row.idText, 8, { inverse: selected, dimColor: row.isDimmed || entry.isRetained })} + ${renderCell(row.stageText, 12, { + inverse: selected, + color: row.statusColor, + dimColor: row.isDimmed || entry.isRetained, + })} + ${renderCell(row.pidText, 8, { inverse: selected, dimColor: row.isDimmed || entry.isRetained })} + ${renderCell(row.ageTurnText, 10, { inverse: selected, dimColor: row.isDimmed || entry.isRetained })} + ${renderCell(row.tokensText, 12, { inverse: selected, dimColor: row.isDimmed || entry.isRetained })} + ${renderCell(row.sessionText, 14, { inverse: selected, dimColor: row.isDimmed || entry.isRetained })} + ${renderCell(row.eventText, eventWidth, { inverse: selected, dimColor: row.isDimmed || entry.isRetained })} + + `; + })} - ${diffView - ? html` <${Box} marginTop=${1} flexDirection="column" borderStyle="single" paddingX=${1}> - <${Text} bold>Diff - <${Text}>${diffView.summary} - ${diffView.files.length - ? diffView.files.map((file) => html` - <${Text} key=${file.name}> - ${file.name} +${file.additions} -${file.deletions} + <${Text} bold> + Backoff queue (${retryQueue.count || 0}) ${showBackoff ? "[B to collapse]" : "[B to expand]"} + + ${showBackoff + ? (retryQueue.items || []).slice(0, 6).map((item, index) => html` + <${Text} key=${item.taskId || item.id || index} wrap="truncate-end"> + ${pad(String(item.taskTitle || item.taskId || item.id || `item-${index}`), 16)} + ${pad(formatRetryQueueCountdown(item, clockMs), 16)} + ${pad(String(item.lastError || item.error || item.reason || "-"), backoffMessageWidth)} `) - : html`<${Text} dimColor>No changed files`} + : null} + ${showBackoff && !(retryQueue.items || []).length + ? html`<${Text} dimColor>No tasks cooling down` + : null} - ` - : null}${statusLine + + ${confirmKill && selectedSession + ? html` + <${Box} marginTop=${1}> + <${Text} color="red">Kill ${describeSelection(selectedSession)}? [y/N] + + ` + : null} + + ${logLines.length + ? html` + <${Box} marginTop=${1} flexDirection="column" borderStyle="single" paddingX=${1}> + <${Text} bold>Logs + ${logLines.map((line, index) => html` + <${Text} key=${index} wrap="truncate-end">${line} + `)} + + ` + : null} + + ${diffView + ? html` + <${Box} marginTop=${1} flexDirection="column" borderStyle="single" paddingX=${1}> + <${Text} bold>Diff + <${Text}>${diffView.summary} + ${diffView.files.length + ? diffView.files.map((file) => html` + <${Text} key=${file.name}> + ${file.name} +${file.additions} -${file.deletions} + + `) + : html`<${Text} dimColor>No changed files`} + + ` + : null} + `} + ${statusLine ? html` <${Box} marginTop=${1}> <${Text} color="yellow">${statusLine} @@ -459,6 +785,3 @@ export default function AgentsScreen({ wsBridge, host = "127.0.0.1", port = 3080 `; } - - - diff --git a/workflow-templates/github.mjs b/workflow-templates/github.mjs index 4ffa1e5f6..200557462 100644 --- a/workflow-templates/github.mjs +++ b/workflow-templates/github.mjs @@ -16,6 +16,7 @@ import { node, edge, resetLayout } from "./_helpers.mjs"; +const BOSUN_CREATED_HTML_MARKER = ""; const GITHUB_CI_DIAGNOSTICS_SNIPPET = [ "const CI_LOG_EXCERPT_MAX_CHARS=12000;", "const CI_MAX_JOB_DIAGNOSTICS=10;", @@ -95,7 +96,7 @@ export const PR_MERGE_STRATEGY_TEMPLATE = { node("automation-eligible", "condition.expression", "Bosun-Created PR?", { expression: - "/* auto-created by bosun */ (() => { if ($data?.requireBosunCreatedPr !== true && String($data?.requireBosunCreatedPr || '').toLowerCase() !== 'true') return true; const raw = $ctx.getNodeOutput('load-pr-context')?.output || '{}'; let pr = {}; try { pr = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return false; } const labels = Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; const body = String(pr?.body || ''); return labels.includes('bosun-pr-bosun-created') || body.includes('') || /auto-created by bosun/i.test(body); })()", + `/* ${BOSUN_CREATED_HTML_MARKER} auto-created by bosun */ (() => { if ($data?.requireBosunCreatedPr !== true && String($data?.requireBosunCreatedPr || '').toLowerCase() !== 'true') return true; const raw = $ctx.getNodeOutput('load-pr-context')?.output || '{}'; let pr = {}; try { pr = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return false; } const labels = Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; const body = String(pr?.body || ''); return labels.includes('bosun-pr-bosun-created') || body.includes('${BOSUN_CREATED_HTML_MARKER}') || /auto-created by bosun/i.test(body); })()`, }, { x: 400, y: 230, outputs: ["yes", "no"] }), node("check-ci", "validation.build", "Check CI Status", {