SLOPSHOPPER

podium

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

newpaneguardcommandtoaststatus
★ 14v0.5.0MITupdated 2026-10-20madjagstudios/podium
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · podium
│ ┃ Podium ✕ › fix the failing auth test and add an audit log call │ ┃ Podium Agents Settings [ + New job ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Podium's engine gave an unreadable answer ⎿ Read 6 lines │ ┃ (exit 0). ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No jobs in this chat yet. Ask Claude to hand ⏺ Bash(bun test) │ ┃ work to another agent, or press + New job. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /podium │ ⎿ podium: Podium pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Podium
Podium Agents Settings [ + New job ] Podium's engine gave an unreadable answer (exit 0). No jobs in this chat yet. Ask Claude to hand work to another agent, or press + New job.
README

Podium

CI Release License: MIT Claude Code mod

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.

What it looks like

Claude hands a read-only review to Codex; the Podium pane shows it working, then the result

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.

Install

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.

  1. In Claude Code, run /plugin marketplace add madjagstudios/plugins.
  2. Run /plugin install podium@madjagstudios.
  3. Restart Claude Code and run /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.

Your files stay yours

  • An agent that may edit works in a separate copy of your repo, with its own sandbox switched on. Nothing touches your folder until you keep the changes.
  • The copy starts from your last commit. Uncommitted work is invisible to the agent unless you tick Include my uncommitted changes.
  • Reviews and questions run read only, in place.
  • Every job shows its permission: Read only, Edit in a separate copy (the default), Agent's own settings, or Full access. The last two are opt-in, one job at a time.
  • If the agent commits on a branch itself, those commits are the result, and Podium removes its copy.

Pull requests

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.

Sprints

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:

  • Each ticket is worked task by task in its own copy, and a second agent reviews every task.
  • A ticket without a plan is planned first, and you approve the plan.
  • Writers report whether their tests passed. A ticket whose tests didn't pass stops and asks you, instead of going to review.
  • Before a PR opens, Podium can run your repo's full check (for example dotnet build && dotnet test).
  • Each ticket ends as one PR with a single KEY: title commit and a plain description.
  • A ticket can wait for another ticket to merge first.

Per repo, Settings → This repo sets:

  • which commands Claude may run in its copy;
  • the full check;
  • whether PRs are squashed to one commit;
  • how they merge.

Using it day to day

  • Ask Claude in plain words, press + New job in the pane, or run /podium run <agent> <task>.
  • To change finished work, press Ask for changes… on its screen. The next step runs in the same copy, with whichever agent you pick.
  • Agents in the pane shows what's installed, sets each agent's default permission, and adds any other command-line agent without editing files.
  • Settings → Instructions for every agent is added to the start of every job's prompt, for example "Follow AGENTS.md".
  • /podium status lists jobs, and /podium cancel <id> stops one.

Developing

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.

Credits

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.

License

MIT. See LICENSE.

Source 15 files
hooks/register.tsx 1027 lines
1// 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}
1027
mod/engine.js 24 lines
1// 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}
24
mod/delivery.js 143 lines
1// 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}
143
mod/commands.js 15 lines
1export 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}
15
mod/tools.js 272 lines
1import { 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}
272
mod/view.js 1065 lines
1// 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(' · ');
1065
hooks/state.ts 35 lines
1// 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
35
hooks/screens.tsx 1384 lines
1// 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 lines
1// 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}
811
mod/jobopen.js 46 lines
1// 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);
46
hooks/client/app.tsx 93 lines
1// 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}
93
hooks/theme.ts 20 lines
1// 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