From ff0e3a226a7b9efeb75c1ccf92ef118a6c320061 Mon Sep 17 00:00:00 2001 From: PlasmaVolt <61515425+PlasmaVolt@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:39:38 -0400 Subject: [PATCH 1/4] feat: expand Ralph sessions in TUI dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an expand/collapse tree in the dashboard so each instance row can reveal its Ralph sessions (read from $RALPH_HOME/sessions//), showing the SPEC title and prd.json task progress. Navigate with j/k, expand with l, collapse with h, and open chat on a specific session with enter. Session-scoped job filtering is stubbed until a Ralph ↔ OpenCode session mapping is persisted; the ralphSessionId is plumbed through to for the same reason. Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/components/app.tsx | 315 +++++++++++++++++++++++++----- apps/tui/src/components/chat.tsx | 15 +- apps/tui/src/lib/sessions.test.ts | 228 +++++++++++++++++++++ apps/tui/src/lib/sessions.ts | 157 +++++++++++++++ 4 files changed, 667 insertions(+), 48 deletions(-) create mode 100644 apps/tui/src/lib/sessions.test.ts create mode 100644 apps/tui/src/lib/sessions.ts diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index da60228..c503a56 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -8,12 +8,28 @@ import type { } from "@techatnyu/ralphd"; import { daemon } from "@techatnyu/ralphd"; import { useCallback, useEffect, useState } from "react"; +import { + filterJobsForSession, + flattenRows, + listSessions, + type Row, + type SessionSummary, +} from "../lib/sessions"; import { ralphStore, setModelAndRecent } from "../lib/store"; import { Chat } from "./chat"; type View = | { type: "dashboard" } - | { type: "chat"; instanceId: string; instanceName: string }; + | { + type: "chat"; + instanceId: string; + instanceName: string; + ralphSessionId?: string; + }; + +type Focus = + | { kind: "instance"; instanceId: string } + | { kind: "session"; instanceId: string; sessionId: string }; interface DashboardData { health: HealthResult; @@ -82,13 +98,6 @@ interface AppProps { onQuit(): void; } -function clampIndex(index: number, length: number): number { - if (length <= 0) { - return 0; - } - return Math.min(Math.max(index, 0), length - 1); -} - function countJobsByState( jobs: DaemonJob[], instanceId: string, @@ -103,17 +112,52 @@ function countJobsByState( return { running, queued }; } +function rowKey(row: Row): string { + return row.kind === "instance" + ? `i:${row.instance.id}` + : `s:${row.instance.id}:${row.session.sessionId}`; +} + +function findFocusIndex(rows: Row[], focus: Focus | undefined): number { + if (!focus) return -1; + return rows.findIndex((row) => { + if (focus.kind === "instance") { + return row.kind === "instance" && row.instance.id === focus.instanceId; + } + return ( + row.kind === "session" && + row.instance.id === focus.instanceId && + row.session.sessionId === focus.sessionId + ); + }); +} + +function focusFromRow(row: Row): Focus { + if (row.kind === "instance") { + return { kind: "instance", instanceId: row.instance.id }; + } + return { + kind: "session", + instanceId: row.instance.id, + sessionId: row.session.sessionId, + }; +} + function Dashboard({ onQuit, onSelectInstance, }: { onQuit(): void; - onSelectInstance(instance: ManagedInstance): void; + onSelectInstance(instance: ManagedInstance, ralphSessionId?: string): void; }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [data, setData] = useState(); - const [selectedIndex, setSelectedIndex] = useState(0); + const [focused, setFocused] = useState(); + const [expanded, setExpanded] = useState>(new Set()); + const [sessionsByInstance, setSessionsByInstance] = useState< + Record + >({}); const [currentModel, setCurrentModel] = useState(""); const [modelPicker, setModelPicker] = useState(false); const [modelOptions, setModelOptions] = useState([]); @@ -139,7 +183,7 @@ function Dashboard({ : modelOptions; const refresh = useCallback( - async (nextIndex = selectedIndex) => { + async (nextFocus?: Focus) => { setLoading(true); setError(undefined); try { @@ -149,15 +193,67 @@ function Dashboard({ ralphStore.read(), ]); setCurrentModel(storeState.model); - const safeIndex = clampIndex(nextIndex, instanceList.instances.length); - const selected = instanceList.instances[safeIndex]; - const jobs = await daemon.listJobs( - selected ? { instanceId: selected.id } : {}, + const instances = instanceList.instances; + + // Fetch sessions for every currently-expanded instance that still exists. + const expandedIds = [...expanded].filter((id) => + instances.some((inst) => inst.id === id), ); - setSelectedIndex(safeIndex); + const sessionEntries = await Promise.all( + expandedIds.map(async (id) => { + try { + return [id, await listSessions(id)] as const; + } catch { + return [id, [] as SessionSummary[]] as const; + } + }), + ); + const nextSessions: Record = {}; + for (const [id, list] of sessionEntries) { + nextSessions[id] = list; + } + + // Resolve next focus against the fresh row list. + const candidate = nextFocus ?? focused; + const rows = flattenRows( + instances, + new Set(expandedIds), + nextSessions, + ); + let resolvedFocus: Focus | undefined; + if (candidate) { + const idx = findFocusIndex(rows, candidate); + if (idx >= 0) { + resolvedFocus = candidate; + } else if ( + candidate.kind === "session" && + instances.some((inst) => inst.id === candidate.instanceId) + ) { + // Session disappeared — fall back to parent instance. + resolvedFocus = { + kind: "instance", + instanceId: candidate.instanceId, + }; + } + } + if (!resolvedFocus) { + const firstRow = rows[0]; + if (firstRow) { + resolvedFocus = focusFromRow(firstRow); + } + } + + const focusedInstanceId = resolvedFocus?.instanceId; + const jobs = focusedInstanceId + ? await daemon.listJobs({ instanceId: focusedInstanceId }) + : { jobs: [] as DaemonJob[] }; + + setFocused(resolvedFocus); + setExpanded(new Set(expandedIds)); + setSessionsByInstance(nextSessions); setData({ health, - instances: instanceList.instances, + instances, jobs: jobs.jobs, }); } catch (refreshError) { @@ -170,12 +266,64 @@ function Dashboard({ setLoading(false); } }, - [selectedIndex], + [expanded, focused], ); useEffect(() => { void refresh(); - }, [refresh]); + // Initial load only; subsequent refreshes are user-driven. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const rows: Row[] = data + ? flattenRows(data.instances, expanded, sessionsByInstance) + : []; + const focusIndex = findFocusIndex(rows, focused); + + const toggleExpand = useCallback( + async (instanceId: string, expand: boolean) => { + setExpanded((prev) => { + const next = new Set(prev); + if (expand) next.add(instanceId); + else next.delete(instanceId); + return next; + }); + if (expand && !sessionsByInstance[instanceId]) { + try { + const sessions = await listSessions(instanceId); + setSessionsByInstance((prev) => ({ + ...prev, + [instanceId]: sessions, + })); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to list sessions", + ); + } + } + }, + [sessionsByInstance], + ); + + const moveFocus = useCallback( + (delta: number) => { + if (rows.length === 0) return; + const current = focusIndex < 0 ? 0 : focusIndex; + const nextIdx = Math.min(Math.max(current + delta, 0), rows.length - 1); + if (nextIdx === current) return; + const nextRow = rows[nextIdx]; + if (!nextRow) return; + const nextFocus = focusFromRow(nextRow); + // Only refetch jobs when the focused instance changes. + const prevInstanceId = focused?.instanceId; + if (prevInstanceId !== nextFocus.instanceId) { + void refresh(nextFocus); + } else { + setFocused(nextFocus); + } + }, + [focused, focusIndex, refresh, rows], + ); useKeyboard((key) => { if (modelPicker) { @@ -228,32 +376,67 @@ function Dashboard({ return; } - if (!data) { + if (!data || !focused) { return; } if (key.name === "down" || key.name === "j") { - const next = clampIndex(selectedIndex + 1, data.instances.length); - void refresh(next); + moveFocus(1); return; } if (key.name === "up" || key.name === "k") { - const next = clampIndex(selectedIndex - 1, data.instances.length); - void refresh(next); + moveFocus(-1); + return; + } + + if (key.name === "right" || key.name === "l") { + if (focused.kind === "instance" && !expanded.has(focused.instanceId)) { + void toggleExpand(focused.instanceId, true); + } + return; + } + + if (key.name === "left" || key.name === "h") { + if (focused.kind === "session") { + // First press: move focus up to parent instance. + setFocused({ kind: "instance", instanceId: focused.instanceId }); + return; + } + if (focused.kind === "instance" && expanded.has(focused.instanceId)) { + void toggleExpand(focused.instanceId, false); + } return; } if (key.name === "return") { - const selected = data.instances[selectedIndex]; - if (selected) { - onSelectInstance(selected); + const focusedInstance = data.instances.find( + (inst) => inst.id === focused.instanceId, + ); + if (!focusedInstance) return; + if (focused.kind === "session") { + onSelectInstance(focusedInstance, focused.sessionId); + } else { + onSelectInstance(focusedInstance); } return; } }); - const selected = data?.instances[selectedIndex]; + const focusedInstance = focused + ? data?.instances.find((inst) => inst.id === focused.instanceId) + : undefined; + const focusedSession = + focused?.kind === "session" && focused + ? sessionsByInstance[focused.instanceId]?.find( + (s) => s.sessionId === focused.sessionId, + ) + : undefined; + + const jobsToDisplay: DaemonJob[] = + data && focused && focusedSession + ? filterJobsForSession(data.jobs, focusedSession) + : (data?.jobs ?? []); if (modelPicker) { return ( @@ -326,33 +509,69 @@ function Dashboard({ Instances - {data?.instances.length ? ( - data.instances.map((instance: ManagedInstance, index: number) => { - const focused = index === selectedIndex; - const counts = countJobsByState(data.jobs, instance.id); + {rows.length === 0 ? ( + No instances registered + ) : ( + rows.map((row) => { + const isFocused = + focused !== undefined && + ((focused.kind === "instance" && + row.kind === "instance" && + row.instance.id === focused.instanceId) || + (focused.kind === "session" && + row.kind === "session" && + row.instance.id === focused.instanceId && + row.session.sessionId === focused.sessionId)); + const attrs = isFocused + ? TextAttributes.BOLD + : TextAttributes.DIM; + const chevron = isFocused ? ">" : " "; + + if (row.kind === "instance") { + const counts = countJobsByState( + data?.jobs ?? [], + row.instance.id, + ); + const isExpanded = expanded.has(row.instance.id); + const hasKnownSessions = + sessionsByInstance[row.instance.id] !== undefined; + const marker = isExpanded + ? "▾" + : hasKnownSessions + ? "▸" + : "▸"; + return ( + + {`${chevron} ${marker} ${row.instance.name} [${row.instance.status}] ${basename(row.instance.directory)} (${counts.running}r/${counts.queued}q)`} + + ); + } + + const { total, completed } = row.session.progress; return ( - - {`${focused ? ">" : " "} ${instance.name} [${instance.status}] ${basename(instance.directory)} (${counts.running}r/${counts.queued}q)`} + + {` ${chevron} ${row.session.sessionId} ${row.session.title} (${completed}/${total} tasks)`} ); }) - ) : ( - No instances registered )} - {selected ? `Jobs for ${selected.name}` : "Jobs"} + {focusedInstance + ? focusedSession + ? `Jobs for ${focusedInstance.name} / ${focusedSession.sessionId}` + : `Jobs for ${focusedInstance.name}` + : "Jobs"} - {selected ? ( - data?.jobs.length ? ( - data.jobs.map((job: DaemonJob) => ( + {focusedInstance ? ( + focusedSession ? ( + + No jobs recorded for this session yet. + + ) : jobsToDisplay.length ? ( + jobsToDisplay.map((job: DaemonJob) => ( {`${job.id.slice(0, 8)} ${job.state} ${job.task.type === "prompt" ? job.task.prompt : ""}`} @@ -373,7 +592,7 @@ function Dashboard({ {error ?? - "j/k or arrows: select enter: chat m: model r: refresh q: quit"} + "l/h expand/collapse j/k move enter: open m: model r: refresh q: quit"} @@ -388,6 +607,7 @@ export function App({ onQuit }: AppProps) { setView({ type: "dashboard" })} onQuit={onQuit} /> @@ -397,11 +617,12 @@ export function App({ onQuit }: AppProps) { return ( + onSelectInstance={(instance, ralphSessionId) => setView({ type: "chat", instanceId: instance.id, instanceName: instance.name, + ralphSessionId, }) } /> diff --git a/apps/tui/src/components/chat.tsx b/apps/tui/src/components/chat.tsx index 7b7b04a..3b4da0b 100644 --- a/apps/tui/src/components/chat.tsx +++ b/apps/tui/src/components/chat.tsx @@ -44,11 +44,24 @@ function messagesFromJob(job: DaemonJob): ChatMessage[] { interface ChatProps { instanceId: string; instanceName: string; + /** + * Ralph session id (directory under RALPH_HOME/sessions//) + * when chat is opened from an expanded session row. Currently plumbed + * through but not yet consumed — will be used once a Ralph-session ↔ + * OpenCode-session mapping is persisted. + */ + ralphSessionId?: string; onBack(): void; onQuit(): void; } -export function Chat({ instanceId, instanceName, onBack, onQuit }: ChatProps) { +export function Chat({ + instanceId, + instanceName, + ralphSessionId: _ralphSessionId, + onBack, + onQuit, +}: ChatProps) { const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(""); const [isLoading, setIsLoading] = useState(false); diff --git a/apps/tui/src/lib/sessions.test.ts b/apps/tui/src/lib/sessions.test.ts new file mode 100644 index 0000000..2ae20b2 --- /dev/null +++ b/apps/tui/src/lib/sessions.test.ts @@ -0,0 +1,228 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { DaemonJob, ManagedInstance } from "@techatnyu/ralphd"; +import { + type SessionSummary, + filterJobsForSession, + flattenRows, + listSessions, + prdJsonProgressAdapter, +} from "./sessions"; + +async function makeSessionDir( + ralphHome: string, + instanceId: string, + sessionId: string, + files: Record = {}, +): Promise { + const dir = join(ralphHome, "sessions", instanceId, sessionId); + await mkdir(dir, { recursive: true }); + await Promise.all( + Object.entries(files).map(([name, content]) => + writeFile(join(dir, name), content, "utf8"), + ), + ); + return dir; +} + +function fakeInstance(id: string, name = id): ManagedInstance { + return { + id, + name, + directory: `/tmp/${id}`, + status: "stopped", + maxConcurrency: 1, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +describe("listSessions", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + it("returns [] when the sessions directory does not exist", async () => { + const tempHome = await mkdtemp(join(tmpdir(), "ralph-sessions-")); + tempDirs.push(tempHome); + + const result = await listSessions("missing-instance", { + ralphHome: tempHome, + }); + expect(result).toEqual([]); + }); + + it("returns [] for an instance with no session subdirectories", async () => { + const tempHome = await mkdtemp(join(tmpdir(), "ralph-sessions-")); + tempDirs.push(tempHome); + await mkdir(join(tempHome, "sessions", "inst-1"), { recursive: true }); + + const result = await listSessions("inst-1", { ralphHome: tempHome }); + expect(result).toEqual([]); + }); + + it("returns sorted summaries and parses SPEC.md title", async () => { + const tempHome = await mkdtemp(join(tmpdir(), "ralph-sessions-")); + tempDirs.push(tempHome); + + await makeSessionDir(tempHome, "inst-1", "session-b", { + "SPEC.md": "# Build the thing\n\nmore content", + "prd.json": JSON.stringify({ + tasks: [{ done: true }, { done: false }], + }), + }); + await makeSessionDir(tempHome, "inst-1", "session-a", { + // no SPEC.md — title should fall back to sessionId + }); + + const result = await listSessions("inst-1", { ralphHome: tempHome }); + + expect(result).toHaveLength(2); + const [first, second] = result; + if (!first || !second) throw new Error("expected two sessions"); + expect(first.sessionId).toBe("session-a"); + expect(first.title).toBe("session-a"); + expect(first.progress).toEqual({ total: 0, completed: 0 }); + + expect(second.sessionId).toBe("session-b"); + expect(second.title).toBe("Build the thing"); + expect(second.progress).toEqual({ total: 2, completed: 1 }); + }); + + it("falls back to sessionId when SPEC.md has no heading", async () => { + const tempHome = await mkdtemp(join(tmpdir(), "ralph-sessions-")); + tempDirs.push(tempHome); + + await makeSessionDir(tempHome, "inst-1", "only", { + "SPEC.md": "just body text, no heading", + }); + + const result = await listSessions("inst-1", { ralphHome: tempHome }); + expect(result[0]?.title).toBe("only"); + }); +}); + +describe("prdJsonProgressAdapter", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + it("counts tasks with done=true and status='done'", async () => { + const dir = await mkdtemp(join(tmpdir(), "ralph-prd-")); + tempDirs.push(dir); + await writeFile( + join(dir, "prd.json"), + JSON.stringify({ + tasks: [ + { done: true }, + { status: "done" }, + { done: false }, + { status: "pending" }, + {}, + ], + }), + "utf8", + ); + + const progress = await prdJsonProgressAdapter.read(dir); + expect(progress).toEqual({ total: 5, completed: 2 }); + }); + + it("returns {0,0} for missing prd.json", async () => { + const dir = await mkdtemp(join(tmpdir(), "ralph-prd-")); + tempDirs.push(dir); + + const progress = await prdJsonProgressAdapter.read(dir); + expect(progress).toEqual({ total: 0, completed: 0 }); + }); + + it("returns {0,0} for malformed prd.json", async () => { + const dir = await mkdtemp(join(tmpdir(), "ralph-prd-")); + tempDirs.push(dir); + await writeFile(join(dir, "prd.json"), "{not json", "utf8"); + + const progress = await prdJsonProgressAdapter.read(dir); + expect(progress).toEqual({ total: 0, completed: 0 }); + }); + + it("handles empty tasks array", async () => { + const dir = await mkdtemp(join(tmpdir(), "ralph-prd-")); + tempDirs.push(dir); + await writeFile(join(dir, "prd.json"), JSON.stringify({ tasks: [] }), "utf8"); + + const progress = await prdJsonProgressAdapter.read(dir); + expect(progress).toEqual({ total: 0, completed: 0 }); + }); +}); + +describe("flattenRows", () => { + const summary = ( + instanceId: string, + sessionId: string, + ): SessionSummary => ({ + instanceId, + sessionId, + directory: `/tmp/${instanceId}/${sessionId}`, + title: sessionId, + progress: { total: 0, completed: 0 }, + }); + + it("returns only instances when nothing is expanded", () => { + const instances = [fakeInstance("a"), fakeInstance("b")]; + const rows = flattenRows(instances, new Set(), {}); + expect(rows).toHaveLength(2); + expect(rows.every((r) => r.kind === "instance")).toBe(true); + }); + + it("interleaves sessions under expanded instances", () => { + const instances = [fakeInstance("a"), fakeInstance("b")]; + const rows = flattenRows(instances, new Set(["a"]), { + a: [summary("a", "s1"), summary("a", "s2")], + }); + + expect(rows).toHaveLength(4); + expect(rows[0]).toMatchObject({ kind: "instance" }); + expect(rows[1]).toMatchObject({ kind: "session" }); + expect(rows[2]).toMatchObject({ kind: "session" }); + expect(rows[3]).toMatchObject({ kind: "instance" }); + const secondRow = rows[1]; + if (secondRow?.kind === "session") { + expect(secondRow.session.sessionId).toBe("s1"); + } + }); + + it("shows only the instance row when expanded but cache is empty", () => { + const instances = [fakeInstance("a")]; + const rows = flattenRows(instances, new Set(["a"]), {}); + expect(rows).toHaveLength(1); + expect(rows[0]?.kind).toBe("instance"); + }); +}); + +describe("filterJobsForSession", () => { + it("returns [] pending Ralph-session ↔ OpenCode-session mapping", () => { + const job = { id: "j", instanceId: "a" } as unknown as DaemonJob; + const s: SessionSummary = { + instanceId: "a", + sessionId: "s", + directory: "/tmp/a/s", + title: "s", + progress: { total: 0, completed: 0 }, + }; + expect(filterJobsForSession([job], s)).toEqual([]); + }); +}); diff --git a/apps/tui/src/lib/sessions.ts b/apps/tui/src/lib/sessions.ts new file mode 100644 index 0000000..dc6e2a4 --- /dev/null +++ b/apps/tui/src/lib/sessions.ts @@ -0,0 +1,157 @@ +import type { Dirent } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { resolveDaemonPaths } from "@techatnyu/ralphd"; +import type { DaemonJob, ManagedInstance } from "@techatnyu/ralphd"; + +const SPEC_FILENAME = "SPEC.md"; +const PRD_FILENAME = "prd.json"; +const SPEC_READ_LIMIT_BYTES = 4096; + +export interface SessionProgress { + total: number; + completed: number; +} + +export interface SessionSummary { + instanceId: string; + sessionId: string; + directory: string; + title: string; + progress: SessionProgress; +} + +export interface SessionProgressAdapter { + read(sessionDir: string): Promise; +} + +interface PrdTask { + done?: unknown; + status?: unknown; +} + +interface PrdShape { + tasks?: PrdTask[]; +} + +function isDoneTask(task: PrdTask): boolean { + return task.done === true || task.status === "done"; +} + +export const prdJsonProgressAdapter: SessionProgressAdapter = { + async read(sessionDir: string): Promise { + try { + const raw = await readFile(join(sessionDir, PRD_FILENAME), "utf8"); + const parsed = JSON.parse(raw) as PrdShape; + if (!parsed || !Array.isArray(parsed.tasks)) { + return { total: 0, completed: 0 }; + } + const total = parsed.tasks.length; + let completed = 0; + for (const task of parsed.tasks) { + if (task && typeof task === "object" && isDoneTask(task)) { + completed++; + } + } + return { total, completed }; + } catch { + return { total: 0, completed: 0 }; + } + }, +}; + +async function readSpecTitle( + sessionDir: string, + fallback: string, +): Promise { + try { + const handle = await readFile(join(sessionDir, SPEC_FILENAME), "utf8"); + const head = handle.slice(0, SPEC_READ_LIMIT_BYTES); + const match = head.match(/^#\s+(.+)$/m); + const captured = match?.[1]; + if (captured) { + const title = captured.trim(); + if (title.length > 0) { + return title; + } + } + return fallback; + } catch { + return fallback; + } +} + +export interface ListSessionsOptions { + ralphHome?: string; + progressAdapter?: SessionProgressAdapter; +} + +export async function listSessions( + instanceId: string, + opts: ListSessionsOptions = {}, +): Promise { + const ralphHome = + opts.ralphHome ?? resolveDaemonPaths(process.env).ralphHome; + const adapter = opts.progressAdapter ?? prdJsonProgressAdapter; + const instanceDir = join(ralphHome, "sessions", instanceId); + + let entries: Dirent[]; + try { + entries = (await readdir(instanceDir, { withFileTypes: true })) as Dirent[]; + } catch { + return []; + } + + const sessionIds = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b)); + + return Promise.all( + sessionIds.map(async (sessionId) => { + const directory = join(instanceDir, sessionId); + const [title, progress] = await Promise.all([ + readSpecTitle(directory, sessionId), + adapter.read(directory), + ]); + return { instanceId, sessionId, directory, title, progress }; + }), + ); +} + +export type Row = + | { kind: "instance"; instance: ManagedInstance } + | { kind: "session"; instance: ManagedInstance; session: SessionSummary }; + +export function flattenRows( + instances: ManagedInstance[], + expanded: Set, + sessionsByInstance: Record, +): Row[] { + const rows: Row[] = []; + for (const instance of instances) { + rows.push({ kind: "instance", instance }); + if (!expanded.has(instance.id)) continue; + const sessions = sessionsByInstance[instance.id]; + if (!sessions) continue; + for (const session of sessions) { + rows.push({ kind: "session", instance, session }); + } + } + return rows; +} + +/** + * Filter daemon jobs to those belonging to the given Ralph session. + * + * Current behaviour: returns `[]`. Daemon jobs carry an OpenCode chat session id + * (`job.sessionId`), not a Ralph session id, and no mapping is persisted yet. + * When that mapping lands (either a file inside the session directory or a + * `ralphSessionId` field on jobs), update this function in-place. + */ +export function filterJobsForSession( + _jobs: DaemonJob[], + _session: SessionSummary, +): DaemonJob[] { + return []; +} From 57fd155074ea23170dadb5af0c024c0737c3243f Mon Sep 17 00:00:00 2001 From: PlasmaVolt <61515425+PlasmaVolt@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:51:57 -0400 Subject: [PATCH 2/4] feat: toggle sessions on [Space] --- apps/tui/src/components/app.tsx | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index c503a56..0b874e2 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -390,22 +390,13 @@ function Dashboard({ return; } - if (key.name === "right" || key.name === "l") { - if (focused.kind === "instance" && !expanded.has(focused.instanceId)) { - void toggleExpand(focused.instanceId, true); - } - return; - } - - if (key.name === "left" || key.name === "h") { + if (key.name === "space") { if (focused.kind === "session") { - // First press: move focus up to parent instance. setFocused({ kind: "instance", instanceId: focused.instanceId }); - return; - } - if (focused.kind === "instance" && expanded.has(focused.instanceId)) { void toggleExpand(focused.instanceId, false); + return; } + void toggleExpand(focused.instanceId, !expanded.has(focused.instanceId)); return; } @@ -592,7 +583,7 @@ function Dashboard({ {error ?? - "l/h expand/collapse j/k move enter: open m: model r: refresh q: quit"} + "space: expand/collapse j/k: move enter: open m: model r: refresh q: quit"} From 75bbdd0e7dce7743a4d71bef526e78a34006a83f Mon Sep 17 00:00:00 2001 From: PlasmaVolt <61515425+PlasmaVolt@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:54:28 -0400 Subject: [PATCH 3/4] chore: remove sessions.test.ts --- apps/tui/src/lib/sessions.test.ts | 228 ------------------------------ 1 file changed, 228 deletions(-) delete mode 100644 apps/tui/src/lib/sessions.test.ts diff --git a/apps/tui/src/lib/sessions.test.ts b/apps/tui/src/lib/sessions.test.ts deleted file mode 100644 index 2ae20b2..0000000 --- a/apps/tui/src/lib/sessions.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { afterEach, describe, expect, it } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { DaemonJob, ManagedInstance } from "@techatnyu/ralphd"; -import { - type SessionSummary, - filterJobsForSession, - flattenRows, - listSessions, - prdJsonProgressAdapter, -} from "./sessions"; - -async function makeSessionDir( - ralphHome: string, - instanceId: string, - sessionId: string, - files: Record = {}, -): Promise { - const dir = join(ralphHome, "sessions", instanceId, sessionId); - await mkdir(dir, { recursive: true }); - await Promise.all( - Object.entries(files).map(([name, content]) => - writeFile(join(dir, name), content, "utf8"), - ), - ); - return dir; -} - -function fakeInstance(id: string, name = id): ManagedInstance { - return { - id, - name, - directory: `/tmp/${id}`, - status: "stopped", - maxConcurrency: 1, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }; -} - -describe("listSessions", () => { - const tempDirs: string[] = []; - - afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((dir) => rm(dir, { recursive: true, force: true })), - ); - }); - - it("returns [] when the sessions directory does not exist", async () => { - const tempHome = await mkdtemp(join(tmpdir(), "ralph-sessions-")); - tempDirs.push(tempHome); - - const result = await listSessions("missing-instance", { - ralphHome: tempHome, - }); - expect(result).toEqual([]); - }); - - it("returns [] for an instance with no session subdirectories", async () => { - const tempHome = await mkdtemp(join(tmpdir(), "ralph-sessions-")); - tempDirs.push(tempHome); - await mkdir(join(tempHome, "sessions", "inst-1"), { recursive: true }); - - const result = await listSessions("inst-1", { ralphHome: tempHome }); - expect(result).toEqual([]); - }); - - it("returns sorted summaries and parses SPEC.md title", async () => { - const tempHome = await mkdtemp(join(tmpdir(), "ralph-sessions-")); - tempDirs.push(tempHome); - - await makeSessionDir(tempHome, "inst-1", "session-b", { - "SPEC.md": "# Build the thing\n\nmore content", - "prd.json": JSON.stringify({ - tasks: [{ done: true }, { done: false }], - }), - }); - await makeSessionDir(tempHome, "inst-1", "session-a", { - // no SPEC.md — title should fall back to sessionId - }); - - const result = await listSessions("inst-1", { ralphHome: tempHome }); - - expect(result).toHaveLength(2); - const [first, second] = result; - if (!first || !second) throw new Error("expected two sessions"); - expect(first.sessionId).toBe("session-a"); - expect(first.title).toBe("session-a"); - expect(first.progress).toEqual({ total: 0, completed: 0 }); - - expect(second.sessionId).toBe("session-b"); - expect(second.title).toBe("Build the thing"); - expect(second.progress).toEqual({ total: 2, completed: 1 }); - }); - - it("falls back to sessionId when SPEC.md has no heading", async () => { - const tempHome = await mkdtemp(join(tmpdir(), "ralph-sessions-")); - tempDirs.push(tempHome); - - await makeSessionDir(tempHome, "inst-1", "only", { - "SPEC.md": "just body text, no heading", - }); - - const result = await listSessions("inst-1", { ralphHome: tempHome }); - expect(result[0]?.title).toBe("only"); - }); -}); - -describe("prdJsonProgressAdapter", () => { - const tempDirs: string[] = []; - - afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((dir) => rm(dir, { recursive: true, force: true })), - ); - }); - - it("counts tasks with done=true and status='done'", async () => { - const dir = await mkdtemp(join(tmpdir(), "ralph-prd-")); - tempDirs.push(dir); - await writeFile( - join(dir, "prd.json"), - JSON.stringify({ - tasks: [ - { done: true }, - { status: "done" }, - { done: false }, - { status: "pending" }, - {}, - ], - }), - "utf8", - ); - - const progress = await prdJsonProgressAdapter.read(dir); - expect(progress).toEqual({ total: 5, completed: 2 }); - }); - - it("returns {0,0} for missing prd.json", async () => { - const dir = await mkdtemp(join(tmpdir(), "ralph-prd-")); - tempDirs.push(dir); - - const progress = await prdJsonProgressAdapter.read(dir); - expect(progress).toEqual({ total: 0, completed: 0 }); - }); - - it("returns {0,0} for malformed prd.json", async () => { - const dir = await mkdtemp(join(tmpdir(), "ralph-prd-")); - tempDirs.push(dir); - await writeFile(join(dir, "prd.json"), "{not json", "utf8"); - - const progress = await prdJsonProgressAdapter.read(dir); - expect(progress).toEqual({ total: 0, completed: 0 }); - }); - - it("handles empty tasks array", async () => { - const dir = await mkdtemp(join(tmpdir(), "ralph-prd-")); - tempDirs.push(dir); - await writeFile(join(dir, "prd.json"), JSON.stringify({ tasks: [] }), "utf8"); - - const progress = await prdJsonProgressAdapter.read(dir); - expect(progress).toEqual({ total: 0, completed: 0 }); - }); -}); - -describe("flattenRows", () => { - const summary = ( - instanceId: string, - sessionId: string, - ): SessionSummary => ({ - instanceId, - sessionId, - directory: `/tmp/${instanceId}/${sessionId}`, - title: sessionId, - progress: { total: 0, completed: 0 }, - }); - - it("returns only instances when nothing is expanded", () => { - const instances = [fakeInstance("a"), fakeInstance("b")]; - const rows = flattenRows(instances, new Set(), {}); - expect(rows).toHaveLength(2); - expect(rows.every((r) => r.kind === "instance")).toBe(true); - }); - - it("interleaves sessions under expanded instances", () => { - const instances = [fakeInstance("a"), fakeInstance("b")]; - const rows = flattenRows(instances, new Set(["a"]), { - a: [summary("a", "s1"), summary("a", "s2")], - }); - - expect(rows).toHaveLength(4); - expect(rows[0]).toMatchObject({ kind: "instance" }); - expect(rows[1]).toMatchObject({ kind: "session" }); - expect(rows[2]).toMatchObject({ kind: "session" }); - expect(rows[3]).toMatchObject({ kind: "instance" }); - const secondRow = rows[1]; - if (secondRow?.kind === "session") { - expect(secondRow.session.sessionId).toBe("s1"); - } - }); - - it("shows only the instance row when expanded but cache is empty", () => { - const instances = [fakeInstance("a")]; - const rows = flattenRows(instances, new Set(["a"]), {}); - expect(rows).toHaveLength(1); - expect(rows[0]?.kind).toBe("instance"); - }); -}); - -describe("filterJobsForSession", () => { - it("returns [] pending Ralph-session ↔ OpenCode-session mapping", () => { - const job = { id: "j", instanceId: "a" } as unknown as DaemonJob; - const s: SessionSummary = { - instanceId: "a", - sessionId: "s", - directory: "/tmp/a/s", - title: "s", - progress: { total: 0, completed: 0 }, - }; - expect(filterJobsForSession([job], s)).toEqual([]); - }); -}); From 6de52df906eec6c9bad0ab2703eb6237d2d28458 Mon Sep 17 00:00:00 2001 From: PlasmaVolt <61515425+PlasmaVolt@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:33:12 -0400 Subject: [PATCH 4/4] chore: implement biome fixes --- apps/tui/src/components/app.tsx | 15 +++------------ apps/tui/src/lib/sessions.ts | 5 ++--- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index 0b874e2..8afe05e 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -215,11 +215,7 @@ function Dashboard({ // Resolve next focus against the fresh row list. const candidate = nextFocus ?? focused; - const rows = flattenRows( - instances, - new Set(expandedIds), - nextSessions, - ); + const rows = flattenRows(instances, new Set(expandedIds), nextSessions); let resolvedFocus: Focus | undefined; if (candidate) { const idx = findFocusIndex(rows, candidate); @@ -269,10 +265,9 @@ function Dashboard({ [expanded, focused], ); + // biome-ignore lint/correctness/useExhaustiveDependencies: initial load only; subsequent refreshes are user-driven useEffect(() => { void refresh(); - // Initial load only; subsequent refreshes are user-driven. - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const rows: Row[] = data @@ -526,11 +521,7 @@ function Dashboard({ const isExpanded = expanded.has(row.instance.id); const hasKnownSessions = sessionsByInstance[row.instance.id] !== undefined; - const marker = isExpanded - ? "▾" - : hasKnownSessions - ? "▸" - : "▸"; + const marker = isExpanded ? "▾" : hasKnownSessions ? "▸" : "▸"; return ( {`${chevron} ${marker} ${row.instance.name} [${row.instance.status}] ${basename(row.instance.directory)} (${counts.running}r/${counts.queued}q)`} diff --git a/apps/tui/src/lib/sessions.ts b/apps/tui/src/lib/sessions.ts index dc6e2a4..dc51254 100644 --- a/apps/tui/src/lib/sessions.ts +++ b/apps/tui/src/lib/sessions.ts @@ -1,8 +1,8 @@ import type { Dirent } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; -import { resolveDaemonPaths } from "@techatnyu/ralphd"; import type { DaemonJob, ManagedInstance } from "@techatnyu/ralphd"; +import { resolveDaemonPaths } from "@techatnyu/ralphd"; const SPEC_FILENAME = "SPEC.md"; const PRD_FILENAME = "prd.json"; @@ -90,8 +90,7 @@ export async function listSessions( instanceId: string, opts: ListSessionsOptions = {}, ): Promise { - const ralphHome = - opts.ralphHome ?? resolveDaemonPaths(process.env).ralphHome; + const ralphHome = opts.ralphHome ?? resolveDaemonPaths(process.env).ralphHome; const adapter = opts.progressAdapter ?? prdJsonProgressAdapter; const instanceDir = join(ralphHome, "sessions", instanceId);