SLOPSHOPPER

ahrena-console-painel

Teste do Ahrena Console: painel lateral que lê o núcleo local

panecommandstatusnetworktimer
v0.0.2Apache-2.0updated 2026-10-19ahrenatechnology/ahrena-console/spikes/painel-teste
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ahrena-console-painel
│ ┃ Ahrena Console ✕ › fix the failing auth test and add an audit log call │ ┃ Ahrena Console │ ┃ sem leitura do núcleo ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /console │ ⎿ ahrena-console-painel: Painel do Ahrena Console aberto. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Ahrena Console
Ahrena Console sem leitura do núcleo
README

Ahrena Console

Console para ver e controlar as sessões e os agentes do Claude Code, de dentro do Claude Desktop.

Versão 0.0.1, em construção. O núcleo local e o painel da sessão já funcionam; o app de bandeja vem em seguida.

Premissas

  • MCP first. Tudo que o console sabe e faz passa por um servidor MCP. O Claude consulta e age por ele na conversa.
  • Dentro do Claude Desktop. A interface principal abre no próprio app: artefato, painel ou UI inline. Nada de depender de terminal ou TUI.
  • Standalone é opcional. Pode existir uma interface web separada, mas ela não é o centro.
  • Projeto novo. Podium e Claude Code Agent Monitor são referências de ideias, não base de código.
  • Interface e marca. Visual na linha do Claude Code Agent Monitor, com a marca da Ahrena (a mesma do The Forward Deployed Engineer). Detalhes em docs/design.md.

Painel lateral

O painel da sessão (/console) está em plugins/ahrena-console, sobre a API local do núcleo (packages/nucleo).

1. Instalar o plugin

No Claude Code:

/plugin marketplace add ahrenatechnology/ahrena-console
/plugin install ahrena-console@ahrena-console

Depois, /reload-plugins e /console abre o painel.

2. Subir o núcleo

O painel lê a API local do núcleo em 127.0.0.1:4720. Precisa do Node 24 ou mais novo e não tem dependências para instalar:

git clone https://github.com/ahrenatechnology/ahrena-console.git
cd ahrena-console/packages/nucleo && npm run api

Ele importa o histórico de ~/.claude/projects, recebe os hooks do plugin e responde ao painel; o banco fica em ~/.ahrena-console/nucleo.sqlite. Para ver o painel com dados de exemplo, sem tocar em nada real: npm run api -- --demo --scenario p1.

Outras opções (porta, sem GitHub, sem histórico) e o que cada tela mostra: plugins/ahrena-console/README.md.

Em aberto

  • Onde roda: só local, só nuvem, ou híbrido (agente local em cada máquina mais um hub para time e clientes).
  • Escopo da 0.0.1: uso interno da Ahrena ou já com clientes.
  • Se os agentes headless de produção entram na 0.0.1.

Proposta

docs/proposta-0.0.1.md: arquitetura da 0.0.1 (local-first, núcleo único, superfícies no Claude Desktop) e o recorte de features.

Backlog

docs/backlog-0.0.1.md: a 0.0.1 na linha do Claude Code Agent Monitor, local primeiro (M1) e AWS serverless em seguida (M2).

Validações

docs/validacoes-0.0.1.md: o que funcionou nos testes de painel, artefato, UI inline e ferramentas internas do Desktop. O código dos testes está em spikes/.

Benchmark

O estudo das referências fica num repositório interno da Ahrena. Os documentos daqui o citam como "benchmark interno".

Licença

Apache-2.0. Copyright 2026 Ahrena.

