Hand work to any agent CLI on your machine and see and control it from one pane.

Podium is a mod for Claude Code. It lets Claude hand work to the other coding agents on your machine (Codex, Muse, Gemini, other Claude models, or any agent with a command line) and shows all of it in one pane inside Claude Code.
I built it because I was using several agents at once and losing track of them. I'd have one reviewing a branch in one terminal and another writing tests in a second, and I was copying results back into Claude by hand. With Podium I ask Claude in plain words, the agents run in the background, and the results come back to the chat when they finish.

The middle is sped up 8×.
You: have Codex review this branch, read only, and have Claude Sonnet add tests for the payout code
Claude starts two jobs. The pane lists them under Working, with a line saying what each agent is doing right now. You can keep talking to Claude while they run, and jobs keep going if you close the app.
When a job finishes, a short note lands in the chat and Claude sums up the result. A job that changed files moves to Your turn, where you open it, read the diff, and press Keep changes or Throw away.
You need Claude Code (desktop app or terminal), Node.js 22 or newer, git 2.28 or newer, and at least one other agent CLI, such as codex, muse, gemini or claude.
/plugin marketplace add madjagstudios/plugins./plugin install podium@madjagstudios./podium. A welcome card checks your setup and offers a small test job.Updates arrive with Claude Code's plugin updates. To remove Podium, use Settings → Remove Podium's data…, then run /plugin uninstall podium.
Ready work becomes a PR with Open a PR instead. Podium watches its checks, can merge it when they pass, and lets any agent review the PR or fix what failed.
For bigger work, ask Claude to plan a sprint across several tickets. Claude proposes which agent writes and which reviews each task, and the draft opens as a board in the pane, where you can change any of it before pressing Start sprint.
From there:
dotnet build && dotnet test).KEY: title commit and a plain description.Per repo, Settings → This repo sets:
/podium run <agent> <task>./podium status lists jobs, and /podium cancel <id> stops one.Point Claude Code at a checkout by adding "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/absolute/path/to/podium" } to ~/.claude/settings.json, then restart. A dev folder overrides an installed copy.
Tests:
npm test runs the engine and logic tests.claude plugin test . runs the pane tests.claude plugin validate . checks the mod.Podium has no dependencies.
Podium's adapter approach was informed by hex/claude-council, and its job commands by openai/codex-plugin-cc.
Podium is built with AI coding agents, many of its own jobs included, and reviewed by me.
MIT. See LICENSE.
hooks/register.tsx 1027 lines1// Podium's hooks module. The mod engine follows `$` only into functions
2// declared in this file and reads $.state references only where they are
3// declared, so every action and atom lives here; hooks/screens.tsx is pure
4// drawing that receives plain data and closures.
5import { atom, read, update } from 'claude-code'
6import type { Engine, Register } from 'claude-code'
7import { callEngine } from '../mod/engine.js'
8import { handOver, sprintNewsPass, keepFresh, arrivals } from '../mod/delivery.js'
9import { parseCommand } from '../mod/commands.js'
10import { TOOLS, engineCall, formatToolResult } from '../mod/tools.js'
11import { agentLabel, organise, threads, sprintNewsHeadline, newsHandOver, jobHandOver, foldsLeaving, startedNotice, syncedText, commitsAnswerText, notedText } from '../mod/view.js'
12import { askText, isPrEvent, jobLine, shortSummary, summaryPrompt, newlyFinished, paletteName, parseLive, same, statusText } from '../mod/view.js'
13import type { AdapterDraft, AgentRecord, AskForm, BoardState, DoctorReport, Draft, JobDetail, JobOpen, JobRecord, LiveInfo, PaneView, RemovalPlan, SprintSummary, WhatsNew } from '../types'
14import { PANE, NOTE_CHARS, FOLDS_KEPT } from './state'
15import { drawPane, NO_BOARD } from './screens'
16import type { PaneModel } from './screens'
17import { slimJob, slimSprints, keepBusy, keepShown, fitModel, turnTickets } from '../mod/client.js'
18import { jobOpenNext, askOf, withQuestion } from '../mod/jobopen.js'
19
20const TOOL_PREFIX = 'mcp__podium__'
21
22const jobs = atom({ plugin: 'podium', key: 'jobs' } as const, [] as JobRecord[])
23const view = atom({ plugin: 'podium', key: 'view' } as const, { screen: 'list', id: null } as PaneView)
24const notice = atom({ plugin: 'podium', key: 'notice' } as const, null as string | null)
25const detail = atom({ plugin: 'podium', key: 'detail' } as const, null as JobDetail | null)
26const agents = atom({ plugin: 'podium', key: 'agents' } as const, [] as AgentRecord[])
27const settings = atom({ plugin: 'podium', key: 'settings' } as const, null as Record<string, unknown> | null)
28const draft = atom({ plugin: 'podium', key: 'draft' } as const, { agent: 'codex', task: '', permission: 'edit-worktree', includeUncommitted: false, model: '', ticket: '', base: '' } as Draft)
29const draftAdapter = atom({ plugin: 'podium', key: 'draftAdapter' } as const, null as AdapterDraft | null)
30const live = atom({ plugin: 'podium', key: 'live' } as const, {} as Record<string, LiveInfo>)
31const palette = atom({ plugin: 'podium', key: 'palette' } as const, 'dark' as 'dark' | 'light')
32// Bumped every 10 s while jobs run, so elapsed and 'quiet for' text move even when no data changed.
33const beat = atom({ plugin: 'podium', key: 'beat' } as const, 0)
34// What a job's screen has open (see mod/jobopen.js), changed only through jobOpenNext.
35const jobOpen = atom({ plugin: 'podium', key: 'jobOpen' } as const, null as JobOpen)
36const moveOpen = ($: Engine, action: Record<string, unknown>) => update($, jobOpen, (cur) => jobOpenNext(cur, action) as JobOpen)
37// When this chat last pressed Clear (ISO time), and whether Done shows everything.
38const cleared = atom({ plugin: 'podium', key: 'cleared' } as const, null as string | null)
39const showAllDone = atom({ plugin: 'podium', key: 'showAllDone' } as const, false)
40// First-run checks, the update notice and a pending removal.
41const checks = atom({ plugin: 'podium', key: 'checks' } as const, null as DoctorReport | null)
42const whatsNew = atom({ plugin: 'podium', key: 'whatsNew' } as const, null as WhatsNew | null)
43const removing = atom({ plugin: 'podium', key: 'removing' } as const, null as RemovalPlan | null)
44const agentsLoaded = atom({ plugin: 'podium', key: 'agentsLoaded' } as const, false)
45const sprints = atom({ plugin: 'podium', key: 'sprints' } as const, [] as SprintSummary[])
46// Finished sprints `podium status --here` did not list (it sends the newest finished ones).
47const olderFinished = atom({ plugin: 'podium', key: 'olderFinished' } as const, { mine: 0, others: 0 } as { mine: number; others: number })
48// The board's pane state: which sprint, its open row, what asks to be confirmed, and
49// which writer/reviewer picker is open ("<KEY>:<n>" or "<KEY>:all"; one at a time).
50const board = atom({ plugin: 'podium', key: 'board' } as const, { sprint: null, open: null, confirm: null, picking: null } as BoardState)
51// Which page of sprint cards the list shows (the list draws its sprints a page at a time, listPage).
52const sprintPage = atom({ plugin: 'podium', key: 'sprintPage' } as const, 0)
53// Folded sections whose state differs from their default, by id ("job:<id>:changes", "list:done"…).
54const folds = atom({ plugin: 'podium', key: 'folds' } as const, [] as string[])
55
56type Answer = { ok: true; value: any } | { ok: false; error: { code: string; message: string } }
57
58// Module state that may be lost on a hot reload without harm.
59let inflight: Promise<void> | null = null
60// When the pane last made sure the PR watcher runs.
61let lastWatchCheck = 0
62// When the pane last made sure sprint runners run.
63let lastSprintCheck = 0
64// Ids of desktop Client actions that have finished.
65const acked: string[] = []
66// Ids of desktop Client presses received; a press is posted again until it shows here.
67const seen: string[] = []
68// Client presses run in order (an edit lands before the Start it feeds); see ui.message.
69let clientWork: Promise<unknown> = Promise.resolve()
70let anyWorking = false
71let ticks = 0
72const delivering = new Set<string>()
73// Sprint news whose headline went in but whose delivered mark is not confirmed yet.
74const announcedNews = new Set<string>()
75// Sprint news being sent right now: a later pass that claims it again skips it.
76const newsInFlight = new Set<string>()
77// This running copy of the mod, as the holder of the sprint news it claims: only it renews those claims, so a copy
78// loaded again in the same chat never keeps the other's alive, nor the other its own.
79const NEWS_HOLDER = `mod-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
80// A news pass is running: sprintNewsPass runs one at a time, so a pass waiting on a busy turn is never joined by another.
81const newsBusy = { on: false }
82// Hand-overs waiting for their prompt to enter: a turn that starts holding one's headline lets it count as in (the
83// host settles only the last of several prompts it starts as one turn). Fed by the turn.start hook.
84const entering = arrivals()
85// The delivery lock goes stale after a minute; its holder refreshes it this often while its submit waits.
86const LOCK_REFRESH_MS = 20_000
87// Set inside agent runs Podium itself started (PODIUM_JOB_ID): the mod then does nothing.
88let inert = false
89// This session's id: dispatches record it so this session gets their messages first.
90let sessionId: string | null = null
91
92// Everything the pane draws, as plain data: the terminal draws it, the desktop Client receives it.
93async function paneModel($: Engine): Promise<PaneModel> {
94 const open = await read($, board)
95 const asking = await read($, jobOpen)
96 return {
97 view: await read($, view), notice: await read($, notice),
98 jobs: (await read($, jobs)).map((j) => slimJob(j) as JobRecord), live: await read($, live), detail: withQuestion(await read($, detail), asking) as JobDetail | null,
99 agents: await read($, agents), agentsLoaded: await read($, agentsLoaded), settings: await read($, settings),
100 draft: await read($, draft), draftAdapter: await read($, draftAdapter), palette: await read($, palette),
101 askFor: askOf(asking) as AskForm, cleared: await read($, cleared),
102 showAllDone: await read($, showAllDone), checks: await read($, checks), whatsNew: await read($, whatsNew),
103 removing: await read($, removing), sprints: slimSprints(await read($, sprints), open) as SprintSummary[], session: sessionId, acked: acked.slice(-50), seen: seen.slice(-100),
104 board: open, sprintPage: await read($, sprintPage), olderFinished: await read($, olderFinished),
105 folds: await read($, folds), turn: turnTickets(await read($, sprints)),
106 }
107}
108
109async function chatId($: Engine): Promise<string | null> {
110 sessionId ??= await $.session.id().catch(() => null)
111 return sessionId
112}
113
114async function sessionArgs($: Engine): Promise<string[]> {
115 const id = await chatId($)
116 return id ? ['--session', id] : []
117}
118
119// The list as this chat sees it. It uses the id openPane and refresh
120// read beforehand: asked for from inside drawing, it came back empty in the kit.
121async function organised($: Engine, list: JobRecord[]) {
122 return organise(threads(list), { session: sessionId, now: Date.now(), clearedAt: await read($, cleared), showAll: await read($, showAllDone) })
123}
124
125// Background work must never leave an unhandled rejection (a hot reload or a
126// closed session can pull state out from under it).
127function quietly(work: Promise<unknown>) {
128 work.catch(() => {})
129}
130
131function engine($: Engine, args: string[], stdin?: string): Promise<Answer> {
132 return callEngine((argv: readonly string[], init: object) => $.process.run(argv, init), $.plugin.root, args, { stdin })
133}
134
135async function say($: Engine, message: string | null) {
136 await update($, notice, () => message)
137}
138
139// The sprint the open screen shows: a board's, a ticket's, or a sprint step's (its Back leads to the ticket).
140async function shownSprintId($: Engine): Promise<string | null> {
141 const shown = await read($, view)
142 if ((shown.screen === 'sprint' || shown.screen === 'ticket') && shown.id) return String(shown.id)
143 const step = shown.screen === 'detail' && shown.id ? (await read($, jobs)).find((j) => j.id === shown.id) : null
144 return step?.sprint?.id ?? null
145}
146
147// Reads the job list. Finished jobs are only noted here: delivery submits a
148// prompt, which the host refuses from inside a command or tool call (it would
149// wait on the turn that call holds), so it runs from the timer and turn.complete.
150function refresh($: Engine): Promise<void> {
151 inflight ??= (async () => {
152 try {
153 // This chat's id, read here (never while drawing), so the engine picks this chat's finished sprints apart from others';
154 // and the sprint the open board or ticket shows, so it stays listed when it archives behind newer finished ones.
155 // A sprint step's own screen counts too: its Back leads to the ticket, which must still be listed.
156 const shownSprint = await shownSprintId($)
157 const open = shownSprint ? ['--sprint', String(shownSprint)] : []
158 const r = await engine($, ['status', '--here', ...(await sessionArgs($)), ...open])
159 if (!r.ok) { await say($, r.error.message); return }
160 const list: JobRecord[] = r.value.jobs
161 anyWorking = list.some((j) => j.status === 'queued' || j.status === 'running')
162 if (list.some((j) => j.status === 'in-review') && Date.now() - lastWatchCheck > 60_000) {
163 lastWatchCheck = Date.now()
164 quietly(engine($, ['pr', 'watch']))
165 }
166 // Sprints: drawn from the same call; keep their runners alive.
167 // A sprint the engine could not read just now keeps its last view (a busy marker never reaches the atom).
168 const lastSprints = await read($, sprints)
169 // The screen may have changed while the call ran: the sprint it shows now keeps its last view if the answer
170 // left it out, so its Back never lands on "no longer listed"; the next refresh names it. One this call named and the
171 // engine still left out is really gone (discarded, or not this repo's), so it is not kept.
172 const nowShown = await shownSprintId($)
173 const sprintList = keepShown(lastSprints, keepBusy(lastSprints, r.value.sprints ?? []), nowShown !== shownSprint ? nowShown : null) as SprintSummary[]
174 if (JSON.stringify(sprintList) !== JSON.stringify(lastSprints)) await update($, sprints, () => sprintList)
175 const of = r.value.olderFinished ?? {}
176 const older = { mine: Number(of.mine) || 0, others: Number(of.others) || 0 }
177 if (!same(older, await read($, olderFinished))) await update($, olderFinished, () => older)
178 // A finished sprint listed though archived needs no runner.
179 if (sprintList.some((s) => !s.finishedAt && !s.draft && !s.unreadable && !s.archived) && Date.now() - lastSprintCheck > 60_000) {
180 lastSprintCheck = Date.now()
181 quietly(engine($, ['sprint', 'ensure']))
182 }
183 if (!same(list, await read($, jobs))) await update($, jobs, () => list)
184 const org = await organised($, list)
185 $.ui.status(statusText([...org.needs, ...org.working].map((t) => t.latest)))
186 } finally {
187 inflight = null
188 }
189 })()
190 return inflight
191}
192
193async function readLive($: Engine) {
194 const running = (await read($, jobs)).filter((j) => j.liveFile)
195 const next: Record<string, LiveInfo> = {}
196 for (const j of running) {
197 const text = await $.fs.read(j.liveFile as string).catch(() => '')
198 const info = parseLive(typeof text === 'string' ? text : '')
199 if (info) next[j.id] = info
200 }
201 if (!same(next, await read($, live))) await update($, live, () => next)
202}
203
204async function loadLook($: Engine) {
205 const rows = await $.config.list().catch(() => [])
206 const theme = rows.find((row: { key: string }) => row.key === 'theme')
207 const name = paletteName(theme ? String(theme.value) : undefined) as 'dark' | 'light'
208 if (name !== (await read($, palette))) await update($, palette, () => name)
209 const id = await chatId($)
210 const clears = await $.store.get('cleared').catch(() => undefined)
211 const mine = id && clears && typeof clears === 'object' ? ((clears as Record<string, string>)[id] ?? null) : null
212 if (mine !== (await read($, cleared))) await update($, cleared, () => mine)
213}
214
215// Clear hides Done up to now, for this chat only; entries older than the
216// engine keeps jobs (90 days) are dropped as we go.
217async function clearDone($: Engine) {
218 const at = new Date().toISOString()
219 await update($, cleared, () => at)
220 await update($, showAllDone, () => false)
221 const id = await chatId($)
222 if (!id) return
223 const saved = await $.store.get('cleared').catch(() => undefined)
224 const all: Record<string, string> = saved && typeof saved === 'object' ? { ...(saved as Record<string, string>) } : {}
225 const cutoff = Date.now() - 90 * 86_400_000
226 for (const [key, value] of Object.entries(all)) if (!(Date.parse(value) >= cutoff)) delete all[key]
227 all[id] = at
228 await $.store.set('cleared', all).catch(() => {})
229}
230
231async function deliverFinished($: Engine) {
232 await refresh($)
233 for (const job of newlyFinished(await read($, jobs), { session: await chatId($), now: Date.now() })) await deliver($, job)
234 await deliverSprintNews($)
235}
236
237// Sprint news: plans, parked tickets and finished sprints, batched into one
238// message. Claim → submit → mark, like jobs; the claim is renewed while the submit waits, and one never confirmed expires
239// in 2 minutes.
240// News announced whose mark failed waits in `announcedNews` and is never announced twice (sprintNewsPass).
241// One pass at a time: a pass that finds one running returns at once (newsBusy, held by sprintNewsPass).
242async function deliverSprintNews($: Engine) {
243 const session = (await sessionArgs($))[1]
244 type News = { id: string; sprint: string; text: string }
245 await sprintNewsPass({
246 busy: newsBusy,
247 announced: announcedNews,
248 inFlight: newsInFlight,
249 // Only when a sprint this chat may take news for has some (its own, or one with no chat): most refreshes ask
250 // nothing, and a sprint whose chat is gone never keeps every other chat asking. A finished sprint listed though archived
251 // has no news and is never polled for it.
252 hasNews: async () => Boolean(session) && (await read($, sprints)).some((s) => !s.archived && s.news > 0 && (!s.chat || s.chat === session)),
253 claim: async () => {
254 const r = await engine($, ['sprint', 'inbox', '--session', session as string, '--instance', NEWS_HOLDER, '--here'])
255 return r.ok ? r.value.messages : []
256 },
257 tellClaude: async () => {
258 const s = await engine($, ['settings'])
259 return !s.ok || s.value.tellClaudeWhenDone !== false
260 },
261 // Details first as a hidden row; if that fails they ride in the visible message. If the submit
262 // fails, nothing is marked: the claim expires and it is tried again.
263 send: (list: News[]) => {
264 const { notes, headline, details } = newsHandOver(list)
265 return handOver({
266 append: () => $.session.append({ message: { type: 'user', content: [{ type: 'text', text: notes }] } } as any),
267 headline,
268 details,
269 submit: (text: string) => $.prompt.submit({ text }),
270 arrived: (h: string) => entering.expect(h),
271 })
272 },
273 toast: (list: News[]) => $.ui.toast(sprintNewsHeadline(list)),
274 // The claims are renewed while the send waits, as a job's delivery lock is.
275 sleep: () => $.clock.sleep(LOCK_REFRESH_MS),
276 refresh: (ids: string[]) => engine($, ['sprint', 'refresh', '--session', session as string, '--instance', NEWS_HOLDER, '--ids', ids.join(',')]),
277 mark: async (ids: string[]) => {
278 const r = await engine($, ['sprint', 'delivered', '--ids', ids.join(',')])
279 return r.ok ? { failed: r.value.failed ?? [] } : null
280 },
281 })
282}
283
284async function summarise($: Engine, job: JobRecord): Promise<string | null> {
285 const full = await engine($, ['result', '--id', job.id, '--full'])
286 const output: string | null = full.ok ? full.value.output : null
287 if (!output || !output.trim()) return null
288 const short = shortSummary(output)
289 if (short) {
290 // Saved like a model summary, so the pane and podium_result show it too.
291 await engine($, ['summary', '--id', job.id, '--text', '-'], short)
292 return short
293 }
294 const r = await $.model.complete({ model: 'haiku', effort: 'low', maxTokens: 400, prompt: summaryPrompt(output) })
295 if (!r.isAnswered || !r.text.trim()) return null
296 await engine($, ['summary', '--id', job.id, '--text', '-'], r.text.trim())
297 return r.text.trim()
298}
299
300// Claim → summary → submit → mark delivered. Marking only after the message
301// is in means a reload, a refused prompt or an error leaves the job to be retried;
302// the engine's claim stops two sessions (or two reloads) delivering it twice.
303async function deliver($: Engine, job: JobRecord) {
304 if (delivering.has(job.id)) return
305 delivering.add(job.id)
306 try {
307 const me = (await sessionArgs($))[1] ?? 'unknown'
308 const claim = await engine($, ['claim', '--id', job.id, '--session', me])
309 if (!claim.ok || !claim.value.claimed) return
310 // The lock is refreshed from right after the claim until the delivery settles (the summary Haiku
311 // writes is covered too, and there is no cap): a submit waiting behind a busy turn never lets it go stale for another
312 // chat to take. A refresh answering refreshed:false (another chat took a stale lock) is accepted: the pending submit
313 // can't be withdrawn. keepFresh awaits a refresh in flight before it returns, so none lands after the release below.
314 const { tell, ok } = await keepFresh(async () => {
315 const res = await engine($, ['result', '--id', job.id])
316 const summary: string | null = isPrEvent(job) ? null : (res.ok && res.value.summary) || (await summarise($, job))
317 const s = await engine($, ['settings'])
318 const tell = !s.ok || s.value.tellClaudeWhenDone !== false
319 if (!tell) return { tell, ok: true }
320 // The details go in first as a row the person doesn't see but Claude reads;
321 // the visible message is one line. (Catching our own prompt in a prompt.submit
322 // hook only worked in the kit: in the app the notes never arrived.) If the hidden row
323 // cannot be added, the details ride in the visible message. A failed submit releases the
324 // job to be tried again, never marked with Claude missing the report.
325 const { notes, headline, details } = jobHandOver(job, summary)
326 const ok = await handOver({
327 append: () => $.session.append({ message: { type: 'user', content: [{ type: 'text', text: notes }] } } as any),
328 headline,
329 details,
330 submit: (text: string) => $.prompt.submit({ text }),
331 arrived: (h: string) => entering.expect(h),
332 })
333 return { tell, ok }
334 }, {
335 sleep: () => $.clock.sleep(LOCK_REFRESH_MS),
336 refresh: () => engine($, ['claim', '--id', job.id, '--session', me, '--refresh']),
337 })
338 if (!ok) {
339 await engine($, ['release', '--id', job.id])
340 return
341 }
342 await engine($, ['deliver', '--id', job.id])
343 // The chat line is the notice: a pop-up only when Claude is not told.
344 if (!tell) {
345 const line = jobLine(job)
346 $.ui.toast(`${line.icon} ${line.who}: ${line.what}`)
347 }
348 await say($, null)
349 } finally {
350 delivering.delete(job.id)
351 }
352}
353
354async function repairSandbox($: Engine, id: string) {
355 await moveOpen($, { type: 'repair', id, on: false })
356 const r = await engine($, ['repair', '--what', 'muse-sandbox'])
357 if (!r.ok) { await say($, r.error.message); return }
358 await say($, r.value.repaired
359 ? `Repaired: removed ${r.value.removed} piled-up entries. Muse's sandbox should work now; run the job again.`
360 : r.value.reason === 'nothing-to-repair' ? "Nothing to repair: Muse's sandbox folder looks healthy." : "Podium can only repair this on Windows.")
361}
362
363async function loadAgents($: Engine) {
364 const r = await engine($, ['agents'])
365 if (r.ok) await update($, agents, () => r.value.agents)
366 if (r.ok) await update($, agentsLoaded, () => true)
367 else await say($, r.error.message)
368}
369
370// The settings, and the project this chat is in (`here`), which Settings → This repo reads.
371async function loadSettings($: Engine) {
372 const [r, here] = await Promise.all([engine($, ['settings']), engine($, ['here'])])
373 if (r.ok) await update($, settings, () => ({ ...r.value, here: here.ok ? String(here.value.project) : null }))
374 else await say($, r.error.message)
375}
376
377async function showScreen($: Engine, screen: PaneView['screen'], id: string | null = null) {
378 await update($, view, () => ({ screen, id }))
379 if (screen !== 'list') await say($, null)
380 if (['agents', 'agent', 'add-agent', 'new'].includes(screen)) quietly(loadAgents($))
381 if (screen === 'settings') quietly(loadSettings($))
382}
383
384async function openDetail($: Engine, id: string) {
385 await showScreen($, 'detail', id)
386 const job = (await read($, jobs)).find((j) => j.id === id)
387 const r = await engine($, ['result', '--id', id])
388 // A sprint step's diff is read even after its checkpoint: Steps and diff shows it.
389 const c = job && (job.hasChanges || job.status === 'needs-review' || job.sprint) ? await engine($, ['changes', '--id', id]) : null
390 const changeSet = c && c.ok ? c.value : null
391 // A screen (re)opened starts with no question open, as when the question lived on its detail.
392 await moveOpen($, { type: 'screenOpened' })
393 await update($, detail, () => ({
394 id,
395 summary: r.ok ? r.value.summary : null,
396 output: null,
397 changes: changeSet,
398 open: changeSet && changeSet.files.length === 1 ? [changeSet.files[0].path] : [],
399 }))
400}
401
402// The board: one sprint. Opening the same board again (Back from Steps and diff) keeps
403// its open row; another sprint's board starts with every row closed.
404async function openBoard($: Engine, id: string) {
405 await update($, board, (cur) => (cur.sprint === id ? { ...cur, confirm: null, picking: null } : { sprint: id, open: null, confirm: null, picking: null, note: '' }))
406 await showScreen($, 'sprint', id)
407 quietly(loadAgents($))
408}
409
410// A ticket's own screen. The board's state names the ticket (open), so its plan's tasks are sent (slimSprints);
411// its notes for the plan stay if it is the same ticket.
412async function openTicket($: Engine, id: string, key: string | null) {
413 if (!key) { await openBoard($, id); return }
414 await update($, board, (cur) => ({ sprint: id, open: key, confirm: null, picking: null, note: cur.sprint === id && cur.open === key ? cur.note ?? '' : '' }))
415 await update($, view, () => ({ screen: 'ticket' as const, id, key }))
416 await say($, null)
417 quietly(loadAgents($))
418}
419
420async function backToList($: Engine) {
421 // The couldn't-draw note's Back: the screen it leaves also loses its open folds, so it does not reopen the same way.
422 const leaving = await read($, view)
423 await update($, folds, (cur) => foldsLeaving(cur, leaving))
424 await update($, board, () => NO_BOARD)
425 await showScreen($, 'list')
426}
427
428// A sprint change from the pane: one engine call, its answer as the notice, then the board redraws.
429// A repeat of the same press while it runs is ignored (Start checks git and takes a moment).
430const sprintBusy = new Set<string>()
431
432// done: the notice once it worked, or a function that makes it from the engine's reply.
433async function sprintCall($: Engine, args: string[], done: string | null | ((value: any) => string | null) = null, stdin?: string): Promise<boolean> {
434 const key = `${args.join(' ')}\n${stdin ?? ''}`
435 if (sprintBusy.has(key)) return false
436 sprintBusy.add(key)
437 try {
438 const r = await engine($, ['sprint', ...args], stdin)
439 await say($, r.ok ? (typeof done === 'function' ? done(r.value) : done) : r.error.message)
440 // A refresh already running may have read the board before this change, and refresh() hands that one
441 // back: let it finish, then read again, so the board never shows the old state after a press.
442 if (inflight) await inflight.catch(() => {})
443 await refresh($)
444 return r.ok
445 } finally {
446 sprintBusy.delete(key)
447 }
448}
449
450// Sprint changes from the pane run strictly in order per sprint: each waits for the one before it to
451// finish, however long that takes, so Start never overtakes an edit pressed before it. Each press joins its
452// sprint's line when it arrives; the same press made again while its twin still waits or runs is dropped.
453const sprintLines = new Map<string, { tail: Promise<unknown>; last: string | null }>()
454
455function inSprintOrder(id: string, press: string, work: () => Promise<unknown>): Promise<unknown> {
456 const line = sprintLines.get(id) ?? { tail: Promise.resolve(), last: null }
457 if (line.last === press) return line.tail
458 const run = line.tail.catch(() => {}).then(work)
459 const entry = { tail: run, last: press }
460 sprintLines.set(id, entry)
461 const clear = () => { if (sprintLines.get(id) === entry) sprintLines.delete(id) }
462 run.then(clear, clear)
463 return run
464}
465
466// The pane's sprint changes, and where each names its sprint (the rest are not sprint changes).
467const SPRINT_CHANGES: Record<string, (args: unknown[]) => unknown> = {
468 sprintAct: (args) => args[1], setSprintOptions: (args) => args[0], moveTicket: (args) => args[0],
469 removeTicket: (args) => args[0], assignTicket: (args) => args[0], assignTask: (args) => args[0],
470 retryTicket: (args) => args[0], stopTicket: (args) => args[0], syncTicket: (args) => args[0], answerCommits: (args) => args[0],
471 approvePlan: (args) => args[0], revisePlan: (args) => args[0], addNote: (args) => args[0], acceptTests: (args) => args[0], reviewWithEvidence: (args) => args[0], setAfter: (args) => args[0],
472 takeSprintNews: (args) => args[0],
473}
474
475// "Take them here": a sprint whose chat is gone moves to this chat, so its waiting news (and its PR news) comes here;
476// the next news pass delivers it.
477async function takeSprintNews($: Engine, id: string) {
478 const session = await chatId($)
479 if (!session) {
480 await say($, "Podium doesn't know this chat yet. Try again in a moment.")
481 return false
482 }
483 return sprintCall($, ['adopt', '--id', id, '--session', session, '--here'], "This sprint's updates come to this chat now.")
484}
485
486const SPRINT_DONE = {
487 start: 'Started.',
488 discard: 'Draft discarded.',
489 pause: 'Paused. Steps already running finish.',
490 resume: null,
491} as const
492
493async function sprintAct($: Engine, verb: 'start' | 'discard' | 'pause' | 'resume', id: string) {
494 await update($, board, (cur) => ({ ...cur, confirm: null }))
495 // Start says what it saved for the repo from the draft's proposal.
496 const ok = await sprintCall($, [verb, '--id', id], verb === 'start' ? (v) => startedNotice(v?.applied) : SPRINT_DONE[verb])
497 // A discarded draft has no board left to show; the list keeps the notice. Another sprint's board stays.
498 const shown = await read($, view)
499 if (ok && verb === 'discard' && shown.screen === 'sprint' && shown.id === id) await showScreen($, 'list')
500}
501
502// A writer or reviewer pick on the board: the picker closes, then the same engine call as ever.
503async function assignAct($: Engine, args: string[]) {
504 await update($, board, (cur) => ({ ...cur, picking: null }))
505 await sprintCall($, ['assign', ...args])
506}
507
508// Stop ends a ticket: the board asked first; the answer clears the question.
509async function stopTicketAct($: Engine, id: string, key: string) {
510 await update($, board, (cur) => ({ ...cur, confirm: null }))
511 await sprintCall($, ['stop', '--id', id, '--ticket', key], `${key} stopped.`)
512}
513
514// Keep or Drop the commits in a ticket's copy that Podium didn't make. Drop's question closes first.
515async function answerCommitsAct($: Engine, id: string, key: string, keep: boolean) {
516 await update($, board, (cur) => ({ ...cur, confirm: null }))
517 await sprintCall($, ['commits', '--id', id, '--ticket', key, '--answer', keep ? 'keep' : 'drop'], (v) => commitsAnswerText(key, keep, v?.dropped ?? [], v?.saved ?? null))
518}
519
520// Ask for changes to a plan: the notes go to its writer; the form closes once Podium has them, and
521// stays with the notes if it refused. The press carries the notes as they were when Send was pressed: a Send
522// may wait behind other changes in its sprint, and by the time it runs the board may show another ticket's notes. So
523// the clean-up closes the form only if it still holds this note (same sprint, ticket and words), or none (Enter can send
524// words the board never kept).
525async function revisePlanAct($: Engine, id: string, key: string, typed?: string) {
526 const text = (typed ?? (await read($, board)).note ?? '').slice(0, NOTE_CHARS).trim()
527 if (!text) { await say($, 'Say what should change in the plan first.'); return }
528 const ok = await sprintCall($, ['revise', '--id', id, '--ticket', key, '--text', '-'], `${key}'s plan goes back with your notes.`, text)
529 const same = (cur: BoardState) => cur.sprint === id && cur.confirm === `revise:${key}` && ['', text].includes((cur.note ?? '').trim())
530 if (ok) await update($, board, (cur) => (same(cur) ? { ...cur, confirm: null, note: '' } : cur))
531}
532
533// A note for a ticket's agents, from its Add a note form: sent and closed as a plan's notes are (revisePlanAct).
534async function addNoteAct($: Engine, id: string, key: string, typed?: string) {
535 const text = (typed ?? (await read($, board)).note ?? '').slice(0, NOTE_CHARS).trim()
536 if (!text) { await say($, 'Write the note first.'); return }
537 const ok = await sprintCall($, ['note', '--id', id, '--ticket', key, '--text', '-'], (v) => notedText(key, Boolean(v?.noted?.running)), text)
538 const same = (cur: BoardState) => cur.sprint === id && cur.confirm === `note:${key}` && ['', text].includes((cur.note ?? '').trim())
539 if (ok) await update($, board, (cur) => (same(cur) ? { ...cur, confirm: null, note: '' } : cur))
540}
541
542// Accept and continue, from its form: the person's reason goes in, and the form closes as a note's does (addNoteAct).
543async function acceptTestsAct($: Engine, id: string, key: string, typed?: string) {
544 const text = (typed ?? (await read($, board)).note ?? '').slice(0, NOTE_CHARS).trim()
545 if (!text) { await say($, 'Say why the test results are fine first.'); return }
546 const ok = await sprintCall($, ['accept', '--id', id, '--ticket', key, '--text', '-'], `Accepted: ${key}'s task goes to its review.`, text)
547 const same = (cur: BoardState) => cur.sprint === id && cur.confirm === `accept:${key}` && ['', text].includes((cur.note ?? '').trim())
548 if (ok) await update($, board, (cur) => (same(cur) ? { ...cur, confirm: null, note: '' } : cur))
549}
550
551// Review again with evidence, from its form: the person's words go in, and the form closes as Accept's does.
552async function evidenceAct($: Engine, id: string, key: string, typed?: string) {
553 const text = (typed ?? (await read($, board)).note ?? '').slice(0, NOTE_CHARS).trim()
554 if (!text) { await say($, 'Say what the reviewer should weigh first.'); return }
555 const ok = await sprintCall($, ['evidence', '--id', id, '--ticket', key, '--text', '-'], `${key}'s task goes back to its review with your evidence.`, text)
556 const same = (cur: BoardState) => cur.sprint === id && cur.confirm === `evidence:${key}` && ['', text].includes((cur.note ?? '').trim())
557 if (ok) await update($, board, (cur) => (same(cur) ? { ...cur, confirm: null, note: '' } : cur))
558}
559
560// Notes typed for a plan or a ticket: kept to the engine's length (it cuts them too), which also keeps them within the pane's props.
561async function editNote($: Engine, id: string, text: string) {
562 const typed = String(text ?? '')
563 const note = typed.slice(0, NOTE_CHARS)
564 await update($, board, (cur) => (cur.sprint === id ? { ...cur, note } : cur))
565 if (typed.length > NOTE_CHARS) await say($, `Your notes were cut to ${NOTE_CHARS.toLocaleString('en-US')} characters.`)
566}
567
568async function toggleFull($: Engine, id: string) {
569 const current = await read($, detail)
570 if (current && current.id === id && current.output) {
571 await update($, detail, () => ({ ...current, output: null }))
572 return
573 }
574 const r = await engine($, ['result', '--id', id, '--full'])
575 const reply = r.ok ? (r.value.output ?? 'No output was saved.') : r.error.message
576 const log = r.ok && r.value.log ? String(r.value.log).trim() : ''
577 const output = log ? `${reply.trim()}\n\n── Activity log ──\n${log.slice(-6000)}` : reply
578 await update($, detail, (cur) => (cur && cur.id === id ? { ...cur, output: output.slice(-8000) } : { id, summary: null, output: output.slice(-8000), changes: null, open: [] }))
579}
580
581async function toggleFile($: Engine, id: string, file: string) {
582 await update($, detail, (cur) => (cur && cur.id === id
583 ? { ...cur, open: cur.open.includes(file) ? cur.open.filter((f) => f !== file) : [...cur.open, file] }
584 : cur))
585}
586
587async function fullDiff($: Engine, id: string) {
588 const c = await engine($, ['changes', '--id', id, '--full'])
589 if (c.ok) await update($, detail, (cur) => (cur && cur.id === id ? { ...cur, changes: c.value } : cur))
590}
591
592async function askClaude($: Engine, id: string) {
593 const job = (await read($, jobs)).find((j) => j.id === id)
594 if (!job) return
595 const info = (await read($, live))[id] ?? null
596 await $.prompt.fill({ text: askText(job, info, Date.now()), mode: 'replace' })
597}
598
599async function act($: Engine, verb: 'cancel' | 'keep' | 'discard', id: string) {
600 await moveOpen($, { type: 'confirm', id, what: null })
601 const r = await engine($, [verb, '--id', id])
602 await say($, r.ok ? null : r.error.message)
603 await refresh($)
604}
605
606async function rate($: Engine, id: string, value: 'used' | 'discarded') {
607 const r = await engine($, ['outcome', '--id', id, '--value', value])
608 await say($, r.ok ? null : r.error.message)
609 await refresh($)
610}
611
612async function setSetting($: Engine, key: string, value: unknown, saved: string | null = null) {
613 const r = await engine($, ['settings', 'set', '--key', key, '--value', JSON.stringify(value)])
614 if (r.ok) await update($, settings, (cur) => ({ ...r.value, here: cur?.here ?? null }))
615 await say($, r.ok ? saved : r.error.message)
616 await loadAgents($)
617}
618
619async function toggleSetting($: Engine, key: string) {
620 const s = (await read($, settings)) ?? {}
621 await setSetting($, key, !s[key])
622}
623
624async function toggleAgent($: Engine, name: string) {
625 const s = ((await read($, settings)) ?? {}) as { disabledAgents?: string[] }
626 const off = new Set(s.disabledAgents ?? [])
627 if (off.has(name)) off.delete(name)
628 else off.add(name)
629 await setSetting($, 'disabledAgents', [...off])
630}
631
632async function testAgent($: Engine, name: string) {
633 await say($, `Testing ${name}…`)
634 const r = await engine($, ['adapter', 'test', '--name', name])
635 if (!r.ok) { await say($, r.error.message); return }
636 const t = r.value
637 const why = String(t.stderr || t.output || (t.errors ?? []).join(' ')).trim().slice(0, 200)
638 await say($, t.ok ? `${name} works (${(t.ms / 1000).toFixed(1)}s).` : `${name} did not pass the test. ${why}`)
639}
640
641async function suggestAgent($: Engine, command: string) {
642 if (!command) return
643 const r = await engine($, ['adapter', 'suggest', '--command', command])
644 if (!r.ok) { await say($, r.error.message); return }
645 await update($, draftAdapter, () => r.value.adapter)
646 await say($, `Filled in from "${command} --help". Check each field, then Save and test.`)
647}
648
649async function saveAgent($: Engine) {
650 const pending = await read($, draftAdapter)
651 if (!pending) return
652 const r = await engine($, ['adapter', 'save', '--json', '-'], JSON.stringify(pending))
653 if (!r.ok) { await say($, r.error.message); return }
654 await update($, draftAdapter, () => null)
655 // The new agent is loaded before its screen opens (it would say "not listed" until then); one load, not showScreen's too.
656 await loadAgents($)
657 await update($, view, () => ({ screen: 'agent' as const, id: pending.name }))
658 await say($, null)
659 await testAgent($, pending.name)
660}
661
662async function startJob($: Engine, task?: string) {
663 const d = await read($, draft)
664 const text = task ?? d.task
665 if (!text.trim()) { await say($, 'Describe the task first.'); return }
666 const args = ['dispatch', '--agent', d.agent, '--permission', d.permission,
667 ...(d.includeUncommitted && d.permission === 'edit-worktree' ? ['--include-uncommitted'] : []),
668 ...(d.model ? ['--model', d.model] : []),
669 ...(d.ticket.trim() ? ['--ticket', d.ticket.trim()] : []),
670 ...(d.base.trim() ? ['--base', d.base.trim()] : []),
671 ...(await sessionArgs($)),
672 '--prompt', '-']
673 const r = await engine($, args, text)
674 if (!r.ok) { await update($, draft, (cur) => ({ ...cur, task: text })); await say($, r.error.message); return }
675 await update($, draft, (cur) => ({ ...cur, task: '' }))
676 await say($, ['Started.', ...r.value.warnings.map((w: { message: string }) => w.message)].join(' '))
677 await showScreen($, 'list')
678 await refresh($)
679}
680
681async function startFollowUp($: Engine, latestId: string, typed?: string) {
682 const form = askOf(await read($, jobOpen)) as AskForm
683 if (!form) return
684 const task = (typed ?? form.task).trim()
685 if (!task && form.mode !== 'review') { await say($, 'Say what should change first.'); return }
686 const r = form.mode === 'review'
687 ? await engine($, ['dispatch', '--pr', String(form.number), '--agent', form.agent, ...(await sessionArgs($)), '--prompt', '-'], task)
688 : await engine($, ['dispatch', '--follow-up', latestId, '--agent', form.agent, ...(await sessionArgs($)), '--prompt', '-'], task)
689 if (!r.ok) { await say($, r.error.message); return }
690 await moveOpen($, { type: 'askClose' })
691 await say($, null)
692 await refresh($)
693}
694
695// PR actions take seconds (git push, gh): say so at once, and ignore repeat presses meanwhile.
696const prBusy = new Set<string>()
697
698async function prAction($: Engine, args: string[], done: string | null = null, working: string | null = null) {
699 const key = args.join(' ')
700 if (prBusy.has(key)) return
701 prBusy.add(key)
702 try {
703 if (working) await say($, working)
704 const r = await engine($, ['pr', ...args])
705 await say($, r.ok ? done : r.error.message)
706 await refresh($)
707 } finally {
708 prBusy.delete(key)
709 }
710}
711
712async function fixOpen($: Engine, threadId: string, latestId: string, agent: string, kind: 'ci' | 'comments') {
713 await openDetail($, latestId)
714 const r = await engine($, ['pr', 'fix', '--id', latestId, '--kind', kind])
715 if (!r.ok) { await say($, r.error.message); return }
716 await openAsk($, { threadId, agent, task: String(r.value.text), mode: 'change' as const })
717}
718
719async function openAsk($: Engine, form: NonNullable<AskForm>) {
720 await moveOpen($, { type: 'ask', form })
721}
722
723async function jobQuestion($: Engine, which: 'confirm' | 'repair', id: string, value: 'discard' | 'cancel' | boolean | null) {
724 await moveOpen($, which === 'confirm' ? { type: 'confirm', id, what: value || null } : { type: 'repair', id, on: Boolean(value) })
725}
726
727async function setRepoInstructions($: Engine, text: string) {
728 const here = await engine($, ['here'])
729 if (!here.ok) { await say($, here.error.message); return }
730 const cur = ((await read($, settings))?.repoInstructions ?? {}) as Record<string, string>
731 await setSetting($, 'repoInstructions', { ...cur, [here.value.project]: text }, text.trim() ? `Saved for ${here.value.project}.` : `Removed for ${here.value.project}.`)
732}
733
734// Settings → This repo: this repo's value in one per-repo map; null, '' or [] removes it. The engine refuses a bad
735// command name, and its message becomes the notice.
736async function setRepoSetting($: Engine, key: string, value: unknown) {
737 const here = await engine($, ['here'])
738 if (!here.ok) { await say($, here.error.message); return }
739 const project = String(here.value.project)
740 // Built on the settings as saved now, not as the pane last read them: another chat, the sprint's Start or a hand edit
741 // may have changed another repo's value since, and this save must not put the old one back.
742 const fresh = await engine($, ['settings'])
743 if (!fresh.ok) { await say($, fresh.error.message); return }
744 const saved = (fresh.value ?? {})[key]
745 const next: Record<string, unknown> = saved && typeof saved === 'object' && !Array.isArray(saved) ? { ...(saved as Record<string, unknown>) } : {}
746 if (value === null || value === '' || (Array.isArray(value) && !value.length)) delete next[project]
747 else next[project] = value
748 await setSetting($, key, next, `Saved for ${project}.`)
749}
750
751async function loadChecks($: Engine) {
752 const r = await engine($, ['doctor'])
753 if (r.ok) await update($, checks, () => r.value)
754 const v = await engine($, ['version'])
755 if (!v.ok || v.value.previous === v.value.version) return
756 if (v.value.previous) await update($, whatsNew, () => ({ version: v.value.version, notes: v.value.notes, open: false }))
757 await engine($, ['version', '--seen'])
758}
759
760async function tryIt($: Engine) {
761 const ready = (await read($, agents)).find((a) => a.installed && a.enabled)
762 if (!ready) { await say($, 'No agent is ready yet. Open Agents to see what is installed.'); return }
763 const r = await engine($, ['dispatch', '--agent', ready.name, '--permission', 'read-only', ...(await sessionArgs($)), '--prompt', '-'], 'Say hello in one short, friendly line. Do not read or change any files.')
764 await say($, r.ok ? `${ready.label} is saying hello.` : r.error.message)
765 await refresh($)
766}
767
768async function gotIt($: Engine) {
769 await engine($, ['welcome', '--done'])
770 await update($, checks, (cur) => (cur ? { ...cur, welcomed: true } : cur))
771}
772
773async function checkAgain($: Engine) {
774 await update($, agentsLoaded, () => false)
775 await Promise.all([loadAgents($), loadChecks($)])
776}
777
778async function planRemoval($: Engine) {
779 const r = await engine($, ['uninstall'])
780 if (r.ok) await update($, removing, () => r.value)
781 else await say($, r.error.message)
782}
783
784async function confirmRemoval($: Engine) {
785 const r = await engine($, ['uninstall', '--yes'])
786 await update($, removing, () => null)
787 await say($, !r.ok ? r.error.message
788 : r.value.removed ? "Podium's data is gone. To remove Podium itself: /plugin uninstall podium"
789 : `Some things could not be removed: ${r.value.left.join('; ')}. Press Remove again to finish.`)
790 await refresh($)
791}
792
793async function prune($: Engine) {
794 const r = await engine($, ['prune'])
795 await say($, r.ok ? `Cleaned up: ${r.value.compressed} compressed, ${r.value.deleted} pruned.` : r.error.message)
796}
797
798// Every action the pane can take, each returning its promise (the desktop Client waits for them).
799function actions($: Engine) {
800 const all = {
801 openDetail: (id: string) => openDetail($, id),
802 showScreen: (screen: PaneView['screen']) => showScreen($, screen),
803 toggleFold: (id: string) => update($, folds, (cur) => (cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id].slice(-FOLDS_KEPT))),
804 confirmJob: (id: string, what: 'discard' | 'cancel' | null) => jobQuestion($, 'confirm', id, what),
805 openAgent: (name: string) => showScreen($, 'agent', name),
806 browsePr: (id: string) => prAction($, ['browse', '--id', id], 'Opened on GitHub.'),
807 act: (verb: 'cancel' | 'keep' | 'discard', id: string) => act($, verb, id),
808 rate: (id: string, value: 'used' | 'discarded') => rate($, id, value),
809 toggleFull: (id: string) => toggleFull($, id),
810 clearDone: () => clearDone($),
811 toggleShowAll: () => update($, showAllDone, (cur) => !cur),
812 askOpen: (threadId: string, agent: string) => openAsk($, { threadId, agent, task: '' }),
813 askEdit: (patch: { agent?: string; task?: string }) => moveOpen($, { type: 'askEdit', patch }),
814 askSend: (latestId: string, task?: string) => startFollowUp($, latestId, task),
815 askCancel: () => moveOpen($, { type: 'askClose' }),
816 askRepair: (id: string, isOn: boolean) => jobQuestion($, 'repair', id, isOn),
817 repair: (id: string) => repairSandbox($, id),
818 askClaude: (id: string) => askClaude($, id),
819 toggleFile: (id: string, file: string) => toggleFile($, id, file),
820 fullDiff: (id: string) => fullDiff($, id),
821 setSetting: (key: string, value: unknown) => setSetting($, key, value),
822 toggleSetting: (key: string) => toggleSetting($, key),
823 toggleAgent: (name: string) => toggleAgent($, name),
824 testAgent: (name: string) => testAgent($, name),
825 suggestAgent: (command: string) => suggestAgent($, command),
826 editAdapter: (patch: Partial<AdapterDraft>) => update($, draftAdapter, (cur) => (cur ? { ...cur, ...patch } : cur)),
827 saveAgent: () => saveAgent($),
828 cancelAgent: () => update($, draftAdapter, () => null),
829 editDraft: (patch: Partial<Draft>) => update($, draft, (cur) => ({ ...cur, ...patch })),
830 startJob: (task?: string) => startJob($, task),
831 prune: () => prune($),
832 tryIt: () => tryIt($),
833 openPr: (id: string) => prAction($, ['open', '--id', id], 'PR opened.', 'Opening the PR…'),
834 pushPr: (id: string) => prAction($, ['push', '--id', id], 'Pushed to the PR.', 'Pushing to the PR…'),
835 toggleAutoMerge: (id: string, isOn: boolean) => prAction($, ['auto', '--id', id, '--on', String(isOn)]),
836 watchAgain: (id: string) => prAction($, ['watch-again', '--id', id]),
837 postReview: (id: string) => prAction($, ['post', '--id', id], 'Review posted.', 'Posting the review…'),
838 reviewOpen: (threadId: string, agent: string, number: number) => openAsk($, { threadId, agent, task: '', mode: 'review' as const, number }),
839 fixOpen: (threadId: string, latestId: string, agent: string, kind: 'ci' | 'comments') => fixOpen($, threadId, latestId, agent, kind),
840 setInstructions: (text: string) => setSetting($, 'instructions', text, text.trim() ? 'Saved.' : 'Removed.'),
841 setRepoInstructions: (text: string) => setRepoInstructions($, text),
842 setRepoSetting: (key: string, value: unknown) => setRepoSetting($, key, value),
843 gotIt: () => gotIt($),
844 toggleWhatsNew: () => update($, whatsNew, (cur) => (cur ? { ...cur, open: !cur.open } : cur)),
845 dismissWhatsNew: () => update($, whatsNew, () => null),
846 checkAgain: () => checkAgain($),
847 planRemoval: () => planRemoval($),
848 confirmRemoval: () => confirmRemoval($),
849 cancelRemoval: () => update($, removing, () => null),
850 openBoard: (id: string) => openBoard($, id),
851 showSprints: (page: number) => update($, sprintPage, () => (Number.isInteger(page) && page > 0 ? page : 0)),
852 sprintAct: (verb: 'start' | 'discard' | 'pause' | 'resume', id: string) => sprintAct($, verb, id),
853 askConfirm: (id: string, what: string | null) => update($, board, (cur) => (cur.sprint === id ? { ...cur, confirm: what } : { sprint: id, open: null, confirm: what, picking: null, note: '' })),
854 setSprintOptions: (id: string, patch: { mergeWhenGreen?: boolean; atOnce?: number }) => sprintCall($, ['options', '--id', id, ...(patch.mergeWhenGreen === undefined ? [] : ['--merge-when-green', String(patch.mergeWhenGreen)]), ...(patch.atOnce === undefined ? [] : ['--at-once', String(patch.atOnce)])]),
855 openTicket: (id: string, key: string | null) => openTicket($, id, key),
856 openPicker: (id: string, which: string | null) => update($, board, (cur) => (cur.sprint === id ? { ...cur, picking: which } : { sprint: id, open: null, confirm: null, picking: which, note: '' })),
857 moveTicket: (id: string, key: string, to: 'up' | 'down') => sprintCall($, ['move', '--id', id, '--ticket', key, '--to', to]),
858 removeTicket: (id: string, key: string) => sprintCall($, ['remove', '--id', id, '--ticket', key]),
859 assignTicket: (id: string, key: string, role: 'agent' | 'reviewer', name: string) => assignAct($, ['--id', id, '--ticket', key, `--${role}`, name]),
860 assignTask: (id: string, key: string, n: number, role: 'agent' | 'reviewer', name: string) => assignAct($, ['--id', id, '--ticket', key, '--task', String(n), `--${role}`, name]),
861 retryTicket: (id: string, key: string, agent?: string) => sprintCall($, ['retry', '--id', id, '--ticket', key, ...(agent ? ['--agent', agent] : [])], agent ? `${key} goes to ${agentLabel(agent)}.` : `${key} tries again.`),
862 stopTicket: (id: string, key: string) => stopTicketAct($, id, key),
863 syncTicket: (id: string, key: string) => sprintCall($, ['sync', '--id', id, '--ticket', key], (v) => syncedText(key, v?.sprint?.base, Number(v?.commits ?? 0), v?.saved ?? null)),
864 answerCommits: (id: string, key: string, keep: boolean) => answerCommitsAct($, id, key, keep),
865 approvePlan: (id: string, key: string, whole?: boolean, acknowledge?: boolean) => sprintCall($, [whole ? 'one-task' : 'approve', '--id', id, '--ticket', key, ...(acknowledge ? ['--acknowledge'] : [])], whole ? `${key} runs as one task.` : `${key} approved.`),
866 revisePlan: (id: string, key: string, text?: string) => revisePlanAct($, id, key, text),
867 addNote: (id: string, key: string, text?: string) => addNoteAct($, id, key, text),
868 acceptTests: (id: string, key: string, text?: string) => acceptTestsAct($, id, key, text),
869 reviewWithEvidence: (id: string, key: string, text?: string) => evidenceAct($, id, key, text),
870 editNote: (id: string, text: string) => editNote($, id, text),
871 setAfter: (id: string, key: string, other: string, on: boolean) => sprintCall($, ['after', '--id', id, '--ticket', key, on ? '--add' : '--drop', other]),
872 takeSprintNews: (id: string) => takeSprintNews($, id),
873 // The "couldn't draw" note's Back: the list, with the board's open row, picker and question closed, so the
874 // same board does not open straight back onto the row that could not be drawn.
875 backToList: () => backToList($),
876 }
877 return all
878}
879
880// A press of the pane: a sprint change joins its sprint's line (inSprintOrder) now, in press order, and `work`
881// runs when the changes before it have finished; anything else runs `work` at once.
882function pressed(name: string, args: unknown[], work: () => Promise<unknown>): Promise<unknown> {
883 const sprintOf = SPRINT_CHANGES[name]
884 return sprintOf ? inSprintOrder(String(sprintOf(args)), `${name} ${JSON.stringify(args)}`, work) : work()
885}
886
887// The closures the terminal's pure screens call: each runs one action in the background.
888function handlers($: Engine) {
889 const a = actions($) as Record<string, (...args: any[]) => Promise<unknown>>
890 const out: Record<string, (...args: any[]) => void> = {}
891 for (const name of Object.keys(a)) out[name] = (...args: any[]) => { quietly(pressed(name, args, () => a[name](...args))) }
892 return out as any
893}
894
895async function openPane($: Engine) {
896 await chatId($)
897 await $.ui.open({ id: PANE, title: 'Podium' })
898 quietly(loadAgents($))
899 quietly(loadSettings($))
900 quietly(loadChecks($))
901 await update($, showAllDone, () => false)
902 await update($, sprintPage, () => 0)
903 await loadLook($)
904 await refresh($)
905 await readLive($).catch(() => {})
906}
907
908export const register: Register = on => {
909 on('session.start', async ($, e, next) => {
910 const started = await next(e)
911 if (await $.env.get('PODIUM_JOB_ID').catch(() => undefined)) {
912 inert = true
913 return started
914 }
915 sessionId = await $.session.id().catch(() => null)
916 for (const tool of TOOLS) await $.tool.register(tool)
917 await $.command.register({
918 name: 'podium',
919 description: 'See and control background agent jobs',
920 argumentHint: '[run <agent> <task> | status | cancel <id>]',
921 })
922 quietly(refresh($))
923 quietly(loadAgents($))
924 quietly(loadLook($))
925 $.clock.every(1000, () => {
926 ticks += 1
927 if (anyWorking && ticks % 2 === 0) quietly(readLive($))
928 if (anyWorking && ticks % 10 === 0) quietly(update($, beat, (n) => n + 1))
929 if ((anyWorking && ticks % 3 === 0) || ticks % 15 === 0) quietly(deliverFinished($))
930 })
931 return started
932 })
933
934 on('turn.start', async ($, e, next) => {
935 if (!inert) entering.turnStarted(e.text)
936 return next(e)
937 })
938
939 on('turn.complete', async ($, e, next) => {
940 const done = await next(e)
941 if (!inert && !e.agentId) await deliverFinished($).catch(() => {})
942 return done
943 })
944
945 on('tool.call', async ($, e, next) => {
946 const name = String(e.tool)
947 if (inert || !name.startsWith(TOOL_PREFIX)) return next(e)
948 const tool = name.slice(TOOL_PREFIX.length)
949 let call
950 try {
951 call = engineCall(tool, e as unknown as Record<string, unknown>)
952 } catch (err) {
953 return { deny: String((err as Error).message) }
954 }
955 const r = await engine($, ['dispatch', 'sprint'].includes(tool) ? [...call.args, ...(await sessionArgs($))] : call.args, call.stdin)
956 // A new draft opens on its board: the person looks it over there and presses Start.
957 if (tool === 'sprint' && (e as any).action === 'draft' && r.ok && r.value.sprint?.id) {
958 await openBoard($, String(r.value.sprint.id))
959 quietly(openPane($))
960 }
961 quietly(refresh($))
962 return { result: formatToolResult(tool, r, e as unknown as Record<string, unknown>) }
963 })
964
965 on('command.run', { command: 'podium' }, async ($, e) => {
966 const cmd = parseCommand(e.args)
967 if (cmd.kind === 'open') {
968 // /podium opens on the list, with no board row, picker or question open: the pane reopens
969 // on the screen it was on, so a screen that breaks would otherwise stay broken across reopening.
970 await update($, view, () => ({ screen: 'list' as const, id: null }))
971 await update($, board, () => NO_BOARD)
972 await openPane($)
973 return { text: 'Podium pane opened.' }
974 }
975 if (cmd.kind === 'usage') return { text: cmd.message }
976 if (cmd.kind === 'status') {
977 const r = await engine($, ['status', '--here'])
978 await refresh($)
979 return { text: formatToolResult('status', r) }
980 }
981 if (cmd.kind === 'cancel') {
982 const r = await engine($, ['cancel', '--id', cmd.id])
983 quietly(refresh($))
984 return { text: formatToolResult('cancel', r) }
985 }
986 const r = await engine($, ['dispatch', '--agent', cmd.agent, ...(await sessionArgs($)), '--prompt', '-'], cmd.task)
987 quietly(refresh($))
988 return { text: formatToolResult('dispatch', r) }
989 })
990
991 // The desktop Client: runs each posted action and answers with the next model.
992 on('ui.message', async ($, e, next) => {
993 if (e.element !== 'podium-app') return next(e)
994 type Act = { kind: 'act'; id: string; name: string; args?: unknown[] }
995 const d = e.data as { kind?: string; acts?: Act[] } | null
996 const fresh = (d?.kind === 'acts' && Array.isArray(d.acts) ? d.acts : []).filter((a) => a && a.id && !seen.includes(a.id))
997 for (const a of fresh) {
998 seen.push(a.id)
999 if (seen.length > 400) seen.splice(0, 200)
1000 const fn = (actions($) as Record<string, (...args: any[]) => Promise<unknown>>)[a.name]
1001 const done = () => { acked.push(a.id); if (acked.length > 200) acked.splice(0, 100) }
1002 // In order, but a slow one (Open PR) holds the next back half a second at most. A sprint change also joins
1003 // its sprint's line as it arrives and waits there for the sprint changes before it, however long they take,
1004 // so Start never overtakes an edit.
1005 const nap = (ms: number) => Promise.resolve().then(() => $.clock.sleep(ms)).catch(() => {})
1006 const before = clientWork
1007 const args = a.args ?? []
1008 const run = pressed(a.name, args, () => Promise.race([before, nap(500)]).then(() => (fn ? fn(...args) : undefined))).catch(() => {}).then(done)
1009 clientWork = run
1010 }
1011 // Quick actions (a screen switch) land in this answer; slow ones arrive on a later sync.
1012 if (fresh.length) await Promise.race([clientWork, Promise.resolve().then(() => $.clock.sleep(80)).catch(() => {})])
1013 return { props: fitModel(await paneModel($)) }
1014 })
1015
1016 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1017 const ui = $.ui.resolve(e)
1018 await read($, beat)
1019 // On desktop the app paints a Pane's own redraws only on the next click; a Client paints itself.
1020 if (e.surface === 'desktop' && ui.Client) {
1021 const { Client } = ui
1022 return <Client key="podium-app" module="./client/app.tsx" props={fitModel(await paneModel($))} width="100%" flexGrow={1} />
1023 }
1024 return drawPane(ui, await paneModel($), handlers($) as any, Date.now())
1025 })
1026}
1027mod/engine.js 24 lines1// Calls the `podium` engine CLI through the mod's $.process.run.
2// No Node imports: hooks modules run without Node, so `run` is injected.
3
4export function engineArgv(root, args) {
5 return ['node', `${root}/bin/podium.js`, ...args];
6}
7
8export async function callEngine(run, root, args, { stdin, timeoutMs = 120_000 } = {}) {
9 let r;
10 try {
11 r = await run(engineArgv(root, args), { stdin, timeoutMs });
12 } catch (e) {
13 return { ok: false, error: { code: 'node-missing', message: `Podium couldn't start its engine. It needs Node.js 22 or newer on PATH. (${String(e?.message ?? e).slice(0, 200)})` } };
14 }
15 const lines = String(r.stdout ?? '').trim().split(/\r?\n/);
16 try {
17 const body = JSON.parse(lines[lines.length - 1]);
18 return body && body.error ? { ok: false, error: body.error } : { ok: true, value: body };
19 } catch {
20 const detail = String(r.stderr ?? '').trim().slice(0, 300);
21 return { ok: false, error: { code: 'engine-unreadable', message: `Podium's engine gave an unreadable answer (exit ${r.exitCode}).${detail ? ` ${detail}` : ''}` } };
22 }
23}
24mod/delivery.js 143 lines1// Handing finished work to Claude. Pure: the mod passes its $ calls in as closures.
2
3// The details go in first, as a row the person doesn't see but Claude reads, then the one-line
4// headline. If the hidden row cannot be added (append throws, or the host has no such call), the
5// details ride in the visible message instead (headline, blank line, details). Claude gets the report
6// and the person sees more than they would, which beats a finished job that never arrives.
7// True only when the message went in, so the caller marks delivered only then (a failed submit
8// leaves the work to be tried again).
9// A row or prompt a hook refused or dropped on purpose still counts as handled: a `deny` from append is a
10// plugin saying no to the row, so the details are not forced into the chat.
11// With `arrived` (an `arrivals()` registry's expect), the message also counts as in once a turn starts holding the
12// headline, which opens the message either way: see arrivals.
13export async function handOver({ append, submit, headline, details, arrived }) {
14 let hidden = true;
15 try {
16 await append();
17 } catch {
18 hidden = false;
19 }
20 const watch = arrived ? arrived(headline) : null;
21 try {
22 const sent = submit(hidden ? headline : `${headline}\n\n${details}`);
23 await (watch ? Promise.race([sent, watch.seen]) : sent);
24 } catch {
25 return false;
26 } finally {
27 watch?.cancel();
28 }
29 return true;
30}
31
32// Prompts that waited behind a busy turn can enter together: the host starts one turn holding every waiting text, one
33// per line, and settles only the last one's submit (the others never settle). So a hand-over waiting on its submit
34// also ends once a turn starts whose text holds its headline (turnStarted, from the mod's turn.start hook): the
35// message is in then, never before. Identical headlines (three reviews of one PR) are counted, oldest first, so a turn
36// holding one confirms one.
37export function arrivals() {
38 const waiting = [];
39 const drop = (entry) => {
40 const i = waiting.indexOf(entry);
41 if (i >= 0) waiting.splice(i, 1);
42 };
43 return {
44 expect(headline) {
45 const entry = { headline };
46 entry.seen = new Promise((resolve) => { entry.settle = resolve; });
47 waiting.push(entry);
48 return { seen: entry.seen, cancel: () => drop(entry) };
49 },
50 turnStarted(text) {
51 const left = new Map();
52 for (const entry of [...waiting]) {
53 if (!left.has(entry.headline)) left.set(entry.headline, occurrences(String(text ?? ''), entry.headline));
54 const n = left.get(entry.headline);
55 if (n < 1) continue;
56 left.set(entry.headline, n - 1);
57 drop(entry);
58 entry.settle(true);
59 }
60 },
61 };
62}
63
64function occurrences(text, part) {
65 if (!part) return 0;
66 let n = 0;
67 for (let i = text.indexOf(part); i !== -1; i = text.indexOf(part, i + part.length)) n += 1;
68 return n;
69}
70
71// One pass of sprint news: claim, hand over, then mark delivered, as for jobs. The mod keeps three things across
72// passes: `announced`, ids handed over but not yet marked (marked first next time, never announced twice); `inFlight`,
73// ids being sent now (a later pass skips them; a failed send clears them); and `busy` ({ on }), so only one pass runs at a
74// time. mark(ids) answers { failed }, or null when the call failed. With `sleep` and `refresh(ids)`, the claims are renewed
75// while the send waits (keepFresh), so a send that outlasts the 2-minute claim never lets another pass, in this mod or a
76// reloaded one, take the news and say it again.
77export async function sprintNewsPass({ busy = { on: false }, ...deps }) {
78 if (busy.on) return;
79 busy.on = true;
80 try {
81 await newsPass(deps);
82 } finally {
83 busy.on = false;
84 }
85}
86
87async function newsPass({ announced, inFlight, hasNews, claim, tellClaude, send, toast, mark, sleep, refresh }) {
88 const confirm = async (ids) => {
89 if (!ids.length) return;
90 const r = await mark(ids);
91 if (!r) return;
92 const failed = new Set(r.failed ?? []);
93 for (const id of ids) if (!failed.has(id)) announced.delete(id);
94 };
95 await confirm([...announced]);
96 if (!(await hasNews())) return;
97 const list = await claim();
98 const again = list.filter((m) => announced.has(m.id));
99 const fresh = list.filter((m) => !announced.has(m.id) && !inFlight.has(m.id));
100 await confirm(again.map((m) => m.id));
101 if (!fresh.length) return;
102 for (const m of fresh) inFlight.add(m.id);
103 try {
104 if (await tellClaude()) {
105 const ids = fresh.map((m) => m.id);
106 const sent = sleep && refresh ? await keepFresh(() => send(fresh), { sleep, refresh: () => refresh(ids) }) : await send(fresh);
107 if (!sent) return; // nothing went in: the claim expires and it is tried again
108 } else {
109 toast(fresh); // Claude isn't told: the person still sees it, as finished jobs do
110 }
111 for (const m of fresh) announced.add(m.id);
112 } finally {
113 for (const m of fresh) inFlight.delete(m.id);
114 }
115 await confirm(fresh.map((m) => m.id));
116}
117
118// While `work` runs, `refresh` is called after each `sleep`, for as long as `work` runs (the delivery lock goes stale
119// after a minute, and a submit can wait behind a busy turn far longer; no cap, since a capped heartbeat would let the lock
120// go stale under a submit waiting over an hour). A refresh that fails (throws or rejects) is let go and the next beat tries
121// again: one bad renewal never ends the heartbeat. Only a `sleep` that fails ends it (there is no safe way left to wait).
122// Answers what `work` answers. A refresh still in flight when `work` ends is awaited (not the sleep after it), so it can't
123// touch the lock once the caller goes on to release it.
124export async function keepFresh(work, { sleep, refresh }) {
125 let done = false;
126 let pending = null;
127 const beat = (async () => {
128 for (;;) {
129 await sleep();
130 if (done) return;
131 pending = Promise.resolve().then(refresh);
132 try { await pending; } catch { /* a failed renewal: the next beat tries again */ }
133 }
134 })();
135 beat.catch(() => {});
136 try {
137 return await work();
138 } finally {
139 done = true;
140 await Promise.resolve(pending).catch(() => {});
141 }
142}
143mod/commands.js 15 lines1export const USAGE = 'Usage: /podium · /podium run <agent> <task> · /podium status · /podium cancel <job id>';
2
3export function parseCommand(args) {
4 const text = String(args ?? '').trim();
5 if (!text) return { kind: 'open' };
6 const [word, ...rest] = text.split(/\s+/);
7 if (word === 'status') return { kind: 'status' };
8 if (word === 'cancel') return rest[0] ? { kind: 'cancel', id: rest[0] } : { kind: 'usage', message: 'Usage: /podium cancel <job id>' };
9 if (word === 'run') {
10 const [agent, ...task] = rest;
11 return agent && task.length ? { kind: 'run', agent, task: task.join(' ') } : { kind: 'usage', message: 'Usage: /podium run <agent> <task>' };
12 }
13 return { kind: 'usage', message: USAGE };
14}
15mod/tools.js 272 lines1import { jobLine, sprintLines, repoValuesText, syncedText, commitsAnswerText, notedText } from './view.js';
2
3const PERMISSIONS = ['edit-worktree', 'read-only', 'own-config', 'full-access'];
4const byId = { type: 'object', properties: { id: { type: 'string', description: 'The job id' } }, required: ['id'] };
5
6export const TOOLS = [
7 {
8 name: 'dispatch',
9 description: 'Hand a task to another coding agent installed on this machine (for example codex, muse, claude or gemini) to run in the background. Use it when the user asks another agent or model to do something. Permission: leave it out for the default, "edit-worktree" (the agent edits a separate copy of the repo and the user keeps or throws away its changes); use "read-only" for reviews, research and questions; use "own-config" or "full-access" only when the user explicitly asks. The task must be self-contained: the other agent cannot see this conversation. Returns a job id at once; the result arrives later as a message. If the agent should commit on a branch, say so in the task and use permission "own-config": the separate-copy level runs agents in sandboxes that block git. Its commits are then the result and there is nothing to keep. When the user wants changes to, a review of, or more work on a job whose work is still open, continue it with followUp instead of starting a new job. Leave agent as the last step\'s agent unless the user names another.',
10 inputSchema: {
11 type: 'object',
12 properties: {
13 agent: { type: 'string', description: 'Agent name, e.g. codex, muse, claude, gemini' },
14 task: { type: 'string', description: 'Complete, self-contained instructions for the agent' },
15 followUp: { type: 'string', description: 'The id of a job to continue: the follow-up runs in that job\'s copy, after its changes, and resumes the agent\'s conversation when the agent is the same. Don\'t use with pr: a PR review can\'t be continued; dispatch with pr again.' },
16 permission: { type: 'string', enum: PERMISSIONS },
17 model: { type: 'string', description: 'Optional model id or alias for that agent' },
18 ticket: { type: 'string', description: 'Optional ticket key, e.g. ABC-123' },
19 base: { type: 'string', description: 'Optional branch or commit to start the agent\'s copy from (e.g. feat/oc-11-chapter-3) instead of the current checkout. Use it when the work belongs on another branch.' },
20 includeUncommitted: { type: 'boolean', description: "Edit jobs only: copy the user's uncommitted changes into the agent's copy" },
21 pr: { type: 'number', description: "Review this pull request number instead of doing new work: a read-only job on the PR's code. Findings come back here; they reach GitHub only when the user asks (pr tool, action post-review). The task may be empty or say what to focus on. Don't use with followUp: a PR review can't be continued; dispatch with pr again." },
22 },
23 required: ['agent', 'task'],
24 },
25 },
26 {
27 name: 'pr',
28 description: 'Work with a pull request for Podium work, only when the user asks. action "open": open a PR from a job whose changes are ready (id). "push": push a finished follow-up to its PR (id). "merge-when-green": turn merging on (on: true) or off for the PR of job id; Podium merges when every check passes. "post-review": post a finished review job (id) to its PR as one comment.',
29 inputSchema: {
30 type: 'object',
31 properties: {
32 action: { type: 'string', enum: ['open', 'push', 'merge-when-green', 'post-review'] },
33 id: { type: 'string', description: 'A job id of the work (or of the review)' },
34 on: { type: 'boolean' },
35 },
36 required: ['action', 'id'],
37 },
38 },
39 { name: 'status', description: 'List Podium jobs in this project, newest first, or one job by id.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, ticket: { type: 'string' } } } },
40 { name: 'result', description: "Read a job's summary, or its whole output with full: true.", inputSchema: { type: 'object', properties: { id: { type: 'string' }, full: { type: 'boolean' } }, required: ['id'] } },
41 { name: 'cancel', description: 'Stop a running job.', inputSchema: byId },
42 { name: 'keep', description: "Apply an edit job's changes to the user's files. Only when the user asks to keep them.", inputSchema: byId },
43 { name: 'discard', description: "Throw away an edit job's changes. Only when the user asks.", inputSchema: byId },
44 { name: 'agents', description: 'List the agents Podium can use here: installed or not, signed in or not, and each default permission.', inputSchema: { type: 'object' } },
45 {
46 name: 'sprint',
47 description: [
48 'Plan and run a sprint, only when the user asks for one: several tickets, each worked task by task by agents, with a reviewer agent checking every task, each ticket ending as a PR the user merges.',
49 'Drafting: first read each ticket and its plan if it has one, and propose in the chat a writer and a reviewer for each task (the reviewer should be a different agent from the writer). Then action "draft" (name, tickets; optional base, default main, and mergeWhenGreen) saves it as a draft and opens its board in the Podium pane. A ticket with no plan is planned first by its writer, from its summary, so give it one. Optional commands, check, taskTest, planDir and keepPlan: pass them only when the user has agreed to them in the chat. They are proposed with the draft and shown on its board, and take effect for this repo only when the user presses Start (Discard drops them); the reply says what was proposed. If the reply says the repo has no full check, propose one to the user before Start.',
50 'Starting: nothing runs yet. Tell the user to look the draft over on its board and press Start, or ask you to start it. "start" (id): start a draft, only when the user asks.',
51 'Changes to a draft before Start (id): "move" (ticket, to), "remove" (ticket), "options" (mergeWhenGreen and/or atOnce), "assign" without task (ticket, agent and/or reviewer: for every task of that ticket, or the writer and reviewer of a ticket with no plan), "after" (ticket, after), "discard" (throw the draft away).',
52 'Once it runs: "status": the board. "pause" / "resume" (id). "retry" (id, ticket; optional agent to give the stuck step to; optional text: a note from the user for the retried step, added as "note" adds one). "note" (id, ticket, text): a decision or instruction from the user for one ticket (e.g. "the date tests fail on main too: leave them"); every later planner, writer and reviewer of that ticket reads it, and a step already running doesn\'t. Only the user\'s own words, when they ask: never add a note on your own. "accept" (id, ticket, text: why the results are fine, in the user\'s words): for a ticket stopped on its task\'s tests (the writer\'s own run, or the task test on this machine), only when the user says those results are fine (e.g. "the date tests fail on main too"); the task goes on to its review, whose reviewer is told not to fail it for them. "evidence" (id, ticket, text: what the reviewer should weigh, e.g. a CI run that passed on this code): for a ticket stopped after its task failed review, only when the user gives it; the task goes back to that review (not to a fix), whose reviewer weighs it before failing the task for tests it could not run. "stop" (id, ticket): end a ticket the user has given up on; its branch is kept, and it cannot be stopped while it is working. "assign" (id, ticket, task, agent and/or reviewer) for a task not yet run (task 0: the writer and reviewer of a ticket whose plan is not approved yet). "adopt" (id): only when the user asks, take a sprint whose own chat is gone into this chat, so its updates waiting for that chat (and all later ones) come here. "sync" (id, ticket): merge the base as it is now into a ticket\'s copy, between its tasks or after the last one (for a fix that landed on the base after the ticket started); after the last task the full check runs again. Never commit in a ticket\'s copy yourself: when a ticket asks about commits in its copy that Podium didn\'t make, ask the user, then "keep-commits" (they become the ticket\'s) or "drop-commits" (id, ticket).',
53 'Answering a plan: when a ticket\'s plan is ready you get a message. Sum it up for the user and ask whether to approve it or change it (or, for a plan with no tasks, run it as one task), and act only when the user says so (id, ticket): "approve" makes its tasks the ticket\'s, which then run; "revise" (text: what should change) sends it back to its writer, in the same conversation; "one-task" runs a plan with no tasks as one task. A plan whose message says it needs your attention runs production commands (deploys, deletes, publishing, pushes): name each one to the user, and only if they agree to them, approve (or one-task) with acknowledge: true; without it Podium refuses.',
54 ].join('\n\n'),
55 inputSchema: {
56 type: 'object',
57 properties: {
58 action: { type: 'string', enum: ['draft', 'start', 'discard', 'move', 'remove', 'options', 'status', 'pause', 'resume', 'retry', 'note', 'accept', 'evidence', 'sync', 'keep-commits', 'drop-commits', 'stop', 'assign', 'after', 'approve', 'revise', 'one-task', 'adopt'] },
59 id: { type: 'string', description: 'The sprint id' },
60 name: { type: 'string' },
61 tickets: { type: 'array', items: { type: 'object' }, description: 'Action "draft": each ticket has key, title, type (feat, fix, chore…), plan, agent and reviewer, and optionally tasks, summary and after. plan: the repo path of its plan, e.g. docs/plans/2026-10-06-x.md, whose "### Task N:" sections become the tasks; leave it out for a ticket with no plan. agent and reviewer: the writer and the reviewer for its tasks. tasks: [{ n, agent, reviewer }] per-task overrides. summary: the ticket\'s text exactly as the user or the tracker gave it, never just its title. after: keys of tickets in this sprint that must be merged first; it then starts from the base branch as GitHub has it.' },
62 base: { type: 'string' },
63 mergeWhenGreen: { type: 'boolean' },
64 acknowledge: { type: 'boolean', description: 'Actions "approve" and "one-task": true only when the user has agreed to the production commands the plan runs' },
65 ticket: { type: 'string', description: 'A ticket key in the sprint' },
66 task: { type: 'number' },
67 agent: { type: 'string' },
68 reviewer: { type: 'string' },
69 to: { type: 'string', enum: ['up', 'down'], description: 'Which way to move a draft ticket' },
70 atOnce: { type: 'number', description: 'How many tickets run at the same time, 1 to 5 (default 3)' },
71 after: { type: 'array', items: { type: 'string' }, description: 'Action "after": the keys of the tickets this one waits for ([] for none)' },
72 text: { type: 'string', description: 'In the user\'s words. Action "revise": what should change in the plan. Action "note": the note for the ticket\'s agents. Action "retry": optional, a note for the retried step. Action "accept": why the task\'s test results are fine. Action "evidence": what the reviewer should weigh.' },
73 commands: { type: 'array', items: { type: 'string' }, description: 'Action "draft": the commands agents may run in this repo while they edit, as plain names (e.g. ["dotnet"]); never a shell.' },
74 check: { type: 'string', description: 'Action "draft": the repo\'s full check, run in each ticket\'s copy before its PR opens (e.g. "dotnet build && dotnet test").' },
75 taskTest: { type: 'string', description: 'Action "draft": the repo\'s task test, which Podium runs on this machine in a ticket\'s copy after each writer\'s step; its result, not the writer\'s own run, decides whether the task goes to review (e.g. "dotnet test"). For repos whose tests need what an agent\'s sandbox lacks.' },
76 planDir: { type: 'string', description: 'Action "draft": the folder sprint plans go in, relative to the repo (e.g. "docs/plans").' },
77 keepPlan: { type: 'boolean', description: 'Action "draft": true to keep each ticket\'s plan file in its PR (by default Podium leaves it out; the plan stays in Podium\'s record).' },
78 },
79 required: ['action'],
80 },
81 },
82];
83
84export function engineCall(tool, input) {
85 const id = input.id ? ['--id', String(input.id)] : [];
86 switch (tool) {
87 case 'dispatch':
88 return {
89 args: [
90 'dispatch', '--agent', String(input.agent), '--prompt', '-',
91 ...(input.permission ? ['--permission', String(input.permission)] : []),
92 ...(input.model ? ['--model', String(input.model)] : []),
93 ...(input.ticket ? ['--ticket', String(input.ticket)] : []),
94 ...(input.base ? ['--base', String(input.base)] : []),
95 ...(input.followUp ? ['--follow-up', String(input.followUp)] : []),
96 ...(input.includeUncommitted === true ? ['--include-uncommitted'] : []),
97 ...(input.pr ? ['--pr', String(input.pr)] : []),
98 ],
99 stdin: String(input.task ?? ''),
100 };
101 case 'status':
102 return { args: ['status', '--here', ...id, ...(input.ticket ? ['--ticket', String(input.ticket).toUpperCase()] : [])] };
103 case 'result':
104 return { args: ['result', ...id, ...(input.full === true ? ['--full'] : [])] };
105 case 'cancel':
106 case 'keep':
107 case 'discard':
108 return { args: [tool, ...id] };
109 case 'agents':
110 return { args: ['agents'] };
111 case 'pr': {
112 const sub = { open: 'open', push: 'push', 'merge-when-green': 'auto', 'post-review': 'post' }[String(input.action)];
113 if (!sub) throw new Error(`Unknown pr action "${input.action}"`);
114 return { args: ['pr', sub, ...id, ...(sub === 'auto' ? ['--on', String(input.on !== false)] : [])] };
115 }
116 case 'sprint': {
117 const action = String(input.action);
118 if (action === 'draft') {
119 const draft = { name: input.name, tickets: input.tickets };
120 if (input.base) draft.base = input.base;
121 if (input.mergeWhenGreen === true) draft.mergeWhenGreen = true;
122 // The repo's commands, full check, task test, plans folder and whether PRs keep the plan, proposed with the draft.
123 // Passed as given, of any type: the engine refuses a bad one with its plain message rather than the value being
124 // dropped here without a word.
125 if (input.commands !== undefined) draft.commands = input.commands;
126 if (input.check !== undefined) draft.check = input.check;
127 if (input.taskTest !== undefined) draft.taskTest = input.taskTest;
128 if (input.planDir !== undefined) draft.planDir = input.planDir;
129 if (input.keepPlan !== undefined) draft.keepPlan = input.keepPlan;
130 return { args: ['sprint', 'draft', '--json', '-'], stdin: JSON.stringify(draft) };
131 }
132 if (action === 'start' && !input.id) throw new Error('Say which sprint to start (its id). To make a new sprint, use action "draft".');
133 if (action === 'status') return { args: ['sprint', 'status', '--here', ...id] };
134 if (['start', 'discard', 'pause', 'resume'].includes(action)) return { args: ['sprint', action, ...id] };
135 // "Take them here": the mod adds this chat's --session, as for every sprint call; --here asks for this repo's
136 // sprints only.
137 if (action === 'adopt') {
138 if (!input.id) throw new Error('Say which sprint to take into this chat (its id).');
139 return { args: ['sprint', 'adopt', ...id, '--here'] };
140 }
141 if (['retry', 'note', 'accept', 'evidence', 'sync', 'keep-commits', 'drop-commits', 'assign', 'stop', 'move', 'remove', 'after', 'approve', 'revise', 'one-task'].includes(action) && !input.ticket) throw new Error('Say which ticket.');
142 const ticket = ['--ticket', String(input.ticket)];
143 // A plan waiting for the user.
144 if (action === 'approve' || action === 'one-task') return { args: ['sprint', action, ...id, ...ticket, ...(input.acknowledge === true ? ['--acknowledge'] : [])] };
145 if (action === 'revise') {
146 const text = String(input.text ?? '').trim();
147 if (!text) throw new Error('Say what should change in the plan (text).');
148 return { args: ['sprint', 'revise', ...id, ...ticket, '--text', '-'], stdin: text };
149 }
150 if (action === 'after') {
151 // A list of ticket keys, or [] for none: anything else (a string, a number, a list with something other than a
152 // key in it) is refused here, plainly, before the engine sees it. A comma would split a key on the command line.
153 const isKey = (k) => typeof k === 'string' && k.trim() !== '' && !k.includes(',');
154 if (!Array.isArray(input.after) || !input.after.every(isKey)) throw new Error('Say which tickets it waits for (after: a list of keys; [] for none).');
155 const keys = input.after.map((k) => k.trim());
156 return { args: ['sprint', 'after', ...id, ...ticket, '--keys', keys.length ? keys.join(',') : 'none'] };
157 }
158 if (action === 'stop') return { args: ['sprint', 'stop', ...id, ...ticket] };
159 if (action === 'sync') return { args: ['sprint', 'sync', ...id, ...ticket] };
160 if (action === 'keep-commits' || action === 'drop-commits') return { args: ['sprint', 'commits', ...id, ...ticket, '--answer', action === 'keep-commits' ? 'keep' : 'drop'] };
161 if (action === 'note') {
162 const text = String(input.text ?? '').trim();
163 if (!text) throw new Error('Say what the note is (text).');
164 return { args: ['sprint', 'note', ...id, ...ticket, '--text', '-'], stdin: text };
165 }
166 if (action === 'evidence') {
167 const text = String(input.text ?? '').trim();
168 if (!text) throw new Error('Say what the reviewer should weigh (text).');
169 return { args: ['sprint', 'evidence', ...id, ...ticket, '--text', '-'], stdin: text };
170 }
171 if (action === 'accept') {
172 const text = String(input.text ?? '').trim();
173 if (!text) throw new Error('Say why the test results are fine (text).');
174 return { args: ['sprint', 'accept', ...id, ...ticket, '--text', '-'], stdin: text };
175 }
176 if (action === 'retry') {
177 // A note goes by stdin, like any text the user wrote.
178 const note = String(input.text ?? '').trim();
179 const args = ['sprint', 'retry', ...id, ...ticket, ...(input.agent ? ['--agent', String(input.agent)] : [])];
180 return note ? { args: [...args, '--note', '-'], stdin: note } : { args };
181 }
182 if (action === 'move') {
183 if (input.to !== 'up' && input.to !== 'down') throw new Error('Say which way to move it: up or down.');
184 return { args: ['sprint', 'move', ...id, ...ticket, '--to', input.to] };
185 }
186 if (action === 'remove') return { args: ['sprint', 'remove', ...id, ...ticket] };
187 if (action === 'options') {
188 const hasMerge = typeof input.mergeWhenGreen === 'boolean';
189 const hasAtOnce = input.atOnce !== undefined && input.atOnce !== null && input.atOnce !== '';
190 if (!hasMerge && !hasAtOnce) throw new Error('Say what to change: mergeWhenGreen or atOnce.');
191 return { args: ['sprint', 'options', ...id, ...(hasMerge ? ['--merge-when-green', String(input.mergeWhenGreen)] : []), ...(hasAtOnce ? ['--at-once', String(input.atOnce)] : [])] };
192 }
193 if (action === 'assign') {
194 // Without a task: every task of a draft ticket.
195 const hasTask = input.task !== undefined && input.task !== null && input.task !== '';
196 return { args: ['sprint', 'assign', ...id, ...ticket, ...(hasTask ? ['--task', String(input.task)] : []), ...(input.agent ? ['--agent', String(input.agent)] : []), ...(input.reviewer ? ['--reviewer', String(input.reviewer)] : [])] };
197 }
198 throw new Error(`Unknown sprint action "${input.action}"`);
199 }
200 default:
201 throw new Error(`Unknown Podium tool "${tool}"`);
202 }
203}
204
205// A job's one line in the status list.
206const statusLine = (j) => { const l = jobLine(j); return `${l.icon} ${j.id} ${l.who}: ${l.what} (${l.when})`; };
207
208// input: the tool's own input (status asked by id shows a sprint step, which the full list leaves out).
209export function formatToolResult(tool, r, input = {}) {
210 if (!r.ok) return `Podium could not do that (${r.error.code}): ${r.error.message}`;
211 const v = r.value;
212 if (tool === 'dispatch') {
213 const warnings = (v.warnings ?? []).map((w) => `- ${w.message}`);
214 return [`Started job ${v.job.id}. ${v.permissionText}.`, ...(warnings.length ? ['Warnings:', ...warnings] : []), 'The result will arrive as a message when it finishes.'].join('\n');
215 }
216 if (tool === 'status') {
217 if (!v.jobs.length) return 'No Podium jobs in this project yet.';
218 // Asked by id, a sprint step is shown, with where it belongs.
219 const asked = input?.id ? v.jobs.find((j) => j.id === String(input.id)) : null;
220 if (asked?.sprint) {
221 const name = (v.sprints ?? []).find((s) => s.id === asked.sprint.id)?.name ?? asked.sprint.id;
222 const what = asked.sprint.plan ? `${asked.sprint.ticket}'s plan` : `${asked.sprint.ticket} task ${asked.sprint.task}`;
223 const role = asked.sprint.role === 'reviewer' ? 'reviewer' : 'writer';
224 return `${statusLine(asked)} · part of sprint ${name}, ${what} (${role}). The sprint tool manages it (action status).`;
225 }
226 // Sprint steps are the sprint's to manage: the sprint tool shows them.
227 const jobs = v.jobs.filter((j) => !j.sprint);
228 if (!jobs.length) return 'No Podium jobs in this project yet. Sprints: use the sprint tool (action status).';
229 return jobs.slice(0, 20).map(statusLine).join('\n');
230 }
231 if (tool === 'result') {
232 return [
233 `Job ${v.job.id}: ${v.job.status}.`,
234 v.summary ? `Summary:\n${v.summary.trim()}` : 'No summary.',
235 v.output ? `Full output:\n${v.output.slice(-50_000)}` : null,
236 v.hasDiff ? 'It has changes waiting for review.' : null,
237 ].filter(Boolean).join('\n\n');
238 }
239 if (tool === 'agents') {
240 return v.agents.map((a) => `${a.name}: ${a.installed ? `installed (${a.version})` : 'not installed'}${a.signedIn === false ? ', not signed in' : ''}${a.enabled ? '' : ', switched off'}; default ${a.defaultPermission}`).join('\n');
241 }
242 if (tool === 'pr' && v.job?.pr) return `PR #${v.job.pr.number}: ${v.job.status}${v.job.pr.autoMerge ? ', merges when green' : ''}. ${v.job.pr.url}`;
243 if (tool === 'sprint') {
244 if (v.sprint) {
245 const note = v.sprint.draft && !v.sprint.discarded
246 ? '\n\nThis is a draft: nothing runs yet. Tell the person to look it over on its board in the Podium pane (they can change who writes and reviews each task, the order and the options there) and press Start, or ask you to start it.'
247 : '';
248 // The draft's notes (draftNotes): a plan-less ticket with only its title to plan from.
249 const notes = (v.notes ?? []).map((n) => `\n\n${n}`).join('');
250 // What the draft proposed for the repo, saved only at Start; and what Start saved.
251 const proposed = repoValuesText(v.proposed);
252 const applied = repoValuesText(v.applied);
253 const repo = [
254 proposed ? `\n\nProposed for this repo: ${proposed}. Nothing is saved yet: these take effect for the repo when the person presses Start (Discard drops them). They are shown on the draft's board.` : '',
255 applied ? `\n\nSaved for this repo at Start: ${applied}.` : '',
256 ].join('');
257 // What Sync with base brought in, or what Keep or Drop did with commits Podium didn't make.
258 const done = [
259 typeof v.commits === 'number' && input.ticket ? `\n\n${syncedText(String(input.ticket).toUpperCase(), v.sprint.base, v.commits, v.saved)}` : '',
260 Array.isArray(v.dropped) && input.ticket ? `\n\n${commitsAnswerText(String(input.ticket).toUpperCase(), input.action === 'keep-commits', v.dropped, v.saved)}` : '',
261 v.noted ? `\n\n${notedText(v.noted.key, v.noted.running)}` : '',
262 ].join('');
263 return `Sprint ${v.sprint.id}:\n${sprintLines(v.sprint).join('\n')}${note}${notes}${repo}${done}`;
264 }
265 // A sprint Podium could not read just now (busy) is left out; the next ask shows it.
266 const list = (v.sprints ?? []).filter((s) => !s.busy);
267 if (!list.length) return v.sprints?.length ? 'Podium could not read the sprint just now. Ask again in a moment.' : 'No sprints in this project yet.';
268 return list.map((s) => (s.unreadable ? sprintLines(s).join('\n') : `Sprint ${s.id}:\n${sprintLines(s).join('\n')}`)).join('\n\n');
269 }
270 return `Job ${v.job.id} is now ${v.job.status}.`;
271}
272mod/view.js 1065 lines1// Plain-language view of engine jobs for the pane, the status line and chat messages.
2
3const WORKING = new Set(['queued', 'running']);
4const NEEDS_YOU = new Set(['needs-review', 'failed', 'lost']);
5const ICON = { working: '⏳', 'needs-you': '✋', done: '✅' };
6const DONE_WORDS = { done: 'Done', kept: 'Kept', discarded: 'Thrown away', cancelled: 'Cancelled' };
7// Mirrors engine/adapters/build.js; the mod cannot import engine code (hooks run without Node).
8const PERMISSION_WORDS = {
9 'read-only': 'could only read files',
10 'edit-worktree': 'could edit files in a separate copy',
11 'own-config': 'used its own settings',
12 'full-access': 'had full access',
13};
14const FINISHED_VERB = {
15 done: 'finished',
16 'needs-review': 'finished with changes to review',
17 failed: 'stopped with an error',
18 lost: 'stopped unexpectedly',
19};
20
21export function jobState(job) {
22 if (job.status === 'in-review') return job.pr?.attention ? 'needs-you' : 'done';
23 if (WORKING.has(job.status)) return job.isLong ? 'needs-you' : 'working';
24 return NEEDS_YOU.has(job.status) ? 'needs-you' : 'done';
25}
26
27export function agentLabel(name) {
28 const s = String(name ?? '');
29 return s.charAt(0).toUpperCase() + s.slice(1);
30}
31
32export function shorten(text, max = 60) {
33 const line = String(text ?? '').split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? '';
34 return line.length > max ? `${line.slice(0, max - 1)}…` : line;
35}
36
37export function duration(ms) {
38 const s = Math.max(0, Math.round(ms / 1000));
39 if (s < 60) return `${s}s`;
40 const m = Math.round(s / 60);
41 return m < 60 ? `${m} min` : `${Math.round(m / 60)} h`;
42}
43
44export function jobLine(job, now = Date.now()) {
45 const state = jobState(job);
46 const since = (iso) => duration(now - Date.parse(iso));
47 let when;
48 if (WORKING.has(job.status)) {
49 const ran = since(job.startedAt ?? job.createdAt);
50 when = job.isLong ? `Still running after ${ran}. Keep going or cancel?` : `${ran} so far`;
51 } else if (job.status === 'needs-review') when = 'Changes ready to review';
52 else if (job.status === 'failed') when = 'Stopped with an error';
53 else if (job.status === 'lost') when = 'Stopped unexpectedly';
54 else when = `${DONE_WORDS[job.status] ?? 'Done'} ${since(job.endedAt ?? job.createdAt)} ago`;
55 return {
56 id: job.id,
57 state,
58 icon: ICON[state],
59 who: `${agentLabel(job.agent)}${state === 'needs-you' ? ' — needs you' : ''}`,
60 what: shorten(job.title || 'Untitled job'),
61 when,
62 ticket: job.ticket,
63 };
64}
65
66export function statusText(jobs) {
67 const working = jobs.filter((j) => jobState(j) === 'working').length;
68 const needs = jobs.filter((j) => jobState(j) === 'needs-you').length;
69 if (!working && !needs) return undefined;
70 const parts = [working ? `${working} working` : null, needs ? `${needs} needs you` : null].filter(Boolean);
71 return `🎼 Podium: ${parts.join(' · ')}`;
72}
73
74// How long another chat may take a plain job's notice; the engine refuses it after that (engine/delivery.js). Those
75// are left out here, so an old backlog costs no claim per job on every refresh. Without a chat id the engine decides each.
76export const OTHER_CHATS_MS = 3_600_000;
77export function newlyFinished(jobs, { session = null, now = Date.now() } = {}) {
78 const tooOld = (j) => Boolean(session) && !j.sprint && j.origin?.session !== session
79 && now - Date.parse(j.notifiedAt ?? j.endedAt ?? j.createdAt) > OTHER_CHATS_MS;
80 return jobs.filter((j) => !j.delivered && ['done', 'needs-review', 'failed', 'lost', 'in-review', 'merged', 'closed'].includes(j.status) && !tooOld(j));
81}
82
83// took: false where the line already says how long it took (the detail card).
84export function detailLines(job, { took: withTook = true } = {}) {
85 const ticket = job.ticket === 'NO-TICKET' ? 'No ticket' : `Ticket ${job.ticket}`;
86 const took = withTook && job.startedAt && job.endedAt ? `took ${duration(Date.parse(job.endedAt) - Date.parse(job.startedAt))}` : null;
87 const perm = `${agentLabel(job.agent)} ${PERMISSION_WORDS[job.permission] ?? `used "${job.permission}"`}`;
88 return [ticket, took, perm].filter(Boolean).join(' · ');
89}
90
91export function committedText(job) {
92 const c = job.committed;
93 if (!c) return null;
94 const where = c.branch ? `Committed to ${c.branch}` : 'Committed on a detached HEAD';
95 return `${where} · ${c.count} commit${c.count === 1 ? '' : 's'}`;
96}
97
98// A PR opened, merged or closed: a message of its own, with no summary to write.
99const PR_VERB = { 'in-review': 'opened', merged: 'merged', closed: 'was closed' };
100
101export function isPrEvent(job) {
102 return Boolean(PR_VERB[job.status] && job.pr);
103}
104
105// What Claude is asked to do with a message the person sees only one line of: a short answer that ends with
106// the decision, never the line again, and the mechanics only when asked.
107export const REPLY_RULE = 'The person sees only Podium\'s one-line message, not this note. Reply in one or two short sentences: what it means for them, ending with the decision if there is one (for example "Keep it?" or "Approve it?"). Do not repeat their line. Leave out paths, ids, branches, timings and how follow-ups work unless they ask.';
108
109// `visible` is for the fallback, where the person sees the whole note: the closing line about what the
110// person saw would then be untrue, so it is left out (the tool hints stay; Claude still needs them).
111function prMessage(job, visible = false) {
112 const ticket = job.ticket && job.ticket !== 'NO-TICKET' ? ` for ${job.ticket}` : '';
113 const pr = job.pr;
114 const what = job.status === 'in-review'
115 ? `PR #${pr.number} (${pr.url}) is open on ${pr.branch}${ticket}. Podium watches its checks; Merge when green is ${pr.autoMerge ? 'on' : 'off'}.`
116 : job.status === 'merged'
117 ? `PR #${pr.number} (${pr.url}) merged into ${pr.base}${ticket}. Podium removed its copy and the branch.`
118 : `PR #${pr.number} (${pr.url}) was closed without merging${ticket}. Podium removed its copy.`;
119 const process = job.status === 'in-review'
120 ? 'If this project has a close-out process for an opened PR (for example moving the ticket to In Review), follow it now.'
121 : job.status === 'merged' ? 'If this project has a post-merge close-out (ticket done with evidence, docs, lessons), follow it now.' : null;
122 return [`[Podium] ${what}`, process, `Job ${job.id}.`, visible ? null : REPLY_RULE]
123 .filter(Boolean).join('\n\n');
124}
125
126// What the person sees in the transcript: one line, what happened and where to act. The details ride along as
127// a note only Claude reads (finishedMessage), so the reply does not repeat a wall of text.
128export function finishedHeadline(job) {
129 // Named for the PR, not the step that ended it ("CI failed on PR #2 …" read as the work).
130 if (isPrEvent(job)) return `Podium: PR #${job.pr.number} ${PR_VERB[job.status]}: "${shorten(job.pr.title ?? job.title, 60)}".`;
131 const who = agentLabel(job.agent);
132 const title = `"${shorten(job.title, 60)}"`;
133 if (job.status === 'needs-review') return `Podium: ${who} finished ${title}: ${job.pr ? `new changes for PR #${job.pr.number}, to push in the pane` : 'changes to review in the pane'}.`;
134 if (job.status === 'failed') return `Podium: ${who} stopped with an error on ${title}.`;
135 if (job.status === 'lost') return `Podium: ${who} stopped unexpectedly on ${title}.`;
136 if (job.committed) return `Podium: ${who} finished ${title}: committed to ${job.committed.branch ?? 'a detached HEAD'}.`;
137 return `Podium: ${who} finished ${title}.`;
138}
139
140export function finishedMessage(job, summary, visible = false) {
141 if (isPrEvent(job)) return prMessage(job, visible);
142 const lines = [`[Podium] ${agentLabel(job.agent)} ${FINISHED_VERB[job.status] ?? 'finished'}: "${shorten(job.title, 80)}" (${detailLines(job)}).`];
143 if (summary && summary.trim()) lines.push('', 'Summary:', summary.trim());
144 else if (job.error) lines.push('', job.error);
145 if (job.warning) lines.push('', `${job.warning.message}${job.warning.repair ? ' Podium can repair this: open the job in the Podium pane and press Repair.' : ''}`);
146 if (job.committed) {
147 const extra = job.leftovers ? " The agent also left uncommitted edits in Podium's copy." : '';
148 lines.push('', `${committedText(job)}. The commits are already on that branch; there is nothing to keep.${extra}`);
149 } else if (job.status === 'needs-review' && job.pr) {
150 lines.push('', `These changes are for PR #${job.pr.number} and are waiting in Podium's copy${job.worktree ? ` at ${job.worktree}` : ''}. Push them to the PR from the pane (Push to PR) or with the pr tool (action push), or ask for more changes.`);
151 } else if (job.status === 'needs-review') {
152 const where = job.worktree ? ` at ${job.worktree}` : '';
153 lines.push('', `Its changes are waiting in the Podium pane. Keep or throw them away there, or ask me to. Until they are kept they live only in Podium's separate copy${where}, not in the person's folder: read them there, not in the folder.`);
154 }
155 if (job.threadId && !['kept', 'discarded'].includes(job.status)) {
156 lines.push('', `Thread ${job.threadId} · step ${job.step ?? 1}. To change this work: dispatch with followUp: ${job.id}.`);
157 }
158 lines.push('', `Job ${job.id}. Use podium_result with full: true if you need the whole output.`);
159 if (!visible) lines.push('', REPLY_RULE);
160 return lines.join('\n');
161}
162
163// ---- Pane UX ----
164
165const LANE = { 'needs-review': 'needs', queued: 'working', running: 'working' };
166const FAILED = new Set(['failed', 'lost', 'cancelled']);
167const DONE_LIMIT = 30;
168const QUIET_MS = 10_000;
169
170export function laneOf(job) {
171 if (job.status === 'in-review') return job.pr?.attention ? 'needs' : 'review';
172 return LANE[job.status] ?? 'done';
173}
174
175export function toneOf(job) {
176 const lane = laneOf(job);
177 if (lane === 'review') return 'neutral';
178 return lane === 'done' && FAILED.has(job.status) ? 'failed' : lane;
179}
180
181export function lanes(jobs) {
182 const out = { needs: [], working: [], done: [] };
183 for (const job of jobs) out[laneOf(job)].push(job);
184 out.done = out.done.slice(0, DONE_LIMIT);
185 return out;
186}
187
188export function clock(ms) {
189 const s = Math.max(0, Math.round(ms / 1000));
190 if (s < 60) return `${s}s`;
191 const m = Math.floor(s / 60);
192 if (m < 60) return `${m}m ${s % 60}s`;
193 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`;
194}
195
196export function elapsedText(job, now = Date.now()) {
197 return clock(now - Date.parse(job.startedAt ?? job.createdAt));
198}
199
200export function doneText(job, now = Date.now()) {
201 const took = job.startedAt && job.endedAt ? `took ${clock(Date.parse(job.endedAt) - Date.parse(job.startedAt))}` : null;
202 const ago = `${clock(now - Date.parse(job.endedAt ?? job.createdAt))} ago`;
203 return [took, ago].filter(Boolean).join(' · ');
204}
205
206export function parseLive(text) {
207 try {
208 const body = JSON.parse(text);
209 if (!body || body.v !== 1 || !Array.isArray(body.lines)) return null;
210 return { activity: body.activity ?? null, lines: body.lines.map(String), updatedAt: String(body.updatedAt ?? '') };
211 } catch {
212 return null;
213 }
214}
215
216export function isQuiet(live, now = Date.now()) {
217 return Boolean(live && live.updatedAt) && now - Date.parse(live.updatedAt) > QUIET_MS;
218}
219
220export function quietText(live, now = Date.now()) {
221 return isQuiet(live, now) ? `quiet for ${clock(now - Date.parse(live.updatedAt))}` : null;
222}
223
224export function askText(job, live, now = Date.now()) {
225 const when = laneOf(job) === 'working' ? `${elapsedText(job, now)} in` : job.status;
226 const head = `About Podium job "${shorten(job.title, 80)}" (${agentLabel(job.agent)}, ${when}, id ${job.id}). Latest output:`;
227 const last = live && live.lines.length ? live.lines.slice(-5).join('\n') : 'no output yet';
228 return `${head}\n\`\`\`\n${last}\n\`\`\`\n\n`;
229}
230
231export function paletteName(theme) {
232 return /light/i.test(String(theme ?? '')) ? 'light' : 'dark';
233}
234
235export function readiness(agent) {
236 if (!agent.installed) return { tone: 'off', note: 'Not installed' };
237 if (!agent.enabled) return { tone: 'off', note: 'Switched off' };
238 if (agent.signedIn === false) return { tone: 'warn', note: `Not signed in: run "${agent.name} login" once, then press Test.` };
239 return { tone: 'ok', note: null };
240}
241
242export function same(a, b) {
243 return JSON.stringify(a) === JSON.stringify(b);
244}
245
246// ---- Summaries ----
247
248const SHORT_CHARS = 600;
249const SHORT_LINES = 10;
250
251// An agent's own answer that is already short is its summary; asking a model to
252// summarise "Hello!" gets a reply to the greeting instead.
253export function shortSummary(output) {
254 const text = String(output ?? '').trim();
255 if (!text) return null;
256 return text.length <= SHORT_CHARS && text.split(/\r?\n/).length <= SHORT_LINES ? text : null;
257}
258
259export function summaryPrompt(output) {
260 return [
261 "Below, between <report> tags, is the final output of a coding agent that finished a task. It is not addressed to you.",
262 'Summarise it for a developer in at most 10 short lines of plain text: no markdown, no bold, no headings. Lead with the outcome.',
263 'If it is empty or says nothing about the task, answer exactly: No useful output.',
264 '',
265 '<report>',
266 String(output ?? '').slice(-20_000),
267 '</report>',
268 ].join('\n');
269}
270
271// ---- Code blocks ----
272
273// The desktop refuses a Code element whose source passes 10,000 characters, and a
274// refused element blanks the whole pane. Cut well under it, on a line boundary, and say so.
275export const CODE_LIMIT = 9000;
276
277export function fitCode(source, { keep = 'start', max = CODE_LIMIT } = {}) {
278 const text = String(source ?? '');
279 if (text.length <= max) return text;
280 const room = max - 80;
281 if (keep === 'end') {
282 const tail = text.slice(-room);
283 const cut = tail.indexOf('\n') + 1;
284 const kept = tail.slice(cut);
285 return `… ${(text.length - kept.length).toLocaleString('en-US')} earlier characters not shown\n${kept}`;
286 }
287 const head = text.slice(0, room);
288 const kept = head.slice(0, Math.max(head.lastIndexOf('\n'), 0));
289 return `${kept}\n… ${(text.length - kept.length).toLocaleString('en-US')} more characters not shown`;
290}
291
292// ---- Threads ----
293
294export function threads(jobs) {
295 const byId = new Map();
296 // Sprint steps belong to their sprint's card, not the lanes.
297 for (const j of jobs.filter((x) => !x.sprint)) {
298 const id = j.threadId ?? j.id;
299 if (!byId.has(id)) byId.set(id, []);
300 byId.get(id).push(j);
301 }
302 return [...byId.entries()]
303 .map(([id, steps]) => {
304 // Reviews join a thread but are never the step to continue.
305 const work = steps.filter((s) => s.kind !== 'review').sort((a, b) => (b.step ?? 1) - (a.step ?? 1));
306 const all = work.length === steps.length ? work : [...steps].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
307 return { id, latest: work[0] ?? all[0], steps: all };
308 })
309 .sort((a, b) => Date.parse(b.latest.createdAt) - Date.parse(a.latest.createdAt));
310}
311
312// A sprint ticket's steps, newest first: its writers' steps and its reviews. The detail
313// screen lists them as it lists a thread's.
314export function ticketSteps(jobs, sprint) {
315 return jobs
316 .filter((j) => j.sprint && j.sprint.id === sprint.id && j.sprint.ticket === sprint.ticket)
317 .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || String(b.id).localeCompare(String(a.id)));
318}
319
320// A sprint step's row in Steps and diff says what the step did ("Claude wrote task 1", "Codex reviewed task 1 ·
321// passed"), not its place in a follow-up chain: every review is a job of its own and an author's steps count on,
322// so that number means nothing here. `steps` is the ticket's steps (ticketSteps), which tell what a review led
323// to. Podium records no verdict on the job, so a review is passed or failed only when a later step shows it: the
324// next task's writer means it passed, a fix of the same task means it failed. Otherwise the outcome is left off.
325// A step's own record wins over any inference: a step run again (Retry, a usage-limit wait) is followed up
326// by the new one, which marks it `continued` with its real end in `endStatus`, so a failed step still says it failed.
327const FIX_TITLE = /fix what the reviewer found/i;
328// The writer's fix after a failed full check: it follows the last task, but no review sent it back.
329const CHECK_FIX_TITLE = /fix what the full check found/i;
330const CHANGE_TITLE = /change the plan/i;
331const STEP_FAILED = new Set(['failed', 'lost', 'cancelled']);
332const startedBefore = (a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt) || String(a.id).localeCompare(String(b.id));
333const stepEnd = (step) => (step.status === 'continued' ? (step.endStatus ?? 'done') : step.status);
334
335function isFix(step, steps) {
336 if (FIX_TITLE.test(step.title ?? '')) return true;
337 // Only a review that ran to its end can send its task back for a fix.
338 return steps.some((x) => x.sprint?.role === 'reviewer' && x.sprint.task === step.sprint.task && !STEP_FAILED.has(stepEnd(x)) && startedBefore(x, step) < 0);
339}
340
341function reviewOutcome(step, steps) {
342 const later = steps.filter((x) => x.sprint?.role === 'author' && !x.sprint.plan && !CHECK_FIX_TITLE.test(x.title ?? '') && startedBefore(step, x) < 0);
343 if (later.some((x) => x.sprint.task === step.sprint.task)) return 'failed';
344 if (later.some((x) => x.sprint.task > step.sprint.task)) return 'passed';
345 return null;
346}
347
348export function sprintStepLine(step, steps, now = Date.now()) {
349 const who = agentLabel(step.agent) || 'No agent';
350 const n = step.sprint.task;
351 const reviewer = step.sprint.role === 'reviewer';
352 // A ticket's plan: written, or changed with the person's notes. The step says so (sprint.plan); its task
353 // number (0) does not, as a plan may number a real task 0.
354 const planning = !reviewer && step.sprint.plan === true;
355 const checkFix = !reviewer && !planning && CHECK_FIX_TITLE.test(step.title ?? '');
356 const fix = !reviewer && !planning && !checkFix && isFix(step, steps);
357 const verbs = reviewer ? ['reviewed', 'reviewing'] : planning && CHANGE_TITLE.test(step.title ?? '') ? ['changed', 'changing'] : fix || checkFix ? ['fixed', 'fixing'] : ['wrote', 'writing'];
358 const what = planning ? 'the plan' : checkFix ? 'what the full check found' : `task ${n}`;
359 const ago = step.endedAt ? `${clock(now - Date.parse(step.endedAt))} ago` : null;
360 const status = stepEnd(step);
361 if (['running', 'queued'].includes(status)) return `${who} ${verbs[1]} ${what} · ${status === 'running' ? 'working' : 'queued'}`;
362 if (STEP_FAILED.has(status)) {
363 const word = status === 'lost' ? 'stopped' : status;
364 return [`${who} couldn't ${reviewer ? 'review' : 'finish'} ${what}`, word, ago].filter(Boolean).join(' · ');
365 }
366 return [`${who} ${verbs[0]} ${what}`, reviewer ? reviewOutcome(step, steps) : null, ago].filter(Boolean).join(' · ');
367}
368
369// ---- Organising jobs ----
370
371const DONE_MIN = 5;
372const OTHERS_LIMIT = 30;
373
374export function finishedAt(thread) {
375 return Date.parse(thread.latest.endedAt ?? thread.latest.createdAt);
376}
377
378function sameDay(a, b) {
379 const x = new Date(a);
380 const y = new Date(b);
381 return x.getFullYear() === y.getFullYear() && x.getMonth() === y.getMonth() && x.getDate() === y.getDate();
382}
383
384// A thread is another chat's when no step of it was started in this one. With no chat id (it could not be read) none is.
385export const fromAnotherChat = (steps, session) => Boolean(session) && !(steps ?? []).some((s) => s.origin?.session === session);
386
387// A thread is this chat's when any step was started here. With no chat id
388// (it could not be read) everything is this chat's: the list as it was.
389export function organise(list, { session = null, now = Date.now(), clearedAt = null, showAll = false } = {}) {
390 const out = { needs: [], working: [], review: [], done: [], doneHidden: 0, others: [] };
391 const mineDone = [];
392 for (const t of list) {
393 const item = { ...t, isMine: !fromAnotherChat(t.steps, session) };
394 const lane = laneOf(t.latest);
395 if (lane !== 'done') out[lane].push(item);
396 else if (item.isMine) mineDone.push(item);
397 else out.others.push(item);
398 }
399 const newestFirst = (a, b) => finishedAt(b) - finishedAt(a);
400 mineDone.sort(newestFirst);
401 out.others = out.others.sort(newestFirst).slice(0, OTHERS_LIMIT);
402 if (showAll) {
403 out.done = mineDone;
404 return out;
405 }
406 const cleared = clearedAt ? Date.parse(clearedAt) : -Infinity;
407 out.done = mineDone.filter((t) => finishedAt(t) > cleared).filter((t, i) => i < DONE_MIN || sameDay(finishedAt(t), now));
408 out.doneHidden = mineDone.length - out.done.length;
409 return out;
410}
411
412// ---- Install path ----
413
414// The welcome card's rows: ok true (✓), false (✋, needs fixing) or null (a neutral note).
415export function checkRows(report, agentList) {
416 const rows = [];
417 rows.push(report.node.ok
418 ? { ok: true, text: `Node ${report.node.version}` }
419 : { ok: false, text: `Node ${report.node.version ?? 'not found'}: Podium needs Node 22 or newer (nodejs.org)` });
420 rows.push(report.git.ok
421 ? { ok: true, text: `git ${report.git.version}` }
422 : { ok: false, text: report.git.version ? `git ${report.git.version}: Podium needs git 2.28 or newer (git-scm.com)` : 'git not found: install it from git-scm.com' });
423 if (agentList === null) rows.push({ ok: null, text: 'Looking for agents…' });
424 else {
425 const found = agentList.filter((a) => a.installed);
426 rows.push(found.length
427 ? { ok: true, text: `Agents: ${found.map((a) => `${a.label} ${a.version ?? ''}`.trim()).join(' · ')}` }
428 : { ok: false, text: 'No agents found: install Codex, Muse, Gemini or another agent CLI, then press Check again' });
429 for (const a of agentList.filter((x) => !x.installed)) rows.push({ ok: null, text: `${a.label} not installed` });
430 }
431 rows.push(!report.gh.installed
432 ? { ok: null, text: 'GitHub CLI not installed: needed for PRs (cli.github.com)' }
433 : report.gh.signedIn
434 ? { ok: true, text: `GitHub CLI ${report.gh.version}` }
435 : { ok: null, text: 'GitHub CLI not signed in: needed for PRs (sign in with gh auth login)' });
436 rows.push(report.tellClaude
437 ? { ok: true, text: 'Claude is told when jobs finish' }
438 : { ok: false, text: "Claude isn't told when jobs finish: turn it on in Settings" });
439 if (report.copies && report.copies.ok === false) {
440 rows.push({ ok: false, text: "Podium's copies are in a folder whose path has a space or %: some repos' tests fail there. Set a copies folder without spaces (setting worktreeRoot, a full path)." });
441 }
442 return rows;
443}
444
445export function sizeText(bytes) {
446 if (bytes < 1024) return `${bytes} bytes`;
447 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
448 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
449}
450
451const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
452
453export function removalText(plan) {
454 const parts = [`This deletes ${sizeText(plan.bytes)} of Podium's data${plan.copies ? `, including ${plural(plan.copies, 'copy', 'copies')}` : ''}.`];
455 if (plan.unkept) parts.push(`${plural(plan.unkept, 'job', 'jobs')} with unkept changes will be lost.`);
456 if (plan.running) parts.push(`${plural(plan.running, 'running job', 'running jobs')} will be stopped.`);
457 return parts.join(' ');
458}
459
460// ---- PR cards ----
461
462export const REVIEW_WORDS = { APPROVED: 'approved', CHANGES_REQUESTED: 'changes requested', REVIEW_REQUIRED: 'waiting' };
463
464// ---- Sprints ----
465
466// Safe for anything (a number or an object from malformed sprint data draws as text, never throws).
467const named = (a) => agentLabel(a);
468const isReviewing = (phase) => phase === 'review' || phase === 'fix-review';
469
470// A plan waiting for the person that runs production commands: their lines on the open ticket (attention, and how many
471// more past them, attentionMore), a count on the others (attentionCount). Said beside "plan ready" wherever that is said,
472// and Approve asks first.
473export const flaggedOf = (t) => ((t?.attention?.length ?? 0) + (t?.attentionMore ?? 0)) || (t?.attentionCount ?? 0);
474const withAttention = (t, text) => (flaggedOf(t) ? `${text} · needs your attention` : text);
475export function attentionHead(n) {
476 return `Needs your attention: this plan runs ${n === 1 ? 'a production command' : `${n} production commands`}`;
477}
478// With only their count on this screen (the pane sent a short form of the sprint), the person agrees in the chat instead.
479export function ackUnseen(n) {
480 return `${n === 1 ? 'Its production command is not shown here' : `Not all of its ${n} production commands are shown here`}. Ask Claude to list ${n === 1 ? 'it' : 'them'}, and approve there once you agree.`;
481}
482export function ackQuestion(n, whole = false) {
483 return `The agent will run ${n === 1 ? 'that command' : 'those commands'} as the plan says. ${whole ? 'Run the plan as one task' : 'Approve the plan'} anyway?`;
484}
485const SHIPPED_STATES = new Set(['pr-open', 'merged', 'closed']);
486// A ticket with nothing left to do: its PR is merged or closed, or the person stopped it.
487export const SPRINT_ENDED = new Set(['merged', 'closed', 'stopped']);
488// A ticket waiting for the person: parked, or its plan waiting for an answer.
489export const NEEDS_YOU_STATES = new Set(['parked', 'plan-ready', 'no-tasks']);
490// How many tickets run at once when a sprint does not say: the engine's default (settings.sprintParallel).
491export const AT_ONCE_DEFAULT = 3;
492// A pending ticket that may start: whether a slot is free for it (the summary's `slot`).
493const slotWords = (t) => (t.slot === 'full' ? 'waiting for a free slot' : t.slot === 'free' ? 'starting' : null);
494const isPlanning = (phase) => phase === 'plan' || phase === 'revise';
495
496// A sprint card stays while any ticket is still going, and always when the sprint's state can't be read. A capped card
497// (`more`) was open when it was cut, so it stays too; a finished sprint's row (`ended`) does not.
498export function isOpenSprint(s) {
499 if (s.unreadable) return true;
500 if (s.ended === true) return false;
501 return s.more > 0 || (s.tickets ?? []).some((t) => !SPRINT_ENDED.has(t.state));
502}
503
504// A finished sprint: started, not thrown away, readable, and every ticket ended. Its list row sits in the
505// Done fold (this chat's) or the Other chats fold. One archived because it ended (its news delivered; `podium status --here`
506// still lists the newest) is finished; one archived because it was discarded never is. A sprint with no tickets has not
507// finished anything.
508export function isFinishedSprint(s) {
509 if (!s || s.draft || s.discarded || s.unreadable) return false;
510 if (s.ended === true) return true;
511 return (s.tickets ?? []).length > 0 && !isOpenSprint(s);
512}
513
514// A finished sprint's tone: failed if any ticket was stopped or its PR closed, otherwise done.
515export function finishedSprintTone(s) {
516 if (typeof s.tone === 'string') return s.tone;
517 return (s.tickets ?? []).some((t) => t.state === 'stopped' || t.state === 'closed') ? 'failed' : 'done';
518}
519
520// How a row says it was started in another chat: a job's (workingRowLine, turnItems, In review) and a sprint's.
521export const FROM_OTHER_CHAT = 'from another chat';
522
523// A sprint is this chat's when it was started here (`chat`), or when either side's chat is unknown: with no
524// session everything is this chat's (as organise), and a sprint with no chat takes news from any chat, so it is not labelled.
525export const isSprintMine = (s, session) => !session || !s?.chat || s.chat === session;
526
527// Finished sprints split into this chat's (Done) and other chats' (Other chats), each newest first by finishedAt. The
528// list shows FINISHED_SPRINTS of each, and fitModel sends no more.
529export const FINISHED_SPRINTS = 5;
530const finishedMs = (s) => Date.parse(s?.finishedAt ?? '') || 0;
531// The engine orders and picks them the same way (engine/sprint.js keepNewest), so the pane's newest are the true newest.
532export const olderSprintsLine = (n) => (n === 1 ? '1 older sprint is not shown here; ask Claude for it.' : `${n} older sprints are not shown here; ask Claude for them.`);
533export function finishedSprints(list, session = null) {
534 const all = (list ?? []).filter(isFinishedSprint).sort((a, b) => finishedMs(b) - finishedMs(a) || String(b.id).localeCompare(String(a.id)));
535 return { mine: all.filter((s) => isSprintMine(s, session)), others: all.filter((s) => !isSprintMine(s, session)) };
536}
537
538// State colours: the tone of a row or screen head, a key of hooks/theme.ts. A sprint's is counted over its
539// tickets (a capped card carries its own: capRows).
540export function sprintTone(s) {
541 if (s.unreadable) return 'failed';
542 if (typeof s.tone === 'string') return s.tone;
543 const tickets = s.tickets ?? [];
544 return tickets.some((t) => NEEDS_YOU_STATES.has(t.state)) ? 'needs' : tickets.some((t) => t.state === 'running') ? 'working' : 'neutral';
545}
546
547// A ticket's: its board line, and the head of its own screen.
548export function ticketTone(state) {
549 if (NEEDS_YOU_STATES.has(state)) return 'needs';
550 if (state === 'running') return 'working';
551 if (state === 'pr-open' || state === 'merged') return 'done';
552 return state === 'stopped' || state === 'closed' ? 'failed' : 'neutral';
553}
554
555// A finished job's row (Done, Other chats): failed, lost or cancelled work is `failed`, the rest `done`.
556export const finishedTone = (job) => (FAILED.has(job.status) ? 'failed' : 'done');
557
558// A job screen's head: the job's tone, and a PR that needs attention needs the person whatever the job's own status.
559export const jobTone = (job) => (job.pr?.state === 'OPEN' && job.pr.attention ? 'needs' : toneOf(job));
560
561export function sprintRow(t, draft = false) {
562 if (draft) {
563 // A draft's row: how many tasks, and who writes and reviews them, and what it waits for.
564 // A card (sprintCard) carries the tasks' writers and reviewers instead.
565 const after = t.after?.length ? ` · after ${t.after.join(', ')}` : '';
566 if (!t.of) return `no plan yet · ${named(t.writer)} plans it · tasks reviewed by ${named(t.reviewer)}${after}`;
567 const listed = (k) => (t.tasks ? t.tasks.map((x) => x[k]) : (k === 'agent' ? t.writers : t.reviewers) ?? []);
568 const who = (k) => { const names = [...new Set(listed(k))]; return names.length === 1 ? named(names[0]) : 'several agents'; };
569 return `${t.of} task${t.of === 1 ? '' : 's'} · written by ${who('agent')} · reviewed by ${who('reviewer')}${after}`;
570 }
571 switch (t.state) {
572 case 'pending': return t.waitingOn?.length ? `waiting (after ${t.waitingOn.join(', ')})` : slotWords(t) ?? 'waiting to start';
573 case 'running':
574 if (t.agentLeft) return `${named(t.agent)}'s runner stopped · waiting for ${named(t.agent)} to finish`;
575 if (isPlanning(t.phase)) return `${t.phase === 'revise' ? 'Changing the plan' : 'Writing the plan'} · ${named(t.agent)} working`;
576 if (t.phase === 'check') return 'Full check before the PR · running';
577 if (t.phase === 'task-test') return `Task ${t.task}/${t.of} · task test running on this machine`;
578 if (t.phase === 'baseline') return t.baselineOf === 'check' ? 'Full check · running it at the base' : `Task ${t.task}/${t.of} · task test running at the base`;
579 if (t.phase === 'check-fix') return `Fixing what the full check found · ${named(t.agent)} working`;
580 return `Task ${t.task}/${t.of} · ${named(t.agent)} ${isReviewing(t.phase) ? 'reviewing' : t.phase === 'fix' ? 'fixing' : 'working'}${t.reviews ? ` · reviews ${t.reviews} ✓` : ''}`;
581 case 'waiting': return t.reason ?? 'waiting';
582 case 'parked': return `✋ ${t.reason}`;
583 case 'plan-ready': return withAttention(t, 'Plan ready: approve?');
584 case 'no-tasks': return withAttention(t, "✋ Couldn't find tasks in this plan");
585 case 'pr-open': return `PR #${t.pr?.number} open${ciWords(t)}`;
586 case 'merged': return `✓ PR #${t.pr?.number} merged`;
587 case 'closed': return `PR #${t.pr?.number} closed`;
588 case 'stopped': return 'stopped';
589 default: return t.state;
590 }
591}
592
593// What a running ticket is doing, in a few words (the sprint's headline in the tool's text). The board's status line names
594// running tickets by key only (sprintStatusLine).
595export function doingText(t) {
596 if (t.agentLeft) return `waiting for ${named(t.agent)} on ${t.key} (its runner stopped)`;
597 if (isPlanning(t.phase)) return `${named(t.agent)} ${t.phase === 'revise' ? 'changing the plan for' : 'planning'} ${t.key}`;
598 if (t.phase === 'check') return `full check on ${t.key}`;
599 if (t.phase === 'task-test') return `task test on ${t.key} task ${t.task}`;
600 if (t.phase === 'baseline') return `${t.baselineOf === 'check' ? 'full check' : 'task test'} at ${t.key}'s base`;
601 if (t.phase === 'check-fix') return `${named(t.agent)} fixing ${t.key} for its full check`;
602 return `${named(t.agent)} ${isReviewing(t.phase) ? 'reviewing' : 'working on'} ${t.key} task ${t.task}`;
603}
604
605export function sprintHeadline(s) {
606 // A capped card carries its own (capRows).
607 if (typeof s.headline === 'string') return s.headline;
608 const count = `${s.tickets.length} ticket${s.tickets.length === 1 ? '' : 's'}`;
609 if (s.discarded) return `Sprint · ${s.name} · draft discarded`;
610 if (s.draft) return `Sprint · ${s.name} · draft, not started · ${count}`;
611 const done = s.tickets.filter((t) => SHIPPED_STATES.has(t.state)).length;
612 const stopped = s.tickets.filter((t) => t.state === 'stopped').length;
613 const needs = s.tickets.filter((t) => NEEDS_YOU_STATES.has(t.state)).length;
614 // How many run at once, and what each one does (naming only the first leads Claude to say one runs at a time).
615 return [
616 `Sprint · ${s.name}`,
617 `${done} of ${s.tickets.length} tickets done${stopped ? `, ${stopped} stopped` : ''}`,
618 needs ? `${needs} needs you` : null,
619 s.paused ? 'paused' : null,
620 runsText(s, (t) => doingText(t)),
621 ].filter(Boolean).join(' · ');
622}
623
624// "N of M running", naming each running ticket with `name`. A ticket waiting out a usage limit holds a slot too, so
625// the count adds ", N waiting": it never reads as a free slot beside a ticket "waiting for a free slot".
626function runsText(s, name) {
627 const running = s.tickets.filter((t) => t.state === 'running');
628 const waiting = s.tickets.filter((t) => t.state === 'waiting').length;
629 if (!running.length && !waiting) return null;
630 const names = running.length ? `: ${running.map(name).join(', ')}` : '';
631 return `${running.length} of ${s.maxParallel ?? AT_ONCE_DEFAULT} running${waiting ? `, ${waiting} waiting` : ''}${names}`;
632}
633
634export function sprintLines(s) {
635 // The name comes from an older state file when the newest can't be read; the id stands in only when none is known.
636 if (s.unreadable) return [`Sprint ${s.name || s.id}: Podium can't read this sprint's saved state`, s.unreadable];
637 return [sprintHeadline(s), ...s.tickets.map((t) => `${t.key} ${t.title} ${sprintRow(t, s.draft)}`)];
638}
639
640// An update's first sentence: its first line, up to a full stop (or ! or ?) followed by a space that is outside
641// brackets and quotes, so a file name or the person's quoted notes stay whole. Its own closing stop is left off.
642export function firstSentence(text) {
643 const line = shorten(text, Infinity);
644 let depth = 0;
645 let quoted = false;
646 for (let i = 0; i < line.length; i += 1) {
647 const c = line[i];
648 if (c === '"') quoted = !quoted;
649 else if (!quoted && c === '(') depth += 1;
650 else if (!quoted && c === ')') depth = Math.max(0, depth - 1);
651 else if (!quoted && !depth && /[.!?]/.test(c) && /\s/.test(line[i + 1] ?? '')) return line.slice(0, i);
652 }
653 return line.replace(/\.$/, '');
654}
655
656const NEWS_HEADLINE = 280;
657const NEWS_PART = 120;
658
659// The transcript line for sprint news. It must make sense on its own ("Podium: 2 sprint
660// updates" reads as a notice without the updates, and sends Claude to the board): one update shows its first line,
661// several show each one's first sentence, joined with " · ", and "and N more" once the line is long.
662export function sprintNewsHeadline(messages) {
663 if (messages.length === 1) return `Podium: ${shorten(messages[0].text, NEWS_PART)}`;
664 const parts = messages.map((m) => shorten(firstSentence(m.text), NEWS_PART));
665 const shown = [];
666 for (const part of parts) {
667 const left = parts.length - shown.length - 1;
668 const line = `Podium: ${[...shown, part, ...(left ? [`and ${left} more`] : [])].join(' · ')}`;
669 if (shown.length && line.length > NEWS_HEADLINE) break;
670 shown.push(part);
671 }
672 const left = parts.length - shown.length;
673 return `Podium: ${[...shown, ...(left ? [`and ${left} more`] : [])].join(' · ')}`;
674}
675
676export function sprintNewsNotes(messages, visible = false) {
677 return [
678 '[Podium] Sprint updates:',
679 ...messages.map((m) => m.text),
680 'Each sprint ticket ends as a PR the person merges. The podium sprint tool shows the board (action status) and can retry a parked ticket, stop one the person has given up on, give a task to another agent, approve a plan or send it back with changes, pause or resume.',
681 visible ? null : REPLY_RULE,
682 ].filter(Boolean).join('\n\n');
683}
684
685// What a hand-over (handOver in delivery.js) carries: the hidden row's notes, the one-line headline, and the details the
686// visible message holds only when the row can't be added. Those are the visible variant: the reply rule speaks of a note
687// the person doesn't see, so it never goes in a message they do.
688export const jobHandOver = (job, summary) => ({ notes: finishedMessage(job, summary), headline: finishedHeadline(job), details: finishedMessage(job, summary, true) });
689export const newsHandOver = (list) => ({ notes: sprintNewsNotes(list), headline: sprintNewsHeadline(list), details: sprintNewsNotes(list, true) });
690
691// ---- Pane clarity ----
692
693// Whether a folded section is open. `folds` (pane state) lists the sections whose state differs from their default, so a
694// section that opens by default (setup checks with something wrong) closes when listed.
695export function isFoldOpen(folds, id, byDefault = false) {
696 return (folds ?? []).includes(id) !== byDefault;
697}
698
699// The folds left when leaving `view` from the couldn't-draw note (Back to the list): that screen's own folds go, so it
700// does not reopen with the same folds open (and the same failure). Every other screen's folds stay.
701export function foldsLeaving(folds, view) {
702 const v = view ?? {};
703 const prefix = !v.id ? null
704 : v.screen === 'detail' ? `job:${v.id}:`
705 : v.screen === 'ticket' ? (v.key ? `ticket:${v.id}:${v.key}:` : `board:${v.id}:`)
706 : v.screen === 'sprint' ? `board:${v.id}:`
707 : v.screen === 'agent' ? `agent:${v.id}:`
708 : null;
709 return (folds ?? []).filter((f) => !prefix || !f.startsWith(prefix));
710}
711
712const ACCESS_WORDS = { 'read-only': 'read only', 'edit-worktree': 'separate copy', 'own-config': "agent's own settings", 'full-access': 'full access' };
713export const accessWord = (permission) => ACCESS_WORDS[permission] ?? String(permission ?? '');
714
715const agoText = (job, now) => `${clock(now - Date.parse(job.endedAt ?? job.createdAt))} ago`;
716
717export function prChecks(pr) {
718 const c = pr.checks ?? { total: 0 };
719 if (!c.total) return 'no checks';
720 return [`${c.passed} of ${c.total} checks passed`, c.failed ? `${c.failed} failed` : null, c.running ? `${c.running} running` : null].filter(Boolean).join(' · ');
721}
722
723export const prSub = (pr) => `${prChecks(pr)} · review: ${REVIEW_WORDS[pr.review] ?? 'none yet'}`;
724
725export function attentionLine(pr) {
726 if (pr.attention === 'ci-failed') return `checks failed${pr.checks?.failing?.length ? `: ${pr.checks.failing.join(', ')}` : ''}`;
727 if (pr.attention === 'changes-requested') return 'a reviewer asked for changes';
728 if (pr.attention === 'conflict') return 'it conflicts with the base branch';
729 if (pr.attention === 'blocked') return `GitHub refused the merge${pr.blocked ? `: ${pr.blocked}` : ''}`;
730 return String(pr.attention ?? '');
731}
732
733// The line under a job's title on its screen.
734export function jobStatusLine(job, now = Date.now()) {
735 const who = agentLabel(job.agent);
736 const ago = agoText(job, now);
737 const pr = job.pr;
738 if (job.status === 'queued') return `${who} · queued · ${accessWord(job.permission)}`;
739 if (job.status === 'running') return `${who} · working ${elapsedText(job, now)} · ${accessWord(job.permission)}`;
740 if (job.status === 'needs-review') return `${who} finished ${ago} · ${pr ? `new changes for PR #${pr.number}` : 'changes to review'}`;
741 if (job.status === 'in-review' && pr) return pr.attention ? `PR #${pr.number} · ${attentionLine(pr)}` : `PR #${pr.number} · ${prSub(pr)}`;
742 if ((job.status === 'merged' || job.status === 'closed') && pr) return `PR #${pr.number} ${job.status} ${ago}`;
743 if (job.status === 'failed') return `${who} stopped with an error ${ago}`;
744 if (job.status === 'lost') return `${who} stopped unexpectedly ${ago}`;
745 if (job.status === 'cancelled') return `Stopped ${ago}`;
746 if (job.status === 'kept') return `Kept ${ago}`;
747 if (job.status === 'discarded') return `Thrown away ${ago}`;
748 const c = job.committed;
749 return `${who} finished ${ago}${c ? ` · ${c.count} commit${c.count === 1 ? '' : 's'} on ${c.branch ?? 'a detached HEAD'}` : ''}`;
750}
751
752// A Working row's second line: who and for how long. The live line under it says what it is doing.
753export function workingRowLine(job, now = Date.now(), live = null, fromOther = false) {
754 return [`${agentLabel(job.agent)}`, elapsedText(job, now), quietText(live, now), fromOther ? FROM_OTHER_CHAT : null].filter(Boolean).join(' · ');
755}
756
757const DONE_ROW_WORDS = { done: 'finished', kept: 'kept', discarded: 'thrown away', cancelled: 'stopped', failed: 'stopped with an error', lost: 'stopped unexpectedly', merged: 'merged', closed: 'closed' };
758
759// A Done row's second line: who, and what happened when.
760export function doneRowLine(job, now = Date.now()) {
761 const word = job.pr && (job.status === 'merged' || job.status === 'closed') ? `PR #${job.pr.number} ${job.status}`
762 : job.committed && job.status === 'done' ? `committed to ${job.committed.branch ?? 'a detached HEAD'}`
763 : DONE_ROW_WORDS[job.status] ?? job.status;
764 return `${agentLabel(job.agent)} · ${word} ${agoText(job, now)}`;
765}
766
767// A sprint ticket's Your turn button: a parked one shows why it stopped; a plan waiting is reviewed.
768export const turnLabel = (state) => (state === 'parked' ? 'See why' : 'Review plan');
769
770const FAILED_TURN = new Set(['failed', 'lost']);
771// A failed or lost job waits on the person (Your turn) until it is cleared, followed up or thrown away.
772export const isFailedTurn = (thread) => FAILED_TURN.has(thread.latest.status);
773const firstTitle = (t) => t.steps[t.steps.length - 1]?.title || t.latest.title || 'Untitled job';
774
775// Everything waiting on the person (Your turn): changes to review and PRs that need attention, failed jobs,
776// then sprint tickets (turnTickets). Each has one button: `act` names the pane action it calls, `open` where its title leads.
777// A job started in another chat says so (isMine false, from organise), as its Working row does.
778export function turnItems(org, tickets = []) {
779 const items = [];
780 const from = (t) => (t.isMine === false ? ` · ${FROM_OTHER_CHAT}` : '');
781 for (const t of org.needs) {
782 const job = t.latest;
783 const who = agentLabel(job.agent);
784 const open = { name: 'openDetail', args: [job.id] };
785 const base = { id: job.id, title: firstTitle(t), open };
786 if (job.status === 'needs-review') {
787 items.push({ ...base, reason: `${who} finished · ${job.pr ? `new changes for PR #${job.pr.number}` : 'changes to review'}${from(t)}`, label: 'Review', act: open });
788 } else if (job.pr) {
789 const reason = `PR #${job.pr.number} · ${attentionLine(job.pr)}${from(t)}`;
790 if (job.pr.attention === 'ci-failed') items.push({ ...base, reason, label: `Send fix to ${who}`, act: { name: 'fixOpen', args: [t.id, job.id, job.agent, 'ci'] } });
791 else if (job.pr.comments > 0) items.push({ ...base, reason, label: 'Answer comments', act: { name: 'fixOpen', args: [t.id, job.id, job.agent, 'comments'] } });
792 else items.push({ ...base, reason, label: 'Open', act: open });
793 }
794 }
795 for (const t of org.done.filter(isFailedTurn)) {
796 const job = t.latest;
797 const open = { name: 'openDetail', args: [job.id] };
798 items.push({ id: job.id, title: firstTitle(t), open, reason: `${agentLabel(job.agent)} ${job.status === 'lost' ? 'stopped unexpectedly' : 'stopped with an error'}${from(t)}`, label: 'See what happened', act: open });
799 }
800 for (const x of tickets) {
801 const open = { name: 'openTicket', args: [x.sprint, x.key] };
802 const why = x.state === 'plan-ready' ? withAttention(x, 'plan ready') : x.state === 'no-tasks' ? withAttention(x, 'no tasks found in the plan') : (x.reason || 'needs you');
803 items.push({ id: `${x.sprint}-${x.key}`, title: `${x.key} ${x.title}`, open, reason: `${x.sprintName} · ${why}`, label: turnLabel(x.state), act: open });
804 }
805 return items;
806}
807
808// "2026-10-05 11:56 UTC" from an ISO time.
809const when = (iso) => `${String(iso).slice(0, 10)} ${String(iso).slice(11, 16)} UTC`;
810
811// The job screen's Details fold: everything about the job that is not what happened. `fromOther`: its thread
812// was started in another chat (fromAnotherChat).
813export function jobDetails(job, fromOther = false) {
814 const took = job.startedAt && job.endedAt ? ` · took ${clock(Date.parse(job.endedAt) - Date.parse(job.startedAt))}` : '';
815 const branch = job.pr?.branch ? `Branch: ${job.pr.branch}` : job.committed?.branch ? `Branch: ${job.committed.branch}` : job.base ? `Started from: ${job.base}` : null;
816 return [
817 `Ticket: ${job.ticket && job.ticket !== 'NO-TICKET' ? job.ticket : 'none'}`,
818 `Access: ${agentLabel(job.agent)} ${PERMISSION_WORDS[job.permission] ?? `used "${job.permission}"`}`,
819 `Agent: ${agentLabel(job.agent)}${job.model ? ` (${job.model})` : ''}`,
820 fromOther ? 'Origin: another chat' : null,
821 `Started ${when(job.startedAt ?? job.createdAt)}${took}`,
822 branch,
823 job.pr?.url ? `PR: ${job.pr.url}` : null,
824 job.worktree ? `Podium's copy: ${job.worktree}` : null,
825 `Job id: ${job.id}`,
826 ].filter(Boolean);
827}
828
829// "ABC-11", "ABC-11 and ABC-12", "ABC-11, ABC-12 and ABC-13".
830export const andList = (keys) => (keys.length < 2 ? keys.join('') : `${keys.slice(0, -1).join(', ')} and ${keys[keys.length - 1]}`);
831
832// A sprint's row title on the list: the list draws it and cardCost (mod/client.js) measures it.
833export const sprintRowTitle = (s) => (s?.unreadable ? `Sprint ${s?.name || s?.id}` : `Sprint · ${s?.name ?? s?.id}`);
834
835// A sprint's one line: on its list row and under its name on the board (a capped card carries its own).
836export function sprintStatusLine(s) {
837 if (typeof s.status === 'string') return s.status;
838 if (s.unreadable) return "Podium can't read this sprint's saved state";
839 const n = s.tickets.length;
840 if (s.draft) return `Draft · ${n} ticket${n === 1 ? '' : 's'} · not started`;
841 const done = s.tickets.filter((t) => SHIPPED_STATES.has(t.state)).length;
842 const stopped = s.tickets.filter((t) => t.state === 'stopped').length;
843 // How many run at once, naming each. The ticket rows say what each one does.
844 return [`${done} of ${n} done`, stopped ? `${stopped} stopped` : null, s.paused ? 'paused' : null, runsText(s, (t) => t.key)].filter(Boolean).join(' · ');
845}
846
847// A sprint's Working row line: its status line, and, when another chat started it, the words a job row has.
848export const sprintWorkingLine = (s, fromOther = false) => `${sprintStatusLine(s)}${fromOther ? ` · ${FROM_OTHER_CHAT}` : ''}`;
849
850// A finished sprint's row line (Done, Other chats): its status line and how long ago it finished, as a Done job's row.
851export function finishedSprintLine(s, now = Date.now()) {
852 const at = Date.parse(s?.finishedAt ?? '');
853 return `${sprintStatusLine(s)}${Number.isFinite(at) ? ` · finished ${clock(now - at)} ago` : ''}`;
854}
855
856const tasksWord = (n) => `${n} task${n === 1 ? '' : 's'}`;
857
858// A sprint PR whose plan left checks to its CI: how that CI stands, after the PR's number. '' for any other ticket.
859// A ticket sent as a row carries ciState; a whole sprint's ticket carries the summary's ci.
860const CI_WORDS = { waiting: 'CI running', passed: 'CI passed', failed: 'CI failed', none: 'no CI ran' };
861export const ciWords = (t) => {
862 const state = t?.ciState ?? t?.ci?.state ?? '';
863 return Object.hasOwn(CI_WORDS, state) ? ` · ${CI_WORDS[state]}` : '';
864};
865
866// A ticket's row on the board: where it stands, in a few words.
867export function ticketState(t, draft = false) {
868 if (draft) {
869 const after = t.after?.length ? ` · after ${t.after.join(', ')}` : '';
870 return `${t.of ? tasksWord(t.of) : 'no plan yet'}${after}`;
871 }
872 switch (t.state) {
873 case 'pending': return t.waitingOn?.length ? `starts after ${andList(t.waitingOn)}` : slotWords(t) ?? 'waiting to start';
874 case 'running':
875 if (t.agentLeft) return `waiting for ${named(t.agent)} to finish (its runner stopped)`;
876 if (isPlanning(t.phase)) return `${named(t.agent)} ${t.phase === 'revise' ? 'changing' : 'writing'} the plan`;
877 if (t.phase === 'check') return 'running the full check';
878 if (t.phase === 'task-test') return `task ${t.task} of ${t.of} · running the task test`;
879 if (t.phase === 'baseline') return t.baselineOf === 'check' ? 'running the full check at the base' : `task ${t.task} of ${t.of} · running the task test at the base`;
880 if (t.phase === 'check-fix') return `${named(t.agent)} fixing what the full check found`;
881 return `task ${t.task} of ${t.of} · ${named(t.agent)} ${isReviewing(t.phase) ? 'reviewing' : t.phase === 'fix' ? 'fixing' : 'working'}`;
882 case 'waiting': return t.reason ?? 'waiting';
883 case 'parked': return 'needs you';
884 case 'plan-ready': return withAttention(t, 'plan ready');
885 case 'no-tasks': return withAttention(t, 'no tasks found in the plan');
886 case 'pr-open': return `PR #${t.pr?.number} open${ciWords(t)}`;
887 case 'merged': return `PR #${t.pr?.number} merged`;
888 case 'closed': return `PR #${t.pr?.number} closed`;
889 case 'stopped': return 'stopped';
890 default: return String(t.state);
891 }
892}
893
894// A ticket's own screen: the line under its key and title, saying what happens next.
895export function ticketStatusLine(t, draft = false) {
896 if (draft) return t.of ? `${tasksWord(t.of)} · nothing runs until the sprint starts` : `No plan yet · ${named(t.writer ?? t.agent)} plans it first`;
897 switch (t.state) {
898 case 'plan-ready': return withAttention(t, 'Plan ready · check it, then approve');
899 case 'no-tasks': return withAttention(t, 'No tasks found in the plan · run it as one task, or ask for changes');
900 case 'parked': return t.reason || 'Stopped · needs you';
901 case 'running':
902 // Its runner is gone, but its agent may still be writing in the copy: nothing else runs there until it ends.
903 if (t.agentLeft) return `Podium's runner for ${named(t.agent)} stopped, and ${named(t.agent)} may still be working in this ticket's copy · nothing else runs there until it finishes`;
904 if (isPlanning(t.phase)) return `${named(t.agent)} is ${t.phase === 'revise' ? 'changing' : 'writing'} the plan`;
905 if (t.phase === 'check') return 'Running the full check before the PR';
906 if (t.phase === 'task-test') return `Task ${t.task} of ${t.of} · running the task test on this machine`;
907 if (t.phase === 'baseline') {
908 return t.baselineOf === 'check'
909 ? 'The full check failed; running it at the base to see if this ticket caused it'
910 : `Task ${t.task} of ${t.of} · the task test failed; running it at the base to see if this change caused it`;
911 }
912 if (t.phase === 'check-fix') return `${named(t.agent)} is fixing what the full check found`;
913 return `Task ${t.task} of ${t.of} · ${named(t.agent)} ${isReviewing(t.phase) ? 'reviewing' : t.phase === 'fix' ? 'fixing' : 'working'}`;
914 case 'pending': return t.waitingOn?.length ? `Starts when ${andList(t.waitingOn)} ${t.waitingOn.length === 1 ? 'is' : 'are'} merged on GitHub`
915 : t.slot === 'full' ? 'Waiting for a free slot' : t.slot === 'free' ? 'Starting' : 'Waiting to start';
916 case 'waiting': return t.reason || 'Waiting';
917 case 'pr-open': return `PR #${t.pr?.number} open${ciWords(t)}`;
918 case 'merged': return `PR #${t.pr?.number} merged`;
919 case 'closed': return `PR #${t.pr?.number} closed without merging`;
920 case 'stopped': return 'Stopped';
921 default: return String(t.state);
922 }
923}
924
925// Who writes and who reviews a ticket, in one line. A ticket with no plan names its planner; a card (sprintCard)
926// carries the tasks' writers and reviewers.
927export function whoLine(t) {
928 const listed = (k) => (t.tasks?.length ? t.tasks.map((x) => x[k]) : (k === 'agent' ? t.writers : t.reviewers) ?? []);
929 const writers = (t.of ?? 0) === 0 && t.writer ? [t.writer] : [...new Set(listed('agent'))];
930 const reviewers = (t.of ?? 0) === 0 && t.writer ? [t.reviewer] : [...new Set(listed('reviewer'))];
931 if (writers.length !== 1 || reviewers.length !== 1) return 'several agents';
932 return writers[0] === reviewers[0] ? `${named(writers[0])} writes and reviews` : `${named(writers[0])} writes, ${named(reviewers[0])} reviews`;
933}
934
935export const optionsLine = (s) => `${s.maxParallel ?? AT_ONCE_DEFAULT} at once${s.autoMerge ? ' · merge when green' : ''}`;
936
937// An agent's row on Agents.
938export const agentState = (a) => (!a.installed ? 'not installed' : !a.enabled ? 'switched off' : a.signedIn === false ? 'not signed in' : 'ready');
939export const agentLine = (a) => `${a.label} · ${agentState(a)}`;
940export const agentNeedsFix = (a) => Boolean(a.installed && a.enabled && a.signedIn === false);
941
942export function setupSummary(rows) {
943 const n = (rows ?? []).filter((r) => r.ok === false).length;
944 return n ? `${n} thing${n === 1 ? '' : 's'} to fix` : 'all good';
945}
946
947export const instructionsSummary = (s) => (String(s?.instructions ?? '').trim() || Object.values(s?.repoInstructions ?? {}).some((x) => String(x ?? '').trim()) ? 'set' : 'none');
948export const storageSummary = (s) => `${s.retention.compressDays} / ${s.retention.deleteDays} days`;
949export const limitsSummary = (s) => `${s.worktreeWarnMB} MB, ${s.longJobMinutes} min`;
950
951const MERGE_WORDS = ['merge', 'squash', 'rebase'];
952// Settings → This repo: its heading says what is set for the repo this chat is in.
953export function repoSummary(s, project) {
954 if (!project) return 'nothing set';
955 // The settings file may have been edited by hand: a value of the wrong kind reads as not set.
956 const listed = s?.repoCommands?.[project];
957 const commands = Array.isArray(listed) ? listed.filter((c) => typeof c === 'string') : [];
958 const check = s?.repoCheck?.[project];
959 const taskTest = s?.repoTaskTest?.[project];
960 const method = String(s?.repoMergeMethod?.[project] ?? '').toLowerCase();
961 const saved = s?.repoPlanDir?.[project];
962 const folder = typeof saved === 'string' ? saved.trim().replace(/\/+$/, '') : '';
963 const parts = [
964 commands.length ? commands.join(' ') : null,
965 typeof check === 'string' && check.trim() ? 'full check set' : null,
966 typeof taskTest === 'string' && taskTest.trim() ? 'task test set' : null,
967 folder ? `plans in ${folder}` : null,
968 s?.repoKeepPlan?.[project] === true ? 'plans kept in PRs' : null,
969 s?.repoSquash?.[project] === false ? 'commits kept' : null,
970 MERGE_WORDS.includes(method) ? `${method} merges` : null,
971 ].filter(Boolean);
972 return parts.length ? parts.join(' · ') : 'nothing set';
973}
974
975// A draft's proposed commands, check, plans folder and whether PRs keep the plan (saved for the repo at Start): the
976// board's This repo fold heading, short.
977export function proposedSummary(p) {
978 const commands = Array.isArray(p?.commands) ? p.commands : null;
979 const parts = [
980 commands ? (commands.length ? commands.join(' ') : 'no commands') : null,
981 typeof p?.check === 'string' ? (p.check ? 'full check' : 'no full check') : null,
982 typeof p?.taskTest === 'string' ? (p.taskTest ? 'task test' : 'no task test') : null,
983 typeof p?.planDir === 'string' ? (p.planDir ? 'plans folder' : 'default plans folder') : null,
984 typeof p?.keepPlan === 'boolean' ? (p.keepPlan ? 'plans in PRs' : 'plans left out of PRs') : null,
985 ].filter(Boolean);
986 return parts.join(' · ');
987}
988
989// The same values in a sentence's words, exact (the tool's reply and Start's notice): "commands dotnet; full check "…"".
990export function repoValuesText(v) {
991 const parts = [
992 Array.isArray(v?.commands) ? `commands ${v.commands.join(', ') || 'none'}` : null,
993 typeof v?.check === 'string' ? (v.check ? `full check "${v.check}"` : 'no full check') : null,
994 typeof v?.taskTest === 'string' ? (v.taskTest ? `task test "${v.taskTest}"` : 'no task test') : null,
995 typeof v?.planDir === 'string' ? (v.planDir ? `plans folder "${v.planDir}"` : 'default plans folder') : null,
996 typeof v?.keepPlan === 'boolean' ? (v.keepPlan ? 'plans kept in PRs' : 'plans left out of PRs') : null,
997 ].filter(Boolean);
998 return parts.join('; ');
999}
1000
1001// The pane's notice after Start: what it saved for the repo from the draft's proposal, if anything.
1002export const startedNotice = (applied) => {
1003 const text = repoValuesText(applied);
1004 return text ? `Started. Saved for this repo: ${text}.` : 'Started.';
1005};
1006
1007// Uncommitted changes Podium saved aside before cleaning a copy, and where to find them.
1008const savedLine = (saved) => (saved ? ` Changes left uncommitted in its copy were saved as a git stash ("${saved}"): git stash list shows it.` : '');
1009// After Sync with base: how much of the base came in (the tool's reply and the pane's notice).
1010export const syncedText = (key, base, commits, saved = null) => `${key} took ${commits} new commit${commits === 1 ? '' : 's'} from ${base || 'its base'}.${savedLine(saved)}`;
1011// After Keep or Drop on commits Podium didn't make. Dropped ones are named, so they can be found again in git's reflog.
1012export const commitsAnswerText = (key, keep, dropped = [], saved = null) => `${keep
1013 ? `${key} keeps the commits and carries on from them.`
1014 : `${key}'s copy is back where Podium left it.${dropped.length ? ` Dropped (git's reflog keeps them for a while): ${dropped.join('; ')}.` : ''}`}${savedLine(saved)}`;
1015// Under a ticket stopped on its task's tests: where they ran, and the ways on.
1016export const testsParkHint = (t) => (t.testsWhere === 'host'
1017 ? 'Podium ran these on this machine. Retry gives the writer the log; if the result is fine, accept it and the task goes to its review.'
1018 : `These ran in ${named(t.agent)}'s sandbox, not on this machine. If the result is fine, accept it and the task goes to its review; or set a task test (Settings → This repo) so Podium runs the tests here.`);
1019// The Accept and continue form's line: who reviews the task next (the stuck task's reviewer, when the pane has it), and what
1020// they are told.
1021export const acceptLine = (reviewer) => `${reviewer ? named(reviewer) : 'Its reviewer'} reviews it next, told what you said and not to fail it for these results.`;
1022
1023// The Review again with evidence form's line: who reviews the task again, and what they do with it.
1024export const evidenceLine = (reviewer) => `${reviewer ? named(reviewer) : 'Its reviewer'} reviews it again, weighing this before failing it for tests it couldn't run.`;
1025
1026// A parked ticket's Retry button: once the person has released the locked file, Retry tries the interrupted
1027// operation again. Anything else, including older tickets without the field, reads ordinary.
1028export const ticketRetryLabel = (t) => t.state === 'parked' && t.checkpointLock
1029 ? "I've released it — Retry"
1030 : 'Retry';
1031
1032// A ticket parked because a file was locked while Podium saved its changes: what to release and how to
1033// retry. Null for anything else, including older tickets without the field and tickets that have moved on
1034// (Retry clears the field; even a stale one reads ordinary once the ticket is not parked). Podium never
1035// releases the file itself; the person does, then retries.
1036export function checkpointLockHint(t) {
1037 const lock = t?.state === 'parked' ? t?.checkpointLock : null;
1038 if (!lock) return null;
1039 const file = typeof lock.file === 'string' && lock.file ? lock.file : 'an unidentified file';
1040 const dotnet = lock.hint === 'dotnet' ? " For a .NET compiler server, run dotnet build-server shutdown in this ticket's copy first." : '';
1041 return `${file} is still open in a program. Close the program holding the file, then press ${ticketRetryLabel(t)}. The ticket's copy and any stash Git made have been kept.${dotnet}`;
1042}
1043
1044// Who reads a note on a ticket (the pane's note form, and the reply once it is saved). A step already running doesn't.
1045export const noteReaders = (key, running = false) => (running
1046 ? `The step running now won't see it; every later step of ${key} will.`
1047 : `Every later planner, writer and reviewer of ${key} reads it.`);
1048export const notedText = (key, running = false) => `Noted on ${key}. ${noteReaders(key, running)}`;
1049
1050// The board's warning while the sprint's repo has no full check.
1051export const NO_CHECK_LINE = 'No full check for this repo: PRs open without one';
1052// The board in a chat other than the sprint's: news waits for the sprint's own chat.
1053export const waitingNews = (n) => `${n} update${n === 1 ? '' : 's'} waiting for this sprint's chat`;
1054
1055const ACCESS_LINES = {
1056 'edit-worktree': 'Works in a separate copy · you review before keeping',
1057 'read-only': 'Only reads files · changes nothing',
1058 'own-config': "Uses the agent's own settings · may edit your files directly",
1059 'full-access': 'Full access · can change anything on this computer',
1060};
1061export const accessLine = (permission) => ACCESS_LINES[permission] ?? String(permission ?? '');
1062
1063// New job's More options heading: what is set there.
1064export const moreSummary = (d) => [accessWord(d.permission), d.ticket.trim() || null, d.base.trim() ? `from ${d.base.trim()}` : null, d.model || null].filter(Boolean).join(' · ');
1065hooks/state.ts 35 lines1// Plain constants shared by the hooks module and the pure screens. Atoms are
2// not declared here: the engine reads $.state references only in the module
3// that uses them, so they live in register.tsx.
4
5export const PANE = 'podium'
6
7// The longest notes for a plan's writer (Ask for changes). The engine keeps 20,000 (TEXT_CHARS in engine/sprint.js), but
8// the notes are the box's value, and the terminal refuses an Input whose value is over 10,000 characters (the desktop
9// page over 16,384): either would blank the pane, so the box keeps the stricter 10,000.
10export const NOTE_CHARS = 10_000
11
12export const PERMISSION_CHOICES = [
13 { value: 'edit-worktree', label: 'Edit in a separate copy' },
14 { value: 'read-only', label: 'Read only' },
15 { value: 'own-config', label: "Agent's own settings" },
16 { value: 'full-access', label: 'Full access ⚠' },
17]
18
19export const CLAUDE_MODELS = [
20 { value: '', label: 'Default' },
21 { value: 'haiku', label: 'Haiku' },
22 { value: 'sonnet', label: 'Sonnet' },
23 { value: 'opus', label: 'Opus' },
24]
25
26export const LEVEL_LABEL: Record<string, string> = {
27 'read-only': 'Read only',
28 'edit-worktree': 'Edit in a separate copy',
29 'own-config': "Agent's own settings",
30 'full-access': 'Full access',
31}
32
33// The most fold states the pane keeps: the oldest are forgotten, and a forgotten fold is back to its default.
34export const FOLDS_KEPT = 200
35hooks/screens.tsx 1384 lines1// The pane's screens: pure drawing. Each takes the element table
2// $.ui.resolve(e) returned, plain data read in register.tsx, and the closures
3// register.tsx made; none touches `$` or $.state (the engine forbids both here).
4import { accessLine, agentLabel, andList, moreSummary, shorten, instructionsSummary, storageSummary, limitsSummary, repoSummary, proposedSummary, NO_CHECK_LINE, waitingNews } from '../mod/view.js'
5import type { AdapterDraft, AgentRecord, BoardState, Draft, JobDetail, JobRecord, SprintSummary, SprintTicketSummary, PaneView, TurnTicket } from '../types'
6import { LEVEL_LABEL } from './state'
7import { CLAUDE_MODELS, PERMISSION_CHOICES } from './state'
8import { readiness, removalText, agentLine, agentState, agentNeedsFix } from '../mod/view.js'
9import type { DoctorReport, RemovalPlan, WhatsNew } from '../types'
10import { organise, threads, checkRows, sprintLines, sprintStatusLine, ticketState, optionsLine, turnLabel, isOpenSprint, ticketSteps, sprintStepLine, NEEDS_YOU_STATES, AT_ONCE_DEFAULT } from '../mod/view.js'
11import { PALETTES } from './theme'
12
13import type { ChangeSet, JobDetail } from '../types'
14import { codeBlock } from './tiles'
15import { fileRows, listPage, turnTickets } from '../mod/client.js'
16
17import type { LiveInfo } from '../types'
18import type { Palette, Tone } from './theme'
19import { choices, fold, row, tile } from './tiles'
20import { doneText, quietText, toneOf, turnItems, isFailedTurn, workingRowLine, doneRowLine, prSub, setupSummary, sprintRowTitle, sprintTone, ticketTone, finishedTone, jobTone } from '../mod/view.js'
21import { isFinishedSprint, finishedSprints, finishedSprintLine, finishedSprintTone, isSprintMine, sprintWorkingLine, olderSprintsLine, FINISHED_SPRINTS, FROM_OTHER_CHAT } from '../mod/view.js'
22import { attentionLine, isFoldOpen, jobStatusLine, jobDetails, fromAnotherChat, REVIEW_WORDS, ticketStatusLine, whoLine, noteReaders, testsParkHint, acceptLine, evidenceLine, attentionHead, ackQuestion, ackUnseen, flaggedOf, checkpointLockHint, ticketRetryLabel } from '../mod/view.js'
23
24
25export type Ui = any // the element table $.ui.resolve(e) returned for the drawing surface
26
27export type ListHandlers = {
28 openDetail: (id: string) => void
29 showScreen: (screen: PaneView['screen']) => void
30 toggleFold: (id: string) => void
31 openAgent: (name: string) => void
32 browsePr: (id: string) => void
33 act: (verb: 'cancel' | 'keep' | 'discard', id: string) => void
34 askOpen: (threadId: string, agent: string) => void
35 askEdit: (patch: { agent?: string; task?: string }) => void
36 askSend: (latestId: string, task?: string) => void
37 askCancel: () => void
38 tryIt: () => void
39 gotIt: () => void
40 toggleWhatsNew: () => void
41 dismissWhatsNew: () => void
42 clearDone: () => void
43 toggleShowAll: () => void
44 openPr: (id: string) => void
45 pushPr: (id: string) => void
46 toggleAutoMerge: (id: string, isOn: boolean) => void
47 watchAgain: (id: string) => void
48 postReview: (id: string) => void
49 reviewOpen: (threadId: string, agent: string, number: number) => void
50 fixOpen: (threadId: string, latestId: string, agent: string, kind: 'ci' | 'comments') => void
51 openBoard: (id: string) => void
52 // Which page of sprint cards the list shows (listPage).
53 showSprints: (page: number) => void
54 sprintAct: (verb: 'start' | 'discard' | 'pause' | 'resume', id: string) => void
55 askConfirm: (id: string, what: string | null) => void
56 setSprintOptions: (id: string, patch: { mergeWhenGreen?: boolean; atOnce?: number }) => void
57 openTicket: (id: string, key: string | null) => void
58 openPicker: (id: string, which: string | null) => void
59 moveTicket: (id: string, key: string, to: 'up' | 'down') => void
60 removeTicket: (id: string, key: string) => void
61 assignTicket: (id: string, key: string, role: 'agent' | 'reviewer', name: string) => void
62 assignTask: (id: string, key: string, n: number, role: 'agent' | 'reviewer', name: string) => void
63 retryTicket: (id: string, key: string, agent?: string) => void
64 stopTicket: (id: string, key: string) => void
65 syncTicket: (id: string, key: string) => void
66 answerCommits: (id: string, key: string, keep: boolean) => void
67 approvePlan: (id: string, key: string, whole?: boolean, acknowledge?: boolean) => void
68 revisePlan: (id: string, key: string, text?: string) => void
69 addNote: (id: string, key: string, text?: string) => void
70 acceptTests: (id: string, key: string, text?: string) => void
71 reviewWithEvidence: (id: string, key: string, text?: string) => void
72 editNote: (id: string, text: string) => void
73 setAfter: (id: string, key: string, other: string, on: boolean) => void
74 takeSprintNews: (id: string) => void
75 backToList: () => void
76}
77
78export type AskFor = { threadId: string; agent: string; task: string; mode?: 'change' | 'review'; number?: number } | null
79export type Thread = { id: string; latest: JobRecord; steps: JobRecord[]; isMine?: boolean }
80export type Organised = { needs: Thread[]; working: Thread[]; review: Thread[]; done: Thread[]; doneHidden: number; others: Thread[] }
81
82const PERMISSION_SHORT: Record<string, string> = {
83 'read-only': 'read only',
84 'edit-worktree': 'edit in a separate copy',
85 'own-config': "the agent's own settings",
86 'full-access': 'full access',
87}
88const STATUS_WORD: Record<string, string> = {
89 done: 'done', 'needs-review': 'changes', failed: 'failed', lost: 'stopped', cancelled: 'cancelled',
90 kept: 'kept', discarded: 'thrown away', running: 'working', queued: 'queued', continued: 'continued',
91 'in-review': 'in review', merged: 'merged', closed: 'closed',
92}
93
94// A thread can continue unless a step is running or the work was kept or thrown away.
95export function canContinue(latest: JobRecord) {
96 return ['needs-review', 'done', 'failed', 'cancelled', 'lost', 'in-review'].includes(latest.status)
97}
98
99function stepLine(s: JobRecord, now: number) {
100 const word = s.status === 'continued' ? (STATUS_WORD[s.endStatus ?? ''] ?? 'done') : (STATUS_WORD[s.status] ?? s.status)
101 const how = s.resumeFailed ? ' · fresh conversation' : s.resumeFrom ? ' · same conversation' : ''
102 const ago = s.endedAt ? ` · ${doneText(s, now).split(' · ').pop()}` : ''
103 if (s.kind === 'review') return `review · ${s.agent} · ${s.title} · ${s.posted ? 'posted' : word}${ago}`
104 return `${s.step ?? 1} · ${s.agent} · ${s.title}${how} · ${word}${ago}`
105}
106
107// Repeats of one step (a review asked for again and again) show as one row with a count:
108// the newest stands for them all.
109export function groupSteps(steps: JobRecord[]) {
110 const same = (a: JobRecord, b: JobRecord) => a.kind === b.kind && a.agent === b.agent && a.title === b.title && a.status === b.status && Boolean(a.posted) === Boolean(b.posted) && a.step === b.step
111 const out: { step: JobRecord; count: number }[] = []
112 for (const s of steps) {
113 const last = out[out.length - 1]
114 if (last && same(last.step, s)) last.count += 1
115 else out.push({ step: s, count: 1 })
116 }
117 return out
118}
119
120const counted = (line: string, count: number) => (count > 1 ? `${line} · ×${count}` : line)
121
122// Step rows a job's detail lists (the rest are counted in a line), each at most STEP_CHARS long.
123const STEP_ROWS = 30
124const STEP_CHARS = 160
125
126export function askForm(ui: Ui, p: Palette, t: Thread, ask: NonNullable<AskFor>, agentList: AgentRecord[], go: ListHandlers) {
127 const { Box, Text, Button, Input } = ui
128 const ready = agentList.filter((x) => x.installed && x.enabled)
129 const options = (ready.length ? ready : [{ name: t.latest.agent, label: t.latest.agent }]).map((x) => ({ value: x.name, label: x.label }))
130 return (
131 <Box key={`askform-${t.id}`} flexDirection="column" gap={1}>
132 <Input key="ask-task" label={ask.mode === 'review' ? 'Anything to focus on? (optional)' : 'What should change?'} placeholder={ask.mode === 'review' ? 'e.g. error handling' : 'e.g. make the heading bigger'} value={ask.task} submitLabel="Send"
133 onInput={(v: string) => go.askEdit({ task: v })} onSubmit={(v: string) => go.askSend(t.latest.id, v)} />
134 {choices(ui, p, 'ask-agent', 'Agent', ask.agent, options, (v) => go.askEdit({ agent: v }))}
135 <Text color={p.dim}>{ask.mode === 'review' ? `Runs read-only on PR #${ask.number}'s code.` : `Runs with: ${PERMISSION_SHORT[t.latest.permission] ?? t.latest.permission}, in the same copy.`}</Text>
136 <Box flexDirection="row" gap={1}>
137 <Button key="ask-send" variant="primary" label="Send" onPress={() => go.askSend(t.latest.id)} />
138 <Button key="ask-cancel" label="Cancel" onPress={() => go.askCancel()} />
139 </Box>
140 </Box>
141 )
142}
143
144export function backButton(ui: Ui, go: ListHandlers) {
145 const { Button } = ui
146 return <Button key="back" plain label="← Back" onPress={() => go.showScreen('list')} />
147}
148
149// Safe for anything: a board row with a missing writer or reviewer, or one that is not a name, must still draw
150// (a throw turns the screen into the "couldn't draw" note). "No agent" reads as a name does ("Reviewer No agent").
151const agentName = (a: unknown) => agentLabel(a) || 'No agent'
152
153export type CheckRow = { ok: boolean | null; text: string }
154
155function checkLines(ui: Ui, p: Palette, rows: CheckRow[], prefix: string) {
156 const { Text } = ui
157 return rows.map((r, i) => (
158 <Text key={`${prefix}-${i}`} color={r.ok === false ? p.needs.text : r.ok ? p.ok.text : p.dim} wrap="wrap">{`${r.ok === false ? '✋' : r.ok ? '✓' : '·'} ${r.text}`}</Text>
159 ))
160}
161
162export type SprintPaging = { ids: string[]; page: number; pages: number; from: number; to: number; total: number; rows?: number }
163
164export const NO_BOARD: BoardState = { sprint: null, open: null, confirm: null, picking: null }
165
166// Rows a capped card was sent without (capRows).
167const moreTickets = (n: number) => `… and ${n} more ticket${n === 1 ? '' : 's'}`
168
169const AT_ONCE = [1, 2, 3, 4, 5].map((n) => ({ value: String(n), label: String(n) }))
170
171const MARK: Record<string, string> = { done: '✓', current: '▸', 'to-do': '·' }
172
173// Agents to pick from: the ready ones, plus any already chosen, so a picker always shows its value.
174function agentOptions(agentList: AgentRecord[], chosen: string[] = []) {
175 const options = agentList.filter((x) => x.installed && x.enabled).map((x) => ({ value: x.name, label: x.label }))
176 for (const name of chosen) if (name && !options.some((o) => o.value === name)) options.push({ value: name, label: agentName(name) })
177 return options
178}
179
180// One thing waiting on the person (Your turn): `open` is where its title leads, `act` what its one button does;
181// each names a pane action and its arguments (turnItems in mod/view.js makes them for the list).
182export type TurnItem = { id: string; title: string; reason: string; label: string; open: { name: string; args: unknown[] }; act: { name: string; args: unknown[] } }
183
184// A Your turn row: the title (opens its screen), then one primary button and a dim reason beside it, in a tile of `tone`
185// (needs: it waits on the person).
186export function turnRow(ui: Ui, p: Palette, it: TurnItem, go: ListHandlers, tone: Tone = 'needs') {
187 const { Box, Text, Button } = ui
188 const call = (a: { name: string; args: unknown[] }) => () => (go as unknown as Record<string, (...args: unknown[]) => void>)[a.name](...a.args)
189 const room = ui.columns ? Math.floor((ui.columns - 8) * 1.25) : 0
190 return tile(ui, p, tone, `turnitem-${it.id}`, [
191 <Button key={`open-${it.id}`} plain label={room > 10 ? shorten(it.title, room) : it.title} onPress={call(it.open)} />,
192 <Box key={`turnline-${it.id}`} flexDirection="row" gap={1} alignItems="center">
193 <Button key={`turn-${it.id}`} variant="primary" label={it.label} onPress={call(it.act)} />
194 <Box key={`turnwhy-${it.id}`} flexShrink={1} minWidth={0}><Text color={p.dim} wrap="truncate-end">{it.reason}</Text></Box>
195 </Box>,
196 ])
197}
198
199// A sprint's board: its name and one status line, the next step (Start sprint on a draft, Resume sprint while
200// paused), its own Your turn, one line per ticket (each opens the ticket's screen), folded Options, and at the bottom Pause
201// sprint, or on a draft Discard draft… (it asks first).
202export function boardScreen(ui: Ui, p: Palette, s: SprintSummary | undefined, board: BoardState, go: ListHandlers, folds: string[] = [], session: string | null = null) {
203 const { Box, Text, Button } = ui
204 const parts: unknown[] = [backButton(ui, go)]
205 if (!s) {
206 parts.push(<Text key="gone" color={p.dim}>That sprint is no longer listed.</Text>)
207 return <Box flexDirection="column" gap={1}>{parts}</Box>
208 }
209 if (s.unreadable) {
210 parts.push(<Text key="board-head" bold wrap="wrap">{sprintLines(s)[0]}</Text>, <Text key="board-why" color={p.failed.text} wrap="wrap">{s.unreadable}</Text>)
211 return <Box flexDirection="column" gap={1}>{parts}</Box>
212 }
213 const draft = Boolean(s.draft)
214 // A finished sprint (archived after it ended or not) is never live: no Pause or Resume.
215 const ended = isFinishedSprint(s)
216 parts.push(
217 <Text key="board-head" bold wrap="wrap">{`${s.name ?? s.id}${draft ? ' · draft' : ''}`}</Text>,
218 <Text key="board-status" color={p.dim} wrap="wrap">{draft ? 'Nothing runs until you start it.' : sprintStatusLine(s)}</Text>,
219 )
220 // No full check for the sprint's repo: until one is set, its PRs open without it. A finished sprint opens no more,
221 // and a draft that proposes one gets it at Start.
222 if (s.check === false && !s.finishedAt && !s.proposed?.check) parts.push(<Text key="board-no-check" color={p.needs.text} wrap="wrap">{NO_CHECK_LINE}</Text>)
223 // News only the sprint's own chat is told: another chat's board says how much waits for it. Not before this chat's
224 // id is known (the sprint's own chat would flash it).
225 // Take them here moves the sprint to this chat, for a chat that is gone.
226 // A finished sprint listed though archived takes no news: never offered.
227 if (!draft && !s.archived && (s.news ?? 0) > 0 && session && s.chat && s.chat !== session) {
228 parts.push(
229 <Text key="board-news" color={p.dim} wrap="wrap">{waitingNews(s.news)}</Text>,
230 <Box key="board-news-links" flexDirection="row"><Button key="link-take-news" plain label="Take them here (if its chat is closed)" onPress={() => go.takeSprintNews(s.id)} /></Box>,
231 )
232 }
233 // Sent as its list row when the desktop model was cut to fit (no tickets): opened before Podium sent the board.
234 const loading = Boolean(s.brief) && !s.tickets.length
235 if (loading) parts.push(<Text key="board-loading" color={p.dim}>Loading…</Text>)
236 // Sent as its card only when the model was cut to fit: say what is missing.
237 else if (s.brief) parts.push(<Text key="board-brief" color={p.dim} wrap="wrap">This sprint's tasks and review notes are not shown here; ask Claude for them.</Text>)
238 // A question open is the one decision: while Discard asks, Start sprint is not drawn; Cancel brings it back.
239 const discarding = draft && board.sprint === s.id && board.confirm === 'discard'
240 if (draft && !discarding) parts.push(<Box key="board-next" flexDirection="row"><Button key="board-start" variant="primary" label="Start sprint" onPress={() => go.sprintAct('start', s.id)} /></Box>)
241 else if (s.paused && !s.finishedAt && !ended) parts.push(<Box key="board-next" flexDirection="row"><Button key="board-resume" variant="primary" label="Resume sprint" onPress={() => go.sprintAct('resume', s.id)} /></Box>)
242 const waiting = draft ? [] : s.tickets.filter((t) => NEEDS_YOU_STATES.has(t.state))
243 if (waiting.length) {
244 parts.push(<Text key="board-turn" bold>{`Your turn · ${waiting.length}`}</Text>)
245 for (const t of waiting) {
246 const open = { name: 'openTicket', args: [s.id, t.key] }
247 parts.push(turnRow(ui, p, { id: `${s.id}-${t.key}`, title: `${t.key} ${t.title}`, reason: t.state === 'parked' ? (t.reason || 'needs you') : ticketState(t), label: turnLabel(t.state), open, act: open }, go))
248 }
249 }
250 // A ticket waiting on the person is in Your turn already: say it once.
251 const rest = s.tickets.filter((t) => !waiting.includes(t))
252 if (!loading && (rest.length || (s.more ?? 0) > 0)) parts.push(<Text key="board-tickets" bold>{waiting.length ? 'Other tickets' : 'Tickets'}</Text>)
253 // One tile per ticket line, coloured by its state (row() keys its button `open-ticket-<KEY>`).
254 for (const t of rest) parts.push(row(ui, p, `ticket-${t.key}`, `${t.key} ${t.title} · ${ticketState(t, draft)}`, [], () => go.openTicket(s.id, t.key), undefined, ticketTone(t.state)))
255 if (!loading && (s.more ?? 0) > 0) parts.push(<Text key="board-more" color={p.dim} wrap="wrap">{`${moreTickets(s.more ?? 0)} are not shown here; ask Claude for them.`}</Text>)
256 // What Claude proposed for the repo with the draft: closed by default, its heading short; inside, the exact
257 // values, which are saved for the repo only when the person presses Start.
258 if (draft && s.proposed) {
259 const rid = `board:${s.id}:repo`
260 const pr = s.proposed
261 parts.push(fold(ui, p, rid, 'This repo', proposedSummary(pr), isFoldOpen(folds, rid), () => go.toggleFold(rid), () => [
262 <Text key="board-repo-what" color={p.dim} wrap="wrap">Claude proposed these. They are saved for this repo when you start the sprint; Discard drops them.</Text>,
263 ...(Array.isArray(pr.commands) ? [<Text key="board-repo-commands" wrap="wrap">{`Commands agents may run: ${pr.commands.join(' ') || 'none'}`}</Text>] : []),
264 ...(typeof pr.check === 'string' ? [<Text key="board-repo-check" wrap="wrap">{pr.check ? `Full check: ${pr.check}` : 'Full check: none (removes this repo\'s check)'}</Text>] : []),
265 ...(typeof pr.taskTest === 'string' ? [<Text key="board-repo-task-test" wrap="wrap">{pr.taskTest ? `Task test after each writer: ${pr.taskTest}` : 'Task test: none (removes this repo\'s task test)'}</Text>] : []),
266 ...(typeof pr.planDir === 'string' ? [<Text key="board-repo-plan-dir" wrap="wrap">{pr.planDir ? `Plans folder: ${pr.planDir}` : 'Plans folder: default (removes this repo\'s plans folder)'}</Text>] : []),
267 ...(typeof pr.keepPlan === 'boolean' ? [<Text key="board-repo-keep-plan" wrap="wrap">{pr.keepPlan ? 'Plans: kept in each PR' : 'Plans: left out of PRs (removes this repo\'s choice)'}</Text>] : []),
268 ]))
269 }
270 const oid = `board:${s.id}:options`
271 parts.push(fold(ui, p, oid, 'Options', optionsLine(s), isFoldOpen(folds, oid), () => go.toggleFold(oid), () => {
272 if (!draft) return [<Text key="options-fixed" color={p.dim} wrap="wrap">{`${s.maxParallel ?? AT_ONCE_DEFAULT} at once · merge when green ${s.autoMerge ? 'on' : 'off'}. Options change only before a sprint starts.`}</Text>]
273 const now = String(s.maxParallel ?? AT_ONCE_DEFAULT)
274 const atOnce = AT_ONCE.some((o) => o.value === now) ? AT_ONCE : [...AT_ONCE, { value: now, label: now }]
275 return [
276 <Button key="board-automerge" plain label={`${s.autoMerge ? '☑' : '☐'} Merge when green`} onPress={() => go.setSprintOptions(s.id, { mergeWhenGreen: !s.autoMerge })} />,
277 choices(ui, p, 'board-atonce', 'At once', now, atOnce, (v) => go.setSprintOptions(s.id, { atOnce: Number(v) })),
278 ]
279 }))
280 if (draft) {
281 if (discarding) {
282 parts.push(
283 <Text key="board-discard-what" wrap="wrap">Discard this draft? Nothing has run, so nothing else changes.</Text>,
284 <Box key="board-discard-confirm" flexDirection="row" gap={1}>
285 <Button key="board-discard-yes" variant="primary" label="Discard the draft" onPress={() => go.sprintAct('discard', s.id)} />
286 <Button key="board-discard-no" label="Cancel" onPress={() => go.askConfirm(s.id, null)} />
287 </Box>,
288 )
289 } else {
290 parts.push(<Box key="board-bottom" flexDirection="row"><Button key="end-board-discard" label="Discard draft…" onPress={() => go.askConfirm(s.id, 'discard')} /></Box>)
291 }
292 } else if (!s.paused && !s.finishedAt && !ended) {
293 parts.push(<Box key="board-bottom" flexDirection="row"><Button key="board-pause" label="Pause sprint" onPress={() => go.sprintAct('pause', s.id)} /></Box>)
294 }
295 return <Box flexDirection="column" gap={1}>{parts}</Box>
296}
297
298// Who works on a ticket, with a Change… per writer/reviewer line that opens its rows of agent buttons (one picker open at a
299// time, board.picking: "<KEY>:<n>", "<KEY>:all" or "<KEY>:plan"). The lines that may no longer change are text.
300function whoRows(ui: Ui, p: Palette, s: SprintSummary, t: SprintTicketSummary, picking: string | null, agentList: AgentRecord[], go: ListHandlers) {
301 const { Box, Text, Button } = ui
302 const out: unknown[] = []
303 const tasks = t.tasks ?? []
304 const who = (agent: string, reviewer: string) => `Writer ${agentName(agent)} · Reviewer ${agentName(reviewer)}`
305 const picker = (which: string, line: string, change: string, rows: () => unknown[]) => {
306 const id = `${t.key}:${which}`
307 const isPicking = picking === id
308 out.push(
309 <Box key={`who-${t.key}-${which}`} flexDirection="row" flexWrap="wrap" gap={1}>
310 <Text color={p.dim}>{line}</Text>
311 <Button key={`change-${t.key}-${which}`} label={isPicking ? 'Done' : change} onPress={() => go.openPicker(s.id, isPicking ? null : id)} />
312 </Box>,
313 )
314 if (isPicking) out.push(<Box key={`picker-${t.key}-${which}`} flexDirection="column" paddingLeft={2} gap={1}>{rows()}</Box>)
315 }
316 if ((t.of ?? 0) === 0 && t.writer) {
317 const line = `Plan written by ${agentName(t.writer)} · tasks reviewed by ${agentName(t.reviewer)}`
318 if (t.canAssignPlan) {
319 picker('plan', line, 'Change…', () => [
320 choices(ui, p, `plan-writer-${t.key}`, 'Writer of the plan (and of its tasks)', t.writer ?? '', agentOptions(agentList, [t.writer ?? '']), (v) => go.assignTask(s.id, t.key, 0, 'agent', v)),
321 choices(ui, p, `plan-reviewer-${t.key}`, 'Reviewer of its tasks', t.reviewer ?? '', agentOptions(agentList, [t.reviewer ?? '']), (v) => go.assignTask(s.id, t.key, 0, 'reviewer', v)),
322 ])
323 } else {
324 out.push(<Text key={`who-${t.key}-plan`} color={p.dim}>{line}</Text>)
325 }
326 }
327 if (s.draft && tasks.length) {
328 // One writer or reviewer for every task; "Several agents" while the tasks differ.
329 const one = (role: 'agent' | 'reviewer') => {
330 const names = [...new Set(tasks.map((x) => x[role]))]
331 return { names, value: names.length === 1 ? names[0] : '' }
332 }
333 const writer = one('agent')
334 const reviewer = one('reviewer')
335 const line = writer.value && reviewer.value ? `Every task: ${who(writer.value, reviewer.value)}` : 'Every task: Several agents'
336 picker('all', line, 'Change for every task…', () => [
337 choices(ui, p, `ticket-writer-${t.key}`, 'Writer for every task', writer.value, agentOptions(agentList, writer.names), (v) => go.assignTicket(s.id, t.key, 'agent', v)),
338 choices(ui, p, `ticket-reviewer-${t.key}`, 'Reviewer for every task', reviewer.value, agentOptions(agentList, reviewer.names), (v) => go.assignTicket(s.id, t.key, 'reviewer', v)),
339 ])
340 }
341 for (const x of tasks) {
342 const line = `Task ${x.n}: ${who(x.agent, x.reviewer)}`
343 if (!x.canAssign) { out.push(<Text key={`who-${t.key}-${x.n}`} color={p.dim}>{line}</Text>); continue }
344 picker(String(x.n), line, 'Change…', () => [
345 choices(ui, p, `writer-${t.key}-${x.n}`, 'Writer', x.agent, agentOptions(agentList, [x.agent]), (v) => go.assignTask(s.id, t.key, x.n, 'agent', v)),
346 choices(ui, p, `reviewer-${t.key}-${x.n}`, 'Reviewer', x.reviewer, agentOptions(agentList, [x.reviewer]), (v) => go.assignTask(s.id, t.key, x.n, 'reviewer', v)),
347 ])
348 }
349 return out
350}
351
352// A ticket's own screen: its status line, what the person decides on (the plan's tasks, or why it stopped),
353// one next step, then folded Who works on it, Waits for, Plan file and Steps. A draft's ticket changes here: writer and
354// reviewer, what it waits for, its place in the order, and Remove from sprint. Stop this ticket… asks first.
355export function ticketScreen(ui: Ui, p: Palette, s: SprintSummary | undefined, key: string | null, board: BoardState, agentList: AgentRecord[], go: ListHandlers, folds: string[] = [], waiting = false) {
356 const { Box, Text, Button, Input } = ui
357 const t = s?.tickets.find((x) => x.key === key)
358 const back = s ? <Button key="back" plain label={`← ${s.name ?? 'Sprint'}`} onPress={() => go.openBoard(s.id)} /> : backButton(ui, go)
359 if (!s || !t) {
360 // The desktop opened it before Podium sent its sprint (sent as its list row, or not on the list's page while the
361 // ticket waits in Your turn, `waiting`): it is on its way, not gone.
362 const loading = s ? Boolean(s.brief) : waiting
363 return (
364 <Box flexDirection="column" gap={1}>
365 {back}
366 {loading
367 ? <Text key="ticket-loading" color={p.dim}>Loading…</Text>
368 : <Text key="gone" color={p.dim}>That ticket is no longer listed.</Text>}
369 </Box>
370 )
371 }
372 const draft = Boolean(s.draft)
373 const tasks = t.tasks ?? []
374 const planless = (t.of ?? 0) === 0
375 const mineBoard = board.sprint === s.id
376 const picking = mineBoard ? (board.picking ?? null) : null
377 const revising = mineBoard && board.confirm === `revise:${t.key}`
378 const noting = Boolean(t.canNote) && mineBoard && board.confirm === `note:${t.key}`
379 const accepting = Boolean(t.canAccept) && mineBoard && board.confirm === `accept:${t.key}`
380 const evidencing = Boolean(t.canEvidence) && mineBoard && board.confirm === `evidence:${t.key}`
381 const stopping = mineBoard && board.confirm === `stop:${t.key}`
382 // Commits in its copy that Podium didn't make: Keep, or Drop after asking.
383 const foreign = t.foreignCommits ?? null
384 const dropping = Boolean(foreign) && mineBoard && board.confirm === `drop:${t.key}`
385 // Production commands its plan runs (their lines on the open board, a count on a brief card): Approve asks first.
386 const attention = t.attention ?? []
387 const flagged = flaggedOf(t)
388 // Every one of them shown here, so the person may agree to them here.
389 const allShown = attention.length > 0 && attention.length === flagged
390 const acking = flagged > 0 && Boolean(t.canApprove || t.canOneTask) && mineBoard && board.confirm === `ack:${t.key}`
391 const parts: unknown[] = [
392 back,
393 // The key, title and status line in one tile, coloured as the ticket's board line is.
394 tile(ui, p, ticketTone(t.state), 'ticket-tile', [
395 <Text key="ticket-head" bold wrap="wrap">{`${t.key} ${t.title}`}</Text>,
396 <Text key="ticket-status" color={NEEDS_YOU_STATES.has(t.state) ? p.needs.text : p.dim} wrap="wrap">{ticketStatusLine(t, draft)}</Text>,
397 ]),
398 ]
399 if (s.brief) parts.push(<Text key="ticket-brief" color={p.dim} wrap="wrap">This ticket's tasks and review notes are not shown here; ask Claude for them.</Text>)
400 // What the person decides on: the plan's tasks (titles only), or the reviewer's findings.
401 const proposed = t.proposed ?? []
402 if (proposed.length) {
403 parts.push(<Text key="proposed-head" color={p.dim}>{`Its tasks (${proposed.length + (t.proposedMore ?? 0)}):`}</Text>)
404 for (const x of proposed) parts.push(<Text key={`proposed-${x.n}`} wrap="truncate-end">{`· Task ${x.n}: ${x.title}`}</Text>)
405 if (t.proposedMore) parts.push(<Text key="proposed-more" color={p.dim}>{`… and ${t.proposedMore} more tasks`}</Text>)
406 }
407 if (flagged) {
408 parts.push(<Text key="attention-head" bold color={p.needs.text} wrap="wrap">{`${attentionHead(flagged)}${attention.length ? ':' : '.'}`}</Text>)
409 attention.forEach((x, i) => parts.push(<Text key={`attention-${i}`} wrap="wrap">{`· Task ${x.n}: ${x.command}`}</Text>))
410 if (attention.length && flagged > attention.length) parts.push(<Text key="attention-more" color={p.dim}>{`… and ${flagged - attention.length} more in the plan`}</Text>)
411 }
412 if (t.state === 'parked' && t.findings) parts.push(<Text key="findings" color={p.dim} wrap="wrap">{t.findings}</Text>)
413 if (t.canAccept) parts.push(<Text key="tests-hint" color={p.dim} wrap="wrap">{testsParkHint(t)}</Text>)
414 // Parked on a file locked while saving changes: what to release and how to retry. The original failure
415 // details above stay for inspection; Retry below tries the interrupted operation again.
416 const lockHint = checkpointLockHint(t)
417 if (lockHint) {
418 parts.push(<Text key="lock-head" bold>Release and retry</Text>)
419 parts.push(<Text key="lock-hint" wrap="wrap">{lockHint}</Text>)
420 }
421 // One next step.
422 const step: unknown[] = []
423 const giving = picking === `${t.key}:give`
424 const stuck = tasks.find((x) => x.status === 'current')
425 const current = planless ? (t.writer ?? '') : stuck ? (t.giveTo === 'reviewer' ? stuck.reviewer : stuck.agent) : ''
426 const others = agentOptions(agentList).filter((o) => o.value !== current)
427 const approve = (whole: boolean) => (flagged ? go.askConfirm(s.id, `ack:${t.key}`) : go.approvePlan(s.id, t.key, whole))
428 if (t.canApprove) step.push(<Button key={`approve-${t.key}`} variant="primary" label={flagged ? 'Approve plan…' : 'Approve plan'} onPress={() => approve(false)} />)
429 if (t.canOneTask) step.push(<Button key={`one-task-${t.key}`} variant="primary" label={flagged ? 'Run as one task…' : 'Run as one task'} onPress={() => approve(true)} />)
430 if (t.canRevise && !revising) step.push(<Button key={`revise-${t.key}`} label="Ask for changes…" onPress={() => go.askConfirm(s.id, `revise:${t.key}`)} />)
431 if (t.canRetry) step.push(<Button key={`retry-${t.key}`} variant="primary" label={ticketRetryLabel(t)} onPress={() => go.retryTicket(s.id, t.key)} />)
432 if (t.canEvidence && !evidencing) step.push(<Button key={`evidence-${t.key}`} label="Review again with evidence…" onPress={() => go.askConfirm(s.id, `evidence:${t.key}`)} />)
433 if (t.canAccept && !accepting) step.push(<Button key={`accept-${t.key}`} label="Accept and continue…" onPress={() => go.askConfirm(s.id, `accept:${t.key}`)} />)
434 if (t.canRetry && t.giveTo && others.length) step.push(<Button key={`give-${t.key}`} label={giving ? 'Done' : 'Give to another agent…'} onPress={() => go.openPicker(s.id, giving ? null : `${t.key}:give`)} />)
435 if (foreign) {
436 const them = foreign.count === 1 ? 'it' : 'them'
437 step.push(<Button key={`keep-commits-${t.key}`} variant="primary" label={`Keep ${them}`} onPress={() => go.answerCommits(s.id, t.key, true)} />)
438 step.push(<Button key={`end-drop-commits-${t.key}`} label={`Drop ${them}…`} onPress={() => go.askConfirm(s.id, `drop:${t.key}`)} />)
439 }
440 if (t.canSync) step.push(<Button key={`sync-${t.key}`} label={`Sync with ${s.base ?? 'base'}`} onPress={() => go.syncTicket(s.id, t.key)} />)
441 // As the one next step it is outlined: not `open-`, which look.tsx draws as a text link on desktop.
442 const stepsButton = (primary: boolean) => (primary
443 ? <Button key={`steps-${t.key}`} variant="primary" label="Steps and diff" onPress={() => go.openDetail(t.latest as string)} />
444 : <Button key={`open-steps-${t.key}`} plain label="Steps and diff" onPress={() => go.openDetail(t.latest as string)} />)
445 const stepsIsNext = !step.length && Boolean(t.latest) && t.state === 'running'
446 if (stepsIsNext) step.push(stepsButton(true))
447 // A question or form open is the one decision: while the plan's notes or a note are open or Stop asks, the next step
448 // (Approve plan, Run as one task, Retry…) is not drawn, so it can't be pressed with notes unsent; Cancel brings it back.
449 const deciding = (t.canRevise && revising) || noting || accepting || evidencing || (t.canStop && stopping) || dropping || acking
450 if (step.length && !deciding) parts.push(<Box key="next" flexDirection="row" flexWrap="wrap" gap={1}>{step}</Box>)
451 if (foreign && dropping) {
452 parts.push(
453 <Text key="drop-what" wrap="wrap">{`Drop ${foreign.count === 1 ? 'the commit' : `the ${foreign.count} commits`} Podium didn't make? ${t.key}'s copy goes back to where Podium left it.`}</Text>,
454 <Box key="drop-confirm" flexDirection="row" gap={1}>
455 <Button key={`drop-yes-${t.key}`} variant="primary" label="Drop" onPress={() => go.answerCommits(s.id, t.key, false)} />
456 <Button key={`drop-no-${t.key}`} label="Cancel" onPress={() => go.askConfirm(s.id, null)} />
457 </Box>,
458 )
459 }
460 if (acking) {
461 const whole = !t.canApprove
462 // Only commands the person can see here may be agreed to here: with some or all of them not shown, Claude lists them.
463 parts.push(
464 <Text key="ack-what" wrap="wrap">{allShown ? ackQuestion(flagged, whole) : ackUnseen(flagged)}</Text>,
465 <Box key="ack-confirm" flexDirection="row" gap={1}>
466 {allShown ? <Button key={`ack-yes-${t.key}`} variant="primary" label={whole ? 'Run as one task' : 'Approve plan'} onPress={() => go.approvePlan(s.id, t.key, whole, true)} /> : null}
467 <Button key={`ack-no-${t.key}`} label="Cancel" onPress={() => go.askConfirm(s.id, null)} />
468 </Box>,
469 )
470 }
471 if (giving && t.canRetry && t.giveTo && !deciding) {
472 const what = planless ? 'to write the plan' : `${t.giveTo === 'reviewer' ? 'to review' : 'to write'}${stuck ? ` task ${stuck.n}` : ''}`
473 parts.push(choices(ui, p, `giveto-${t.key}`, `Give it to (${what}):`, '', others, (v) => go.retryTicket(s.id, t.key, v)))
474 }
475 if (t.canRevise && revising) {
476 parts.push(
477 <Box key={`revise-form-${t.key}`} flexDirection="column" gap={1}>
478 <Input key={`revise-text-${t.key}`} label="What should change in the plan?" placeholder="e.g. split task 2, and add a test for an empty file" value={board.note ?? ''} submitLabel="Send"
479 onInput={(v: string) => go.editNote(s.id, v)} onSubmit={(v: string) => go.revisePlan(s.id, t.key, v)} />
480 <Text color={p.dim} wrap="wrap">{`${agentName(t.writer)} changes the plan in the same conversation, then it is ready to approve again.`}</Text>
481 <Box flexDirection="row" gap={1}>
482 <Button key={`revise-send-${t.key}`} variant="primary" label="Send" onPress={() => go.revisePlan(s.id, t.key, board.note ?? '')} />
483 <Button key={`revise-cancel-${t.key}`} label="Cancel" onPress={() => go.askConfirm(s.id, null)} />
484 </Box>
485 </Box>,
486 )
487 }
488 if (evidencing) {
489 parts.push(
490 <Box key={`evidence-form-${t.key}`} flexDirection="column" gap={1}>
491 <Input key={`evidence-text-${t.key}`} label="What should the reviewer weigh?" placeholder="e.g. CI passed on this commit: https://…" value={board.note ?? ''} submitLabel="Review again"
492 onInput={(v: string) => go.editNote(s.id, v)} onSubmit={(v: string) => go.reviewWithEvidence(s.id, t.key, v)} />
493 <Text color={p.dim} wrap="wrap">{evidenceLine(stuck?.reviewer ?? null)}</Text>
494 <Box flexDirection="row" gap={1}>
495 <Button key={`evidence-save-${t.key}`} variant="primary" label="Review again" onPress={() => go.reviewWithEvidence(s.id, t.key, board.note ?? '')} />
496 <Button key={`evidence-cancel-${t.key}`} label="Cancel" onPress={() => go.askConfirm(s.id, null)} />
497 </Box>
498 </Box>,
499 )
500 }
501 if (accepting) {
502 // A card sent without its tasks has no task reviewer: the line then names none.
503 const reviewer = stuck?.reviewer ?? null
504 parts.push(
505 <Box key={`accept-form-${t.key}`} flexDirection="column" gap={1}>
506 <Input key={`accept-text-${t.key}`} label="Why are these test results fine?" placeholder="e.g. the date tests fail on main too" value={board.note ?? ''} submitLabel="Accept"
507 onInput={(v: string) => go.editNote(s.id, v)} onSubmit={(v: string) => go.acceptTests(s.id, t.key, v)} />
508 <Text color={p.dim} wrap="wrap">{acceptLine(reviewer)}</Text>
509 <Box flexDirection="row" gap={1}>
510 <Button key={`accept-save-${t.key}`} variant="primary" label="Accept and continue" onPress={() => go.acceptTests(s.id, t.key, board.note ?? '')} />
511 <Button key={`accept-cancel-${t.key}`} label="Cancel" onPress={() => go.askConfirm(s.id, null)} />
512 </Box>
513 </Box>,
514 )
515 }
516 if (noting) {
517 parts.push(
518 <Box key={`note-form-${t.key}`} flexDirection="column" gap={1}>
519 <Input key={`note-text-${t.key}`} label={`A note for ${t.key}'s agents`} placeholder="e.g. the date tests fail on main too: leave them" value={board.note ?? ''} submitLabel="Save"
520 onInput={(v: string) => go.editNote(s.id, v)} onSubmit={(v: string) => go.addNote(s.id, t.key, v)} />
521 <Text color={p.dim} wrap="wrap">{noteReaders(t.key, t.state === 'running')}</Text>
522 <Box flexDirection="row" gap={1}>
523 <Button key={`note-save-${t.key}`} variant="primary" label="Save note" onPress={() => go.addNote(s.id, t.key, board.note ?? '')} />
524 <Button key={`note-cancel-${t.key}`} label="Cancel" onPress={() => go.askConfirm(s.id, null)} />
525 </Box>
526 </Box>,
527 )
528 }
529 const links: unknown[] = []
530 if (t.latest && (!stepsIsNext || deciding)) links.push(stepsButton(false))
531 if (t.canNote && !deciding) links.push(<Button key={`link-note-${t.key}`} plain label="Add a note…" onPress={() => go.askConfirm(s.id, `note:${t.key}`)} />)
532 if (links.length) parts.push(<Box key="links" flexDirection="row" flexWrap="wrap" gap={1}>{links}</Box>)
533 // Folded detail.
534 const fid = (name: string) => `ticket:${s.id}:${t.key}:${name}`
535 const f = (name: string, title: string, summary: string, body: () => unknown[]) => fold(ui, p, fid(name), title, summary, isFoldOpen(folds, fid(name)), () => go.toggleFold(fid(name)), body)
536 // The notes come only with the open ticket's row (slimSprints): drawn once they are here.
537 const notes = t.notes ?? []
538 const noteCount = Math.max(t.noteCount ?? 0, notes.length)
539 if (notes.length) {
540 parts.push(f('notes', 'Your notes', String(noteCount), () => [
541 ...notes.map((x) => <Text key={`note-${t.key}-${x.n}`} wrap="wrap">{`${x.n}. ${x.text}`}</Text>),
542 ...(noteCount > notes.length ? [<Text key="notes-older" color={p.dim}>{`${noteCount - notes.length} older not shown here`}</Text>] : []),
543 ]))
544 }
545 parts.push(f('who', 'Who works on it', whoLine(t), () => whoRows(ui, p, s, t, picking, agentList, go)))
546 const after = t.after ?? []
547 const otherKeys = [...new Set([...s.tickets.map((x) => x.key), ...after])].filter((k) => k !== t.key)
548 if ((draft && otherKeys.length) || after.length) {
549 parts.push(f('after', 'Waits for', after.length ? andList(after) : 'nothing', () => (draft
550 ? [
551 <Text key="after-what" color={p.dim} wrap="wrap">Starts after these are merged:</Text>,
552 <Box key={`after-row-${t.key}`} flexDirection="row" flexWrap="wrap" gap={1}>
553 {otherKeys.map((k) => {
554 const on = after.includes(k)
555 return <Button key={`after-${t.key}-${k}`} label={`${on ? '☑' : '☐'} ${k}`} onPress={() => go.setAfter(s.id, t.key, k, !on)} />
556 })}
557 </Box>,
558 ]
559 : [<Text key="after-what" color={p.dim} wrap="wrap">{`Starts when ${andList(after)} ${after.length === 1 ? 'is' : 'are'} merged on GitHub, from the code as merged there.`}</Text>])))
560 }
561 if (t.plan) parts.push(f('plan', 'Plan file', String(t.plan).split('/').pop() ?? '', () => [<Text key="plan-path" color={p.dim} wrap="wrap">{String(t.plan)}</Text>]))
562 if (tasks.length) {
563 parts.push(f('steps', 'Steps', `${tasks.filter((x) => x.status === 'done').length} of ${tasks.length} done`, () => tasks.map((x) => (
564 <Text key={`task-${t.key}-${x.n}`} wrap="truncate-end">{`${MARK[x.status] ?? '·'} Task ${x.n}: ${x.title}`}</Text>
565 ))))
566 }
567 // A draft's order and Remove; Stop at the bottom, asking first.
568 if (draft) {
569 // A capped card's rows know their place in the whole sprint (pos).
570 const total = s.tickets.length + (s.more ?? 0)
571 const at = t.pos ?? s.tickets.indexOf(t)
572 const order: unknown[] = []
573 if (at > 0) order.push(<Button key={`up-${t.key}`} plain label="Move up" onPress={() => go.moveTicket(s.id, t.key, 'up')} />)
574 if (at < total - 1) order.push(<Button key={`down-${t.key}`} plain label="Move down" onPress={() => go.moveTicket(s.id, t.key, 'down')} />)
575 if (total > 1) order.push(<Button key={`remove-${t.key}`} label="Remove from sprint" onPress={() => go.removeTicket(s.id, t.key)} />)
576 if (order.length) parts.push(<Box key="order" flexDirection="row" flexWrap="wrap" gap={1}>{order}</Box>)
577 }
578 if (t.canStop) {
579 if (stopping) {
580 parts.push(
581 <Text key="stop-what" wrap="wrap">{`Stop ${t.key}? No more tasks run and no PR is opened. Any work it did stays on its branch.`}</Text>,
582 <Box key="stop-confirm" flexDirection="row" gap={1}>
583 <Button key={`stop-yes-${t.key}`} variant="primary" label="Stop ticket" onPress={() => go.stopTicket(s.id, t.key)} />
584 <Button key={`stop-no-${t.key}`} label="Cancel" onPress={() => go.askConfirm(s.id, null)} />
585 </Box>,
586 )
587 } else {
588 parts.push(<Box key="bottom" flexDirection="row"><Button key={`end-stop-${t.key}`} label="Stop this ticket…" onPress={() => go.askConfirm(s.id, `stop:${t.key}`)} /></Box>)
589 }
590 }
591 return <Box flexDirection="column" gap={1}>{parts}</Box>
592}
593
594// The first-run welcome: one line on how to start, Try it, and the setup checks folded, open on their own
595// only when something needs fixing.
596function welcomeCard(ui: Ui, p: Palette, rows: CheckRow[], folds: string[], go: ListHandlers) {
597 const { Box, Text, Button } = ui
598 const wrong = rows.some((r) => r.ok === false)
599 return tile(ui, p, 'neutral', 'welcome', [
600 <Text key="welcome-title" bold>Welcome to Podium</Text>,
601 <Text key="welcome-how" color={p.dim} wrap="wrap">Hand work to another agent from the chat, or press + New job.</Text>,
602 <Box key="welcome-buttons" flexDirection="row" gap={1}>
603 <Button key="welcome-try" variant="primary" label="Try it" onPress={() => go.tryIt()} />
604 <Button key="welcome-done" label="Got it" onPress={() => go.gotIt()} />
605 </Box>,
606 fold(ui, p, 'list:setup', 'Your setup', setupSummary(rows), isFoldOpen(folds, 'list:setup', wrong), () => go.toggleFold('list:setup'), () => checkLines(ui, p, rows, 'check')),
607 ])
608}
609
610const titleOf = (t: Thread) => t.steps[t.steps.length - 1]?.title || t.latest.title || 'Untitled job'
611
612// Finished sprints' rows in the Done or Other chats fold: each opens its board, coloured done or failed,
613// and a dim line for the older ones not shown (`older`, exact: the engine counts per fold what it did not list).
614function finishedSprintRows(ui: Ui, p: Palette, list: SprintSummary[], older: number, where: string, now: number, go: ListHandlers) {
615 const { Text } = ui
616 const rows: unknown[] = list.map((s) => row(ui, p, `board-${s.id}`, sprintRowTitle(s), [finishedSprintLine(s, now)], () => go.openBoard(s.id), undefined, finishedSprintTone(s)))
617 if (older > 0) rows.push(<Text key={`sprints-older-${where}`} color={p.dim} wrap="wrap">{olderSprintsLine(older)}</Text>)
618 return rows
619}
620
621// The list: Your turn first (everything waiting on the person, one line and one button each), then Working
622// (jobs with a live line, then a row per sprint, a page of them at a time), In review, and Done and other chats' finished
623// work, folded. Every row opens its own screen.
624export function listScreen(ui: Ui, p: Palette, model: PaneModel, org: Organised, now: number, go: ListHandlers) {
625 const { Box, Text, Button } = ui
626 const folds = model.folds ?? []
627 const parts: unknown[] = []
628 const report = model.checks
629 if (report && !report.welcomed) parts.push(welcomeCard(ui, p, checkRows(report, model.agentsLoaded ? model.agents : null), folds, go))
630 const turn = model.turn ?? turnTickets(model.sprints ?? [])
631 const items = turnItems(org, turn.tickets) as TurnItem[]
632 const openSprints = (model.sprints ?? []).filter(isOpenSprint)
633 const shown = listPage(openSprints, model.sprintPage ?? 0, model.sprintPaging ?? null)
634 const done = org.done.filter((t) => !isFailedTurn(t))
635 // Finished sprints: this chat's in Done, other chats' in Other chats, the newest FINISHED_SPRINTS of each.
636 // On desktop fitModel sent no more, and counts the rest (`olderSprints`); the engine counts, per fold, the archived ones it
637 // did not list at all (`olderFinished`), so the "older sprints" line is exact.
638 const session = model.session ?? null
639 const fin = finishedSprints(model.sprints ?? [], session)
640 const finMine = fin.mine.slice(0, FINISHED_SPRINTS)
641 const finOthers = fin.others.slice(0, FINISHED_SPRINTS)
642 const olderMine = (model.olderSprints?.mine ?? 0) + (model.olderFinished?.mine ?? 0) + fin.mine.length - finMine.length
643 const olderOthers = (model.olderSprints?.others ?? 0) + (model.olderFinished?.others ?? 0) + fin.others.length - finOthers.length
644 // Clear takes failed jobs out of Your turn too (they are in org.done): it sits in the Done fold, or, when there is no
645 // Done fold (every finished job failed), as a quiet link under Your turn.
646 const doneFold = Boolean(done.length || org.doneHidden || model.showAllDone || finMine.length || olderMine)
647 const hiddenJobs = model.hiddenJobs ?? 0
648 const hiddenAtWork = model.hiddenAtWork ?? 0
649 const hiddenSprints = model.hiddenSprints ?? 0
650 const mine = items.length || org.working.length || org.review.length || done.length || org.doneHidden || model.showAllDone || openSprints.length || hiddenJobs || hiddenSprints || finMine.length || olderMine
651 // Nothing of this chat's: the text, with the Other chats fold below it when that has anything, jobs or sprints alike
652 // (an empty chat shows the text and the other-chats lane).
653 if (!mine) parts.push(<Text key="empty" color={p.dim} wrap="wrap">No jobs in this chat yet. Ask Claude to hand work to another agent, or press + New job.</Text>)
654 if (mine) {
655 // Your turn.
656 const waiting = items.length + turn.more
657 parts.push(<Text key="h-turn" bold>{waiting ? `Your turn · ${waiting}` : 'Your turn'}</Text>)
658 if (!waiting) parts.push(<Text key="turn-none" color={p.dim}>Nothing needs you right now.</Text>)
659 for (const it of items) parts.push(turnRow(ui, p, it, go))
660 if (turn.more) parts.push(<Text key="turn-more" color={p.dim} wrap="wrap">{`… and ${turn.more} more sprint ticket${turn.more === 1 ? '' : 's'}; open their boards to see them.`}</Text>)
661 if (!doneFold && org.done.length) parts.push(<Box key="turn-links" flexDirection="row"><Button key="link-clear" plain label="Clear stopped jobs" onPress={() => go.clearDone()} /></Box>)
662 }
663 // Working: jobs, then sprints.
664 const working = org.working.length + shown.total
665 if (working) {
666 parts.push(<Text key="h-working" bold>{`Working · ${working}`}</Text>)
667 for (const t of org.working) {
668 const info = model.live[t.latest.id]
669 parts.push(row(ui, p, t.latest.id, titleOf(t), [workingRowLine(t.latest, now, info ?? null, t.isMine === false), info?.activity ?? 'starting…'], () => go.openDetail(t.latest.id), undefined, 'working'))
670 }
671 for (const s of shown.sprints) {
672 parts.push(row(ui, p, `board-${s.id}`, sprintRowTitle(s), [sprintWorkingLine(s, !isSprintMine(s, session))], () => go.openBoard(s.id), s.unreadable ? p.failed.text : undefined, sprintTone(s)))
673 }
674 if (shown.pages > 1) {
675 const after = shown.total - shown.to
676 parts.push(<Text key="sprints-page" color={p.dim} wrap="wrap">{`Sprints ${shown.from}–${shown.to} of ${shown.total}${after > 0 ? ` · ${after} more` : ''}`}</Text>)
677 parts.push(
678 <Box key="sprints-paging" flexDirection="row" flexWrap="wrap" gap={1}>
679 {shown.page > 0 ? <Button key="sprints-earlier" label="Show earlier sprints" onPress={() => go.showSprints(shown.page - 1)} /> : null}
680 {after > 0 ? <Button key="sprints-more" label="Show more sprints" onPress={() => go.showSprints(shown.page + 1)} /> : null}
681 </Box>,
682 )
683 }
684 }
685 // Sprints the desktop pane was not sent (fitModel's last resort).
686 if (hiddenSprints > 0) parts.push(<Text key="sprints-hidden" color={p.dim} wrap="wrap">{`${hiddenSprints} more sprint${hiddenSprints === 1 ? ' is' : 's are'} not shown here; ask Claude for ${hiddenSprints === 1 ? 'it' : 'them'}.`}</Text>)
687 // In review.
688 if (org.review.length) {
689 parts.push(<Text key="h-review" bold>{`In review · ${org.review.length}`}</Text>)
690 for (const t of org.review) {
691 const job = t.latest
692 const sub = [job.pr ? prSub(job.pr) : null, t.isMine === false ? FROM_OTHER_CHAT : null].filter(Boolean).join(' · ')
693 parts.push(row(ui, p, job.id, job.pr ? `PR #${job.pr.number} ${titleOf(t)}` : titleOf(t), [sub || null], () => go.openDetail(job.id), undefined, 'neutral'))
694 }
695 }
696 // Done, folded. Its Clear also clears failed jobs (Your turn). This chat's finished sprints follow its jobs.
697 if (doneFold) {
698 parts.push(fold(ui, p, 'list:done', 'Done', String(done.length + finMine.length), isFoldOpen(folds, 'list:done'), () => go.toggleFold('list:done'), () => [
699 ...done.map((t) => row(ui, p, t.latest.id, titleOf(t), [doneRowLine(t.latest, now)], () => go.openDetail(t.latest.id), undefined, finishedTone(t.latest))),
700 ...finishedSprintRows(ui, p, finMine, olderMine, 'done', now, go),
701 <Box key="done-links" flexDirection="row" flexWrap="wrap" gap={1}>
702 {org.done.length ? <Button key="link-clear" plain label="Clear" onPress={() => go.clearDone()} /> : null}
703 {org.doneHidden > 0 || model.showAllDone ? <Button key="link-done-more" plain label={model.showAllDone ? 'Show fewer' : `Show ${org.doneHidden} more`} onPress={() => go.toggleShowAll()} /> : null}
704 </Box>,
705 ]))
706 }
707 // Other chats' finished work, folded: their jobs, then their finished sprints.
708 if (org.others.length || finOthers.length || olderOthers) {
709 parts.push(fold(ui, p, 'list:others', 'Other chats', String(org.others.length + finOthers.length), isFoldOpen(folds, 'list:others'), () => go.toggleFold('list:others'), () => [
710 ...org.others.map((t) => row(ui, p, t.latest.id, titleOf(t), [doneRowLine(t.latest, now)], () => go.openDetail(t.latest.id), undefined, finishedTone(t.latest))),
711 ...finishedSprintRows(ui, p, finOthers, olderOthers, 'others', now, go),
712 ]))
713 }
714 // Jobs the desktop pane was not sent (fitModel).
715 if (hiddenAtWork > 0) parts.push(<Text key="older-hidden" color={p.dim} wrap="wrap">Some jobs are not shown here; ask Claude for them.</Text>)
716 else if (hiddenJobs > 0) parts.push(<Text key="older-hidden" color={p.dim} wrap="wrap">{`${hiddenJobs} finished job${hiddenJobs === 1 ? ' is' : 's are'} not shown here; ask Claude for ${hiddenJobs === 1 ? 'it' : 'them'}.`}</Text>)
717 return <Box flexDirection="column" gap={1}>{parts}</Box>
718}
719
720export type DetailHandlers = ListHandlers & {
721 confirmJob: (id: string, what: 'discard' | 'cancel' | null) => void
722 rate: (id: string, value: 'used' | 'discarded') => void
723 toggleFull: (id: string) => void
724 askClaude: (id: string) => void
725 toggleFile: (id: string, file: string) => void
726 fullDiff: (id: string) => void
727 askRepair: (id: string, isOn: boolean) => void
728 repair: (id: string) => void
729}
730
731function changeRows(ui: Ui, p: Palette, id: string, set: ChangeSet, open: string[], go: DetailHandlers) {
732 const { Box, Text, Button } = ui
733 // `more`: files the desktop Client was not sent (fitModel), counted here as the rest are.
734 const total = set.files.length + (set.more ?? 0)
735 const rows: unknown[] = []
736 // The desktop page refuses a tree over 2,000 nodes or 100,000 characters, and each file row adds several nodes and
737 // its path three times: draw the first FILE_ROWS only, fewer when their paths are long (fileRows).
738 const shown = set.files.slice(0, fileRows(set.files))
739 // Full diff fetches the whole job's diff, so it is offered once, under the first open file that was cut.
740 const cutOpen = shown.find((f) => open.includes(f.path) && f.isTruncated)
741 for (const f of shown) {
742 const isOpen = open.includes(f.path)
743 const counts = f.added === null ? 'binary file' : `+${f.added} −${f.removed}`
744 rows.push(
745 <Box key={`row-${f.path}`} flexDirection="row" justifyContent="space-between">
746 <Button key={`file-${f.path}`} plain label={`${isOpen ? '▾' : '▸'} ${f.path}`} onPress={() => go.toggleFile(id, f.path)} />
747 <Text color={p.dim}>{counts}</Text>
748 </Box>,
749 )
750 if (isOpen && f.patch) rows.push(codeBlock(ui, p, `diff-${f.path}`, f.patch, 'diff'))
751 if (f === cutOpen) rows.push(<Button key={`full-${f.path}`} plain label="Full diff" onPress={() => go.fullDiff(id)} />)
752 }
753 const more = total - shown.length
754 if (more > 0) rows.push(<Text key="chg-more" color={p.dim}>{`… and ${more} more file${more === 1 ? '' : 's'}`}</Text>)
755 return rows
756}
757
758// A job's own screen: the title, one status line, what the agent did (or, while it works, its last lines),
759// one next step, then folded detail. Throw away and Stop sit at the bottom and ask first. A sprint's step says the sprint
760// manages it, and goes back to its ticket; the sprint keeps or opens its work, so it offers no Keep or Throw away.
761export function detailScreen(ui: Ui, p: Palette, job: JobRecord | undefined, d: JobDetail | null, info: LiveInfo | undefined, now: number, go: DetailHandlers, thread: Thread | null = null, ask: AskFor = null, agentList: AgentRecord[] = [], session: string | null = null, folds: string[] = [], prReady = false) {
762 const { Box, Text, Button } = ui
763 const sprint = job?.sprint ?? null
764 const back = sprint ? <Button key="back" plain label={`← ${sprint.ticket}`} onPress={() => go.openTicket(sprint.id, sprint.ticket)} /> : backButton(ui, go)
765 if (!job) {
766 return (
767 <Box flexDirection="column" gap={1}>
768 {back}
769 <Text color={p.dim}>That job is no longer listed.</Text>
770 </Box>
771 )
772 }
773 const id = job.id
774 const mine = d && d.id === id ? d : null
775 const working = job.status === 'queued' || job.status === 'running'
776 const pr = job.pr ?? null
777 const threadId = thread?.id ?? job.threadId ?? job.id
778 const isLatest = !thread || thread.latest.id === id
779 const asking = Boolean(ask && thread && ask.threadId === thread.id && isLatest)
780 const tone = toneOf(job)
781 const quiet = working ? quietText(info ?? null, now) : null
782 const statusColor = pr?.state === 'OPEN' && pr.attention ? p.needs.text : tone === 'needs' ? p.needs.text : tone === 'failed' ? p.failed.text : tone === 'working' ? p.working.text : p.dim
783 // An in-review job's status line already says its PR's problem; on any other job with an open PR it is added here.
784 const problem = isLatest && pr?.state === 'OPEN' && pr.attention && job.status !== 'in-review' ? attentionLine(pr) : null
785 const parts: unknown[] = [
786 back,
787 // The title and status line in one tile, coloured by the job's state (a PR that needs attention needs the person).
788 tile(ui, p, jobTone(job), 'job-tile', [
789 <Text key="title" bold wrap="wrap">{job.title || 'Untitled job'}</Text>,
790 <Text key="status" color={statusColor} wrap="wrap">{[jobStatusLine(job, now), problem, quiet].filter(Boolean).join(' · ')}</Text>,
791 ]),
792 ]
793 if (sprint) parts.push(<Text key="sprint-managed" color={p.dim} wrap="wrap">The sprint manages this step.</Text>)
794 if (working) parts.push(codeBlock(ui, p, 'live', info && info.lines.length ? info.lines.slice(-5).join('\n') : 'Waiting for the first output…'))
795 else parts.push(<Text key="summary" wrap="wrap">{mine?.summary ?? job.error ?? 'No summary yet.'}</Text>)
796 // What ends something, at the bottom, asking first.
797 type Ender = { what: 'discard' | 'cancel'; label: string; ask: string; yes: string }
798 const ender: Ender | null = working
799 ? { what: 'cancel', label: 'Stop job…', ask: "Stop this job? The agent stops now; what it did so far stays in Podium's copy.", yes: 'Stop job' }
800 : !sprint && job.status === 'needs-review' && !pr
801 ? { what: 'discard', label: 'Throw away…', ask: 'Throw away these changes? Podium deletes its copy, and the changes with it.', yes: 'Throw away' }
802 : !sprint && job.worktree && (['cancelled', 'failed', 'lost'].includes(job.status) || job.committed)
803 ? { what: 'discard', label: 'Throw away its copy…', ask: "Delete Podium's copy of this job? Anything committed stays on its branch.", yes: 'Throw away' }
804 : null
805 const ending = Boolean(ender && mine?.confirm === ender.what)
806 // A question or form open is the one decision: while Throw away or Stop asks, Repair asks, or the ask form
807 // is open, the next step (Keep changes, Push to PR, Send fix, Ask Claude…) is not drawn; Cancel brings it back.
808 const deciding = ending || asking || Boolean(job.warning?.repair === 'muse-sandbox' && mine?.confirmRepair)
809 if (job.warning) {
810 parts.push(<Text key="warning" color={p.needs.text} wrap="wrap">{`⚠ ${job.warning.message}`}</Text>)
811 if (job.warning.repair === 'muse-sandbox') {
812 if (mine?.confirmRepair) {
813 parts.push(
814 <Text key="repair-what" color={p.dim} wrap="wrap">This removes the piled-up "MuseSandboxUsers" deny entries from your Muse config folder (…\.config\muse). Muse adds back the one it needs next time. Nothing else changes.</Text>,
815 <Box key="repair-confirm" flexDirection="row" gap={1}>
816 <Button key="repair-yes" variant="primary" label="Repair" onPress={() => go.repair(id)} />
817 <Button key="repair-no" label="Cancel" onPress={() => go.askRepair(id, false)} />
818 </Box>,
819 )
820 } else {
821 parts.push(<Box key="repair-row" flexDirection="row"><Button key="repair" label="Repair Muse's sandbox…" onPress={() => go.askRepair(id, true)} /></Box>)
822 }
823 }
824 }
825 // One next step: a primary button, at most one beside it, the rest quiet links.
826 const step: unknown[] = []
827 const links: unknown[] = []
828 const canAsk = !sprint && isLatest && !asking && canContinue(job) && job.kind !== 'review'
829 // New changes on an open PR can be pushed to it.
830 const canPush = !sprint && isLatest && pr?.state === 'OPEN' && ['needs-review', 'done'].includes(job.status) && Boolean(job.worktree)
831 const askButton = (primary: boolean) => (primary
832 ? <Button key="ask-detail" variant="primary" label="Ask for changes…" onPress={() => go.askOpen(threadId, job.agent)} />
833 : <Button key="ask-detail" label="Ask for changes…" onPress={() => go.askOpen(threadId, job.agent)} />)
834 if (working) {
835 step.push(<Button key="ask" variant="primary" label="Ask Claude about it" onPress={() => go.askClaude(id)} />)
836 } else if (!sprint && job.status === 'needs-review' && !pr) {
837 step.push(<Button key="keep" variant="primary" label="Keep changes" onPress={() => go.act('keep', id)} />)
838 if (canAsk) step.push(askButton(false))
839 if (prReady) links.push(<Button key="link-openpr" plain label="Open a PR instead" onPress={() => go.openPr(id)} />)
840 } else if (canPush) {
841 step.push(<Button key="push" variant="primary" label="Push to PR" onPress={() => go.pushPr(id)} />)
842 if (canAsk) step.push(askButton(false))
843 } else if (!sprint && pr && job.status === 'in-review') {
844 if (pr.attention === 'ci-failed') step.push(<Button key="fix-ci" variant="primary" label={`Send fix to ${agentName(job.agent)}`} onPress={() => go.fixOpen(threadId, id, job.agent, 'ci')} />)
845 else if (pr.comments > 0) step.push(<Button key="fix-comments" variant="primary" label="Answer comments" onPress={() => go.fixOpen(threadId, id, job.agent, 'comments')} />)
846 else if (pr.attention && canAsk) step.push(askButton(true))
847 // A quiet PR (nothing needs the person) still has one next step: Open on GitHub.
848 step.push(step.length
849 ? <Button key="browse" label="Open on GitHub" onPress={() => go.browsePr(id)} />
850 : <Button key="browse" variant="primary" label="Open on GitHub" onPress={() => go.browsePr(id)} />)
851 links.push(<Button key="automerge" plain label={`${pr.autoMerge ? '☑' : '☐'} Merge when green`} onPress={() => go.toggleAutoMerge(id, !pr.autoMerge)} />)
852 if (pr.watching === false) links.push(<Button key="link-watch" plain label="Watch again" onPress={() => go.watchAgain(id)} />)
853 if (!pr.attention && canAsk) links.push(<Button key="link-ask" plain label="Ask for changes…" onPress={() => go.askOpen(threadId, job.agent)} />)
854 links.push(<Button key="link-reviewwith" plain label="Review with…" onPress={() => go.reviewOpen(threadId, job.agent === 'codex' ? 'claude' : 'codex', pr.number)} />)
855 } else if (canAsk) {
856 step.push(askButton(true))
857 }
858 // Any other job with an open PR keeps the PR's actions, as quiet links (the in-review step above has its own).
859 if (!sprint && isLatest && !working && pr?.state === 'OPEN' && job.status !== 'in-review') {
860 links.push(
861 <Button key="link-browse" plain label="Open on GitHub" onPress={() => go.browsePr(id)} />,
862 <Button key="automerge" plain label={`${pr.autoMerge ? '☑' : '☐'} Merge when green`} onPress={() => go.toggleAutoMerge(id, !pr.autoMerge)} />,
863 )
864 if (pr.watching === false) links.push(<Button key="link-watch" plain label="Watch again" onPress={() => go.watchAgain(id)} />)
865 links.push(<Button key="link-reviewwith" plain label="Review with…" onPress={() => go.reviewOpen(threadId, job.agent === 'codex' ? 'claude' : 'codex', pr.number)} />)
866 }
867 // A finished review waiting to be posted: one per reviewer, its newest, while the PR is open.
868 if (thread && pr?.state === 'OPEN') {
869 const toPost = thread.steps.filter((x) => x.kind === 'review' && x.status === 'done' && !x.posted)
870 for (const s of toPost.filter((x, i) => toPost.findIndex((y) => y.agent === x.agent) === i)) {
871 links.push(<Button key={`link-post-${s.id}`} plain label={`Post ${agentName(s.agent)}'s review to PR #${s.reviewOf?.number ?? pr.number}`} onPress={() => go.postReview(s.id)} />)
872 }
873 }
874 if (step.length && !deciding) parts.push(<Box key="next" flexDirection="row" flexWrap="wrap" gap={1}>{step}</Box>)
875 if (asking && thread && ask) parts.push(askForm(ui, p, thread, ask, agentList, go))
876 if (links.length) parts.push(<Box key="links" flexDirection="row" flexWrap="wrap" gap={1}>{links}</Box>)
877 // Folded detail: each only when it has something in it.
878 const f = (name: string, title: string, summary: string, body: () => unknown[]) => {
879 const fid = `job:${id}:${name}`
880 return fold(ui, p, fid, title, summary, isFoldOpen(folds, fid), () => go.toggleFold(fid), body)
881 }
882 const set = mine?.changes ?? null
883 const counts = set ? { files: set.files.length + (set.more ?? 0), added: set.added, removed: set.removed } : job.changes ?? null
884 const c = job.committed ?? null
885 if ((counts && counts.files) || c) {
886 const summary = counts && counts.files
887 ? `${counts.files} file${counts.files === 1 ? '' : 's'} · +${counts.added} −${counts.removed}`
888 : `${c?.count} commit${c?.count === 1 ? '' : 's'} on ${c?.branch ?? 'a detached HEAD'}`
889 parts.push(f('changes', 'Changes', summary, () => [
890 ...(c && c.commits.length ? [codeBlock(ui, p, 'commits', c.commits.map((x) => `${x.sha} ${x.subject}`).join('\n'))] : []),
891 ...(job.leftovers ? [<Text key="leftovers" color={p.needs.text} wrap="wrap">The agent also left uncommitted edits in Podium's copy.</Text>] : []),
892 ...(set && set.files.length ? changeRows(ui, p, id, set, mine?.open ?? [], go) : []),
893 ...(counts && counts.files && !set ? [<Text key="changes-loading" color={p.dim}>Loading the changes…</Text>] : []),
894 ]))
895 }
896 // The PR's checks, and its review state. The heading stays short: the status line already says what failed.
897 if (pr) {
898 const ck = pr.checks ?? { total: 0, passed: 0, failed: 0, running: 0, failing: [] }
899 const short = !ck.total ? 'none' : ck.failed ? `${ck.failed} failed` : ck.running ? `${ck.running} running` : `${ck.passed} of ${ck.total} passed`
900 parts.push(f('checks', 'Checks', short, () => [
901 ...(ck.failing ?? []).map((name: string, i: number) => <Text key={`failing-${i}`} color={p.failed.text} wrap="wrap">{`✗ ${name}`}</Text>),
902 <Text key="checks-all" color={p.dim} wrap="wrap">{`${ck.passed} passed · ${ck.failed} failed · ${ck.running} running`}</Text>,
903 <Text key="checks-review" color={p.dim} wrap="wrap">{`review: ${REVIEW_WORDS[pr.review ?? ''] ?? 'none yet'}`}</Text>,
904 ...(pr.watching === false ? [<Text key="unwatched" color={p.dim} wrap="wrap">Podium stopped watching this PR after 7 days.</Text>] : []),
905 ]))
906 }
907 // Comments, when there are any (the review state is said once, under Checks; a PR's problem, in the status line).
908 if (pr && pr.comments > 0) {
909 parts.push(f('comments', 'Comments', String(pr.comments), () => [
910 <Text key="comments-count" color={p.dim} wrap="wrap">{`${pr.comments} comment${pr.comments === 1 ? '' : 's'} and reviews on GitHub.`}</Text>,
911 ]))
912 }
913 if (thread && thread.steps.length > 1) {
914 parts.push(f('steps', 'Steps', String(thread.steps.length), () => {
915 // The newest STEP_ROWS rows (and the open step's, if older): a long thread must not crowd the desktop page's
916 // 2,000 elements on its own. A sprint's steps say what each did, one row each; a thread's are grouped.
917 const rows = sprint ? thread.steps.map((step) => ({ step, count: 1 })) : groupSteps(thread.steps)
918 const listed = rows.filter((r, i) => i < STEP_ROWS || r.step.id === id)
919 const out: unknown[] = listed.map(({ step: s, count }) => {
920 const line = s.sprint ? sprintStepLine(s, thread.steps, now) : stepLine(s, now)
921 return <Button key={`step-${s.id}`} plain label={shorten(counted(`${s.id === id ? '● ' : ''}${line}`, count), STEP_CHARS)} onPress={() => go.openDetail(s.id)} />
922 })
923 const unlisted = rows.filter((r) => !listed.includes(r)).reduce((n, r) => n + r.count, 0)
924 if (unlisted) out.push(<Text key="steps-more" color={p.dim}>{`… ${unlisted} earlier step${unlisted === 1 ? '' : 's'} not listed`}</Text>)
925 return out
926 }))
927 }
928 // Full output is fetched when opened (toggleFull), so its fold state is whether it is loaded.
929 parts.push(fold(ui, p, `job:${id}:output`, 'Full output', '', Boolean(mine?.output), () => go.toggleFull(id), () => [codeBlock(ui, p, 'full-output', mine?.output ?? '')]))
930 parts.push(f('details', 'Details', '', () => jobDetails(job, !job.sprint && fromAnotherChat(thread?.steps ?? [job], session)).map((line: string, i: number) => <Text key={`detail-${i}`} color={p.dim} wrap="wrap">{line}</Text>)))
931 // At the bottom: what ends something, asking first, and the rating.
932 if (ender && ending) {
933 parts.push(
934 <Text key="end-what" wrap="wrap">{ender.ask}</Text>,
935 <Box key="end-confirm" flexDirection="row" gap={1}>
936 <Button key={`${ender.what}-yes`} variant="primary" label={ender.yes} onPress={() => go.act(ender.what, id)} />
937 <Button key={`${ender.what}-no`} label="Cancel" onPress={() => go.confirmJob(id, null)} />
938 </Box>,
939 )
940 }
941 const bottom: unknown[] = []
942 if (ender && mine?.confirm !== ender.what) bottom.push(<Button key={`end-${ender.what}`} label={ender.label} onPress={() => go.confirmJob(id, ender.what)} />)
943 if (!working) {
944 const rated = job.outcomeSource === 'user' ? job.outcome : null
945 bottom.push(
946 <Button key="up" plain label={rated === 'used' ? '👍 ✓' : '👍'} onPress={() => go.rate(id, 'used')} />,
947 <Button key="down" plain label={rated === 'discarded' ? '👎 ✓' : '👎'} onPress={() => go.rate(id, 'discarded')} />,
948 )
949 }
950 if (bottom.length) parts.push(<Box key="bottom" flexDirection="row" flexWrap="wrap" gap={1}>{bottom}</Box>)
951 return <Box flexDirection="column" gap={1}>{parts}</Box>
952}
953
954// The steps listed under a job's detail: its thread's, or for a sprint step its ticket's.
955function stepsFor(list: JobRecord[], job: JobRecord | undefined): Thread | null {
956 if (!job) return null
957 if (job.sprint) {
958 const steps = ticketSteps(list, job.sprint)
959 return steps.length ? { id: `sprint-${job.sprint.id}-${job.sprint.ticket}`, latest: steps[0], steps } : null
960 }
961 return threads(list).find((t) => t.steps.some((s) => s.id === job.id)) ?? null
962}
963
964export type FormHandlers = ListHandlers & {
965 setSetting: (key: string, value: unknown) => void
966 toggleSetting: (key: string) => void
967 toggleAgent: (name: string) => void
968 testAgent: (name: string) => void
969 suggestAgent: (command: string) => void
970 editAdapter: (patch: Partial<AdapterDraft>) => void
971 saveAgent: () => void
972 cancelAgent: () => void
973 editDraft: (patch: Partial<Draft>) => void
974 startJob: (task?: string) => void
975 prune: () => void
976 setInstructions: (text: string) => void
977 setRepoInstructions: (text: string) => void
978 setRepoSetting: (key: string, value: unknown) => void
979 checkAgain: () => void
980 planRemoval: () => void
981 confirmRemoval: () => void
982 cancelRemoval: () => void
983}
984
985// Agents: one line per agent (it opens the agent's screen), Fix where one needs attention, + Add an agent,
986// and the setup checks folded, open on their own when something needs fixing.
987export function agentsScreen(ui: Ui, p: Palette, list: AgentRecord[], go: FormHandlers, setup: CheckRow[] = [], folds: string[] = []) {
988 const { Box, Text, Button } = ui
989 const parts: unknown[] = [backButton(ui, go), <Text key="agents-head" bold>Agents</Text>]
990 for (const a of list) {
991 parts.push(tile(ui, p, readiness(a).tone, `agentrow-${a.name}`, [
992 <Box key={`agentline-${a.name}`} flexDirection="row" gap={1} alignItems="center">
993 <Button key={`open-agent-${a.name}`} plain label={agentLine(a)} onPress={() => go.openAgent(a.name)} />
994 {agentNeedsFix(a) ? <Button key={`fix-${a.name}`} variant="primary" label="Fix" onPress={() => go.openAgent(a.name)} /> : null}
995 </Box>,
996 ]))
997 }
998 parts.push(<Box key="agents-add" flexDirection="row"><Button key="add-agent" label="+ Add an agent" onPress={() => go.showScreen('add-agent')} /></Box>)
999 const wrong = setup.some((r) => r.ok === false)
1000 parts.push(fold(ui, p, 'agents:setup', 'Your setup', setupSummary(setup), isFoldOpen(folds, 'agents:setup', wrong), () => go.toggleFold('agents:setup'), () => [
1001 ...checkLines(ui, p, setup, 'agent-check'),
1002 <Button key="check-again" plain label="Check again" onPress={() => go.checkAgain()} />,
1003 ]))
1004 return <Box flexDirection="column" gap={1}>{parts}</Box>
1005}
1006
1007// One agent's screen: its state and version, what to do about it (Test, or Switch on), its default access
1008// and stats folded, and Switch off at the bottom.
1009export function agentScreen(ui: Ui, p: Palette, a: AgentRecord | undefined, go: FormHandlers, folds: string[] = []) {
1010 const { Box, Text, Button } = ui
1011 const back = <Button key="back" plain label="← Agents" onPress={() => go.showScreen('agents')} />
1012 if (!a) {
1013 return (
1014 <Box flexDirection="column" gap={1}>
1015 {back}
1016 <Text key="gone" color={p.dim}>That agent is no longer listed.</Text>
1017 </Box>
1018 )
1019 }
1020 const ready = readiness(a)
1021 const stats = a.stats ?? { jobs: 0, used: 0 }
1022 const jobs = `${stats.jobs} job${stats.jobs === 1 ? '' : 's'}`
1023 const parts: unknown[] = [
1024 back,
1025 // The name and status line in one tile, coloured by how ready the agent is.
1026 tile(ui, p, ready.tone, 'agent-tile', [
1027 <Text key="agent-head" bold>{a.label}</Text>,
1028 <Text key="agent-status" color={ready.tone === 'ok' ? p.dim : p[ready.tone].text} wrap="wrap">{[agentState(a), a.version, a.installed ? (a.verified ? 'tested' : 'not tested yet') : null].filter(Boolean).join(' · ')}</Text>,
1029 ]),
1030 ]
1031 if (ready.note) parts.push(<Text key="agent-fix" color={p[ready.tone].text} wrap="wrap">{ready.note}</Text>)
1032 if (!a.installed) parts.push(<Text key="agent-missing" color={p.dim} wrap="wrap">Install it, then press Check again on Agents.</Text>)
1033 else if (!a.enabled) parts.push(<Box key="agent-next" flexDirection="row"><Button key={`toggle-${a.name}`} variant="primary" label="Switch on" onPress={() => go.toggleAgent(a.name)} /></Box>)
1034 else parts.push(<Box key="agent-next" flexDirection="row"><Button key={`test-${a.name}`} variant="primary" label="Test" onPress={() => go.testAgent(a.name)} /></Box>)
1035 if (a.installed) {
1036 const accessId = `agent:${a.name}:access`
1037 const level = PERMISSION_CHOICES.find((c) => c.value === a.defaultPermission)?.label ?? a.defaultPermission
1038 parts.push(fold(ui, p, accessId, 'Default access', level, isFoldOpen(folds, accessId), () => go.toggleFold(accessId), () => [
1039 choices(ui, p, `defperm-${a.name}`, '', a.defaultPermission, PERMISSION_CHOICES, (value) => go.setSetting(`defaultPermission.${a.name}`, value)),
1040 ]))
1041 const statsId = `agent:${a.name}:stats`
1042 parts.push(fold(ui, p, statsId, 'Stats', `${jobs} · ${stats.used} 👍`, isFoldOpen(folds, statsId), () => go.toggleFold(statsId), () => [
1043 <Text key="stats-text" color={p.dim} wrap="wrap">{`${jobs} run for you; you marked ${stats.used} useful.`}</Text>,
1044 ]))
1045 }
1046 if (a.installed && a.enabled) parts.push(<Box key="agent-bottom" flexDirection="row"><Button key={`toggle-${a.name}`} label="Switch off" onPress={() => go.toggleAgent(a.name)} /></Box>)
1047 return <Box flexDirection="column" gap={1}>{parts}</Box>
1048}
1049
1050// Add an agent: its command, then the form Podium fills in from its --help (unchanged).
1051export function addAgentScreen(ui: Ui, p: Palette, pending: AdapterDraft | null, go: FormHandlers) {
1052 const { Box, Text, Button, Input } = ui
1053 return (
1054 <Box flexDirection="column" gap={1}>
1055 <Button key="back" plain label="← Agents" onPress={() => go.showScreen('agents')} />
1056 <Text key="add-head" bold>Add an agent</Text>
1057 {pending
1058 ? adapterForm(ui, p, pending, go)
1059 : <Input key="add-command" label="Its command" placeholder="e.g. gemini, opencode, aider" submitLabel="Look it up" onSubmit={(value: string) => go.suggestAgent(value.trim())} />}
1060 {pending ? null : <Text key="add-how" color={p.dim} wrap="wrap">Podium reads its --help to fill in the rest; you check it before saving.</Text>}
1061 </Box>
1062 )
1063}
1064
1065function adapterForm(ui: Ui, p: Palette, ad: AdapterDraft, go: FormHandlers) {
1066 const { Box, Text, Button, Input } = ui
1067 const words = (v: string) => v.split(/\s+/).filter(Boolean)
1068 const level = (name: keyof AdapterDraft['permissions']) => (
1069 <Input key={`level-${name}`} label={LEVEL_LABEL[name]} value={ad.permissions[name].join(' ')}
1070 onSubmit={(v: string) => go.editAdapter({ permissions: { ...ad.permissions, [name]: words(v) } })} />
1071 )
1072 const fileFlag = ad.promptVia === 'file'
1073 ? [<Input key="promptFileFlag" label="Prompt file option" value={ad.promptFileFlag ?? ''} onSubmit={(v: string) => go.editAdapter({ promptFileFlag: v.trim() })} />]
1074 : []
1075 return (
1076 <Box flexDirection="column" gap={1}>
1077 <Text color={p.dim}>{`Command: ${ad.command}. Press Enter in a field to keep a change.`}</Text>
1078 <Input key="label" label="Name shown" value={ad.label} onSubmit={(v: string) => go.editAdapter({ label: v.trim() })} />
1079 <Input key="baseArgs" label="Run it with (arguments)" value={ad.baseArgs.join(' ')} onSubmit={(v: string) => go.editAdapter({ baseArgs: words(v) })} />
1080 {choices(ui, p, 'promptVia', 'Send the task by', ad.promptVia, [{ value: 'stdin', label: 'Standard input' }, { value: 'file', label: 'A prompt file' }], (v) => go.editAdapter({ promptVia: v as AdapterDraft['promptVia'] }))}
1081 {fileFlag}
1082 <Input key="modelFlag" label="Model option (optional)" value={ad.modelFlag ?? ''} onSubmit={(v: string) => go.editAdapter({ modelFlag: v.trim() || null })} />
1083 <Text color={p.dim}>Arguments for each permission level:</Text>
1084 {level('read-only')}
1085 {level('edit-worktree')}
1086 {level('own-config')}
1087 {level('full-access')}
1088 <Box flexDirection="row" gap={1}>
1089 <Button key="save-agent" variant="primary" label="Save and test" onPress={() => go.saveAgent()} />
1090 <Button key="cancel-agent" label="Cancel" onPress={() => go.cancelAgent()} />
1091 </Box>
1092 </Box>
1093 )
1094}
1095
1096const MERGE_CHOICES = [
1097 { value: 'default', label: 'Repo default' }, { value: 'merge', label: 'Merge' }, { value: 'squash', label: 'Squash' }, { value: 'rebase', label: 'Rebase' },
1098]
1099
1100// The merge method as the engine reads it (repoSettings): a known one in any case, else the repo's default.
1101const mergeChoice = (m: unknown) => {
1102 const v = String(m ?? '').toLowerCase()
1103 return MERGE_CHOICES.some((o) => o.value === v) ? v : 'default'
1104}
1105
1106// Settings → This repo: the commands agents may run while editing, the full check before a sprint ticket's PR, the task
1107// test after each writer's step, the folder sprint plans go in, whether a sprint PR keeps its plan, one commit per sprint
1108// PR, and the merge method; each saves for the repo this chat is in (`here`).
1109function repoRows(ui: Ui, p: Palette, s: Record<string, any>, go: FormHandlers) {
1110 const { Text, Button, Input } = ui
1111 const here = s.here ?? null
1112 if (!here) return [<Text key="repo-none" color={p.dim} wrap="wrap">Open Podium from a chat in a repo to set these.</Text>]
1113 // The settings file may have been edited by hand: a value of the wrong kind draws as not set.
1114 const listed = s.repoCommands?.[here]
1115 const commands: string[] = Array.isArray(listed) ? listed.filter((c: unknown) => typeof c === 'string') : []
1116 const check = s.repoCheck?.[here]
1117 const taskTest = s.repoTaskTest?.[here]
1118 const planDir = s.repoPlanDir?.[here]
1119 const keepPlan = s.repoKeepPlan?.[here] === true
1120 const squash = s.repoSquash?.[here] !== false
1121 return [
1122 <Text key="repo-name" color={p.dim} wrap="wrap">{`For ${here}.`}</Text>,
1123 <Input key="repo-commands" label="Commands agents may run while editing" value={commands.join(' ')} placeholder="e.g. dotnet npm"
1124 onSubmit={(v: string) => go.setRepoSetting('repoCommands', v.split(/[\s,]+/).filter(Boolean))} />,
1125 <Input key="repo-check" label="Full check before a sprint PR" value={typeof check === 'string' ? check : ''} placeholder="e.g. dotnet build && dotnet test"
1126 onSubmit={(v: string) => go.setRepoSetting('repoCheck', v.trim())} />,
1127 <Input key="repo-task-test" label="Task test, run on this machine after each writer's step" value={typeof taskTest === 'string' ? taskTest : ''} placeholder="e.g. dotnet test"
1128 onSubmit={(v: string) => go.setRepoSetting('repoTaskTest', v.trim())} />,
1129 <Input key="repo-plan-dir" label="Folder for sprint plans" value={typeof planDir === 'string' ? planDir : ''} placeholder="docs/plans"
1130 onSubmit={(v: string) => go.setRepoSetting('repoPlanDir', v.trim())} />,
1131 <Button key="repo-keep-plan" plain label={`${keepPlan ? '☑' : '☐'} Keep sprint plans in the PR`} onPress={() => go.setRepoSetting('repoKeepPlan', keepPlan ? null : true)} />,
1132 <Button key="repo-squash" plain label={`${squash ? '☑' : '☐'} One commit per sprint PR`} onPress={() => go.setRepoSetting('repoSquash', squash ? false : null)} />,
1133 choices(ui, p, 'repo-merge', 'Merge method', mergeChoice(s.repoMergeMethod?.[here]), MERGE_CHOICES, (v) => go.setRepoSetting('repoMergeMethod', v === 'default' ? null : v)),
1134 ]
1135}
1136
1137// Settings: Tell Claude on top; the rest folded, each heading showing its value; Remove Podium's data last,
1138// asking first.
1139export function settingsScreen(ui: Ui, p: Palette, s: Record<string, any> | null, go: FormHandlers, removing: RemovalPlan | null = null, folds: string[] = []) {
1140 const { Box, Text, Button, Input } = ui
1141 if (!s) {
1142 return (
1143 <Box flexDirection="column" gap={1}>
1144 {backButton(ui, go)}
1145 <Text color={p.dim}>Loading settings…</Text>
1146 </Box>
1147 )
1148 }
1149 const number = (key: string, label: string, value: number) => (
1150 <Input key={key} label={label} value={String(value)}
1151 onSubmit={(v: string) => { const n = Number(v); if (Number.isFinite(n) && n >= 0) go.setSetting(key, n) }} />
1152 )
1153 const f = (name: string, title: string, summary: string, body: () => unknown[]) => {
1154 const id = `settings:${name}`
1155 return fold(ui, p, id, title, summary, isFoldOpen(folds, id), () => go.toggleFold(id), body)
1156 }
1157 return (
1158 <Box flexDirection="column" gap={1}>
1159 {backButton(ui, go)}
1160 <Text key="settings-head" bold>Settings</Text>
1161 <Button key="tell" plain label={`${s.tellClaudeWhenDone ? '☑' : '☐'} Tell Claude when a job finishes`} onPress={() => go.toggleSetting('tellClaudeWhenDone')} />
1162 {f('instructions', 'Instructions for every agent', instructionsSummary(s), () => [
1163 <Input key="instructions" label="All repos" value={String(s.instructions ?? '')} placeholder="e.g. Follow AGENTS.md and the skills in …" onSubmit={(v: string) => go.setInstructions(v)} />,
1164 <Input key="repo-instructions" label="This repo (added after)" value="" placeholder="Only for the repo this chat is in" onSubmit={(v: string) => go.setRepoInstructions(v)} />,
1165 <Text key="instructions-note" color={p.dim} wrap="wrap">Added to the start of every job's prompt, including follow-ups and reviews.</Text>,
1166 ])}
1167 {f('repo', 'This repo', repoSummary(s, s.here ?? null), () => repoRows(ui, p, s, go))}
1168 {f('storage', 'Storage', storageSummary(s), () => [
1169 number('retention.compressDays', 'Compress full output after (days)', s.retention.compressDays),
1170 number('retention.deleteDays', 'Delete full output after (days)', s.retention.deleteDays),
1171 <Button key="prune" label="Clean up now" onPress={() => go.prune()} />,
1172 ])}
1173 {f('limits', 'Limits', limitsSummary(s), () => [
1174 number('worktreeWarnMB', 'Warn before copying repos larger than (MB)', s.worktreeWarnMB),
1175 number('longJobMinutes', 'Ask about long jobs after (minutes)', s.longJobMinutes),
1176 <Text key="limits-note" color={p.dim} wrap="wrap">Default access is set for each agent in Agents.</Text>,
1177 ])}
1178 {removing
1179 ? [
1180 <Text key="remove-text" wrap="wrap">{`${removalText(removing)} Your repos and agents' branches stay.`}</Text>,
1181 <Box key="remove-buttons" flexDirection="row" gap={1}>
1182 <Button key="remove-confirm" variant="primary" label="Remove everything" onPress={() => go.confirmRemoval()} />
1183 <Button key="remove-cancel" label="Cancel" onPress={() => go.cancelRemoval()} />
1184 </Box>,
1185 ]
1186 : <Box key="settings-bottom" flexDirection="column" gap={1}>
1187 <Text key="remove-help" color={p.dim} wrap="wrap">Removes every job, Podium's copies and its settings. Your repos and agents' branches stay.</Text>
1188 <Box flexDirection="row"><Button key="end-remove-data" label="Remove Podium's data…" onPress={() => go.planRemoval()} /></Box>
1189 </Box>}
1190 </Box>
1191 )
1192}
1193
1194// New job: who and what, one line for the access level the job will use, Start job; everything else under
1195// More options (folded).
1196export function newJobScreen(ui: Ui, p: Palette, d: Draft, list: AgentRecord[], go: FormHandlers, standing = false, folds: string[] = []) {
1197 const { Box, Text, Button, Input } = ui
1198 const ready = list.filter((x) => x.installed && x.enabled)
1199 if (!ready.length) {
1200 return (mod/client.js 811 lines1// Pure helpers for the desktop Client pane. No Node imports: the Client runs in the app's view.
2import { isOpenSprint, isFinishedSprint, finishedSprintTone, finishedSprints, isSprintMine, FINISHED_SPRINTS, shorten, laneOf, fitCode, CODE_LIMIT, organise, threads, sprintHeadline, sprintStatusLine, sprintRowTitle, sprintTone, SPRINT_ENDED, NEEDS_YOU_STATES } from './view.js';
3
4const JOB_FIELDS = [
5 'id', 'title', 'project', 'ticket', 'agent', 'model', 'permission', 'status', 'createdAt', 'startedAt', 'endedAt',
6 'isLong', 'delivered', 'hasChanges', 'changes', 'outcome', 'outcomeSource', 'error', 'warning', 'committed', 'leftovers',
7 'base', 'worktree', 'threadId', 'step', 'followUpOf', 'endStatus', 'resumeFrom', 'resumeFailed', 'origin',
8 'pr', 'kind', 'reviewOf', 'posted', 'sprint',
9];
10
11// What the pane draws from a job; runner internals stay behind (Client props are size-bounded).
12export function slimJob(job) {
13 const out = {};
14 for (const k of JOB_FIELDS) if (job[k] !== undefined) out[k] = job[k];
15 return out;
16}
17
18// The listing from the engine, with each busy marker ({ id, busy: true }: the sprint could not be read just
19// now) replaced by the previous view of that sprint, or dropped if there is none. Nothing else ever sees one.
20export function keepBusy(prev, next) {
21 const last = new Map((prev ?? []).filter((s) => s && !s.busy).map((s) => [s.id, s]));
22 return (next ?? []).flatMap((s) => (s?.busy ? (last.has(s.id) ? [last.get(s.id)] : []) : [s]));
23}
24
25// The sprint the open screen shows (shownId), left out of an answer asked for before that screen opened, keeps its last
26// view: an archived sprint behind newer finished ones is listed only when named, and
27// the next refresh names it. Kept at the end; nothing else changes.
28export function keepShown(prev, next, shownId) {
29 if (!shownId || (next ?? []).some((s) => s?.id === shownId)) return next ?? [];
30 const last = (prev ?? []).find((s) => s?.id === shownId && !s.busy);
31 return last ? [...(next ?? []), last] : next ?? [];
32}
33
34const cut = (s, max) => (typeof s === 'string' && s.length > max ? `${s.slice(0, Math.max(0, max - 1))}…` : s);
35
36// Sprint tickets waiting on the person, across every sprint (Your turn), whichever page of sprints the list shows:
37// parked, or a plan waiting for an answer. Drafts never wait on anyone. At most TURN_TICKETS; `more` counts the rest.
38export const TURN_TICKETS = 20;
39// How many production commands a waiting plan runs: those listed, those past the list, or a card's count.
40const flaggedIn = (t) => ((t.attention?.length ?? 0) + (t.attentionMore ?? 0)) || (t.attentionCount ?? 0);
41
42export function turnTickets(list) {
43 const out = [];
44 for (const s of (list ?? []).filter(isOpenSprint)) {
45 if (s.unreadable || s.draft) continue;
46 for (const t of s.tickets ?? []) {
47 if (!NEEDS_YOU_STATES.has(t.state)) continue;
48 out.push({
49 sprint: s.id, sprintName: cut(String(s.name ?? s.id), 60), key: t.key, title: cut(String(t.title ?? ''), 80), state: t.state, reason: cut(String(t.reason ?? ''), 160),
50 ...(flaggedIn(t) ? { attentionCount: flaggedIn(t) } : {}),
51 });
52 }
53 }
54 return { tickets: out.slice(0, TURN_TICKETS), more: Math.max(0, out.length - TURN_TICKETS) };
55}
56
57const FINDINGS_LIMIT = 1200;
58const TASK_TITLE_LIMIT = 80;
59
60// A plan's tasks a row shows while it waits for the person; the rest are a "… and N more tasks" line.
61export const PLAN_ROWS = 30;
62
63// What the pane draws from a sprint: open sprints and finished ones (the Done and Other chats
64// folds list them and their boards open), no other sprint whose tickets have all ended; review findings only on a parked
65// ticket, whose open row shows them, cut short; short task titles; a waiting plan's first PLAN_ROWS task titles, cut short,
66// and how many more (never the plan's text), the production commands it runs, and the person's latest notes, only for the
67// open board's open row (board: { sprint, open }), the one row that draws them; the other rows count those commands.
68export function slimSprints(list, board = null) {
69 const isOpenRow = (s, t) => Boolean(board?.open) && board.sprint === s.id && board.open === t.key;
70 return (list ?? []).filter((s) => isOpenSprint(s) || isFinishedSprint(s)).map((s) => ({
71 ...s,
72 tickets: s.tickets.map(({ findings, tasks, proposed, notes, attention, attentionMore, ...t }) => ({
73 ...t,
74 ...(Array.isArray(notes) && notes.length && isOpenRow(s, t) ? { notes } : {}),
75 ...(flaggedIn({ attention, attentionMore })
76 ? (isOpenRow(s, t) && attention?.length ? { attention, ...(attentionMore ? { attentionMore } : {}) } : { attentionCount: flaggedIn({ attention, attentionMore }) })
77 : {}),
78 ...(t.state === 'parked' && findings ? { findings: findings.length > FINDINGS_LIMIT ? `${findings.slice(0, FINDINGS_LIMIT - 1)}…` : findings } : {}),
79 ...(tasks ? { tasks: tasks.map((x) => ({ ...x, title: shorten(x.title, TASK_TITLE_LIMIT) })) } : {}),
80 ...(Array.isArray(proposed) && isOpenRow(s, t) ? {
81 proposed: proposed.slice(0, PLAN_ROWS).map((x) => ({ n: x.n, title: shorten(x.title, TASK_TITLE_LIMIT) })),
82 ...(proposed.length > PLAN_ROWS ? { proposedMore: proposed.length - PLAN_ROWS } : {}),
83 } : {}),
84 })),
85 }));
86}
87
88// ---- What the desktop Client is sent (its props count toward its tree's 100,000 characters) ----
89// The engine refuses a Client whose props serialize past 100,000 characters (and drops a `{ props }` answer past it):
90// the desktop pane goes blank for good. Sending every job in the project would blank it once the history is long.
91export const PROPS_BUDGET = 80_000;
92// File rows the detail draws (screens.tsx changeRows); the rest are a "… and N more files" line.
93export const FILE_ROWS = 150;
94// A row draws its path three times (row key, button key, label) in about 270 more characters of drawing; rows stop
95// before ROW_CHARS, so long paths cannot outgrow a Client tree's 100,000 characters with the screen's code.
96export const ROW_CHARS = 50_000;
97export function fileRows(files) {
98 let chars = 0;
99 let n = 0;
100 for (const f of (files ?? []).slice(0, FILE_ROWS)) {
101 chars += 3 * JSON.stringify(String(f?.path ?? '')).length + 270;
102 if (chars > ROW_CHARS) break;
103 n += 1;
104 }
105 return n;
106}
107
108const sizeOf = (v) => JSON.stringify(v).length;
109const isAtWork = (j) => laneOf(j) !== 'done';
110// A job travels with its thread (a card counts its steps and shows its first title); a sprint step on its own.
111const unitOf = (j) => (j.sprint ? `step:${j.id}` : `thread:${j.threadId ?? j.id}`);
112const endOf = (j) => Date.parse(j.endedAt ?? j.createdAt) || 0;
113// The detail as the screen draws it: the files it has rows for (fileRows; `more` counts the rest), only open files'
114// patches, each cut as codeBlock cuts it (fitCode), so nothing the pane would not draw is sent.
115function drawnDetail(d, max) {
116 if (!d) return d;
117 const out = { ...d };
118 if (typeof d.output === 'string' && d.output.length > max) out.output = fitCode(d.output, { keep: 'end', max });
119 if (!Array.isArray(d.changes?.files)) return out;
120 const open = new Set(d.open ?? []);
121 const files = d.changes.files.slice(0, fileRows(d.changes.files)).map((f) => ({
122 ...f, patch: open.has(f.path) && typeof f.patch === 'string' && f.patch ? fitCode(f.patch, { keep: 'start', max }) : '',
123 }));
124 const more = (d.changes.more ?? 0) + d.changes.files.length - files.length;
125 out.changes = { ...d.changes, files, ...(more > 0 ? { more } : {}) };
126 return out;
127}
128
129// A job with its long text cut to `max` characters (the jobs Podium must keep, when even they are over the budget).
130function shortJob(j, max) {
131 const out = { ...j, title: cut(j.title, max) };
132 if (j.error) out.error = cut(j.error, max);
133 if (j.warning?.message) out.warning = { ...j.warning, message: cut(j.warning.message, max) };
134 if (j.committed?.commits) out.committed = { ...j.committed, commits: j.committed.commits.slice(0, Math.max(1, Math.floor(max / 40))).map((c) => ({ ...c, subject: cut(c.subject, max) })) };
135 if (j.pr?.attention) out.pr = { ...j.pr, attention: cut(j.pr.attention, max) };
136 return out;
137}
138
139function shortLive(live, max) {
140 return Object.fromEntries(Object.entries(live ?? {}).map(([id, l]) => [id, { ...l, activity: cut(l?.activity, max), lines: (l?.lines ?? []).slice(-10).map((x) => cut(x, max)) }]));
141}
142
143const CUTS = [2000, 1000, 500, 250, 120, 60, 30];
144
145// A sprint as its list card draws it (a sprint is never left out): the headline's fields and a row per
146// ticket (key, title, state, why it stopped, its PR) and what the ticket allows now, with titles and reasons cut to
147// `max`. No tasks or review findings: only the open board draws those. `brief` tells the board so. A sprint already
148// capped keeps what capRows gave it.
149const REASON_CHARS = 160;
150const unique = (list) => [...new Set(list)];
151// A ticket's plan buttons' state, sent only when the summary has it (the page refuses a Client prop that is undefined).
152const PLAN_FLAGS = ['canApprove', 'canRevise', 'canOneTask', 'canAssignPlan', 'canNote', 'canAccept', 'canEvidence'];
153// Fields the summary did not have are left out, not sent as undefined (the page refuses a Client prop that is undefined).
154const defined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
155// A ticket's locked-file recovery details for its card: the blocked file (cut only past what the engine ever
156// sends, so the ticket's screen names it whole even when the card's reason was cut), the .NET hint, and the
157// stash refs Git had before and after. Missing fields become null or the engine's own fallback, never undefined.
158const LOCK_FILE_CHARS = 1024;
159const lockCard = (lock) => ({
160 file: typeof lock?.file === 'string' && lock.file ? cut(lock.file, LOCK_FILE_CHARS) : 'an unidentified file',
161 hint: lock?.hint ?? null,
162 stashBefore: lock?.stashBefore ?? null,
163 stashAfter: lock?.stashAfter ?? null,
164});
165export function sprintCard(s, max = TASK_TITLE_LIMIT) {
166 if (s.unreadable) return defined({ ...s, name: cut(s.name, max), unreadable: cut(s.unreadable, Math.max(max * 4, 120)), brief: true });
167 return defined({
168 id: s.id, name: cut(s.name, max), draft: s.draft, discarded: s.discarded, paused: s.paused, finishedAt: s.finishedAt,
169 autoMerge: s.autoMerge, maxParallel: s.maxParallel, news: s.news, check: s.check, chat: s.chat, base: s.base, brief: true,
170 // A draft's proposed commands, check and plans folder: the board's This repo fold.
171 ...(s.proposed ? { proposed: s.proposed } : {}),
172 ...(typeof s.headline === 'string' ? { headline: s.headline } : {}), ...(typeof s.status === 'string' ? { status: s.status } : {}), ...(typeof s.tone === 'string' ? { tone: s.tone } : {}), ...(s.more > 0 ? { more: s.more } : {}),
173 ...(s.ended === true ? { ended: true } : {}),
174 // Listed though archived: every reduced form keeps it, so nothing treats the sprint as live.
175 ...(s.archived === true ? { archived: true } : {}),
176 tickets: (s.tickets ?? []).map((t) => defined({
177 key: t.key, title: cut(t.title, max), state: t.state, task: t.task, of: t.of, phase: t.phase, agent: t.agent,
178 reason: cut(t.reason, Math.min(REASON_CHARS, max * 2)), pr: t.pr ?? null, reviews: t.reviews, latest: t.latest,
179 canRetry: t.canRetry, giveTo: t.giveTo, canStop: t.canStop, ...(t.pos !== undefined ? { pos: t.pos } : {}), ...(t.slot ? { slot: t.slot } : {}),
180 // Sync with base, and Keep or Drop for commits Podium didn't make.
181 ...(t.canSync ? { canSync: true } : {}), ...(t.baselineOf ? { baselineOf: t.baselineOf } : {}), ...(t.testsWhere ? { testsWhere: t.testsWhere } : {}), ...(t.agentLeft ? { agentLeft: true } : {}), ...(t.foreignCommits ? { foreignCommits: { count: t.foreignCommits.count, newest: cut(t.foreignCommits.newest, max * 2) } } : {}),
182 // How the PR's CI stands for what its plan left to it: one flat word, never the whole object.
183 ...(typeof t.ci?.state === 'string' ? { ciState: t.ci.state } : {}),
184 // What its row reads (who plans it, what it waits for) and its buttons' state; the plan's tasks only on the open board.
185 plan: cut(t.plan ?? null, 200), writer: t.writer ?? null, reviewer: t.reviewer ?? null, after: t.after ?? [], waitingOn: t.waitingOn ?? [],
186 ...Object.fromEntries(PLAN_FLAGS.filter((k) => t[k] !== undefined).map((k) => [k, t[k] === true])),
187 // How many production commands its plan runs (their lines only on the open board): Approve asks first.
188 ...(flaggedIn(t) ? { attentionCount: flaggedIn(t) } : {}),
189 // A park on a locked file: what to release and how to retry (the ticket's screen reads it).
190 ...(t.checkpointLock ? { checkpointLock: lockCard(t.checkpointLock) } : {}),
191 ...(s.draft && Array.isArray(t.tasks) ? { writers: unique(t.tasks.map((x) => x.agent)), reviewers: unique(t.tasks.map((x) => x.reviewer)) } : {}),
192 })),
193 });
194}
195
196// Every sprint as its card, but the one whose board is open (`keep`) whole.
197const cards = (list, keep = null, max = TASK_TITLE_LIMIT) => (list ?? []).map((s) => (s?.id === keep && keep !== null ? s : sprintCard(s, max)));
198const CARD_CUTS = [40, 20, 8];
199
200// Rows a capped card keeps (15 drafts of 30 tickets can be over the budget even as cards): its pinned rows (the
201// board's open row, the open step's ticket), then those parked or waiting for an answer, running and waiting ones, then the
202// rest in order.
203export const CARD_ROWS = 5;
204const ROW_RANK = { parked: 0, 'plan-ready': 0, 'no-tasks': 0, running: 1, waiting: 2 };
205const rowRank = (t) => ROW_RANK[t?.state] ?? (SPRINT_ENDED.has(t?.state) ? 4 : 3);
206
207// A sprint (or its card) with at most `n` ticket rows, drawn in the sprint's order: a card sent with rows left out.
208// The screens can't work out a sprint's state from the rows it keeps, so the card carries its own: `headline`, `status`
209// and `tone` counted over every ticket, `ended` when it finished, `more` for the rows left out, and each row's place in
210// the whole list (`pos`: Move up and Move down read it). The open board's sprint keeps its tasks this way.
211export function capRows(s, n = CARD_ROWS, pins = []) {
212 const tickets = s?.tickets ?? [];
213 if (!s || s.unreadable || tickets.length <= n) return s;
214 const pinned = new Set(pins);
215 const order = tickets.map((t, i) => i).sort((i, j) => (pinned.has(tickets[j].key) - pinned.has(tickets[i].key)) || rowRank(tickets[i]) - rowRank(tickets[j]) || i - j);
216 const keep = new Set(order.slice(0, Math.max(0, n)));
217 const rows = tickets.flatMap((t, i) => (keep.has(i) ? [{ ...t, pos: t.pos ?? i }] : []));
218 const ended = isFinishedSprint(s);
219 return {
220 ...s, headline: sprintHeadline(s), status: sprintStatusLine(s), tone: ended ? finishedSprintTone(s) : sprintTone(s), tickets: rows,
221 more: (s.more ?? 0) + tickets.length - rows.length, ...(ended ? { ended: true } : {}),
222 };
223}
224
225// A sprint as little as the pane draws it (the last resort): its name, headline and buttons' state, no rows.
226function sprintIdentity(s, max = 40) {
227 if (s?.unreadable) return { id: s.id, name: cut(s.name, max), unreadable: cut(s.unreadable, 120), brief: true, tickets: [] };
228 return capRows(sprintCard(s, max), 0);
229}
230
231// The order sprints matter in, shared by fitModel (which sprints it keeps) and the list (which come first):
232// `first` (the open board's) if given, then those with a ticket running, then those with one that needs you (parked, or a
233// plan waiting for an answer), then drafts and the rest, newest first (a sprint's id starts with when it was made). Indexes into `list`.
234export function sprintOrder(list, first = null) {
235 const has = (s, state) => (s?.tickets ?? []).some((t) => t.state === state);
236 const needsYou = (s) => (s?.tickets ?? []).some((t) => NEEDS_YOU_STATES.has(t.state));
237 const rank = (s) => (first !== null && s?.id === first ? 0 : has(s, 'running') ? 1 : needsYou(s) ? 2 : 3);
238 return (list ?? []).map((s, i) => i).sort((i, j) => rank(list[i]) - rank(list[j]) || String(list[j]?.id).localeCompare(String(list[i]?.id)) || i - j);
239}
240
241// As many of `shown` (the sprints of `list`, as sent) as fit beside `base`: the open board's sprint first, then those
242// with a ticket running, then parked, then the rest, newest first (sprintOrder). Drawn in Podium's order; `hiddenSprints` counts the
243// rest, and the list says so. Null when not even one fits.
244function someSprints(base, shown, list, boardId, budget) {
245 const order = sprintOrder(list, boardId);
246 let used = sizeOf({ ...base, sprints: [], hiddenSprints: list.length });
247 const keep = new Set();
248 for (const i of order) {
249 const c = sizeOf(shown[i]) + 1;
250 if (used + c > budget) continue;
251 used += c;
252 keep.add(i);
253 }
254 if (!keep.size) return null;
255 const out = { ...base, sprints: shown.filter((s, i) => keep.has(i)), hiddenSprints: list.length - keep.size };
256 return sizeOf(out) <= budget ? out : null;
257}
258
259const PANE_FULL = 'Podium has more than this pane can hold; ask Claude about your jobs.';
260
261// The model the desktop Client is sent, at most `budget` characters as JSON. A model that fits goes whole. Otherwise jobs
262// are left out in the order of `tiers` (in fitted). If what must be kept is still too big, long text is cut shorter, then
263// the jobs go, then the open board's rows are capped, then card titles are cut, then whole sprints are left out. Whatever
264// is left out is counted (`hiddenJobs`, `hiddenAtWork`, `hiddenSprints`, `olderSprints`) so the list can say so, and
265// anything still over the budget becomes the smallest model. The terminal draws the whole model.
266export function fitModel(model, { budget = PROPS_BUDGET, now = Date.now() } = {}) {
267 const paged = pagedSprints(model);
268 const out = fitted(paged, budget, now);
269 return sizeOf(out) <= budget ? out : smallest(paged, budget);
270}
271
272// The sprints the Client is sent (the list draws a page of sprints, so the budget is not spent on sprints it
273// never draws, leaving out jobs at work): the list's page (listPage, the same pages the terminal draws), with the open
274// board's sprint and the open step's sprint, and `sprintPaging`, which ones are on the page and where it is in the list.
275// They go whole while the model fits; past the budget fitted sends the page's sprints as rows first (sprintAsRow).
276// Finished sprints: the newest FINISHED_SPRINTS of each fold (this chat's Done, Other chats) go as rows
277// (sprintAsRow), never whole, but the open board's or step's sprint stays whole; `olderSprints` counts, per fold, the
278// finished sprints not sent, so the list can say so. It is sent only when some sprint has finished.
279function pagedSprints(model) {
280 const list = model.sprints ?? [];
281 const at = listPage(list.filter(isOpenSprint), model.sprintPage, null, CARD_ROWS);
282 const ids = at.sprints.map((s) => s.id);
283 const keep = new Set(ids);
284 const v = model.view ?? {};
285 if ((v.screen === 'sprint' || v.screen === 'ticket') && v.id) keep.add(v.id);
286 const step = v.screen === 'detail' && v.id ? (model.jobs ?? []).find((j) => j.id === v.id) : null;
287 if (step?.sprint?.id) keep.add(step.sprint.id);
288 const fin = finishedSprints(list, model.session ?? null);
289 const asRows = new Set([...fin.mine.slice(0, FINISHED_SPRINTS), ...fin.others.slice(0, FINISHED_SPRINTS)].map((s) => s.id).filter((id) => !keep.has(id)));
290 const sent = (s) => keep.has(s.id) || asRows.has(s.id);
291 const { sprints: _, ...where } = at;
292 return {
293 ...model, sprints: list.filter((s) => sent(s)).map((s) => (asRows.has(s.id) ? sprintAsRow(s) : s)), sprintPaging: { ...where, ids, rows: CARD_ROWS },
294 ...(fin.mine.length || fin.others.length ? { olderSprints: { mine: fin.mine.filter((s) => !sent(s)).length, others: fin.others.filter((s) => !sent(s)).length } } : {}),
295 };
296}
297
298// A sprint as its list row draws it: its card with no ticket rows (`status`, `headline` and `more` counted over
299// every ticket). Your turn comes from the model's `turn`, so the list needs nothing else.
300const sprintAsRow = (s) => capRows(sprintCard(s), 0);
301
302function fitted(model, budget, now) {
303 const whole = { ...model, hiddenJobs: 0, hiddenAtWork: 0 };
304 if (sizeOf(whole) <= budget) return whole;
305 const all = model.jobs ?? [];
306 const v = model.view ?? {};
307 const boardId = v.screen === 'sprint' || v.screen === 'ticket' ? (v.id ?? null) : null;
308 const open = v.screen === 'detail' && v.id ? all.find((j) => j.id === v.id) : null;
309 const isBoard = (s) => boardId !== null && s?.id === boardId;
310 const isStep = (s) => Boolean(open?.sprint) && s?.id === open.sprint.id;
311 // Finished sprints' rows are a tier of their own, each a unit (`sprint:<id>`), this chat's first; every other path
312 // leaves them out and counts them in `olderSprints`. The open board's or step's sprint is never one of them.
313 const isDoneRow = (s) => !isBoard(s) && !isStep(s) && isFinishedSprint(s);
314 const session = model.session ?? null;
315 const fin = finishedSprints((model.sprints ?? []).filter(isDoneRow), session);
316 const doneRows = new Map([...fin.mine, ...fin.others].map((s) => [`sprint:${s.id}`, sprintAsRow(s)]));
317 const olderOf = (kept = new Set()) => {
318 if (!doneRows.size) return model.olderSprints ? { olderSprints: model.olderSprints } : {};
319 const left = [...doneRows].filter(([k]) => !kept.has(k)).map(([, s]) => s);
320 const mine = left.filter((s) => isSprintMine(s, session)).length;
321 const base = model.olderSprints ?? { mine: 0, others: 0 };
322 return { olderSprints: { mine: base.mine + mine, others: base.others + left.length - mine } };
323 };
324 const list = (model.sprints ?? []).filter((s) => !isDoneRow(s));
325 // A capped sprint keeps these rows first: the board's open row and the open step's ticket.
326 const pins = (s) => [model.board?.sprint === s?.id ? model.board?.open : null, open?.sprint?.id === s?.id ? open.sprint.ticket : null].filter(Boolean);
327 const capped = (max = TASK_TITLE_LIMIT) => list.map((s) => capRows(sprintCard(s, max), CARD_ROWS, pins(s)));
328 // Every card whole; then each card capped. The open board's sprint is whole in both.
329 const full = cards(list, boardId);
330 const short = capped().map((s, i) => (isBoard(list[i]) ? list[i] : s));
331 // First past the budget: the list's sprints as rows (the list draws a row each), the open board's whole and the
332 // open step's as its capped card (its ticket pinned), so a page of rows leaves room for the jobs at work.
333 const rows = list.map((s, i) => (isBoard(s) ? s : isStep(s) ? short[i] : sprintAsRow(s)));
334 const units = new Map();
335 for (const j of all) {
336 const k = unitOf(j);
337 if (!units.has(k)) units.set(k, []);
338 units.get(k).push(j);
339 }
340 const must = new Set([...units].filter(([, js]) => js.some(isAtWork)).map(([k]) => k));
341 if (open?.sprint) for (const j of all) { if (j.sprint?.id === open.sprint.id && j.sprint.ticket === open.sprint.ticket) must.add(unitOf(j)); }
342 else if (open) must.add(unitOf(open));
343 const asking = model.askFor?.threadId ? `thread:${model.askFor.threadId}` : null;
344 if (asking && units.has(asking)) must.add(asking);
345 // The lanes as the list draws them (threads() leaves sprint steps out; a thread's id is its unit's).
346 const org = organise(threads(all), { session: model.session ?? null, now, clearedAt: model.cleared ?? null, showAll: Boolean(model.showAllDone) });
347 const newest = (a, b) => Math.max(...units.get(b).map(endOf)) - Math.max(...units.get(a).map(endOf));
348 const rest = [...units.keys()].filter((k) => !must.has(k));
349 // After `must` (threads with a job still at work, waiting on the person or in review; the open screen's and the ask form's thread), while
350 // they fit, in this order; each tier stops at its first unit that does not fit.
351 const tiers = [
352 rest.filter((k) => units.get(k).some((j) => j.sprint?.id === boardId)).sort(newest), // the open board's steps
353 org.done.map((t) => `thread:${t.id}`), // this chat's Done lane, as the list shows it
354 org.others.map((t) => `thread:${t.id}`), // other chats' finished work the list shows
355 [...doneRows.keys()], // finished sprints as rows
356 rest.filter((k) => k.startsWith('thread:')).sort(newest), // the rest, newest first
357 rest.filter((k) => k.startsWith('step:')).sort(newest), // then other sprint steps
358 ];
359 const lanes = all.filter((j) => !j.sprint);
360 const keeping = (keys, base, shorten = (j) => j) => {
361 const keySet = new Set(keys);
362 const kept = new Set(keys.flatMap((k) => units.get(k) ?? []));
363 const hidden = lanes.filter((j) => !kept.has(j));
364 const done = [...doneRows].filter(([k]) => keySet.has(k)).map(([, s]) => s);
365 return {
366 ...base, jobs: all.filter((j) => kept.has(j)).map(shorten), hiddenJobs: hidden.length, hiddenAtWork: hidden.filter(isAtWork).length,
367 sprints: [...(base.sprints ?? []), ...done], ...olderOf(keySet),
368 };
369 };
370 // Each job costs its JSON and a comma (a finished sprint's row too); the hidden counts are sized at their largest, so the
371 // sum is an upper bound.
372 const cost = (k) => (doneRows.has(k) ? sizeOf(doneRows.get(k)) + 1 : units.get(k).reduce((n, j) => n + sizeOf(j) + 1, 0));
373 const fill = (lean) => {
374 let total = sizeOf({ ...lean, jobs: [], hiddenJobs: lanes.length, hiddenAtWork: lanes.length, ...olderOf() }) + [...must].reduce((n, k) => n + cost(k), 0);
375 if (total > budget) return null;
376 const keys = new Set(must);
377 for (const tier of tiers) {
378 for (const k of tier) {
379 if (keys.has(k)) continue;
380 const c = cost(k);
381 if (total + c > budget) break;
382 total += c;
383 keys.add(k);
384 }
385 }
386 const out = keeping([...keys], lean);
387 return sizeOf(out) <= budget ? out : null;
388 };
389 // Rows are nearly always the smallest; full and short are a safety net for a sprint whose row (its headline and status
390 // line added) outweighs its card, e.g. one short ticket.
391 for (const sprints of [rows, full, short]) {
392 const out = fill({ ...model, detail: drawnDetail(model.detail, CODE_LIMIT), sprints });
393 if (out) return out;
394 }
395 // What must stay is over the budget: cut its long text shorter and shorter.
396 for (const max of CUTS) {
397 const base = { ...model, sprints: rows, detail: drawnDetail(model.detail && { ...model.detail, summary: cut(model.detail.summary, max * 2) }, Math.max(200, max * 4)), live: shortLive(model.live, max) };
398 const squeezed = keeping([...must], base, (j) => shortJob(j, max));
399 if (sizeOf(squeezed) <= budget) return squeezed;
400 }
401 // Still over: no jobs; then the open board's rows capped too (with their tasks), then it as a capped card, then
402 // shorter card titles; then fewer sprints; then the bare list (never no sprints while one fits).
403 const bare = (sprints) => ({ ...model, jobs: [], detail: null, live: {}, hiddenJobs: lanes.length, hiddenAtWork: lanes.filter(isAtWork).length, sprints, ...olderOf() });
404 const boardCapped = rows.map((s, i) => (isBoard(list[i]) ? capRows(s, CARD_ROWS, pins(s)) : s));
405 for (const sprints of [rows, boardCapped, capped(), ...CARD_CUTS.map(capped)]) {
406 const out = bare(sprints);
407 if (sizeOf(out) <= budget) return out;
408 }
409 const least = capped(CARD_CUTS.at(-1));
410 const few = someSprints(bare([]), least, list, boardId, budget);
411 if (few) return few;
412 const stripped = {
413 ...bare(least), view: { screen: 'list', id: null }, notice: PANE_FULL,
414 agents: [], settings: null, checks: null, whatsNew: null, removing: null, askFor: null, draftAdapter: null,
415 draft: { ...model.draft, task: '' }, folds: [], acked: (model.acked ?? []).slice(-20), seen: (model.seen ?? []).slice(-20),
416 };
417 if (sizeOf(stripped) <= budget) return stripped;
418 return someSprints({ ...stripped, sprints: [] }, least, list, null, budget) ?? stripped;
419}
420
421// The smallest model the pane draws (fitModel's final check): the list, no jobs, and each sprint's name, headline and
422// buttons, as many as fit (`hiddenSprints` counts the rest). Every field the screens read is present. It keeps `turn`
423// whole (TURN_TICKETS tickets at most, about 8.5 KB at their longest), so it fits any budget above about 1,000
424// characters plus that.
425function smallest(model, budget) {
426 const lanes = (model.jobs ?? []).filter((j) => !j.sprint);
427 const list = model.sprints ?? [];
428 const draft = Object.fromEntries(Object.entries(model.draft ?? {}).map(([k, x]) => [k, k === 'task' ? '' : typeof x === 'string' ? cut(x, 200) : x]));
429 const base = {
430 view: { screen: 'list', id: null }, notice: PANE_FULL, jobs: [], live: {}, detail: null, agents: [], agentsLoaded: Boolean(model.agentsLoaded),
431 settings: null, draft, draftAdapter: null, palette: model.palette === 'light' ? 'light' : 'dark', askFor: null,
432 cleared: typeof model.cleared === 'string' ? cut(model.cleared, 40) : null, showAllDone: false, checks: null, whatsNew: null, removing: null,
433 board: { sprint: null, open: null, confirm: null, picking: null }, session: typeof model.session === 'string' ? cut(model.session, 100) : null,
434 acked: (model.acked ?? []).slice(-20), seen: (model.seen ?? []).slice(-20),
435 folds: [], turn: model.turn ?? { tickets: [], more: 0 }, olderFinished: { mine: Number(model.olderFinished?.mine) || 0, others: Number(model.olderFinished?.others) || 0 },
436 hiddenJobs: lanes.length, hiddenAtWork: lanes.filter(isAtWork).length,
437 sprintPage: Number.isInteger(model.sprintPage) ? model.sprintPage : 0, sprintPaging: model.sprintPaging ?? null,
438 };
439 const shown = list.map((s) => sprintIdentity(s));
440 const out = { ...base, sprints: shown };
441 if (sizeOf(out) <= budget) return out;
442 return someSprints({ ...base, sprints: [] }, shown, list, null, budget) ?? { ...base, sprints: [], hiddenSprints: list.length };
443}
444
445// ---- The list's sprint rows, a page at a time ----
446// 60 saved drafts fit the model's budget, but 60 sprints drawn were past the desktop page's 2,000 elements and texts:
447// the list drew the "couldn't draw" note, and its Back led to the same list. The list draws its sprint rows a page at
448// a time (listPage, in sprintOrder), each page well inside the page's bounds, with the rest of the list beside it.
449// A row draws the sprint's title and status line, each at most LIST_LINE_CHARS (the pane cuts a line on screen
450// anyway). In the terminal a page's sprints keep at most LIST_ROWS tickets; on desktop fitModel sends them with none.
451export const LIST_ROWS = 40;
452export const LIST_LINE_CHARS = 240;
453// What one page of sprint rows may take of the page's 2,000 elements and texts and 100,000 characters.
454export const SPRINT_PAGE_NODES = 500;
455export const SPRINT_PAGE_CHARS = 30_000;
456// A sprint's row on the list (screens.tsx listScreen: row()), measured from the kit's drawing, with margin: its box,
457// title button and status line; two nodes and about LINE_EXTRA characters around each text; its id in each key.
458// (SPRINT_ROW_CHARS, not ROW_CHARS: that name is the file rows' bound above.) Its box is a tile (tiles.tsx tile:
459// border, background and padding props), measured at 5 nodes and about 500 characters.
460const SPRINT_ROW_NODES = 6;
461const SPRINT_ROW_CHARS = 850;
462const LINE_NODES = 2;
463const LINE_EXTRA = 90;
464const ID_USES = 6;
465
466// What a sprint's row takes of the page, at most.
467export function cardCost(s) {
468 const lines = [sprintRowTitle(s), sprintStatusLine(s ?? { tickets: [] })].map((l) => cut(String(l), LIST_LINE_CHARS));
469 return {
470 nodes: SPRINT_ROW_NODES + LINE_NODES * lines.length,
471 chars: SPRINT_ROW_CHARS + ID_USES * sizeOf(String(s?.id ?? '')) + lines.reduce((n, l) => n + sizeOf(l) + LINE_EXTRA, 0),
472 };
473}
474
475// The list's sprints in sprintOrder, cut into pages that stay inside SPRINT_PAGE_NODES and SPRINT_PAGE_CHARS (a row
476// alone always fits one), and the page `page` of them (the last if past it): its sprints (tickets capped at `rows`),
477// which page it is of how many, and its first and last row's place in the whole list. On desktop fitModel has paged
478// them already (`paging`, sprintPaging): the page is the sprints it names, in its order, each with at most the ticket
479// rows fitModel sent (`paging.rows`, 0: rows only), so the list draws what fitModel measured.
480export function listPage(list, page = 0, paging = null, rows = LIST_ROWS) {
481 const all = list ?? [];
482 if (paging && Array.isArray(paging.ids)) {
483 const byId = new Map(all.map((s) => [s?.id, s]));
484 const sprints = paging.ids.flatMap((id) => (byId.has(id) ? [capRows(byId.get(id), paging.rows ?? CARD_ROWS)] : []));
485 return { sprints, page: paging.page ?? 0, pages: paging.pages ?? 1, from: paging.from ?? 1, to: paging.to ?? sprints.length, total: paging.total ?? sprints.length };
486 }
487 const pages = [];
488 let cur = [];
489 let nodes = 0;
490 let chars = 0;
491 for (const i of sprintOrder(all)) {
492 const s = capRows(all[i], rows);
493 const c = cardCost(s);
494 if (cur.length && (nodes + c.nodes > SPRINT_PAGE_NODES || chars + c.chars > SPRINT_PAGE_CHARS)) {
495 pages.push(cur);
496 cur = [];
497 nodes = 0;
498 chars = 0;
499 }
500 cur.push(s);
501 nodes += c.nodes;
502 chars += c.chars;
503 }
504 if (cur.length) pages.push(cur);
505 const at = Math.max(0, Math.min(Number.isInteger(page) ? page : 0, pages.length - 1));
506 const before = pages.slice(0, at).reduce((n, p) => n + p.length, 0);
507 const sprints = pages[at] ?? [];
508 return { sprints, page: at, pages: pages.length, from: before + 1, to: before + sprints.length, total: all.length };
509}
510
511// The short list the Client draws when the whole list is past the page's bounds (else the "couldn't draw" note's
512// Back would lead back to the same list, a loop). The header, Your turn and the list's page of sprint rows (each sprint sent as
513// its card of at most CARD_ROWS tickets, short titles); no job rows (the list says some are not shown) and nothing that
514// opens on the list (an ask form, the welcome card). It is always the list, so nothing leads from it back to a screen that failed.
515export const LIST_TOO_LONG = 'This list is too long for this pane, so it shows only your sprints. Ask Claude about your jobs.';
516export function safeList(model) {
517 const lanes = (model.jobs ?? []).filter((j) => !j.sprint);
518 const card = (s) => capRows(s?.brief || s?.unreadable ? s : sprintCard(s, CARD_CUTS[0]), CARD_ROWS);
519 return {
520 ...model, view: { screen: 'list', id: null }, notice: model.notice ? `${model.notice}\n${LIST_TOO_LONG}` : LIST_TOO_LONG,
521 jobs: [], live: {}, detail: null, askFor: null, checks: null, whatsNew: null,
522 hiddenJobs: (model.hiddenJobs ?? 0) + lanes.length, hiddenAtWork: (model.hiddenAtWork ?? 0) + lanes.filter(isAtWork).length,
523 sprints: (model.sprints ?? []).map(card),
524 };
525}
526
527// Presses the Client shows at once, before Podium answers.
528export const NAV_ACTIONS = new Set(['showScreen', 'openDetail', 'openBoard', 'backToList', 'openAgent', 'fixOpen', 'openTicket']);
529
530// The screen a navigation press shows at once (the Client's overlay) until Podium's model agrees.
531export function overlayFor(name, args) {
532 if (name === 'openDetail') return { screen: 'detail', id: String(args[0]) };
533 if (name === 'fixOpen') return { screen: 'detail', id: String(args[1]) };
534 if (name === 'openTicket') return args[1] ? { screen: 'ticket', id: String(args[0]), key: String(args[1]) } : { screen: 'sprint', id: String(args[0]) };
535 if (name === 'openBoard') return { screen: 'sprint', id: String(args[0]) };
536 if (name === 'openAgent') return { screen: 'agent', id: String(args[0]) };
537 if (name === 'backToList') return { screen: 'list', id: null };
538 return { screen: args[0], id: null };
539}
540
541// Presses that set a value: a newer one replaces an unsent older one, and none shows "Working…".
542export const EDIT_ACTIONS = new Set(['editDraft', 'askEdit', 'editAdapter', 'askConfirm', 'openPicker', 'showSprints', 'editNote', 'confirmJob']);
543
544// Presses that show no "Working…" but are never replaced by a newer one (two folds opened quickly both open).
545export const QUIET_ACTIONS = new Set(['toggleFold']);
546
547export function enqueue(outbox, msg) {
548 const kept = EDIT_ACTIONS.has(msg.name) ? outbox.filter((m) => m.name !== msg.name) : outbox;
549 return [...kept, msg];
550}
551
552// What the Client posts on this tick. The app keeps only the last post of a frame, and a
553// busy or background window can run several ticks in one frame, so a press posted on its
554// own could be silently replaced (a Push to PR that never ran). Instead every post carries
555// all the presses Podium has not yet said it received (model.seen); Podium skips ids it
556// has seen, so a replaced post loses nothing. Unchanged, the batch is sent again after
557// RESEND ticks; with nothing waiting, a due sync goes.
558export const RESEND = 8;
559export function nextPost(outbox, s) {
560 const key = outbox.map((m) => m.id).join(',');
561 if (outbox.length && (key !== s.sentKey || s.sinceSend >= RESEND)) {
562 return { post: { kind: 'acts', acts: outbox }, s: { syncDue: false, sentKey: key, sinceSend: 0 } };
563 }
564 if (!outbox.length && s.syncDue) return { post: { kind: 'sync' }, s: { syncDue: false, sentKey: '', sinceSend: 0 } };
565 return { post: null, s: { ...s, sinceSend: s.sinceSend + 1 } };
566}
567
568// Drops the presses Podium has received.
569export function unseen(outbox, seen) {
570 const got = new Set(seen ?? []);
571 return outbox.filter((m) => !got.has(m.id));
572}
573
574// ---- The desktop page's rules for a Client's tree ----
575// The Client's runtime sends the page JSON of its tree (props copied plain, functions and undefined dropped, a
576// Button, Input or Select as a handle with no children). The page throws the whole tree away (a blank pane, logged
577// as "Client instance unmounted (render)") unless it fits these bounds and every element is one it draws. The bounds
578// were measured against the Claude desktop app (2026-10). There is no Code, Link or Markdown on the page.
579// The page itself allows 262,144 characters, but the Client runtime unmounts a tree past 100,000 (ClientElements), so the stricter bound.
580const PAGE_CHARS = 100000;
581const PAGE_NODES = 2000;
582const PAGE_DEPTH = 32;
583const PAGE_NESTING = 66;
584const PAGE_TAGS = new Set(['Box', 'Text', 'div', 'span', 'b']);
585const PRESSABLE = new Set(['Button', 'Input', 'Select']);
586const KEY_CHARS = 256;
587const VALUE_CHARS = 16384;
588const SVG_CHARS = 131072;
589const SVG_SIDE = 4096;
590
591const count = (n) => n.toLocaleString('en-US');
592const what = (v) => (v === null ? 'null' : Array.isArray(v) ? 'a list' : typeof v === 'object' ? 'an object' : `a ${typeof v}`);
593class Refused extends Error {}
594const refuse = (reason) => { throw new Refused(reason); };
595
596// Props as the runtime copies them: null kept, a number that is not finite becomes null, functions and undefined drop.
597function plainValue(v) {
598 if (v === null) return null;
599 const kind = typeof v;
600 if (kind === 'string' || kind === 'boolean') return v;
601 if (kind === 'number') return Number.isFinite(v) ? v : null;
602 if (kind !== 'object') return undefined;
603 if (Array.isArray(v)) return v.map((x) => { const y = plainValue(x); return y === undefined ? null : y; });
604 const out = {};
605 for (const k of Object.keys(v)) { const y = plainValue(v[k]); if (y !== undefined) out[k] = y; }
606 return out;
607}
608
609// The tree the page receives (the runtime's exportTree). The runtime numbers each Button, Input and Select handle
610// from a counter that grows for the pane's life (it redraws every second): counted here at its largest.
611function sent(node) {
612 if (typeof node === 'string') return node;
613 if (typeof node !== 'object' || node === null || Array.isArray(node)) refuse(`a child that is neither text nor an element (${what(node)})`);
614 if (typeof node.type !== 'string') refuse('an element without a type');
615 const out = { type: node.type };
616 if (node.props !== undefined) out.props = plainValue(node.props);
617 if (PRESSABLE.has(node.type)) return { ...out, held: Number.MAX_SAFE_INTEGER };
618 if (node.children !== undefined) {
619 if (!Array.isArray(node.children)) refuse(`${node.type} children that are not a list`);
620 out.children = node.children.map(sent);
621 }
622 return out;
623}
624
625// The page's bounds: every element and every text child counts; element depth; how deep any data nests.
626function measure(tree) {
627 let nodes = 0;
628 let depth = 0;
629 let nesting = 0;
630 const stack = [{ value: tree, depth: 0, nesting: 0, isChild: true }];
631 while (stack.length) {
632 const e = stack.pop();
633 const v = e.value;
634 const isObject = typeof v === 'object' && v !== null;
635 const isElement = isObject && 'type' in v;
636 const d = isElement ? e.depth + 1 : e.depth;
637 const n = isObject ? e.nesting + 1 : e.nesting;
638 if (isElement || (e.isChild && typeof v === 'string')) nodes += 1;
639 depth = Math.max(depth, d);
640 nesting = Math.max(nesting, n);
641 if (isObject) for (const x of Object.values(v)) stack.push({ value: x, depth: d, nesting: n, isChild: Array.isArray(v) });
642 }
643 return { nodes, depth, nesting };
644}
645
646function bounds(tree) {
647 const { nodes, depth, nesting } = measure(tree);
648 if (nodes > PAGE_NODES) refuse(`${count(nodes)} elements and texts, more than the desktop pane's ${count(PAGE_NODES)}`);
649 if (depth > PAGE_DEPTH) refuse(`elements nested ${depth} deep, more than the desktop pane's ${PAGE_DEPTH}`);
650 if (nesting > PAGE_NESTING) refuse(`data nested ${nesting} deep, more than the desktop pane's ${PAGE_NESTING}`);
651}
652
653const isText = (v) => typeof v === 'string';
654
655// A Button, Input or Select needs a key of 1 to 256 characters; the rest of its text props are checked by name.
656function keyed(type, props) {
657 const an = /^[AEIOU]/.test(type) ? 'an' : 'a';
658 if (!isText(props.key) || props.key.length < 1) refuse(`${an} ${type} with no key`);
659 if (props.key.length > KEY_CHARS) refuse(`${an} ${type} key longer than ${KEY_CHARS} characters`);
660 const name = `${an} ${type} ${props.key}`;
661 const optionalText = (field, max = Infinity) => {
662 if (props[field] === undefined) return;
663 if (!isText(props[field])) refuse(`${name} whose ${field} is not text`);
664 if (props[field].length > max) refuse(`${name} whose ${field} is longer than ${count(max)} characters`);
665 };
666 return { name, optionalText };
667}
668
669// The elements the page draws and their props.
670function element(node) {
671 if (isText(node)) return;
672 const { type } = node;
673 const props = node.props ?? {};
674 if (typeof props !== 'object' || Array.isArray(props)) refuse(`a ${type} whose props are ${what(props)}`);
675 if (PAGE_TAGS.has(type)) {
676 for (const [k, v] of Object.entries(props)) {
677 if (!['string', 'number', 'boolean'].includes(typeof v)) refuse(`a ${type} prop ${k} that is ${what(v)}`);
678 }
679 for (const c of node.children ?? []) element(c);
680 return;
681 }
682 if (type === 'Svg') {
683 if (!isText(props.source) || !props.source.length) refuse('an Svg with no source');
684 if (props.source.length > SVG_CHARS) refuse(`an Svg source longer than ${count(SVG_CHARS)} characters`);
685 if (!isText(props.alt) || !props.alt.trim()) refuse('an Svg with no alt text');
686 for (const side of ['width', 'height']) {
687 const v = props[side];
688 if (typeof v !== 'number' || !(v > 0) || v > SVG_SIDE) refuse(`an Svg whose ${side} is not a number from 1 to ${count(SVG_SIDE)}`);
689 }
690 if (props.isInteractive !== undefined && typeof props.isInteractive !== 'boolean') refuse('an Svg whose isInteractive is not true or false');
691 return;
692 }
693 if (type === 'Button') {
694 const { name } = keyed(type, props);
695 if (!isText(props.label)) refuse(`${name} with no label`);
696 if (props.plain !== undefined && typeof props.plain !== 'boolean') refuse(`${name} whose plain is not true or false`);
697 return;
698 }
699 if (type === 'Input') {
700 const { optionalText } = keyed(type, props);
701 for (const f of ['label', 'placeholder', 'submitLabel']) optionalText(f);
702 optionalText('value', VALUE_CHARS);
703 return;
704 }
705 if (type === 'Select') {
706 const { name, optionalText } = keyed(type, props);
707 if (!Array.isArray(props.options) || !props.options.length) refuse(`${name} with no options`);
708 for (const o of props.options) {
709 if (typeof o !== 'object' || o === null || !isText(o.value)) refuse(`${name} with an option that has no value`);
710 if (o.value.length > VALUE_CHARS) refuse(`${name} with an option value longer than ${count(VALUE_CHARS)} characters`);
711 if (o.label !== undefined && !isText(o.label)) refuse(`${name} with an option label that is not text`);
712 }
713 optionalText('label');
714 optionalText('value', VALUE_CHARS);
715 return;
716 }
717 refuse(`a ${type} element, which the desktop pane cannot draw`);
718}
719
720// null when the desktop page will draw this Client tree; otherwise why it would refuse it, a short phrase that
721// reads after "The screen has". Takes the tree as the Client builds it or as the kit draws it.
722export function pageProblem(tree) {
723 try {
724 const text = JSON.stringify(sent(tree));
725 if (text.length > PAGE_CHARS) refuse(`a drawing of ${count(text.length)} characters, more than the desktop pane's ${count(PAGE_CHARS)}`);
726 const page = JSON.parse(text);
727 bounds(page);
728 element(page);
729 return null;
730 } catch (err) {
731 if (err instanceof Refused) return err.message;
732 return `a drawing that could not be checked (${String(err?.message ?? err)})`;
733 }
734}
735
736// How much of the page's bounds a Client tree (or a part of one) takes: elements and texts, and characters as sent.
737export function pageSize(tree) {
738 const page = JSON.parse(JSON.stringify(sent(tree)));
739 return { nodes: measure(page).nodes, chars: JSON.stringify(page).length };
740}
741
742// How many words a drawing shows (the first view's "fewer words" check): every text, every Button's label, and
743// an Input's label and placeholder (its value is the person's own words). A word is a run of characters with a letter or a
744// digit in it, so "·", "›" and "▸" are not words.
745export function wordsDrawn(tree) {
746 let n = 0;
747 const count = (s) => { n += String(s ?? '').split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length; };
748 const walk = (node) => {
749 if (typeof node === 'string' || typeof node === 'number') { count(node); return; }
750 if (Array.isArray(node)) { node.forEach(walk); return; }
751 if (!node || typeof node !== 'object') return;
752 const props = node.props ?? {};
753 if (node.type === 'Button') count(props.label);
754 if (node.type === 'Input') { count(props.label); count(props.placeholder); }
755 for (const c of node.children ?? []) walk(c);
756 };
757 walk(tree);
758 return n;
759}
760
761// What the "couldn't draw" note says first, in plain words; pageProblem's reason goes below it, dim.
762const TOO_MUCH = /^(a drawing of [\d,]+ characters|[\d,]+ elements and texts|elements nested \d+ deep|data nested \d+ deep)/;
763export function plainProblem(reason) {
764 return TOO_MUCH.test(String(reason ?? ''))
765 ? 'This screen has too much to show here. Close some open files, or ask Claude.'
766 : 'Something on this screen can\'t be shown here.';
767}
768
769// Code drawn as lines of text for the desktop Client, which has no Code element. A diff colours its added and
770// removed lines, dims its headers and keeps its start; other output keeps its end (the newest lines). At most
771// `max` lines and `maxChars` characters of drawing (lineCost each), with a dim note for the rest; a line longer
772// than LINE_CHARS is cut (the pane cuts it on screen anyway, but sends all of it). `used` is how many lines it
773// drew, for the screen's budget (look.tsx).
774export const CODE_LINES = 300;
775export const LINE_CHARS = 400;
776// A line's share of the drawing: its text as JSON and its Text element around it ({"type":"Text","props":{…}}).
777export const lineCost = (text) => JSON.stringify(String(text)).length + 80;
778export function codeLines(source, { format = 'source', max = CODE_LINES, maxChars = Infinity } = {}) {
779 const all = String(source ?? '').split('\n');
780 const isDiff = format === 'diff';
781 const tone = (line) => {
782 if (line.startsWith('… ')) return 'dim';
783 if (!isDiff) return 'plain';
784 if (/^(@@|diff |index |\+\+\+ |--- )/.test(line)) return 'head';
785 if (line.startsWith('+')) return 'added';
786 if (line.startsWith('-')) return 'removed';
787 return 'plain';
788 };
789 const room = Math.max(0, max);
790 const fit = (line) => (line === '' ? ' ' : line.length > LINE_CHARS ? `${line.slice(0, LINE_CHARS - 1)}…` : line);
791 // Lines kept from the end that matters (a diff's start, output's end) while they fit both bounds.
792 const order = isDiff ? all : [...all].reverse();
793 const kept = [];
794 let chars = 0;
795 for (const line of order) {
796 if (kept.length >= room) break;
797 const text = fit(line);
798 chars += lineCost(text);
799 if (chars > maxChars) break;
800 kept.push({ text, tone: tone(line) });
801 }
802 const used = kept.length;
803 const lines = isDiff ? [...kept] : [...kept].reverse();
804 const rest = all.length - used;
805 if (rest > 0) {
806 const note = { text: `… ${count(rest)} ${isDiff ? 'more' : 'earlier'} line${rest === 1 ? '' : 's'}`, tone: 'dim' };
807 if (isDiff) lines.push(note); else lines.unshift(note);
808 }
809 return { used, lines };
810}
811mod/jobopen.js 46 lines1// What a job's screen has open: the ask form (Ask for changes, Send fix, Answer comments, Review with…), the ender's
2// question (Throw away, Stop) or Repair's question. Pure: register.tsx keeps the value in one atom and moves it only
3// through `jobOpenNext` inside one update, so the three are exclusive however presses overlap.
4//
5// The value: null, { ask: <form> }, { confirm: 'discard' | 'cancel', id }, or { repair: id }.
6
7// The next value after `action`:
8// { type: 'ask', form } opens the ask form (closing any question)
9// { type: 'askEdit', patch } changes the open form (nothing else open: no change)
10// { type: 'askClose' } closes the form (Cancel, or sent); a question stays
11// { type: 'confirm', id, what } opens the ender's question; what null closes that job's question
12// { type: 'repair', id, on } opens Repair's question; on false closes that job's
13// { type: 'questionClose', id } closes that job's question, whichever it is
14// { type: 'screenOpened' } a job's screen was (re)opened: any question closes; the form stays
15export function jobOpenNext(cur, action) {
16 const now = cur ?? null;
17 switch (action?.type) {
18 case 'ask': return action.form ? { ask: action.form } : now;
19 case 'askEdit': return now?.ask ? { ask: { ...now.ask, ...action.patch } } : now;
20 case 'askClose': return now?.ask ? null : now;
21 case 'confirm':
22 if (action.what) return { confirm: action.what, id: action.id };
23 return now?.confirm && now.id === action.id ? null : now;
24 case 'repair':
25 if (action.on) return { repair: action.id };
26 return now?.repair === action.id ? null : now;
27 case 'questionClose':
28 return (now?.confirm && now.id === action.id) || now?.repair === action.id ? null : now;
29 case 'screenOpened':
30 return now?.confirm || now?.repair ? null : now;
31 default: return now;
32 }
33}
34
35// What the screens read: the ask form, and the job's own question folded into its detail.
36export const askOf = (open) => open?.ask ?? null;
37export function withQuestion(detail, open) {
38 if (!detail) return detail;
39 const confirm = open?.confirm && open.id === detail.id ? open.confirm : null;
40 const confirmRepair = open?.repair === detail.id;
41 return { ...detail, confirm, confirmRepair };
42}
43
44// How many things are open at once (for tests): never more than one.
45export const openCount = (open) => (open ? ['ask', 'confirm', 'repair'].filter((k) => open[k]).length : 0);
46hooks/client/app.tsx 93 lines1// The desktop pane. The app paints a Client on its own frame clock, so this draws
2// at once; Podium's own redraws of a Pane are not painted until the next click.
3// Every press is posted to Podium (ui.message); its answer, and a sync each second,
4// bring the next model as props. A screen switch shows at once from a local overlay.
5import { drawPane, drawFailed, isListView, HANDLER_NAMES } from '../screens'
6import type { PaneModel } from '../screens'
7import { enqueue, nextPost, unseen, NAV_ACTIONS, EDIT_ACTIONS, overlayFor, pageProblem, plainProblem, safeList, QUIET_ACTIONS } from '../../mod/client.js'
8import { PALETTES } from '../theme'
9import { clientLook } from './look'
10
11type Msg = { kind: 'act'; id: string; name: string; args: unknown[] }
12// typed: the notes last typed for a plan here (editNote), with the board's question they were typed under.
13type Typed = { sprint: string; confirm: string | null; text: string }
14type Outbox = { outbox: Msg[]; seq: number; overlay: PaneModel['view'] | null; pending: string[]; tick: number; typed: Typed | null }
15type Local = { box: Outbox; n: number }
16
17export default function PodiumApp(model: PaneModel, surface: any) {
18 let created: Outbox | null = null
19 if (surface.state === undefined) {
20 // First call: start the timers. State is set from a timer, never while drawing.
21 const box: Outbox = { outbox: [], seq: 0, overlay: null, pending: [], tick: 0, typed: null }
22 created = box
23 // One poster: every post carries all presses Podium has not yet received (nextPost).
24 let sent = { syncDue: false, sentKey: '', sinceSend: 0 }
25 surface.every(60, () => {
26 if (surface.state === undefined) surface.setState({ box, n: 0 })
27 const r = nextPost(box.outbox, sent)
28 sent = r.s
29 if (r.post) surface.post(r.post)
30 })
31 // Once a second: a sync is due, and a redraw so elapsed times move.
32 surface.every(1000, () => {
33 sent = { ...sent, syncDue: true }
34 box.tick += 1
35 surface.setState({ box, n: ((surface.state as Local | undefined)?.n ?? 0) + 1 })
36 })
37 }
38 const local = surface.state as Local | undefined
39 const box = local?.box ?? created
40 const acked = new Set(model.acked ?? [])
41 if (box) {
42 box.pending = box.pending.filter((id) => !acked.has(id))
43 box.outbox = unseen(box.outbox, model.seen)
44 if (box.overlay && model.view.screen === box.overlay.screen && model.view.id === box.overlay.id && (model.view.key ?? null) === (box.overlay.key ?? null)) box.overlay = null
45 }
46 const send = (name: string, args: unknown[]) => {
47 if (!box) return
48 // A plan's Send (or a note's Save) carries the words as they are now, and the last words typed here may not be in this
49 // model's board yet: it takes them when they were typed under its own form. Anything else that moves the board drops them.
50 if (name === 'editNote') box.typed = { sprint: String(args[0]), confirm: model.board?.confirm ?? null, text: String(args[1] ?? '') }
51 else if (name === 'revisePlan' || name === 'addNote' || name === 'acceptTests' || name === 'reviewWithEvidence') {
52 const t = box.typed
53 const form = ({ revisePlan: 'revise', addNote: 'note', acceptTests: 'accept', reviewWithEvidence: 'evidence' } as Record<string, string>)[name]
54 if (t && t.sprint === args[0] && t.confirm === `${form}:${args[1]}`) args = [args[0], args[1], t.text]
55 box.typed = null
56 } else if (NAV_ACTIONS.has(name) || ['askConfirm', 'openPicker'].includes(name)) box.typed = null
57 box.seq += 1
58 const id = `${Date.now()}-${box.seq}`
59 if (NAV_ACTIONS.has(name)) box.overlay = overlayFor(name, args) as PaneModel['view']
60 box.outbox = enqueue(box.outbox, { kind: 'act', id, name, args })
61 if (!NAV_ACTIONS.has(name) && !EDIT_ACTIONS.has(name) && !QUIET_ACTIONS.has(name)) box.pending.push(id)
62 // Called from a press, not while drawing: safe to set state here.
63 surface.setState({ box, n: (local?.n ?? 0) + 1 })
64 }
65 const go: Record<string, (...args: unknown[]) => void> = {}
66 for (const name of HANDLER_NAMES) go[name] = (...args: unknown[]) => send(name, args)
67 const shown: PaneModel = box?.overlay ? { ...model, view: box.overlay, notice: null } : model
68 const { Box, Text } = surface.elements
69 const p = PALETTES[model.palette]
70 const ui = clientLook(surface.elements, p, surface.columns)
71 const now = Date.now()
72 const draw = (m: PaneModel) => (
73 <Box flexDirection="column">
74 {box && box.pending.length ? <Text key="working" color={p.dim}>Working…</Text> : null}
75 {drawPane(clientLook(surface.elements, p, surface.columns), m, go as any, now)}
76 </Box>
77 )
78 const tree = draw(shown)
79 // The page throws away a whole tree it cannot draw (a blank pane that reopens blank), so never hand it one:
80 // say why instead, with Back to the list.
81 const problem = pageProblem(tree)
82 if (!problem) return tree
83 // The list is where Back leads, so it never shows the note (its Back would lead to the same list, a loop): past the
84 // page's bounds it draws the short list (safeList: the header and a page of sprint rows, no job rows), and if
85 // even that can't be drawn, the note with no Back.
86 const isList = isListView(shown.view)
87 if (isList) {
88 const short = draw(safeList(shown) as PaneModel)
89 if (!pageProblem(short)) return short
90 }
91 return <Box flexDirection="column">{drawFailed(ui, shown, go as any, plainProblem(problem), `The screen has ${problem}.`, !isList)}</Box>
92}
93hooks/theme.ts 20 lines1// Colours for the pane: a tinted tile with a matching edge per state.
2// Every value is a theme key, not a hex colour: the drawing surface resolves keys for
3// the app's own light or dark mode, which a plugin cannot read. Chosen from a live probe
4// of the desktop app in both modes (2026-10-05). The theme has no soft amber, so
5// "needs you" uses a neutral tile with an amber edge and title.
6export type Tone = 'needs' | 'working' | 'done' | 'failed' | 'neutral' | 'ok' | 'warn' | 'off'
7export type Swatch = { bg: string; edge: string; text: string }
8export type Palette = Record<Tone, Swatch> & { dim: string; accent: string }
9
10const needs = { bg: 'userMessageBackground', edge: 'warning', text: 'warning' }
11const working = { bg: 'background', edge: 'suggestion', text: 'suggestion' }
12const done = { bg: 'diffAddedDimmed', edge: 'success', text: 'success' }
13const failed = { bg: 'diffRemovedDimmed', edge: 'error', text: 'error' }
14const neutral = { bg: 'userMessageBackground', edge: 'promptBorder', text: 'text' }
15
16const KEYED: Palette = { needs, working, done, failed, neutral, ok: done, warn: needs, off: neutral, dim: 'inactive', accent: 'claude' }
17
18// Kept as two names so callers that pick by theme still work; both resolve per mode.
19export const PALETTES: { dark: Palette; light: Palette } = { dark: KEYED, light: KEYED }
20