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

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.
O painel da sessão (/console) está em plugins/ahrena-console, sobre a API local do núcleo (packages/nucleo).
No Claude Code:
/plugin marketplace add ahrenatechnology/ahrena-console
/plugin install ahrena-console@ahrena-console
Depois, /reload-plugins e /console abre o painel.
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.
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.
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).
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/.
O estudo das referências fica num repositório interno da Ahrena. Os documentos daqui o citam como "benchmark interno".
Apache-2.0. Copyright 2026 Ahrena.
hooks/register.tsx 833 lines1// 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}
833hooks/marca.ts 208 lines1// 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]
208hooks/pulso.tsx 31 lines1// 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}
31types/index.d.ts 123 lines1export 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