Source 4 files
hooks/register.tsx 833 lines
1// Painel do Ahrena Console (teste): abas Esta sessão, Visão geral, Kanban, Trabalho e
2// Sessões, montadas com os elementos que a aba Code do Claude Desktop oferece a um mod.
3//
4// Consumo (issue #81, decisão 0004): o painel só consulta o núcleo enquanto está aberto
5// e à vista. Abre com /console (ou desenhado de novo depois de um reload) e para quando
6// fecha, sai de cena ou a sessão termina; sessão sem painel aberto não faz nenhum pedido.
7// A cada 5 s pede só /api/sessao/<id> (esta sessão e quantas precisam de você). A frota
8// (/api/fleet), o trabalho (/api/work, 60 s) e o kanban (/api/kanban, 2 min) só são
9// pedidos com a aba deles à vista; mover um card chama /api/kanban/move.
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface as Engine, Register, RenderElement } from 'claude-code'
12
13import type { Fleet, Kanban, SessionPulse, KanbanCard, KanbanLane, Reading, SessionRow, Tab, View, Work, WorkNode } from '../types'
14import { paleta } from './marca'
15
16const PANE = 'ahrena-console'
17const CORE = 'http://127.0.0.1:4720'
18const REPO = 'https://github.com/ahrenatechnology/ahrena-console'
19const EVERY_MS = 5000
20const WORK_EVERY_MS = 60_000
21// O mesmo TTL do Project no núcleo (decisão 0001): reler antes disso sairia do cache.
22const KANBAN_EVERY_MS = 2 * 60_000
23
24const ORANGE = paleta.orange['500']
25const PINK = paleta.rose['500']
26const YELLOW = paleta.yellow['500']
27const GREEN = paleta.green['500']
28const VIOLET = '#9085e9'
29const RED = paleta.red['500']
30const MUTED = paleta.gray['200']
31const STATE_COLOR: Record<SessionRow['state'], string> = { ativa: ORANGE, ociosa: YELLOW, encerrada: MUTED }
32const STAGES = ['a fazer', 'em andamento', 'ci rodando', 'ci falhou', 'ajustes pedidos', 'em revisão', 'mergeado', 'concluída'] as const
33const STAGE_COLOR: Record<string, string> = {
34  'a fazer': MUTED,
35  'em andamento': YELLOW,
36  'ci rodando': YELLOW,
37  'ci falhou': RED,
38  'ajustes pedidos': RED,
39  'em revisão': ORANGE,
40  mergeado: GREEN,
41  concluída: GREEN,
42}
43// Cor e rótulo de cada tipo de issue: o nível na hierarquia salta aos olhos no card.
44const TYPE_STYLE: Record<string, { color: string; label: string }> = {
45  Initiative: { color: ORANGE, label: 'Iniciativa' },
46  'Epic / Milestone': { color: PINK, label: 'Épico' },
47  Epic: { color: PINK, label: 'Épico' },
48  Feature: { color: VIOLET, label: 'Feature' },
49  'User Story': { color: VIOLET, label: 'História' },
50  Task: { color: '#5aa9e6', label: 'Tarefa' },
51  'Tech Task': { color: '#5aa9e6', label: 'Tarefa técnica' },
52  Spike: { color: YELLOW, label: 'Spike' },
53  Bug: { color: PINK, label: 'Bug' },
54  Plan: { color: MUTED, label: 'Plano' },
55}
56// Raias do kanban: cada estágio cai numa raia.
57const LANES: { id: string; label: string; color: string; stages: string[] }[] = [
58  { id: 'fazer', label: 'A fazer', color: MUTED, stages: ['a fazer'] },
59  { id: 'andamento', label: 'Em andamento', color: YELLOW, stages: ['em andamento', 'ci rodando'] },
60  { id: 'revisao', label: 'Em revisão', color: ORANGE, stages: ['em revisão', 'ajustes pedidos', 'ci falhou'] },
61  { id: 'feito', label: 'Feito', color: GREEN, stages: ['mergeado', 'concluída'] },
62]
63const LANE_LIMIT = 8
64// Cores das opções de um campo do GitHub Project.
65const GH_COLOR: Record<string, string> = {
66  GRAY: MUTED, BLUE: '#5aa9e6', GREEN: GREEN, YELLOW: YELLOW, ORANGE: ORANGE, RED: RED, PINK: PINK, PURPLE: VIOLET,
67}
68const KIND_STYLE: Record<KanbanCard['kind'], { color: string; label: string } | null> = {
69  Issue: null,
70  PullRequest: { color: GREEN, label: 'PR' },
71  DraftIssue: { color: MUTED, label: 'Rascunho' },
72}
73const short = (s: string, max: number) => (s.length > max ? `${s.slice(0, max - 1)}…` : s)
74// Abaixo desta largura (em colunas) as raias do kanban ficam empilhadas na vertical.
75const KANBAN_WIDE_COLUMNS = 150
76// Tamanho único dos cards, medido no painel do Claude Desktop (estreito ≈ 38 colunas,
77// expandido ≈ 121): a largura da tela muda quantos cabem por linha, nunca o tamanho.
78const CARD_W = 34
79const CARD_H = 6
80const KPI_W = 16
81const KPI_H = 4
82const laneOf = (stage: string) => LANES.find(l => l.stages.includes(stage)) ?? LANES[0]!
83// Selo do CI com cor semântica: verde passou, vermelho falhou, amarelo rodando.
84function ciBadge(ci: string | null) {
85  if (!ci) return null
86  if (ci === 'SUCCESS') return { text: 'CI ✓', color: GREEN }
87  if (ci === 'FAILURE' || ci === 'ERROR') return { text: 'CI ✕', color: RED }
88  return { text: 'CI …', color: YELLOW }
89}
90const typeStyle = (t: string | null) => (t && TYPE_STYLE[t]) || { color: MUTED, label: t ?? 'Issue' }
91const TABS: { id: Tab; label: string }[] = [
92  { id: 'sessao', label: 'Esta sessão' },
93  { id: 'visao', label: 'Visão geral' },
94  { id: 'kanban', label: 'Kanban' },
95  { id: 'trabalho', label: 'Trabalho' },
96  { id: 'sessoes', label: 'Sessões' },
97]
98
99const EMPTY_READING: Reading = {
100  session: null, fleet: null, work: null, kanban: null, error: null, workError: null, kanbanError: null, moving: null, polls: 0, latencies: [],
101}
102const EMPTY_FLEET: Fleet = {
103  totals: { projects: 0, sessions: 0, active: 0, idle: 0, sizeMb: 0 }, recent: [], activityByHour: [], topProjects: [], generatedAt: '',
104}
105const EMPTY_VIEW: View = {
106  tab: 'sessao', filter: 'todas', query: '', selected: null, stage: 'todas', org: 'todas', issueQuery: '', selectedIssue: null,
107  workMode: 'kanban', expanded: [], fullLanes: [],
108}
109
110const reading = atom({ plugin: 'ahrena-console-painel', key: 'reading' } as const, EMPTY_READING)
111const view = atom({ plugin: 'ahrena-console-painel', key: 'view' } as const, EMPTY_VIEW)
112
113// O relógio do polling: existe só enquanto o painel está aberto (startPolling/stopPolling).
114let ticker: { cancel: () => void } | undefined
115let sessionId: string | undefined
116let lastWorkAt = 0
117let lastKanbanAt = 0
118// Largura que o painel recebeu no último desenho; vai ao núcleo para calibrar o layout.
119let lastBodyColumns: number | null = null
120
121// Estado salvo por uma versão anterior do painel pode vir sem campos novos.
122function normalize(value: unknown): Reading {
123  const raw = value as Partial<Reading> | undefined
124  return {
125    ...EMPTY_READING,
126    ...raw,
127    session: raw?.session && typeof raw.session.needsYou === 'number' ? raw.session : null,
128    fleet: raw?.fleet?.totals ? raw.fleet : null,
129    work: raw?.work?.nodes ? raw.work : null,
130    kanban: raw?.kanban?.lanes ? raw.kanban : null,
131    latencies: raw?.latencies ?? [],
132  }
133}
134
135async function readingNow($: Engine): Promise<Reading> {
136  return normalize(await read($, reading))
137}
138
139// Cada leitura grava só a parte dela, sobre o estado do momento da gravação:
140// leituras em paralelo (sessões e trabalho) não apagam umas às outras.
141async function patchReading($: Engine, fn: (r: Reading) => Partial<Reading>) {
142  await update($, reading, cur => {
143    const r = normalize(cur)
144    return { ...r, ...fn(r) }
145  })
146}
147
148// Lê a resposta do núcleo sem quebrar: corpo vazio ou erro viram uma mensagem legível,
149// e quem chamou mantém o último dado bom na tela.
150function readJson<T>(res: { ok: boolean; status: number; text: string }): { data: T | null; error: string | null } {
151  if (!res.text) return { data: null, error: `HTTP ${res.status}: resposta vazia` }
152  try {
153    const json = JSON.parse(res.text) as T & { error?: string; stale?: boolean; staleReason?: string }
154    if (!res.ok) return { data: null, error: json.error ?? `HTTP ${res.status}` }
155    return { data: json, error: json.stale ? `mostrando dados antigos: ${json.staleReason ?? 'GitHub indisponível'}` : null }
156  } catch {
157    return { data: null, error: `HTTP ${res.status}: resposta inválida` }
158  }
159}
160
161async function viewNow($: Engine): Promise<View> {
162  const v = { ...EMPTY_VIEW, ...((await read($, view)) as Partial<View> | undefined) }
163  return {
164    ...v,
165    tab: TABS.some(t => t.id === v.tab) ? v.tab : 'sessao',
166    expanded: Array.isArray(v.expanded) ? v.expanded : [],
167    fullLanes: Array.isArray(v.fullLanes) ? v.fullLanes : [],
168  }
169}
170
171const needsYouText = (n: number) => (n === 1 ? '1 precisa de você' : `${n} precisam de você`)
172
173// O pedido de todo tick: esta sessão e a contagem de quem precisa de você, nunca a frota.
174async function pollSession($: Engine) {
175  const t0 = await $.clock.now()
176  try {
177    sessionId ??= await $.session.id()
178    const res = await $.http.fetch(`${CORE}/api/sessao/${encodeURIComponent(sessionId)}`)
179    const ms = (await $.clock.now()) - t0
180    const { data: session, error } = readJson<SessionPulse>(res)
181    await patchReading($, r => ({
182      session: session ?? r.session,
183      error,
184      polls: r.polls + 1,
185      latencies: [...r.latencies, ms].slice(-24),
186    }))
187    $.ui.status(session ? `ahrena-console ● ${session.session?.state ?? 'sessão nova'} · ${needsYouText(session.needsYou)}` : 'ahrena-console ○ erro')
188  } catch (err) {
189    await patchReading($, r => ({ error: String(err), polls: r.polls + 1 }))
190    $.ui.status('ahrena-console ○ offline')
191  }
192}
193
194// A frota inteira: só com a aba Visão geral ou Sessões à vista (elas vão para o artefato, 3.10).
195async function pollFleet($: Engine) {
196  try {
197    const res = await $.http.fetch(`${CORE}/api/fleet${lastBodyColumns ? `?cols=${lastBodyColumns}` : ''}`)
198    const { data: fleet, error } = readJson<Fleet>(res)
199    await patchReading($, r => ({ fleet: fleet ?? r.fleet, error }))
200  } catch (err) {
201    await patchReading($, () => ({ error: String(err) }))
202  }
203}
204
205async function pollWork($: Engine) {
206  try {
207    const res = await $.http.fetch(`${CORE}/api/work`)
208    const { data: work, error } = readJson<Work>(res)
209    await patchReading($, r => ({ work: work ?? r.work, workError: error }))
210  } catch (err) {
211    await patchReading($, () => ({ workError: String(err) }))
212  }
213}
214
215// O quadro do Project. `force` é o botão de atualizar (o núcleo respeita o mínimo de 30 s).
216async function pollKanban($: Engine, force = false) {
217  try {
218    const res = await $.http.fetch(`${CORE}/api/kanban${force ? '?force=1' : ''}`)
219    const { data: kanban, error } = readJson<Kanban>(res)
220    await patchReading($, r => ({ kanban: kanban ?? r.kanban, kanbanError: error }))
221  } catch (err) {
222    await patchReading($, () => ({ kanbanError: String(err) }))
223  }
224}
225
226// Move um card de raia: o núcleo muda o Status no GitHub e devolve o quadro já atualizado,
227// sem reler o Project. Enquanto isso, o card mostra que está esperando.
228async function moveCard($: Engine, itemId: string, optionId: string) {
229  if ((await readingNow($)).moving) return
230  await patchReading($, () => ({ moving: itemId }))
231  try {
232    const res = await $.http.fetch(`${CORE}/api/kanban/move`, {
233      method: 'POST',
234      headers: { 'content-type': 'application/json' },
235      body: JSON.stringify({ itemId, optionId }),
236    })
237    const { data: kanban, error } = readJson<Kanban>(res)
238    await patchReading($, r => ({ kanban: kanban ?? r.kanban, kanbanError: error && `Não deu para mover: ${error}`, moving: null }))
239  } catch (err) {
240    await patchReading($, () => ({ kanbanError: `Não deu para mover: ${String(err)}`, moving: null }))
241  }
242}
243
244const FLEET_TABS: Tab[] = ['visao', 'sessoes']
245
246// Uma rodada: a sessão sempre; o resto só se a aba à vista usa. `force` (botão, troca de
247// aba) ignora a validade do trabalho e do kanban; o timer respeita.
248async function pollNow($: Engine, force = false) {
249  const v = await viewNow($)
250  const now = await $.clock.now()
251  const jobs = [pollSession($)]
252  if (FLEET_TABS.includes(v.tab)) jobs.push(pollFleet($))
253  if (v.tab === 'trabalho' && (force || now - lastWorkAt >= WORK_EVERY_MS)) {
254    lastWorkAt = now
255    jobs.push(pollWork($))
256  }
257  if (v.tab === 'kanban' && (force || now - lastKanbanAt >= KANBAN_EVERY_MS)) {
258    lastKanbanAt = now
259    jobs.push(pollKanban($, force))
260  }
261  await Promise.all(jobs)
262}
263
264// O painel deste plugin como o engine o vê: ausente quando fechado.
265async function ourPane($: Engine) {
266  return (await $.ui.panes()).find(p => p.id === PANE)
267}
268
269// Cada tick confere o painel antes de pedir: fechado ou não desenhado, o polling para;
270// aberto atrás de outra aba, o tick passa sem pedir nada.
271async function tick($: Engine) {
272  try {
273    const pane = await ourPane($)
274    if (!pane?.isPlaced) return stopPolling($)
275    if (!pane.isShown) return
276    await pollNow($)
277  } catch (err) {
278    await patchReading($, () => ({ error: String(err) }))
279  }
280}
281
282function startPolling($: Engine) {
283  if (ticker) return
284  lastWorkAt = 0
285  lastKanbanAt = 0
286  ticker = $.clock.every(EVERY_MS, () => void tick($))
287  void tick($)
288}
289
290function stopPolling($: Engine) {
291  if (!ticker) return
292  ticker.cancel()
293  ticker = undefined
294  $.ui.status(undefined)
295}
296
297async function setView($: Engine, patch: Partial<View>) {
298  const v = await viewNow($)
299  await update($, view, () => ({ ...v, ...patch }))
300  // Trocar de aba busca na hora o que a aba nova mostra.
301  if (patch.tab && patch.tab !== v.tab) await pollNow($, true)
302}
303
304async function toggle($: Engine, key: string) {
305  const v = await viewNow($)
306  const expanded = v.expanded.includes(key) ? v.expanded.filter(k => k !== key) : [...v.expanded, key]
307  await update($, view, () => ({ ...v, expanded }))
308}
309
310function ago(iso: string) {
311  const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000))
312  if (s < 60) return `${s}s`
313  if (s < 3600) return `${Math.round(s / 60)}min`
314  if (s < 86400) return `${Math.round(s / 3600)}h`
315  return `${Math.round(s / 86400)}d`
316}
317
318function logoSvg() {
319  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="${ORANGE}"/><path d="M9 23 L16 8 L23 23" stroke="${paleta.gray['900']}" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 16.5 L19 16.5 L16 21.5 Z" fill="${PINK}" stroke="${PINK}" stroke-width="1" stroke-linejoin="round"/></svg>`
320}
321
322function barsSvg(values: number[]) {
323  const max = Math.max(1, ...values)
324  const w = 12 * values.length
325  const bars = values
326    .map((v, i) => {
327      const h = Math.max(2, Math.round((v / max) * 36))
328      return `<rect x="${i * 12 + 2}" y="${40 - h}" width="8" height="${h}" rx="2" fill="${i === values.length - 1 ? ORANGE : PINK}"/>`
329    })
330    .join('')
331  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} 42">${bars}<line x1="0" y1="41" x2="${w}" y2="41" stroke="${MUTED}" stroke-width="0.5"/></svg>`
332}
333
334function sparkSvg(values: number[]) {
335  if (values.length < 2) return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"></svg>`
336  const max = Math.max(1, ...values)
337  const pts = values.map((v, i) => `${(i / (values.length - 1)) * 100},${18 - (v / max) * 16}`).join(' ')
338  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><polyline points="${pts}" fill="none" stroke="${ORANGE}" stroke-width="1.5"/></svg>`
339}
340
341// Barra de progresso das sub-issues: a parte concluída na cor do tipo.
342function progressSvg(pct: number, color: string) {
343  const w = Math.round(Math.max(0, Math.min(100, pct)) * 1.6)
344  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 6"><rect x="0" y="0" width="160" height="6" rx="3" fill="${MUTED}" fill-opacity="0.25"/><rect x="0" y="0" width="${w}" height="6" rx="3" fill="${color}"/></svg>`
345}
346
347// Barra empilhada com a distribuição por estágio.
348function stagesSvg(byStage: Record<string, number>) {
349  const total = Math.max(1, STAGES.reduce((n, s) => n + (byStage[s] ?? 0), 0))
350  let x = 0
351  const parts = STAGES.filter(s => byStage[s])
352    .map(s => {
353      const w = ((byStage[s] ?? 0) / total) * 300
354      const rect = `<rect x="${x}" y="0" width="${Math.max(w - 2, 1)}" height="10" rx="2" fill="${STAGE_COLOR[s]}"/>`
355      x += w
356      return rect
357    })
358    .join('')
359  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 10">${parts}</svg>`
360}
361
362function prLine(n: WorkNode) {
363  const p = n.prs.find(x => x.state === 'OPEN') ?? n.prs[0]
364  if (!p) return null
365  return `PR #${p.number} · ${p.isDraft ? 'rascunho' : p.state === 'OPEN' ? 'aberto' : p.state === 'MERGED' ? 'mergeado' : 'fechado'}`
366}
367
368export const register: Register = on => {
369  // A sessão começa sem consultar nada: só registra o /console. Se o painel já estava
370  // aberto (o módulo recarregou com ele à vista), retoma o polling.
371  on('session.start', async ($, e, next) => {
372    await $.command.register({ name: 'console', description: 'Abre o painel do Ahrena Console' })
373    if ((await ourPane($).catch(() => undefined))?.isPlaced) startPolling($)
374    return next(e)
375  })
376
377  on('command.run', { command: 'console' }, async $ => {
378    const { isPlaced } = await $.ui.open({ id: PANE, title: 'Ahrena Console' })
379    if (isPlaced) startPolling($)
380    return { text: isPlaced ? 'Painel do Ahrena Console aberto.' : 'Esta superfície não desenha painéis.' }
381  })
382
383  // Fechou (a pessoa, o plugin ou um unload): o polling para na hora.
384  on('ui.close', async ($, e, next) => {
385    if (e.id === PANE) stopPolling($)
386    return next(e)
387  })
388
389  on('session.end', async ($, e, next) => {
390    stopPolling($)
391    return next(e)
392  })
393
394  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
395    const r = await readingNow($)
396    if (e.surface !== 'desktop') {
397      const { Box, Text } = $.ui.resolve(e)
398      const p = r.session
399      return (
400        <Box flexDirection="column">
401          <Text bold color={ORANGE}>Ahrena Console</Text>
402          <Text>{p ? `Esta sessão: ${p.session?.state ?? 'nova'} · ${needsYouText(p.needsYou)}` : 'sem leitura do núcleo'}</Text>
403        </Box>
404      )
405    }
406    const { Box, Text, Button, Input, Select, Svg, Link, Code, Markdown, Client } = $.ui.resolve(e)
407    const v = await viewNow($)
408    const f = r.fleet
409    const w = r.work
410
411    // Layout responsivo: a largura do painel (em colunas) decide estreito, médio ou largo.
412    // Gráficos em SVG crescem com o painel; recuos e larguras mínimas encolhem.
413    const cols = e.props.bodyColumns ?? 60
414    lastBodyColumns = cols
415    const narrow = cols < 90
416    const PX_PER_COL = 6
417    const svgW = (max: number) => Math.max(60, Math.min(max, Math.floor((cols - 8) * PX_PER_COL)))
418    const indent = (depth: number) => Math.min(depth, 3) * (narrow ? 1 : 2)
419
420    const header = (
421      <Box flexDirection="column" gap="sm">
422        <Box flexDirection="row" alignItems="center" gap="sm">
423          <Svg source={logoSvg()} alt="Ahrena Console" width={28} height={28} />
424          <Box flexDirection="column" flexGrow={1}>
425            <Text size="heading" weight="bold" color={ORANGE}>Ahrena Console</Text>
426            <Text size="caption" dimColor>v0.0.1 · teste</Text>
427          </Box>
428          <Link href={REPO} label="GitHub" />
429        </Box>
430        <Client key="pulso" module="./pulso.tsx" props={{ generatedAt: r.session?.generatedAt ?? null }} />
431        <Box flexDirection="row" gap="xs" flexWrap="wrap">
432          {TABS.map(t => (
433            <Button key={`tab-${t.id}`} label={t.label} variant={v.tab === t.id ? 'primary' : 'secondary'} onPress={() => setView($, { tab: t.id })} />
434          ))}
435        </Box>
436      </Box>
437    )
438
439    const p = r.session
440    if (!p || (!f && FLEET_TABS.includes(v.tab))) {
441      return (
442        <Box flexDirection="column" gap="md" padding="sm">
443          {header}
444          <Text color={PINK}>Sem conexão com o núcleo: {r.error ?? 'aguardando a primeira leitura'}</Text>
445          <Button key="retry" label="Tentar de novo" variant="primary" onPress={() => pollNow($, true)} />
446        </Box>
447      )
448    }
449
450    // Abas que não mostram a frota desenham sem ela; as que mostram já saíram acima sem ela.
451    const fl = f ?? EMPTY_FLEET
452
453    // Esta sessão: o que o painel pede a cada 5 s. O redesenho completo é a 4.1.
454    const me = p.session
455    const sessao = (
456      <Box flexDirection="column" gap="sm">
457        {me ? (
458          <Box flexDirection="column" gap="xs">
459            <Text weight="bold" color={STATE_COLOR[me.state]}>● {me.state} · última escrita há {ago(me.lastActivity)}</Text>
460            <Text size="caption" dimColor wrap="truncate-end">{me.project}</Text>
461            <Text size="caption" dimColor tabular>transcript {me.sizeKb} KB</Text>
462          </Box>
463        ) : (
464          <Text dimColor>Esta sessão ainda não tem transcript.</Text>
465        )}
466        <Text weight="semibold" color={p.needsYou ? YELLOW : MUTED}>⚠ {needsYouText(p.needsYou)}</Text>
467        <Text size="caption" dimColor>Estimativa pelo transcript: sessões que pararam há pouco, à espera da próxima mensagem.</Text>
468      </Box>
469    )
470
471    const avgMs = r.latencies.length ? Math.round(r.latencies.reduce((a, b) => a + b, 0) / r.latencies.length) : 0
472    const nodes = w ? Object.values(w.nodes) : []
473    const openLeafs = nodes.filter(n => n.state === 'OPEN' && !n.children.length)
474    const byStage: Record<string, number> = {}
475    for (const n of openLeafs) byStage[n.stage] = (byStage[n.stage] ?? 0) + 1
476
477    // Card de uma issue: tipo em destaque, título, estágio, progresso das filhas e PR.
478    // Card único de item de trabalho, o mesmo em todas as visões (Kanban, árvore, por
479    // estágio): sempre 4 linhas, na altura exata do conteúdo, e a mesma largura.
480    const card = (n: WorkNode, depth: number) => {
481      const ts = typeStyle(n.type)
482      const isOpen = v.expanded.includes(n.key)
483      const openPr = n.prs.find(x => x.state === 'OPEN') ?? n.prs[0]
484      const ci = ciBadge(openPr?.ci ?? null)
485      const lane = n.state === 'CLOSED' ? 'Feito' : laneOf(n.stage).label
486      return (
487        <Box key={`card-${n.key}`} flexDirection="column" marginLeft={indent(depth)} width={CARD_W} height={CARD_H} flexShrink={0} overflow="hidden" borderStyle="round" borderColor={ts.color} paddingX="xs">
488          <Box flexDirection="row" gap="xs" overflow="hidden">
489            <Text size="caption" weight="bold" color={ts.color}>{ts.label}</Text>
490            <Text size="caption" weight="semibold" color={laneOf(n.stage).color}>· {lane}</Text>
491            <Text size="caption" dimColor>· #{n.number}</Text>
492          </Box>
493          <Text size="footnote" weight="semibold" wrap="truncate-end">{n.title}</Text>
494          <Box flexDirection="row" gap="xs" overflow="hidden">
495            {n.progress && <Text size="caption" tabular color={ts.color}>{n.progress.completed}/{n.progress.total}</Text>}
496            {openPr && <Text size="caption" dimColor>PR #{openPr.number}</Text>}
497            {ci && <Text size="caption" weight="bold" color={ci.color}>{ci.text}</Text>}
498            {openPr?.review === 'CHANGES_REQUESTED' && <Text size="caption" weight="bold" color={RED}>ajustes</Text>}
499            {openPr?.review === 'APPROVED' && <Text size="caption" weight="bold" color={GREEN}>aprovado</Text>}
500            <Text size="caption" dimColor>{ago(n.updatedAt)}</Text>
501          </Box>
502          <Box flexDirection="row" gap="sm" overflow="hidden">
503            {n.children.length > 0 && (
504              <Button key={`toggle-${n.key}`} plain onPress={() => toggle($, n.key)}>
505                <Text color={ts.color}>{isOpen ? '▾' : '▸'}</Text> {isOpen ? 'Recolher' : `${n.children.length} ${n.children.length === 1 ? 'item' : 'itens'}`}
506              </Button>
507            )}
508            <Link href={n.url} label="Abrir" />
509          </Box>
510        </Box>
511      )
512    }
513
514    const matches = (n: WorkNode) =>
515      (v.org === 'todas' || n.repo.startsWith(`${v.org}/`)) &&
516      (!v.issueQuery || `${n.repo} ${n.title}`.toLowerCase().includes(v.issueQuery.toLowerCase()))
517
518    // Árvore: a raiz e, se expandida, as filhas, recursivamente.
519    const tree = (key: string, depth: number): RenderElement[] => {
520      const n = w?.nodes[key]
521      if (!n) return []
522      const out: RenderElement[] = [card(n, depth)]
523      if (v.expanded.includes(key)) for (const c of n.children) out.push(...tree(c, depth + 1))
524      return out
525    }
526
527    // Visão geral: números, atividade, distribuição do trabalho e projetos mais usados.
528    const visao = (
529      <Box flexDirection="column" gap="md">
530        <Box flexDirection="row" gap="sm" flexWrap="wrap">
531          {[
532            { k: 'Ativas', n: fl.totals.active, c: ORANGE },
533            { k: 'Ociosas', n: fl.totals.idle, c: YELLOW },
534            { k: 'Iniciativas', n: w?.byType.Initiative ?? 0, c: ORANGE },
535            { k: 'Itens abertos', n: openLeafs.length, c: PINK },
536          ].map(c => (
537            <Box key={c.k} flexDirection="column" borderStyle="round" borderColor={c.c} paddingX="sm" width={KPI_W} height={KPI_H} flexShrink={0} overflow="hidden">
538              <Text size="caption" dimColor>{c.k}</Text>
539              <Text size="title" weight="bold" tabular color={c.c}>{c.n}</Text>
540            </Box>
541          ))}
542        </Box>
543        <Box flexDirection="column" gap="xs">
544          <Text size="footnote" weight="semibold">Atividade nas últimas 12 h</Text>
545          <Svg source={barsSvg(fl.activityByHour)} alt={`Sessões com atividade por hora: ${fl.activityByHour.join(', ')}`} width={svgW(420)} height={46} />
546        </Box>
547        {w && (
548          <Box flexDirection="column" gap="xs">
549            <Text size="footnote" weight="semibold">Itens de trabalho abertos por estágio</Text>
550            <Svg source={stagesSvg(byStage)} alt={STAGES.filter(s => byStage[s]).map(s => `${s}: ${byStage[s]}`).join(', ')} width={svgW(480)} height={10} />
551            <Box flexDirection="row" gap="sm" flexWrap="wrap">
552              {STAGES.filter(s => byStage[s]).map(s => (
553                <Button key={`stage-${s}`} plain onPress={() => setView($, { tab: 'trabalho', workMode: 'estagio', stage: s })}>
554                  <Text color={STAGE_COLOR[s]}>●</Text> {s} <Text dimColor>{byStage[s]}</Text>
555                </Button>
556              ))}
557            </Box>
558          </Box>
559        )}
560        <Box flexDirection="row" alignItems="center" gap="sm" flexWrap="wrap">
561          <Text size="caption" dimColor>Latência do núcleo</Text>
562          <Svg source={sparkSvg(r.latencies)} alt={`Latência média ${avgMs} ms`} width={svgW(160)} height={20} />
563          <Text size="caption" tabular>{avgMs} ms</Text>
564        </Box>
565        <Markdown dimColor text={`**Projetos mais usados:** ${fl.topProjects.map(p => `${p.project} (${p.sessions})`).join(', ')}`} />
566      </Box>
567    )
568
569    // Sessões: filtro por estado, busca por projeto, lista e detalhe.
570    const rows = fl.recent
571      .filter(s => v.filter === 'todas' || s.state === v.filter)
572      .filter(s => !v.query || s.project.toLowerCase().includes(v.query.toLowerCase()))
573    const sel = fl.recent.find(s => s.id === v.selected) ?? null
574    const sessoes = (
575      <Box flexDirection="column" gap="md">
576        <Box flexDirection={narrow ? 'column' : 'row'} gap="sm" alignItems={narrow ? 'stretch' : 'flex-end'} flexWrap="wrap">
577          <Select
578            key="filtro"
579            label="Estado"
580            value={v.filter}
581            options={[
582              { value: 'todas', label: 'Todas' },
583              { value: 'ativa', label: 'Ativas' },
584              { value: 'ociosa', label: 'Ociosas' },
585              { value: 'encerrada', label: 'Encerradas' },
586            ]}
587            onSelect={(value: string) => setView($, { filter: value as View['filter'] })}
588          />
589          <Input key="busca" label="Projeto" placeholder="filtrar por nome" value={v.query} submitLabel="Filtrar" onInput={(value: string) => setView($, { query: value })} onSubmit={(value: string) => setView($, { query: value })} />
590        </Box>
591        <Box flexDirection="column">
592          <Text size="footnote" weight="semibold">Sessões recentes ({rows.length})</Text>
593          {rows.length === 0 && <Text dimColor>Nenhuma sessão com esse filtro.</Text>}
594          {rows.map(s => (
595            <Button key={s.id} plain onPress={() => setView($, { selected: v.selected === s.id ? null : s.id })}>
596              <Text color={STATE_COLOR[s.state]}>●</Text> <Text wrap="truncate-end">{s.project}</Text> <Text dimColor>{narrow ? ago(s.lastActivity) : `${s.state} · ${ago(s.lastActivity)}`}</Text>
597            </Button>
598          ))}
599        </Box>
600        {sel && (
601          <Box flexDirection="column" gap="xs" borderStyle="round" borderColor={PINK} padding="sm">
602            <Text weight="semibold" color={PINK}>{sel.project}</Text>
603            <Code language="json" source={JSON.stringify(sel, null, 2)} />
604            <Button key="close-session" label="Fechar detalhe" role="dismiss" onPress={() => setView($, { selected: null })} />
605          </Box>
606        )}
607      </Box>
608    )
609
610    // Quadro em raias, o mesmo para o Kanban do Project e para o fluxo do Trabalho.
611    // Responsivo: com o painel largo, as raias lado a lado, quebrando linha se não couberem
612    // (nunca estoura a largura); estreito, as raias empilhadas, cada uma com os cards embaixo.
613    // Cada raia mostra os primeiros cards e abre por inteiro no "+N mais".
614    type BoardLane = { id: string; label: string; color: string; cards: RenderElement[] }
615    const wide = (e.props.bodyColumns ?? 0) >= KANBAN_WIDE_COLUMNS
616    const laneHeader = (l: BoardLane) => (
617      <Box flexDirection="row" gap="xs" borderStyle="round" borderColor={l.color} paddingX="xs">
618        <Text size="footnote" weight="bold" color={l.color} wrap="truncate-end">{l.label}</Text>
619        <Text size="footnote" tabular dimColor>{l.cards.length}</Text>
620      </Box>
621    )
622    const laneFooter = (l: BoardLane) => {
623      const full = v.fullLanes.includes(l.id)
624      if (l.cards.length === 0) return <Text size="caption" dimColor>vazio</Text>
625      if (l.cards.length <= LANE_LIMIT) return null
626      return (
627        <Button key={`more-${l.id}`} plain onPress={() => setView($, { fullLanes: full ? v.fullLanes.filter(x => x !== l.id) : [...v.fullLanes, l.id] })}>
628          <Text size="caption" dimColor>{full ? '▴ mostrar menos' : `+${l.cards.length - LANE_LIMIT} mais`}</Text>
629        </Button>
630      )
631    }
632    const visible = (l: BoardLane) => (v.fullLanes.includes(l.id) ? l.cards : l.cards.slice(0, LANE_LIMIT))
633    const laneBoard = (prefix: string, lanes: BoardLane[]) =>
634      wide ? (
635        <Box key={`${prefix}-wide`} flexDirection="row" gap="xs" alignItems="flex-start" flexWrap="wrap">
636          {lanes.map(l => (
637            <Box key={`lane-${l.id}`} flexDirection="column" gap="xs" width={CARD_W} flexShrink={0}>
638              {laneHeader(l)}
639              {visible(l)}
640              {laneFooter(l)}
641            </Box>
642          ))}
643        </Box>
644      ) : (
645        <Box key={`${prefix}-narrow`} flexDirection="column" gap="md">
646          {lanes.map(l => (
647            <Box key={`lane-${l.id}`} flexDirection="column" gap="xs" minWidth={0} overflow="hidden">
648              {laneHeader(l)}
649              <Box flexDirection="row" gap="xs" flexWrap="wrap">{visible(l)}</Box>
650              {laneFooter(l)}
651            </Box>
652          ))}
653        </Box>
654      )
655
656    // Fluxo do Trabalho: as folhas da hierarquia (o que de fato se executa) por estágio do PR.
657    const fluxoBoard = laneBoard(
658      'fluxo',
659      LANES.map(l => ({
660        ...l,
661        cards: nodes.filter(n => !n.children.length && matches(n) && l.stages.includes(n.stage)).map(n => <Box key={`slot-${n.key}`} flexShrink={0}>{card(n, 0)}</Box>),
662      })),
663    )
664
665    // Kanban do GitHub Project: raias na ordem das opções do Status, e cada card com ◂ ▸
666    // para ir à raia vizinha, o que muda o Status no GitHub. Mesmo tamanho dos outros cards.
667    const k = r.kanban
668    const options = k ? k.lanes.filter((l): l is KanbanLane & { id: string } => l.id !== null) : []
669    const laneColor = (l: KanbanLane) => (l.color && GH_COLOR[l.color]) || MUTED
670    const kanbanCard = (c: KanbanCard, lane: KanbanLane) => {
671      const ts = KIND_STYLE[c.kind] ?? typeStyle(c.type)
672      const at = options.findIndex(o => o.id === lane.id)
673      const prev = at > 0 ? options[at - 1] : undefined
674      const next = at < 0 ? options[0] : options[at + 1]
675      const moving = r.moving === c.itemId
676      const parent = c.parent?.split('#')[1]
677      return (
678        <Box key={`card-${c.itemId}`} flexDirection="column" width={CARD_W} height={CARD_H} flexShrink={0} overflow="hidden" borderStyle="round" borderColor={ts.color} paddingX="xs">
679          <Box flexDirection="row" gap="xs" overflow="hidden">
680            <Text size="caption" weight="bold" color={ts.color}>{ts.label}</Text>
681            <Text size="caption" weight="semibold" color={laneColor(lane)} wrap="truncate-end">· {lane.name}</Text>
682            {c.number !== null && <Text size="caption" dimColor>· #{c.number}</Text>}
683          </Box>
684          <Text size="footnote" weight="semibold" wrap="truncate-end">{c.title}</Text>
685          <Box flexDirection="row" gap="xs" overflow="hidden">
686            {parent && <Text size="caption" dimColor>↑ #{parent}</Text>}
687            {c.state === 'CLOSED' && <Text size="caption" weight="bold" color={MUTED}>fechada</Text>}
688            {c.state === 'MERGED' && <Text size="caption" weight="bold" color={GREEN}>mergeado</Text>}
689            {c.updatedAt && <Text size="caption" dimColor>{ago(c.updatedAt)}</Text>}
690          </Box>
691          <Box flexDirection="row" gap="xs" overflow="hidden">
692            {moving && <Text size="caption" color={YELLOW}>movendo…</Text>}
693            {!moving && prev && (
694              <Button key={`move-prev-${c.itemId}`} plain onPress={() => moveCard($, c.itemId, prev.id)}>
695                <Text color={laneColor(prev)}>◂</Text> {short(prev.name, 9)}
696              </Button>
697            )}
698            {!moving && next && (
699              <Button key={`move-next-${c.itemId}`} plain onPress={() => moveCard($, c.itemId, next.id)}>
700                {short(next.name, 9)} <Text color={laneColor(next)}>▸</Text>
701              </Button>
702            )}
703            {c.url && <Link href={c.url} label="Abrir" />}
704          </Box>
705        </Box>
706      )
707    }
708    const kanbanMatches = (c: KanbanCard) =>
709      !v.issueQuery || `${c.repo ?? ''} ${c.number ?? ''} ${c.title}`.toLowerCase().includes(v.issueQuery.toLowerCase())
710    const kanbanBoard =
711      k &&
712      laneBoard(
713        'kanban',
714        k.lanes.map(l => ({
715          id: l.id ?? 'sem-status',
716          label: l.name,
717          color: laneColor(l),
718          cards: l.items.filter(kanbanMatches).map(c => kanbanCard(c, l)),
719        })),
720      )
721
722    // Trabalho: a hierarquia do GitHub em cards, como árvore ou agrupada por estágio.
723    const orgs = [...new Set(nodes.map(n => n.repo.split('/')[0] ?? ''))].sort()
724    const roots = (w?.roots ?? []).filter(k => w && matches(w.nodes[k] as WorkNode))
725    const withChildren = roots.filter(k => (w?.nodes[k]?.children.length ?? 0) > 0)
726    const loose = roots.filter(k => (w?.nodes[k]?.children.length ?? 0) === 0)
727    const stageCards = openLeafs.filter(matches).filter(n => v.stage === 'todas' || n.stage === v.stage)
728    const trabalho = (
729      <Box flexDirection="column" gap="md">
730        {r.workError && <Text size="caption" color={w ? YELLOW : PINK}>{w ? `⚠ ${r.workError}` : `Não deu para ler o GitHub: ${r.workError}`}</Text>}
731        {!w && !r.workError && <Text dimColor>Lendo a hierarquia do GitHub…</Text>}
732        {w && (
733          <Box flexDirection="column" gap="md">
734            <Text size="caption" dimColor>
735              {w.count} issues ligadas ao seu trabalho · {Object.entries(w.byType).map(([t, n]) => `${typeStyle(t === 'Sem tipo' ? null : t).label} ${n}`).join(' · ')} · lido há {ago(w.generatedAt)}
736            </Text>
737            <Box flexDirection={narrow ? 'column' : 'row'} gap="sm" alignItems={narrow ? 'stretch' : 'flex-end'} flexWrap="wrap">
738              <Select
739                key="modo"
740                label="Ver como"
741                value={v.workMode}
742                options={[
743                  { value: 'arvore', label: 'Árvore' },
744                  { value: 'estagio', label: 'Por estágio' },
745                  { value: 'kanban', label: 'Kanban' },
746                ]}
747                onSelect={(value: string) => setView($, { workMode: value as View['workMode'] })}
748              />
749              <Select
750                key="org"
751                label="Organização"
752                value={v.org}
753                options={[{ value: 'todas', label: 'Todas' }, ...orgs.map(o => ({ value: o, label: o }))]}
754                onSelect={(value: string) => setView($, { org: value })}
755              />
756              {v.workMode === 'estagio' && (
757                <Select
758                  key="stage"
759                  label="Estágio"
760                  value={v.stage}
761                  options={[{ value: 'todas', label: 'Todos' }, ...STAGES.map(s => ({ value: s, label: `${s} (${byStage[s] ?? 0})` }))]}
762                  onSelect={(value: string) => setView($, { stage: value })}
763                />
764              )}
765              <Input key="busca-issue" label="Busca" placeholder="título ou repositório" value={v.issueQuery} submitLabel="Buscar" onInput={(value: string) => setView($, { issueQuery: value })} onSubmit={(value: string) => setView($, { issueQuery: value })} />
766            </Box>
767            {v.workMode === 'arvore' && (
768              <Box flexDirection="column" gap="sm">
769                <Box flexDirection="row" gap="sm" flexWrap="wrap">
770                  <Button key="expand-all" plain onPress={() => setView($, { expanded: nodes.filter(n => n.children.length).map(n => n.key) })}>▾ Expandir tudo</Button>
771                  <Button key="collapse-all" plain onPress={() => setView($, { expanded: [] })}>▸ Recolher tudo</Button>
772                </Box>
773                {withChildren.length === 0 && loose.length === 0 && <Text dimColor>Nada com esses filtros.</Text>}
774                {withChildren.flatMap(k => tree(k, 0))}
775                {loose.length > 0 && <Text size="footnote" weight="semibold">Fora de uma iniciativa ({loose.length})</Text>}
776                <Box flexDirection="row" gap="xs" flexWrap="wrap">{loose.slice(0, 15).map(k => card(w.nodes[k] as WorkNode, 0))}</Box>
777              </Box>
778            )}
779            {v.workMode === 'kanban' && fluxoBoard}
780            {v.workMode === 'estagio' && (
781              <Box flexDirection="column" gap="sm">
782                {STAGES.filter(s => stageCards.some(n => n.stage === s)).map(s => (
783                  <Box key={`grp-${s}`} flexDirection="column" gap="xs">
784                    <Text size="footnote" weight="semibold" color={STAGE_COLOR[s]}>● {s} ({stageCards.filter(n => n.stage === s).length})</Text>
785                    <Box flexDirection="row" gap="xs" flexWrap="wrap">{stageCards.filter(n => n.stage === s).slice(0, 12).map(n => card(n, 0))}</Box>
786                  </Box>
787                ))}
788                {stageCards.length === 0 && <Text dimColor>Nada com esses filtros.</Text>}
789              </Box>
790            )}
791          </Box>
792        )}
793      </Box>
794    )
795
796    return (
797      <Box flexDirection="column" gap="md" padding="sm" minWidth={0} overflow="hidden">
798        {header}
799        {v.tab === 'sessao' && sessao}
800        {v.tab === 'visao' && visao}
801        {v.tab === 'sessoes' && sessoes}
802        {v.tab === 'kanban' && (
803          <Box flexDirection="column" gap="md">
804            {r.kanbanError && <Text size="caption" color={k ? YELLOW : PINK}>{k ? `⚠ ${r.kanbanError}` : `Não deu para ler o GitHub Project: ${r.kanbanError}`}</Text>}
805            {!k && !r.kanbanError && <Text dimColor>Lendo o GitHub Project…</Text>}
806            {k && (
807              <Box flexDirection="row" gap="xs" flexWrap="wrap" alignItems="center">
808                <Text size="footnote" weight="semibold" wrap="truncate-end">{k.title}</Text>
809                <Text size="caption" dimColor>· {k.count} itens por {k.field} · lido há {ago(new Date(k.fetchedAt).toISOString())}</Text>
810                <Link href={k.url} label="Abrir no GitHub" />
811              </Box>
812            )}
813            {k && (
814              <Input key="busca-kanban" label="Busca" placeholder="título ou número" value={v.issueQuery} submitLabel="Buscar" onInput={(value: string) => setView($, { issueQuery: value })} onSubmit={(value: string) => setView($, { issueQuery: value })} />
815            )}
816            {kanbanBoard}
817          </Box>
818        )}
819        {v.tab === 'trabalho' && trabalho}
820        <Box flexDirection="row" gap="sm" flexWrap="wrap">
821          <Button key="refresh" label="Atualizar agora" variant="primary" onPress={() => pollNow($, true)} />
822          <Button
823            key="clear"
824            label="Limpar filtros"
825            variant="secondary"
826            onPress={() => setView($, { filter: 'todas', query: '', stage: 'todas', org: 'todas', issueQuery: '' })}
827          />
828        </Box>
829      </Box>
830    )
831  })
832}
833
hooks/marca.ts 208 lines
1// Gerado por packages/marca/src/gerar.ts a partir de docs/brand/tokens.json. Não edite: mude o tokens.json e rode `node packages/marca/src/gerar.ts`.
2
3export const paleta = {
4  "orange": {
5    "100": "#fde3d1",
6    "200": "#fac29b",
7    "500": "#f47720",
8    "700": "#ab5316",
9    "900": "#612f0d"
10  },
11  "rose": {
12    "500": "#df285d",
13    "700": "#b81f4b"
14  },
15  "yellow": {
16    "100": "#fff3ce",
17    "200": "#ffe490",
18    "500": "#ffc30a",
19    "700": "#b28807",
20    "900": "#664e04"
21  },
22  "green": {
23    "500": "#1baf7a",
24    "700": "#0d7350"
25  },
26  "red": {
27    "300": "#f06463",
28    "500": "#e34948",
29    "700": "#b42a2a"
30  },
31  "gray": {
32    "100": "#d7d7d9",
33    "200": "#a6a6aa",
34    "300": "#5a5a63",
35    "500": "#3a3a44",
36    "700": "#28282f",
37    "800": "#1e1e24",
38    "900": "#17171b"
39  },
40  "mono": {
41    "white": "#fdfdfd",
42    "black": "#0e1016"
43  }
44} as const
45
46export const ci = {
47  "success": "#1baf7a",
48  "failure": "#e34948",
49  "pending": "#ffc30a"
50} as const
51
52export const temas = {
53  "dark": {
54    "bg": "#17171b",
55    "surface": "#1e1e24",
56    "raised": "#28282f",
57    "border": "#3a3a44",
58    "text": "#d7d7d9",
59    "muted": "#a6a6aa",
60    "accent": "#f47720",
61    "onAccent": "#17171b",
62    "accentText": "#f47720",
63    "rose": "#df285d"
64  },
65  "light": {
66    "bg": "#f4f4f5",
67    "surface": "#fdfdfd",
68    "raised": "#efeff1",
69    "border": "#d7d7d9",
70    "text": "#17171b",
71    "muted": "#5a5a63",
72    "accent": "#f47720",
73    "onAccent": "#17171b",
74    "accentText": "#ab5316",
75    "rose": "#df285d"
76  }
77} as const
78
79export const ordemStatus = [
80  "active",
81  "waiting",
82  "completed",
83  "error",
84  "abandoned"
85] as const
86
87export const status = {
88  "active": {
89    "label": "Ativa",
90    "glyph": "●",
91    "color": "#f47720",
92    "dark": {
93      "dot": "#f47720",
94      "text": "#f47720",
95      "badge": "#382924"
96    },
97    "light": {
98      "dot": "#ab5316",
99      "text": "#ab5316",
100      "badge": "#fcede2"
101    }
102  },
103  "waiting": {
104    "label": "Esperando você",
105    "glyph": "◐",
106    "color": "#ffc30a",
107    "motivos": {
108      "notification": {
109        "label": "Pede permissão",
110        "urgente": true
111      },
112      "interrupted": {
113        "label": "Interrompida",
114        "urgente": true
115      },
116      "stop": {
117        "label": "Sua vez",
118        "urgente": false
119      },
120      "session_start": {
121        "label": "No prompt",
122        "urgente": false
123      }
124    },
125    "dark": {
126      "dot": "#ffc30a",
127      "text": "#ffc30a",
128      "badge": "#393221"
129    },
130    "light": {
131      "dot": "#664e04",
132      "text": "#664e04",
133      "badge": "#fdf6e0"
134    }
135  },
136  "completed": {
137    "label": "Concluída",
138    "glyph": "✓",
139    "color": "#1baf7a",
140    "dark": {
141      "dot": "#1baf7a",
142      "text": "#1baf7a",
143      "badge": "#1e2f2e"
144    },
145    "light": {
146      "dot": "#0d7350",
147      "text": "#0d7350",
148      "badge": "#e2f4ed"
149    }
150  },
151  "error": {
152    "label": "Com erro",
153    "glyph": "✕",
154    "color": "#e34948",
155    "dark": {
156      "dot": "#e34948",
157      "text": "#f06463",
158      "badge": "#362328"
159    },
160    "light": {
161      "dot": "#b42a2a",
162      "text": "#b42a2a",
163      "badge": "#fae7e7"
164    }
165  },
166  "abandoned": {
167    "label": "Abandonada",
168    "glyph": "○",
169    "color": "#a6a6aa",
170    "dark": {
171      "dot": "#a6a6aa",
172      "text": "#a6a6aa",
173      "badge": "#2e2e34"
174    },
175    "light": {
176      "dot": "#5a5a63",
177      "text": "#5a5a63",
178      "badge": "#f3f3f3"
179    }
180  }
181} as const
182
183export const tipografia = {
184  "sans": "\"Poppins\", \"Roboto\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif",
185  "mono": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, Menlo, monospace",
186  "googleFonts": "https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&family=JetBrains+Mono:wght@400;600&display=swap",
187  "size": {
188    "xs": "12px",
189    "sm": "13px",
190    "md": "14px",
191    "lg": "16px",
192    "xl": "20px",
193    "xxl": "28px"
194  },
195  "weight": {
196    "regular": 400,
197    "medium": 500,
198    "semibold": 600
199  },
200  "usos": {
201    "sans": "títulos, rótulos e texto corrido no artefato",
202    "mono": "ids, caminhos, números de tabela, custo e tokens; e tudo no painel nativo"
203  }
204} as const
205
206export type Tema = keyof typeof temas
207export type StatusId = (typeof ordemStatus)[number]
208
hooks/pulso.tsx 31 lines
1// Client do painel: uma linha de pulso animada que mostra há quanto tempo veio a
2// última leitura do núcleo. Roda com o próprio timer, sem redesenhar o painel todo.
3import type { ClientSurface } from 'claude-code'
4
5type Props = { generatedAt: string | null }
6type State = { frame: number; started: boolean }
7
8const WAVE = '▁▂▃▅▇▅▃▂▁▁▁▁'
9
10export default function Pulso(props: Props, surface: ClientSurface<State>) {
11  const { Box, Text } = surface.elements
12  const st = surface.state ?? { frame: 0, started: false }
13  if (!st.started) {
14    surface.every(150, () => {
15      const cur = surface.state ?? { frame: 0, started: true }
16      surface.setState({ frame: cur.frame + 1, started: true })
17    })
18    surface.setState({ frame: 0, started: true })
19  }
20  const width = Math.max(12, Math.min(48, surface.columns - 18))
21  let line = ''
22  for (let i = 0; i < width; i++) line += WAVE[(i + st.frame) % WAVE.length]
23  const age = props.generatedAt ? Math.max(0, Math.round((Date.now() - Date.parse(props.generatedAt)) / 1000)) : null
24  return (
25    <Box flexDirection="row" gap={1}>
26      <Text color={age !== null && age < 10 ? '#f47720' : '#a6a6aa'}>{line}</Text>
27      <Text dimColor>{age === null ? 'sem leitura' : `há ${age}s`}</Text>
28    </Box>
29  )
30}
31
types/index.d.ts 123 lines
1export type SessionRow = {
2  project: string
3  id: string
4  state: 'ativa' | 'ociosa' | 'encerrada'
5  lastActivity: string
6  sizeKb: number
7}
8
9export type Fleet = {
10  totals: { projects: number; sessions: number; active: number; idle: number; sizeMb: number }
11  recent: SessionRow[]
12  activityByHour: number[]
13  topProjects: { project: string; sessions: number }[]
14  generatedAt: string
15}
16
17/** O que o painel pede a cada 5 s: esta sessão e quantas outras precisam de você (/api/sessao/<id>). */
18export type SessionPulse = {
19  session: SessionRow | null
20  needsYou: number
21  generatedAt: string
22}
23
24export type IssuePr = { number: number; state: string; isDraft: boolean; url: string; review: string | null; ci: string | null }
25export type IssueRow = {
26  repo: string
27  number: number
28  title: string
29  url: string
30  updatedAt: string
31  labels: string[]
32  assignees: string[]
33  prs: IssuePr[]
34  stage: string
35}
36export type Issues = { total: number; shown: number; byStage: Record<string, number>; items: IssueRow[]; generatedAt: string }
37
38export type Tab = 'sessao' | 'visao' | 'kanban' | 'trabalho' | 'sessoes'
39
40export type Reading = {
41  session: SessionPulse | null
42  fleet: Fleet | null
43  work: Work | null
44  kanban: Kanban | null
45  error: string | null
46  workError: string | null
47  kanbanError: string | null
48  /** Item do Project sendo movido agora, para o card mostrar que está esperando o GitHub. */
49  moving: string | null
50  polls: number
51  latencies: number[]
52}
53
54export type View = {
55  tab: Tab
56  filter: 'todas' | 'ativa' | 'ociosa' | 'encerrada'
57  query: string
58  selected: string | null
59  stage: string
60  org: string
61  issueQuery: string
62  selectedIssue: string | null
63  workMode: 'arvore' | 'estagio' | 'kanban'
64  expanded: string[]
65  /** Raias do kanban abertas por inteiro, além dos primeiros cards. */
66  fullLanes: string[]
67}
68
69declare module 'claude-code' {
70  interface PluginState {
71    'ahrena-console-painel': { reading: Reading; view: View }
72  }
73}
74
75export type WorkNode = {
76  key: string
77  repo: string
78  number: number
79  title: string
80  url: string
81  state: string
82  updatedAt: string
83  type: string | null
84  rank: number
85  progress: { total: number; completed: number; pct: number } | null
86  assignees: string[]
87  labels: string[]
88  prs: IssuePr[]
89  stage: string
90  parent: string | null
91  children: string[]
92}
93export type Work = { nodes: Record<string, WorkNode>; roots: string[]; byType: Record<string, number>; count: number; generatedAt: string }
94
95// Kanban do GitHub Project (GET /api/kanban): raias na ordem das opções do Status.
96export type KanbanCard = {
97  itemId: string
98  kind: 'Issue' | 'PullRequest' | 'DraftIssue'
99  key: string | null
100  repo: string | null
101  number: number | null
102  title: string
103  url: string | null
104  state: string
105  updatedAt: string
106  type: string | null
107  parent: string | null
108  statusId: string | null
109}
110export type KanbanLane = { id: string | null; name: string; color: string | null; items: KanbanCard[] }
111export type Kanban = {
112  owner: string
113  number: number
114  title: string
115  url: string
116  field: string
117  lanes: KanbanLane[]
118  count: number
119  fetchedAt: number
120  stale: boolean
121  staleReason: string | null
122}
123