agent-hub-sdd 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,10 +15,10 @@ npm run app
15
15
  Pelo npm (precisa do Node 20+ no PATH):
16
16
 
17
17
  ```powershell
18
- npx --yes agent-hub-sdd@0.1.8
18
+ npx --yes agent-hub-sdd@0.1.9
19
19
  ```
20
20
 
21
- Na primeira abertura a janela diz que está compilando. O **N** no canto é o Next.js em modo dev, não o app. Sem `@0.1.8`, o Windows pode reusar cache antigo.
21
+ Na primeira abertura a janela diz que está compilando. O **N** no canto é o Next.js em modo dev, não o app. Sem `@0.1.9`, o Windows pode reusar cache antigo.
22
22
 
23
23
  Se ainda abrir a 0.1.2, ou se o npm disser `EBUSY` / `resource busy or locked`, o Next da tentativa antiga ainda está com o cache aberto. O `Stop-Process electron` não mata esse `node`:
24
24
 
@@ -27,13 +27,13 @@ Get-CimInstance Win32_Process | Where-Object {
27
27
  $_.CommandLine -and ($_.CommandLine -match 'agent-hub|_npx|next dev|43147')
28
28
  } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
29
29
  Remove-Item -Recurse -Force "$env:LOCALAPPDATA\npm-cache\_npx" -ErrorAction SilentlyContinue
30
- npx --yes agent-hub-sdd@0.1.8
30
+ npx --yes agent-hub-sdd@0.1.9
31
31
  ```
32
32
 
33
33
  Instalar global evita o rename do `npx` nessa pasta:
34
34
 
35
35
  ```powershell
36
- npm install -g agent-hub-sdd@0.1.8
36
+ npm install -g agent-hub-sdd@0.1.9
37
37
  agent-hub-sdd
38
38
  ```
39
39
 
@@ -76,7 +76,9 @@ O Hub não carrega skills, MCP, plugins, hooks nem regras. Quem carrega é a CLI
76
76
 
77
77
  ## Simulador
78
78
 
79
- Se a CLI pedida não estiver no `PATH`, o Hub responde no simulador e não edita o projeto.
79
+ Se a CLI pedida (`agent` / `cursor-agent` ou `claude`) não estiver no `PATH`, o Hub **cai no Simulador**: respostas fictícias com prefixo `[mock]`, custo zero, disco intocado. A faixa âmbar e o selo **Simulador · Cursor** (ou Claude) deixam isso explícito — o hub não finge que a CLI está viva.
80
+
81
+ Com a CLI da assinatura no PATH e login feito (`agent login` ou `claude auth login`), os turnos passam a ser reais. O Hub nunca envia chave de API.
80
82
 
81
83
  ## Dados
82
84
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub-sdd",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "author": "Agent Hub",
@@ -8,4 +8,4 @@ Get-CimInstance Win32_Process |
8
8
  Stop-Process -Name electron -Force
9
9
  $npx = Join-Path $env:LOCALAPPDATA "npm-cache\_npx"
10
10
  if (Test-Path $npx) { Remove-Item -Recurse -Force $npx }
11
- Write-Host "Cache do npx limpo. Rode: npx --yes agent-hub-sdd@0.1.8"
11
+ Write-Host "Cache do npx limpo. Rode: npx --yes agent-hub-sdd@0.1.9"
@@ -7,5 +7,9 @@ export const dynamic = "force-dynamic";
7
7
 
8
8
  export default function GastosPage() {
9
9
  const events = listUsage();
10
- return <SpendView events={events} agents={listAgents()} summary={aggregateSpend(events)} />;
10
+ return (
11
+ <div className="min-h-0 flex-1 overflow-auto">
12
+ <SpendView events={events} agents={listAgents()} summary={aggregateSpend(events)} />
13
+ </div>
14
+ );
11
15
  }
package/src/app/shell.css CHANGED
@@ -1,7 +1,7 @@
1
1
  html, body {
2
2
  margin: 0;
3
3
  min-height: 100%;
4
- background: #000;
4
+ background: #09090b;
5
5
  color: #f4f4f5;
6
6
  font-family: "Segoe UI", ui-sans-serif, system-ui, sans-serif;
7
7
  }
@@ -10,21 +10,23 @@ button, input, textarea { font: inherit; color: inherit; }
10
10
  button {
11
11
  cursor: pointer;
12
12
  background: #18181b;
13
- border: 1px solid #27272a;
13
+ border: 1px solid #3f3f46;
14
14
  border-radius: 0.5rem;
15
- padding: 0.25rem 0.6rem;
15
+ padding: 0.35rem 0.7rem;
16
16
  }
17
17
  input, textarea {
18
18
  background: #18181b;
19
- border: 1px solid #27272a;
19
+ border: 1px solid #3f3f46;
20
20
  border-radius: 0.5rem;
21
- padding: 0.35rem 0.55rem;
21
+ padding: 0.4rem 0.6rem;
22
22
  }
23
23
  .flex { display: flex; }
24
24
  .flex-col { flex-direction: column; }
25
25
  .flex-1 { flex: 1 1 0%; }
26
+ .flex-wrap { flex-wrap: wrap; }
26
27
  .items-center { align-items: center; }
27
28
  .justify-center { justify-content: center; }
29
+ .justify-between { justify-content: space-between; }
28
30
  .min-h-0 { min-height: 0; }
29
31
  .min-h-dvh { min-height: 100dvh; }
30
32
  .min-w-0 { min-width: 0; }
@@ -33,22 +35,32 @@ input, textarea {
33
35
  .h-9 { height: 2.25rem; }
34
36
  .w-full { width: 100%; }
35
37
  .w-\[280px\] { width: 280px; }
38
+ .w-\[300px\] { width: 300px; }
36
39
  .max-w-md { max-width: 28rem; }
37
40
  .max-w-\[280px\] { max-width: 280px; }
41
+ .max-w-\[300px\] { max-width: 300px; }
38
42
  .shrink-0 { flex-shrink: 0; }
39
43
  .gap-1 { gap: 0.25rem; }
44
+ .gap-1\.5 { gap: 0.375rem; }
40
45
  .gap-2 { gap: 0.5rem; }
41
46
  .gap-3 { gap: 0.75rem; }
42
47
  .overflow-hidden { overflow: hidden; }
43
48
  .overflow-auto { overflow: auto; }
44
49
  .grid { display: grid; }
45
50
  .hidden { display: none; }
51
+ .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
46
52
  .border { border: 1px solid #27272a; }
47
53
  .border-r { border-right: 1px solid #27272a; }
48
54
  .border-b { border-bottom: 1px solid #27272a; }
55
+ .border-t { border-top: 1px solid #27272a; }
49
56
  .border-zinc-800 { border-color: #27272a; }
50
- .bg-black { background: #000; }
57
+ .bg-black { background: #09090b; }
58
+ .bg-zinc-950 { background: #09090b; }
59
+ .bg-zinc-900 { background: #18181b; }
60
+ .bg-zinc-800 { background: #27272a; }
61
+ .text-zinc-50 { color: #fafafa; }
51
62
  .text-zinc-100 { color: #f4f4f5; }
63
+ .text-zinc-200 { color: #e4e4e7; }
52
64
  .text-zinc-300 { color: #d4d4d8; }
53
65
  .text-zinc-400 { color: #a1a1aa; }
54
66
  .text-zinc-500 { color: #71717a; }
@@ -57,17 +69,349 @@ input, textarea {
57
69
  .text-lg { font-size: 1.125rem; }
58
70
  .font-medium { font-weight: 500; }
59
71
  .font-semibold { font-weight: 600; }
72
+ .p-2 { padding: 0.5rem; }
60
73
  .p-3 { padding: 0.75rem; }
61
74
  .p-4 { padding: 1rem; }
75
+ .p-6 { padding: 1.5rem; }
62
76
  .px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
77
+ .px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
78
+ .px-4 { padding-left: 1rem; padding-right: 1rem; }
79
+ .py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
80
+ .py-1\.5 { padding-top: 0.375rem; padding-bottom: 0.375rem; }
63
81
  .py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
82
+ .py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
64
83
  .pr-8 { padding-right: 2rem; }
84
+ .ml-auto { margin-left: auto; }
85
+ .mb-1 { margin-bottom: 0.25rem; }
86
+ .size-3 { width: 0.75rem; height: 0.75rem; }
87
+ .size-3\.5 { width: 0.875rem; height: 0.875rem; }
88
+ .rounded-md { border-radius: 0.375rem; }
65
89
  .rounded-lg { border-radius: 0.5rem; }
90
+ .rounded-2xl { border-radius: 1rem; }
66
91
  .rounded-full { border-radius: 9999px; }
67
92
  .leading-6 { line-height: 1.5rem; }
68
93
  .text-center { text-align: center; }
94
+ .text-left { text-align: left; }
95
+ .size-2 { width: 0.5rem; height: 0.5rem; }
69
96
  .ah-drag { -webkit-app-region: drag; app-region: drag; }
70
97
  .ah-no-drag { -webkit-app-region: no-drag; app-region: no-drag; }
98
+
99
+ .hub-sidebar {
100
+ background: #0a0a0a;
101
+ border-right: 1px solid #27272a;
102
+ }
103
+ .hub-brand {
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 0.5rem;
107
+ font-size: 0.95rem;
108
+ font-weight: 650;
109
+ letter-spacing: -0.03em;
110
+ }
111
+ .hub-mark {
112
+ width: 0.7rem;
113
+ height: 0.7rem;
114
+ border-radius: 0.2rem;
115
+ background: #f4f4f5;
116
+ box-shadow: 3px 3px 0 0 #52525b;
117
+ }
118
+ .hub-status {
119
+ border-radius: 0.6rem;
120
+ padding: 0.55rem 0.7rem;
121
+ font-size: 0.75rem;
122
+ line-height: 1.45;
123
+ }
124
+ .hub-status-mock {
125
+ background: rgba(245, 158, 11, 0.12);
126
+ color: #fde68a;
127
+ border: 1px solid rgba(245, 158, 11, 0.35);
128
+ }
129
+ .hub-status-live {
130
+ background: rgba(16, 185, 129, 0.1);
131
+ color: #a7f3d0;
132
+ border: 1px solid rgba(16, 185, 129, 0.28);
133
+ }
134
+ .hub-chip {
135
+ display: inline-flex;
136
+ align-items: center;
137
+ border-radius: 999px;
138
+ border: 1px solid #3f3f46;
139
+ padding: 0.1rem 0.5rem;
140
+ font-size: 0.7rem;
141
+ color: #a1a1aa;
142
+ }
143
+ .hub-chip-on {
144
+ border-color: rgba(16, 185, 129, 0.4);
145
+ background: rgba(16, 185, 129, 0.12);
146
+ color: #6ee7b7;
147
+ }
148
+ .hub-chip-off {
149
+ color: #71717a;
150
+ }
151
+ .hub-chip-mock {
152
+ border-color: rgba(245, 158, 11, 0.45);
153
+ background: rgba(245, 158, 11, 0.12);
154
+ color: #fde68a;
155
+ }
156
+ .hub-agent {
157
+ border: 1px solid #27272a;
158
+ border-radius: 0.7rem;
159
+ margin-bottom: 0.5rem;
160
+ background: #0c0c0e;
161
+ overflow: hidden;
162
+ }
163
+ .hub-agent-title {
164
+ display: flex;
165
+ min-width: 0;
166
+ flex: 1;
167
+ align-items: center;
168
+ gap: 0.5rem;
169
+ }
170
+ .hub-dot {
171
+ width: 0.5rem;
172
+ height: 0.5rem;
173
+ border-radius: 999px;
174
+ flex-shrink: 0;
175
+ }
176
+ .hub-stage {
177
+ margin-left: auto;
178
+ font-size: 0.65rem;
179
+ color: #a1a1aa;
180
+ background: #18181b;
181
+ border: 1px solid #27272a;
182
+ border-radius: 999px;
183
+ padding: 0.1rem 0.45rem;
184
+ flex-shrink: 0;
185
+ }
186
+ .hub-home {
187
+ width: 100%;
188
+ max-width: 36rem;
189
+ display: grid;
190
+ gap: 1rem;
191
+ }
192
+ .hub-home h1 {
193
+ margin: 0;
194
+ font-size: 1.25rem;
195
+ font-weight: 600;
196
+ letter-spacing: -0.03em;
197
+ }
198
+ .hub-card-grid {
199
+ display: grid;
200
+ gap: 0.6rem;
201
+ grid-template-columns: 1fr;
202
+ }
203
+ .hub-card {
204
+ display: flex;
205
+ flex-direction: column;
206
+ gap: 0.25rem;
207
+ width: 100%;
208
+ text-align: left;
209
+ background: #111113;
210
+ border: 1px solid #27272a;
211
+ border-radius: 0.75rem;
212
+ padding: 0.8rem 0.9rem;
213
+ }
214
+ .hub-card:hover {
215
+ border-color: #3f3f46;
216
+ background: #18181b;
217
+ }
218
+ .hub-banner-mock {
219
+ border-bottom: 1px solid rgba(245, 158, 11, 0.3);
220
+ background: rgba(245, 158, 11, 0.1);
221
+ color: #fde68a;
222
+ padding: 0.6rem 1rem;
223
+ font-size: 0.85rem;
224
+ line-height: 1.45;
225
+ }
226
+ .hub-kicker {
227
+ margin: 0;
228
+ font-size: 0.68rem;
229
+ font-weight: 600;
230
+ letter-spacing: 0.14em;
231
+ text-transform: uppercase;
232
+ color: #71717a;
233
+ }
234
+ .hub-card-row {
235
+ display: flex;
236
+ align-items: center;
237
+ gap: 0.5rem;
238
+ }
239
+ .hub-card-copy {
240
+ color: #a1a1aa;
241
+ font-size: 0.75rem;
242
+ line-height: 1.45;
243
+ }
244
+ .hub-topbar {
245
+ display: flex;
246
+ flex-wrap: wrap;
247
+ align-items: center;
248
+ gap: 0.5rem;
249
+ border-bottom: 1px solid #27272a;
250
+ padding: 0.7rem 1rem;
251
+ background: #0c0c0e;
252
+ }
253
+ .hub-composer {
254
+ display: flex;
255
+ align-items: flex-end;
256
+ gap: 0.5rem;
257
+ margin: 0.75rem;
258
+ padding: 0.55rem 0.6rem;
259
+ border: 1px solid #27272a;
260
+ border-radius: 1rem;
261
+ background: #111113;
262
+ }
263
+ .hub-msg {
264
+ max-width: 85%;
265
+ border-radius: 1rem;
266
+ padding: 0.55rem 0.85rem;
267
+ font-size: 0.875rem;
268
+ line-height: 1.5rem;
269
+ }
270
+ .hub-msg-user {
271
+ margin-left: auto;
272
+ background: #27272a;
273
+ color: #fafafa;
274
+ }
275
+ .hub-msg-agent {
276
+ margin-right: auto;
277
+ background: #18181b;
278
+ color: #f4f4f5;
279
+ border: 1px solid #27272a;
280
+ }
281
+ .hub-msg-tool {
282
+ margin-right: auto;
283
+ max-width: 90%;
284
+ border-radius: 0.5rem;
285
+ border: 1px solid #27272a;
286
+ background: #09090b;
287
+ padding: 0.3rem 0.55rem;
288
+ font-family: "Cascadia Mono", Consolas, ui-monospace, monospace;
289
+ font-size: 11px;
290
+ line-height: 1.25rem;
291
+ color: #a1a1aa;
292
+ }
293
+ .hub-msg-handoff {
294
+ margin-right: auto;
295
+ border-radius: 0;
296
+ border-left: 2px solid #fbbf24;
297
+ background: #18181b;
298
+ color: #f4f4f5;
299
+ }
300
+ .hub-msg-error {
301
+ margin-right: auto;
302
+ border: 1px solid rgba(239, 68, 68, 0.4);
303
+ background: rgba(239, 68, 68, 0.1);
304
+ color: #fecaca;
305
+ }
306
+ .hub-empty-chat {
307
+ max-width: 32rem;
308
+ margin: 1.5rem auto 0.5rem;
309
+ display: grid;
310
+ gap: 0.4rem;
311
+ text-align: center;
312
+ color: #a1a1aa;
313
+ font-size: 0.875rem;
314
+ line-height: 1.5rem;
315
+ }
316
+ .hub-empty-chat h2 {
317
+ margin: 0;
318
+ font-size: 1.15rem;
319
+ font-weight: 600;
320
+ color: #fafafa;
321
+ letter-spacing: -0.03em;
322
+ }
323
+ .hub-session {
324
+ display: flex;
325
+ align-items: center;
326
+ gap: 0.4rem;
327
+ width: 100%;
328
+ overflow: hidden;
329
+ text-align: left;
330
+ border: 0;
331
+ background: transparent;
332
+ border-radius: 0.5rem;
333
+ padding: 0.4rem 0.5rem;
334
+ font-size: 0.75rem;
335
+ color: #d4d4d8;
336
+ }
337
+ .hub-session:hover { background: #18181b; }
338
+ .hub-session-active {
339
+ background: #27272a;
340
+ color: #fafafa;
341
+ }
342
+ .hub-session-tag {
343
+ margin-left: auto;
344
+ flex-shrink: 0;
345
+ font-size: 0.62rem;
346
+ color: #6ee7b7;
347
+ }
348
+ .hub-session-tag-err { color: #fca5a5; }
349
+ .hub-quiet-btn {
350
+ display: inline-flex;
351
+ align-items: center;
352
+ gap: 0.3rem;
353
+ border: 0;
354
+ background: transparent;
355
+ padding: 0;
356
+ font-size: 0.75rem;
357
+ color: #a1a1aa;
358
+ }
359
+ .hub-quiet-btn:hover { color: #e4e4e7; }
360
+ .hub-footer-link {
361
+ display: flex;
362
+ align-items: center;
363
+ gap: 0.45rem;
364
+ flex-shrink: 0;
365
+ padding: 0.85rem 1rem;
366
+ font-size: 0.875rem;
367
+ color: #a1a1aa;
368
+ }
369
+ .hub-footer-link:hover { color: #f4f4f5; }
370
+ .hub-spend {
371
+ width: 100%;
372
+ max-width: 56rem;
373
+ margin: 0 auto;
374
+ display: flex;
375
+ flex-direction: column;
376
+ flex: 1;
377
+ gap: 1.5rem;
378
+ padding: 1.5rem;
379
+ }
380
+ .hub-spend h1 {
381
+ margin: 0;
382
+ font-size: 1.25rem;
383
+ font-weight: 600;
384
+ letter-spacing: -0.03em;
385
+ }
386
+ .hub-stat-grid {
387
+ display: grid;
388
+ gap: 0.75rem;
389
+ grid-template-columns: 1fr;
390
+ }
391
+ .hub-stat {
392
+ border: 1px solid #27272a;
393
+ border-radius: 0.85rem;
394
+ padding: 0.9rem 1rem;
395
+ background: #111113;
396
+ }
397
+ .hub-stat p {
398
+ margin: 0;
399
+ font-size: 0.75rem;
400
+ color: #a1a1aa;
401
+ }
402
+ .hub-stat strong {
403
+ display: block;
404
+ margin-top: 0.35rem;
405
+ font-size: 1.15rem;
406
+ font-weight: 600;
407
+ }
408
+ .hub-table-wrap { overflow-x: auto; }
409
+ @media (min-width: 640px) {
410
+ .hub-stat-grid { grid-template-columns: 1fr 1fr 1fr; }
411
+ }
412
+ @media (min-width: 700px) {
413
+ .hub-card-grid { grid-template-columns: 1fr 1fr; }
414
+ }
71
415
  @media (min-width: 768px) {
72
416
  .md\:block { display: block; }
73
417
  .md\:hidden { display: none; }
@@ -81,7 +81,7 @@ export function AgentDialog({
81
81
  <DialogHeader>
82
82
  <DialogTitle>{agent ? "Editar agente" : "Novo agente"}</DialogTitle>
83
83
  <DialogDescription>
84
- Nome, etapa e harness definem como esta persona atua no workspace.
84
+ Nome, etapa e harness definem como esta persona atua no workspace. Simulador não chama CLI e não edita o disco.
85
85
  </DialogDescription>
86
86
  </DialogHeader>
87
87
  <form
@@ -1,13 +1,20 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useRef, type ReactNode } from "react";
4
+ import { ArrowRightLeft, GitBranch } from "lucide-react";
4
5
  import { Composer } from "@/components/composer";
5
6
  import { MarkdownLite } from "@/components/markdown-lite";
6
7
  import { Badge } from "@/components/ui/badge";
7
8
  import { Button } from "@/components/ui/button";
8
- import { WorkspaceField } from "@/components/workspace-field";
9
9
  import { ScrollArea } from "@/components/ui/scroll-area";
10
- import { HARNESS_LABEL, STAGE_LABEL } from "@/lib/labels";
10
+ import {
11
+ STAGE_LABEL,
12
+ clisMissing,
13
+ isHarnessSimulated,
14
+ liveHarnessLabel,
15
+ simulatedBanner,
16
+ type HarnessAvailability,
17
+ } from "@/lib/labels";
11
18
  import { cn } from "@/lib/utils";
12
19
  import type { Agent, ChatMessage, Session } from "@/lib/types";
13
20
 
@@ -15,57 +22,90 @@ export type LiveSegment =
15
22
  | { id: string; kind: "assistant"; text: string }
16
23
  | { id: string; kind: "tool"; name: string; detail: string; status: "started" | "completed" };
17
24
 
18
- const CLI_BANNER = "Nenhum CLI encontrado. O Simulador está ativo — o projeto no disco não será editado.";
19
-
20
- function Bubble({ role, children, live }: { role: ChatMessage["role"]; children: ReactNode; live?: boolean }) {
25
+ function Bubble({
26
+ role,
27
+ children,
28
+ live,
29
+ simulated,
30
+ }: {
31
+ role: ChatMessage["role"];
32
+ children: ReactNode;
33
+ live?: boolean;
34
+ simulated?: boolean;
35
+ }) {
21
36
  if (role === "tool") {
22
- return (
23
- <div className="mr-auto max-w-[90%] rounded-md border border-zinc-800 bg-black px-2 py-1 font-mono text-[11px] leading-5 text-zinc-400">
24
- {children}
25
- </div>
26
- );
37
+ return <div className="hub-msg hub-msg-tool">{children}</div>;
27
38
  }
28
39
  if (role === "handoff") {
29
- return (
30
- <div className="mr-auto max-w-[85%] border-l-2 border-amber-400 bg-zinc-900 px-3 py-2 text-sm text-zinc-100">
31
- {children}
32
- </div>
33
- );
40
+ return <div className="hub-msg hub-msg-handoff">{children}</div>;
34
41
  }
35
42
  if (role === "system") {
36
43
  return <p className="text-center text-xs text-zinc-500">{children}</p>;
37
44
  }
38
45
  const user = role === "user";
39
46
  return (
40
- <div
41
- className={cn(
42
- "max-w-[85%] rounded-2xl px-3 py-2 text-sm leading-6",
43
- user ? "ml-auto bg-zinc-800 text-zinc-50" : "mr-auto bg-zinc-900 text-zinc-100",
44
- )}
45
- >
47
+ <div className={cn("hub-msg", user ? "hub-msg-user" : "hub-msg-agent")}>
46
48
  <span className="sr-only">{user ? "Você" : "Agente"}</span>
49
+ {!user && simulated && <p className="hub-kicker mb-1">Simulador</p>}
47
50
  {children}
48
51
  {live && <span className="ml-0.5 inline-block animate-pulse text-zinc-500">▍</span>}
49
52
  </div>
50
53
  );
51
54
  }
52
55
 
56
+ function HomeBoard({
57
+ agents,
58
+ workspaceMissing,
59
+ onNewSession,
60
+ }: {
61
+ agents: Agent[];
62
+ workspaceMissing: boolean;
63
+ onNewSession: (agentId: string) => void;
64
+ }) {
65
+ return (
66
+ <div className="hub-home">
67
+ <p className="hub-kicker">SDLC</p>
68
+ <h1>Escolha um agente para começar</h1>
69
+ <p className="text-sm leading-6 text-zinc-400">
70
+ Cada persona opera no workspace local. Handoff passa o contexto para a próxima etapa; subagente abre uma conversa
71
+ filha sem sair desta.
72
+ </p>
73
+ {workspaceMissing && (
74
+ <p className="hub-status hub-status-mock">
75
+ O workspace ainda está vazio. Informe o caminho na barra lateral. Sem pasta, o Simulador responde e o disco não
76
+ é alterado.
77
+ </p>
78
+ )}
79
+ <div className="hub-card-grid">
80
+ {agents.map((agent) => (
81
+ <button key={agent.id} type="button" className="hub-card" onClick={() => onNewSession(agent.id)}>
82
+ <span className="hub-card-row">
83
+ <span className="hub-dot" style={{ backgroundColor: agent.color }} />
84
+ <span className="font-medium text-zinc-50">{agent.name}</span>
85
+ <span className="hub-stage">{STAGE_LABEL[agent.stage]}</span>
86
+ </span>
87
+ <span className="hub-card-copy">{agent.specs}</span>
88
+ </button>
89
+ ))}
90
+ </div>
91
+ </div>
92
+ );
93
+ }
94
+
53
95
  export function ChatPane({
54
96
  phase,
55
97
  agent,
56
98
  session,
99
+ agents,
57
100
  messages,
58
101
  live,
59
102
  streamError,
60
103
  running,
61
- cliMissing,
62
- workspace,
104
+ harness,
63
105
  workspaceMissing,
64
- ready,
65
- onWorkspaceChange,
66
- onWorkspaceCommit,
67
106
  onCreateAgent,
68
107
  onLoadStarters,
108
+ onNewSession,
69
109
  onRetry,
70
110
  onSend,
71
111
  onHandoff,
@@ -73,21 +113,19 @@ export function ChatPane({
73
113
  draft,
74
114
  onDraftChange,
75
115
  }: {
76
- phase: "loading" | "error" | "workspace" | "agents" | "session" | "chat";
116
+ phase: "loading" | "error" | "agents" | "session" | "chat";
77
117
  agent: Agent | null;
78
118
  session: Session | null;
119
+ agents: Agent[];
79
120
  messages: ChatMessage[];
80
121
  live: LiveSegment[];
81
122
  streamError: string | null;
82
123
  running: boolean;
83
- cliMissing: boolean;
84
- workspace: string;
124
+ harness: HarnessAvailability | null;
85
125
  workspaceMissing: boolean;
86
- ready: boolean;
87
- onWorkspaceChange: (value: string) => void;
88
- onWorkspaceCommit: (value?: string) => void;
89
126
  onCreateAgent: () => void;
90
127
  onLoadStarters: () => void;
128
+ onNewSession: (agentId: string) => void;
91
129
  onRetry: () => void;
92
130
  onSend: (text: string) => Promise<boolean>;
93
131
  onHandoff: () => void;
@@ -100,23 +138,30 @@ export function ChatPane({
100
138
  endRef.current?.scrollIntoView({ block: "end" });
101
139
  }, [messages, live, streamError, phase]);
102
140
 
141
+ const simulated = agent ? isHarnessSimulated(agent.harness, harness) : clisMissing(harness);
142
+ const emptyChat = messages.length === 0 && live.length === 0 && !streamError;
143
+
103
144
  return (
104
145
  <section className="flex min-h-0 min-w-0 flex-1 flex-col bg-zinc-950">
105
- {cliMissing && (
106
- <p className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-sm text-amber-200">{CLI_BANNER}</p>
146
+ {simulated && (
147
+ <p className="hub-banner-mock">{agent ? simulatedBanner(agent.harness) : "Nenhum CLI encontrado. O Simulador está ativo — o projeto no disco não será editado."}</p>
107
148
  )}
108
149
  {phase === "chat" && agent && session ? (
109
150
  <>
110
- <header className="flex flex-wrap items-center gap-2 border-b border-zinc-800 px-4 py-3">
151
+ <header className="hub-topbar">
111
152
  <span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: agent.color }} />
112
153
  <span className="font-medium">{agent.name}</span>
113
154
  <Badge variant="secondary">{STAGE_LABEL[agent.stage]}</Badge>
114
- <Badge variant="outline">{HARNESS_LABEL[agent.harness]}</Badge>
155
+ <span className={cn("hub-chip", simulated ? "hub-chip-mock" : "hub-chip-on")}>
156
+ {liveHarnessLabel(agent.harness, harness)}
157
+ </span>
115
158
  <span className="min-w-0 flex-1 truncate text-sm text-zinc-400">{session.title}</span>
116
159
  <Button type="button" size="sm" variant="outline" disabled={running} onClick={onHandoff}>
160
+ <ArrowRightLeft />
117
161
  Handoff
118
162
  </Button>
119
163
  <Button type="button" size="sm" variant="outline" disabled={running} onClick={onSubagent}>
164
+ <GitBranch />
120
165
  Subagente
121
166
  </Button>
122
167
  </header>
@@ -128,8 +173,20 @@ export function ChatPane({
128
173
  <div className="relative min-h-0 flex-1">
129
174
  <ScrollArea className="absolute inset-0">
130
175
  <div className="flex flex-col gap-3 p-4">
176
+ {emptyChat && (
177
+ <div className="hub-empty-chat">
178
+ <p className="hub-kicker">{STAGE_LABEL[agent.stage]}</p>
179
+ <h2>{agent.name}</h2>
180
+ <p>{agent.specs}</p>
181
+ {simulated ? (
182
+ <p className="text-amber-200/90">Modo Simulador — nada será gravado no disco neste turno.</p>
183
+ ) : (
184
+ <p>Escreva o pedido. O agente usa a CLI da sua assinatura no workspace.</p>
185
+ )}
186
+ </div>
187
+ )}
131
188
  {messages.map((message) => (
132
- <Bubble key={message.id} role={message.role}>
189
+ <Bubble key={message.id} role={message.role} simulated={simulated}>
133
190
  {message.role === "user" || message.role === "tool" || message.role === "system" ? (
134
191
  <span className="whitespace-pre-wrap">
135
192
  {message.role === "tool" && message.toolName ? `${message.toolName} · ` : ""}
@@ -146,18 +203,18 @@ export function ChatPane({
146
203
  {segment.name} · {segment.status}: {segment.detail}
147
204
  </Bubble>
148
205
  ) : (
149
- <Bubble key={segment.id} role="assistant" live={running && index === live.length - 1}>
206
+ <Bubble key={segment.id} role="assistant" live={running && index === live.length - 1} simulated={simulated}>
150
207
  <MarkdownLite text={segment.text} />
151
208
  </Bubble>
152
209
  ),
153
210
  )}
154
211
  {running && live.length === 0 && (
155
212
  <Bubble role="assistant" live>
156
- <span className="text-zinc-500">…</span>
213
+ <span className="text-zinc-500">Aguardando o harness…</span>
157
214
  </Bubble>
158
215
  )}
159
216
  {streamError && (
160
- <div className="mr-auto max-w-[85%] rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
217
+ <div className="hub-msg hub-msg-error">
161
218
  <p>{streamError}</p>
162
219
  <p className="mt-1 text-xs text-red-200/80">Envie a mensagem de novo para tentar outra vez.</p>
163
220
  </div>
@@ -171,6 +228,7 @@ export function ChatPane({
171
228
  sessionId={session.id}
172
229
  draft={draft}
173
230
  disabled={running}
231
+ simulated={simulated}
174
232
  onDraftChange={onDraftChange}
175
233
  onSend={onSend}
176
234
  />
@@ -186,23 +244,10 @@ export function ChatPane({
186
244
  </Button>
187
245
  </div>
188
246
  )}
189
- {phase === "workspace" && (
190
- <div className="grid w-full max-w-md gap-3">
191
- <h1 className="text-lg font-medium">Informe o caminho do workspace</h1>
192
- <p className="text-sm leading-6 text-zinc-400">
193
- No app do Windows, use Pasta e escolha o repositório. Ou cole um caminho absoluto como C:\src\meu-app.
194
- </p>
195
- <WorkspaceField
196
- value={workspace}
197
- disabled={!ready}
198
- onChange={onWorkspaceChange}
199
- onCommit={onWorkspaceCommit}
200
- />
201
- </div>
202
- )}
203
247
  {phase === "agents" && (
204
- <div className="grid w-full max-w-md gap-3">
205
- <h1 className="text-lg font-medium">Nenhum agente neste hub</h1>
248
+ <div className="hub-home">
249
+ <p className="hub-kicker">Agentes</p>
250
+ <h1>Nenhum agente neste hub</h1>
206
251
  <p className="text-sm leading-6 text-zinc-400">
207
252
  Crie uma persona ou carregue os seis agentes do SDLC: Spec, Arquiteto, Implementador, Revisor, Testador e
208
253
  Release.
@@ -218,10 +263,7 @@ export function ChatPane({
218
263
  </div>
219
264
  )}
220
265
  {phase === "session" && (
221
- <div className="grid max-w-md gap-2 text-center">
222
- <h1 className="text-lg font-medium">Escolha ou comece uma conversa</h1>
223
- <p className="text-sm text-zinc-400">Selecione um agente na barra lateral e abra uma conversa nova.</p>
224
- </div>
266
+ <HomeBoard agents={agents} workspaceMissing={workspaceMissing} onNewSession={onNewSession} />
225
267
  )}
226
268
  </div>
227
269
  )}
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { ArrowUp } from "lucide-react";
3
4
  import { Button } from "@/components/ui/button";
4
5
  import { Textarea } from "@/components/ui/textarea";
5
6
 
@@ -7,12 +8,14 @@ export function Composer({
7
8
  sessionId,
8
9
  draft,
9
10
  disabled,
11
+ simulated,
10
12
  onDraftChange,
11
13
  onSend,
12
14
  }: {
13
15
  sessionId: string;
14
16
  draft: string;
15
17
  disabled: boolean;
18
+ simulated: boolean;
16
19
  onDraftChange: (sessionId: string, text: string) => void;
17
20
  onSend: (text: string) => Promise<boolean>;
18
21
  }) {
@@ -28,7 +31,7 @@ export function Composer({
28
31
 
29
32
  return (
30
33
  <form
31
- className="flex items-end gap-2 border-t border-zinc-800 bg-black p-3"
34
+ className="hub-composer"
32
35
  onSubmit={(event) => {
33
36
  event.preventDefault();
34
37
  void submit();
@@ -38,8 +41,14 @@ export function Composer({
38
41
  value={draft}
39
42
  disabled={disabled}
40
43
  rows={2}
41
- placeholder={disabled ? "Aguardando a resposta do agente…" : "Escreva uma mensagem"}
42
- className="max-h-40 min-h-16 flex-1 resize-none bg-zinc-950"
44
+ placeholder={
45
+ disabled
46
+ ? "Aguardando a resposta do agente…"
47
+ : simulated
48
+ ? "Escreva no Simulador — o disco não será alterado"
49
+ : "Escreva uma mensagem"
50
+ }
51
+ className="max-h-40 min-h-16 flex-1 resize-none border-0 bg-transparent shadow-none focus-visible:ring-0"
43
52
  onChange={(event) => onDraftChange(sessionId, event.target.value)}
44
53
  onKeyDown={(event) => {
45
54
  if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
@@ -47,8 +56,8 @@ export function Composer({
47
56
  void submit();
48
57
  }}
49
58
  />
50
- <Button type="submit" disabled={disabled || !trimmed}>
51
- Enviar
59
+ <Button type="submit" size="icon" disabled={disabled || !trimmed} aria-label="Enviar">
60
+ <ArrowUp />
52
61
  </Button>
53
62
  </form>
54
63
  );
@@ -7,5 +7,12 @@ export function DesktopChrome() {
7
7
  const [windows, setWindows] = useState(false);
8
8
  useEffect(() => setWindows(isWindowsDesktop()), []);
9
9
  if (!windows) return null;
10
- return <div className="ah-drag h-9 shrink-0 bg-black" aria-hidden />;
10
+ return (
11
+ <div className="ah-drag flex h-9 shrink-0 items-center bg-black px-3" aria-hidden>
12
+ <span className="hub-brand text-xs text-zinc-400">
13
+ <span className="hub-mark" />
14
+ Agent Hub
15
+ </span>
16
+ </div>
17
+ );
11
18
  }
@@ -113,19 +113,16 @@ export function HubShell() {
113
113
 
114
114
  const selectedSession = sessions.find((session) => session.id === selectedSessionId) ?? null;
115
115
  const selectedAgent = selectedSession ? (agents.find((agent) => agent.id === selectedSession.agentId) ?? null) : null;
116
- const cliMissing = harness !== null && !harness.cursor && !harness.claude;
117
116
  const workspaceMissing = !(settings?.workspace ?? "").trim();
118
117
  const phase = !ready
119
118
  ? loadError
120
119
  ? "error"
121
120
  : "loading"
122
- : workspaceMissing && !selectedSession
123
- ? "workspace"
124
- : agents.length === 0 && !selectedSession
125
- ? "agents"
126
- : selectedSession && selectedAgent
127
- ? "chat"
128
- : "session";
121
+ : agents.length === 0 && !selectedSession
122
+ ? "agents"
123
+ : selectedSession && selectedAgent
124
+ ? "chat"
125
+ : "session";
129
126
 
130
127
  async function commitWorkspace(next?: string) {
131
128
  const workspace = next ?? workspaceDraft;
@@ -347,7 +344,7 @@ export function HubShell() {
347
344
 
348
345
  return (
349
346
  <div className="flex h-dvh min-h-0 w-full overflow-hidden bg-black text-zinc-100">
350
- <aside className="hidden h-full w-[280px] shrink-0 border-r border-zinc-800 md:block">
347
+ <aside className="hidden h-full w-[300px] shrink-0 border-r border-zinc-800 md:block">
351
348
  <Sidebar idPrefix="desktop" {...sidebarProps} />
352
349
  </aside>
353
350
  <div className="flex min-h-0 min-w-0 flex-1 flex-col">
@@ -355,24 +352,25 @@ export function HubShell() {
355
352
  <Button type="button" variant="ghost" size="icon" aria-label="Abrir menu" onClick={() => setSheetOpen(true)}>
356
353
  <Menu />
357
354
  </Button>
358
- <span className="text-sm font-medium">Agent Hub</span>
355
+ <span className="hub-brand text-sm">
356
+ <span className="hub-mark" aria-hidden />
357
+ Agent Hub
358
+ </span>
359
359
  </div>
360
360
  <ChatPane
361
361
  phase={phase}
362
362
  agent={selectedAgent}
363
363
  session={selectedSession}
364
+ agents={agents}
364
365
  messages={messages}
365
366
  live={live}
366
367
  streamError={streamError}
367
368
  running={selectedSession?.status === "running"}
368
- cliMissing={cliMissing}
369
- workspace={workspaceDraft}
369
+ harness={harness}
370
370
  workspaceMissing={workspaceMissing}
371
- ready={ready}
372
- onWorkspaceChange={setWorkspaceDraft}
373
- onWorkspaceCommit={(value) => void commitWorkspace(value)}
374
371
  onCreateAgent={openCreate}
375
372
  onLoadStarters={() => void loadStarters()}
373
+ onNewSession={(agentId) => void createSession(agentId)}
376
374
  onRetry={() => {
377
375
  setReady(false);
378
376
  setLoadError(null);
@@ -394,7 +392,7 @@ export function HubShell() {
394
392
  />
395
393
  </div>
396
394
  <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
397
- <SheetContent side="left" className="w-[280px] max-w-[280px] gap-0 bg-black p-0 sm:max-w-[280px]">
395
+ <SheetContent side="left" className="w-[300px] max-w-[300px] gap-0 bg-black p-0 sm:max-w-[300px]">
398
396
  <SheetTitle className="sr-only">Agent Hub</SheetTitle>
399
397
  <Sidebar idPrefix="mobile" {...sidebarProps} />
400
398
  </SheetContent>
@@ -1,31 +1,22 @@
1
1
  "use client";
2
2
 
3
3
  import Link from "next/link";
4
+ import { MessageSquarePlus, Pencil, Plus, Wallet } from "lucide-react";
4
5
  import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
5
- import { Badge } from "@/components/ui/badge";
6
6
  import { Button } from "@/components/ui/button";
7
7
  import { WorkspaceField } from "@/components/workspace-field";
8
8
  import { ScrollArea } from "@/components/ui/scroll-area";
9
9
  import { Separator } from "@/components/ui/separator";
10
10
  import { sessionsForAgent } from "@/lib/group-sessions";
11
- import { STAGE_LABEL } from "@/lib/labels";
11
+ import { clisMissing, STAGE_LABEL, type HarnessAvailability } from "@/lib/labels";
12
12
  import { cn } from "@/lib/utils";
13
13
  import type { Agent, Session } from "@/lib/types";
14
14
 
15
- export type HarnessAvailability = {
16
- cursor: boolean;
17
- claude: boolean;
18
- };
15
+ export type { HarnessAvailability };
19
16
 
20
- function Chip({ label, ok }: { label: string; ok: boolean }) {
17
+ function Chip({ label, on }: { label: string; on: boolean }) {
21
18
  return (
22
- <span
23
- title={ok ? `${label} disponível` : `${label} ausente`}
24
- className={cn(
25
- "rounded-full border px-2 py-0.5 text-[11px]",
26
- ok ? "border-emerald-500/40 bg-emerald-500/15 text-emerald-400" : "border-zinc-700 bg-zinc-800 text-zinc-500",
27
- )}
28
- >
19
+ <span className={cn("hub-chip", on ? "hub-chip-on" : "hub-chip-off")} title={on ? `${label} no PATH` : `${label} ausente`}>
29
20
  {label}
30
21
  </span>
31
22
  );
@@ -66,11 +57,15 @@ export function Sidebar({
66
57
  }) {
67
58
  const selected = sessions.find((session) => session.id === selectedSessionId);
68
59
  const fallbackAgentId = selected?.agentId ?? agents[0]?.id;
60
+ const mocked = clisMissing(harness);
69
61
 
70
62
  return (
71
- <div className="flex h-full w-full flex-col bg-black text-zinc-100">
63
+ <div className="hub-sidebar flex h-full w-full flex-col text-zinc-100">
72
64
  <div className="flex shrink-0 flex-col gap-3 p-3">
73
- <div className="pr-8 text-sm font-semibold tracking-tight">Agent Hub</div>
65
+ <div className="hub-brand">
66
+ <span className="hub-mark" aria-hidden />
67
+ Agent Hub
68
+ </div>
74
69
  <label className="grid gap-1 text-xs text-zinc-400" htmlFor={`${idPrefix}-workspace`}>
75
70
  Workspace
76
71
  <WorkspaceField
@@ -82,14 +77,29 @@ export function Sidebar({
82
77
  />
83
78
  </label>
84
79
  {harness ? (
85
- <div className="flex flex-wrap gap-1.5">
86
- <Chip label="Cursor" ok={harness.cursor} />
87
- <Chip label="Claude" ok={harness.claude} />
88
- <Chip label="Simulador" ok />
89
- </div>
80
+ mocked ? (
81
+ <p className="hub-status hub-status-mock">
82
+ Simulador ativo. Cursor e Claude Code não estão no PATH — as respostas são fictícias e o disco não é
83
+ alterado.
84
+ </p>
85
+ ) : (
86
+ <div className="grid gap-1.5">
87
+ <p className="hub-status hub-status-live">CLI da assinatura encontrada. O Hub não usa chave de API.</p>
88
+ <div className="flex flex-wrap gap-1.5">
89
+ <Chip label="Cursor" on={harness.cursor} />
90
+ <Chip label="Claude" on={harness.claude} />
91
+ </div>
92
+ </div>
93
+ )
90
94
  ) : (
91
95
  <p className="text-xs text-zinc-500">Verificando CLIs…</p>
92
96
  )}
97
+ {harness && mocked && (
98
+ <div className="flex flex-wrap gap-1.5">
99
+ <Chip label="Cursor" on={false} />
100
+ <Chip label="Claude" on={false} />
101
+ </div>
102
+ )}
93
103
  <div className="grid grid-cols-2 gap-2">
94
104
  <Button
95
105
  type="button"
@@ -99,9 +109,11 @@ export function Sidebar({
99
109
  if (fallbackAgentId) onNewSession(fallbackAgentId);
100
110
  }}
101
111
  >
112
+ <MessageSquarePlus />
102
113
  Nova conversa
103
114
  </Button>
104
115
  <Button type="button" size="sm" variant="outline" onClick={onNewAgent}>
116
+ <Plus />
105
117
  Novo agente
106
118
  </Button>
107
119
  </div>
@@ -123,31 +135,22 @@ export function Sidebar({
123
135
  {agents.map((agent) => {
124
136
  const rows = sessionsForAgent(sessions, agent.id);
125
137
  return (
126
- <AccordionItem key={agent.id} value={agent.id} className="border-zinc-800">
138
+ <AccordionItem key={agent.id} value={agent.id} className="hub-agent border-zinc-800">
127
139
  <AccordionTrigger className="px-2 hover:no-underline">
128
- <span className="flex min-w-0 flex-1 items-center gap-2">
129
- <span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: agent.color }} />
140
+ <span className="hub-agent-title">
141
+ <span className="hub-dot" style={{ backgroundColor: agent.color }} />
130
142
  <span className="truncate">{agent.name}</span>
131
- <Badge variant="secondary" className="ml-auto">
132
- {STAGE_LABEL[agent.stage]}
133
- </Badge>
143
+ <span className="hub-stage">{STAGE_LABEL[agent.stage]}</span>
134
144
  </span>
135
145
  </AccordionTrigger>
136
146
  <AccordionContent className="pb-2">
137
147
  <div className="grid gap-1">
138
148
  <div className="flex items-center justify-between px-2">
139
- <button
140
- type="button"
141
- className="text-xs text-zinc-400 hover:text-zinc-100"
142
- onClick={() => onNewSession(agent.id)}
143
- >
149
+ <button type="button" className="hub-quiet-btn" onClick={() => onNewSession(agent.id)}>
144
150
  Nova conversa
145
151
  </button>
146
- <button
147
- type="button"
148
- className="text-xs text-zinc-400 hover:text-zinc-100"
149
- onClick={() => onEditAgent(agent)}
150
- >
152
+ <button type="button" className="hub-quiet-btn" onClick={() => onEditAgent(agent)}>
153
+ <Pencil className="size-3" />
151
154
  Editar
152
155
  </button>
153
156
  </div>
@@ -161,15 +164,15 @@ export function Sidebar({
161
164
  title={session.title}
162
165
  aria-current={session.id === selectedSessionId ? "true" : undefined}
163
166
  className={cn(
164
- "truncate rounded-md py-1.5 pr-2 text-left text-xs hover:bg-zinc-900",
165
- session.id === selectedSessionId ? "bg-zinc-800 text-zinc-50" : "text-zinc-300",
167
+ "hub-session",
168
+ session.id === selectedSessionId && "hub-session-active",
166
169
  )}
167
170
  style={{ paddingLeft: 8 + depth * 12 }}
168
171
  onClick={() => onSelectSession(session.id)}
169
172
  >
170
- {session.title}
171
- {session.status === "running" ? " · em curso" : ""}
172
- {session.status === "error" ? " · erro" : ""}
173
+ <span className="truncate">{session.title}</span>
174
+ {session.status === "running" && <span className="hub-session-tag">em curso</span>}
175
+ {session.status === "error" && <span className="hub-session-tag hub-session-tag-err">erro</span>}
173
176
  </button>
174
177
  ))
175
178
  )}
@@ -184,7 +187,8 @@ export function Sidebar({
184
187
  </ScrollArea>
185
188
  </div>
186
189
  <Separator className="shrink-0" />
187
- <Link href="/gastos" className="shrink-0 px-3 py-3 text-sm text-zinc-400 hover:text-zinc-100">
190
+ <Link href="/gastos" className="hub-footer-link">
191
+ <Wallet className="size-3.5" />
188
192
  Gastos
189
193
  </Link>
190
194
  </div>
@@ -1,4 +1,5 @@
1
1
  import Link from "next/link";
2
+ import { ArrowLeft } from "lucide-react";
2
3
  import { HARNESS_LABEL } from "@/lib/labels";
3
4
  import { usageDay, type SpendSummary } from "@/lib/spend";
4
5
  import type { Agent, UsageEvent } from "@/lib/types";
@@ -59,7 +60,7 @@ function Table({
59
60
  rows: string[][];
60
61
  }) {
61
62
  return (
62
- <div className="overflow-x-auto">
63
+ <div className="hub-table-wrap">
63
64
  <table className="w-full border-collapse text-left text-sm">
64
65
  <caption className="mb-2 text-left font-medium text-zinc-100">{caption}</caption>
65
66
  <thead>
@@ -133,32 +134,34 @@ export function SpendView({
133
134
  const columns = ["Grupo", "Execuções", "Duração", ...tokenHeaders, "USD"];
134
135
 
135
136
  return (
136
- <main className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 p-6">
137
+ <main className="hub-spend">
137
138
  <div className="flex flex-wrap items-end justify-between gap-3">
138
139
  <div className="grid gap-1">
139
- <h1 className="text-lg font-medium">Gastos</h1>
140
+ <p className="hub-kicker">Uso</p>
141
+ <h1>Gastos</h1>
140
142
  <p className="max-w-2xl text-sm leading-6 text-zinc-400">{DISCLAIMER}</p>
141
143
  </div>
142
- <Link href="/" className="text-sm text-zinc-200 underline">
144
+ <Link href="/" className="hub-quiet-btn">
145
+ <ArrowLeft className="size-3.5" />
143
146
  Voltar ao hub
144
147
  </Link>
145
148
  </div>
146
- <section className="grid gap-3 sm:grid-cols-3">
147
- <div className="rounded-lg border border-zinc-800 px-3 py-3">
148
- <p className="text-xs text-zinc-400">Total USD</p>
149
- <p className="mt-1 text-lg">{summary.hasAnyCost ? formatUsd(summary.totalCostUsd) : "—"}</p>
149
+ <section className="hub-stat-grid">
150
+ <div className="hub-stat">
151
+ <p>Total USD</p>
152
+ <strong>{summary.hasAnyCost ? formatUsd(summary.totalCostUsd) : "—"}</strong>
150
153
  </div>
151
- <div className="rounded-lg border border-zinc-800 px-3 py-3">
152
- <p className="text-xs text-zinc-400">Execuções</p>
153
- <p className="mt-1 text-lg">{events.length}</p>
154
+ <div className="hub-stat">
155
+ <p>Execuções</p>
156
+ <strong>{events.length}</strong>
154
157
  </div>
155
- <div className="rounded-lg border border-zinc-800 px-3 py-3">
156
- <p className="text-xs text-zinc-400">Duração</p>
157
- <p className="mt-1 text-lg">{formatDuration(sumDuration(events))}</p>
158
+ <div className="hub-stat">
159
+ <p>Duração</p>
160
+ <strong>{formatDuration(sumDuration(events))}</strong>
158
161
  </div>
159
162
  </section>
160
163
  {events.length === 0 ? (
161
- <p className="text-sm text-zinc-400">Nenhuma execução registrada.</p>
164
+ <p className="text-sm text-zinc-400">Nenhuma execução registrada. Os turnos do Simulador entram aqui com custo zero.</p>
162
165
  ) : (
163
166
  <div className="grid gap-6">
164
167
  <Table caption="Por agente" columns={["Agente", ...columns.slice(1)]} rows={agentRows} />
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { clisMissing, isHarnessSimulated, liveHarnessLabel, simulatedBanner } from "./labels";
3
+
4
+ describe("harness labels", () => {
5
+ it("treats missing CLIs as the simulator", () => {
6
+ expect(clisMissing(null)).toBe(false);
7
+ expect(clisMissing({ cursor: false, claude: false })).toBe(true);
8
+ expect(clisMissing({ cursor: true, claude: false })).toBe(false);
9
+ });
10
+
11
+ it("simulates a cursor agent when the Cursor CLI is absent", () => {
12
+ expect(isHarnessSimulated("cursor", { cursor: false, claude: true })).toBe(true);
13
+ expect(isHarnessSimulated("cursor", { cursor: true, claude: false })).toBe(false);
14
+ expect(isHarnessSimulated("mock", { cursor: true, claude: true })).toBe(true);
15
+ expect(liveHarnessLabel("cursor", { cursor: false, claude: false })).toBe("Simulador · Cursor");
16
+ expect(liveHarnessLabel("cursor", { cursor: true, claude: false })).toBe("Cursor");
17
+ expect(liveHarnessLabel("mock", { cursor: true, claude: true })).toBe("Simulador");
18
+ expect(simulatedBanner("cursor")).toMatch(/Cursor/);
19
+ expect(simulatedBanner("mock")).toMatch(/Simulador/);
20
+ });
21
+ });
package/src/lib/labels.ts CHANGED
@@ -18,3 +18,32 @@ export const HARNESS_LABEL: Record<HarnessId, string> = {
18
18
 
19
19
  export const STAGES: SdlcStage[] = ["spec", "architect", "implement", "review", "test", "release", "custom"];
20
20
  export const HARNESSES: HarnessId[] = ["cursor", "claude", "mock"];
21
+
22
+ export type HarnessAvailability = {
23
+ cursor: boolean;
24
+ claude: boolean;
25
+ };
26
+
27
+ export function clisMissing(avail: HarnessAvailability | null): boolean {
28
+ return avail !== null && !avail.cursor && !avail.claude;
29
+ }
30
+
31
+ export function isHarnessSimulated(harness: HarnessId, avail: HarnessAvailability | null): boolean {
32
+ if (harness === "mock") return true;
33
+ if (!avail) return false;
34
+ if (harness === "cursor") return !avail.cursor;
35
+ return !avail.claude;
36
+ }
37
+
38
+ export function liveHarnessLabel(harness: HarnessId, avail: HarnessAvailability | null): string {
39
+ if (!isHarnessSimulated(harness, avail)) return HARNESS_LABEL[harness];
40
+ if (harness === "mock") return "Simulador";
41
+ return `Simulador · ${HARNESS_LABEL[harness]}`;
42
+ }
43
+
44
+ export function simulatedBanner(harness: HarnessId): string {
45
+ if (harness === "mock") {
46
+ return "Este agente está no Simulador. As respostas são fictícias e o projeto no disco não é editado.";
47
+ }
48
+ return `A CLI do ${HARNESS_LABEL[harness]} não está no PATH. Este turno usa o Simulador — o projeto no disco não será editado.`;
49
+ }