@payconductor/houndsman 0.6.0 → 0.8.0

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.
Files changed (71) hide show
  1. package/README.md +20 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/command.ts +10 -1
  4. package/src/cli/commands/context-command.ts +40 -2
  5. package/src/cli/commands/hunt-command.ts +28 -2
  6. package/src/cli/commands/review-command.ts +30 -2
  7. package/src/cli/commands/server-command.ts +7 -1
  8. package/src/cli/commands/worktree-command.ts +118 -0
  9. package/src/cli/help.ts +13 -3
  10. package/src/cli/index.ts +14 -4
  11. package/src/core/ast/service.ts +7 -2
  12. package/src/core/brief.ts +1 -0
  13. package/src/core/commands/context.ts +9 -1
  14. package/src/core/commands/findings.ts +17 -6
  15. package/src/core/commands/review.ts +13 -0
  16. package/src/core/commands/scaffold.ts +4 -2
  17. package/src/core/config.ts +13 -0
  18. package/src/core/defaults.ts +8 -1
  19. package/src/core/engine/houndsman-engine.ts +26 -3
  20. package/src/core/engine/review-scope.ts +17 -22
  21. package/src/core/engine/review-session.ts +25 -2
  22. package/src/core/engine/review-workspace.ts +204 -0
  23. package/src/core/git-runner.ts +25 -13
  24. package/src/core/github-review.ts +10 -7
  25. package/src/core/jev/jev-detector.ts +10 -2
  26. package/src/core/orchestrator.ts +62 -13
  27. package/src/core/project-config.ts +12 -0
  28. package/src/core/services/gh-client.ts +38 -22
  29. package/src/core/services/git-repository.ts +46 -0
  30. package/src/core/services/pr-resolver.ts +147 -0
  31. package/src/core/services/worktree-manager.ts +269 -0
  32. package/src/core/storage/file/file-storage.ts +14 -1
  33. package/src/core/storage/paths.ts +30 -1
  34. package/src/core/storage/sqlite/mappers.ts +4 -0
  35. package/src/core/storage/sqlite/schema.ts +13 -3
  36. package/src/core/storage/sqlite/sqlite-storage.ts +28 -8
  37. package/src/core/storage/types.ts +12 -0
  38. package/src/core/storage/work-area.ts +20 -1
  39. package/src/core/types.ts +2 -0
  40. package/src/harness/hunt/hunt-options.ts +17 -0
  41. package/src/harness/hunt/hunt-session.ts +7 -20
  42. package/src/index.ts +31 -0
  43. package/src/server/client/App.tsx +86 -22
  44. package/src/server/client/CommentsPanel.tsx +255 -0
  45. package/src/server/client/DeleteRunModal.tsx +163 -0
  46. package/src/server/client/FindingCard.tsx +12 -4
  47. package/src/server/client/FindingsList.tsx +4 -1
  48. package/src/server/client/ResumePanel.tsx +8 -8
  49. package/src/server/client/Sidebar.tsx +452 -0
  50. package/src/server/client/SubmitModal.tsx +61 -5
  51. package/src/server/client/icons.tsx +29 -0
  52. package/src/server/client/index.tsx +1 -1
  53. package/src/server/client/utils/constants.ts +2 -0
  54. package/src/server/client/utils/format.ts +47 -2
  55. package/src/server/client/utils/types.ts +108 -0
  56. package/src/server/editor.ts +129 -0
  57. package/src/server/index.ts +205 -3
  58. package/src/server/pr-comments.ts +256 -0
  59. package/src/server/pr-inbox.ts +213 -0
  60. package/src/server/run-repository.ts +136 -3
  61. package/src/skill/README.md +11 -3
  62. package/src/skill/SKILL.md +44 -10
  63. package/src/skill/hunt.md +33 -7
  64. package/src/tests/__snapshots__/baseline.test.ts.snap +94 -24
  65. package/src/tests/ai-core.test.ts +2 -2
  66. package/src/tests/cli.test.ts +35 -1
  67. package/src/tests/github-review.test.ts +9 -0
  68. package/src/tests/server-editor.test.ts +100 -0
  69. package/src/tests/server.test.ts +333 -2
  70. package/src/tests/worktree.test.ts +369 -0
  71. package/src/server/client/RunList.tsx +0 -75
@@ -0,0 +1,452 @@
1
+ /**
2
+ * Sidebar: logo do houndsman + abas `Execuções` e `PR's`.
3
+ *
4
+ * A aba de execuções mantém a lista de runs (status, data e findings). A aba de
5
+ * PRs lista as PRs abertas via `gh`, permite abrir a run correspondente e faz
6
+ * checkout somente quando a árvore de trabalho está limpa.
7
+ */
8
+
9
+ import { useCallback, useEffect, useRef, useState } from "preact/hooks";
10
+ import {
11
+ BranchIcon,
12
+ Clock,
13
+ GitHub,
14
+ GitPullRequest,
15
+ ListIcon,
16
+ Refresh,
17
+ Sparkles,
18
+ Spinner,
19
+ } from "./icons";
20
+ import { api } from "./utils/api";
21
+ import { describeRunStatus } from "./utils/constants";
22
+ import { formatRunTime } from "./utils/format";
23
+ import type {
24
+ CheckoutResponse,
25
+ PrListItem,
26
+ PrListResponse,
27
+ PrWorktreeResponse,
28
+ RunSummary,
29
+ WorktreeState,
30
+ } from "./utils/types";
31
+
32
+ type SidebarView = "runs" | "prs";
33
+
34
+ const REVIEW_LABELS: Record<string, string> = {
35
+ APPROVED: "aprovada",
36
+ CHANGES_REQUESTED: "mudanças",
37
+ REVIEW_REQUIRED: "revisão pendente",
38
+ };
39
+
40
+ function RunItem({
41
+ run,
42
+ active,
43
+ onSelect,
44
+ }: {
45
+ run: RunSummary;
46
+ active: boolean;
47
+ onSelect: (slug: string) => void;
48
+ }) {
49
+ const status = describeRunStatus(run);
50
+ const fixReady = status?.kind === "fix_done";
51
+ return (
52
+ <button
53
+ type="button"
54
+ title={`Abrir a run da branch ${run.branch}`}
55
+ class={`run${active ? " active" : ""}`}
56
+ onClick={() => onSelect(run.slug)}
57
+ >
58
+ <span class="run-top">
59
+ <BranchIcon size={14} class="run-icon" />
60
+ <span class="run-branch">{run.branch}</span>
61
+ </span>
62
+ <span class="run-meta">
63
+ <span class="run-when">
64
+ <Clock size={12} />
65
+ {formatRunTime(run.updatedAt) || "sem data"}
66
+ </span>
67
+ <span
68
+ class="run-count"
69
+ title={`${run.summary.present} de ${run.summary.total} findings presentes`}
70
+ >
71
+ {run.summary.present}/{run.summary.total}
72
+ </span>
73
+ </span>
74
+ {fixReady ? (
75
+ <span class="run-badge">
76
+ <Sparkles size={12} /> Correção pronta
77
+ </span>
78
+ ) : (
79
+ status && (
80
+ <span class={`run-status status-${status.kind}`}>{status.label}</span>
81
+ )
82
+ )}
83
+ </button>
84
+ );
85
+ }
86
+
87
+ function PrItem({
88
+ pr,
89
+ currentBranch,
90
+ worktree,
91
+ busy,
92
+ confirming,
93
+ worktreeBusy,
94
+ onConfirm,
95
+ onCancel,
96
+ onCheckout,
97
+ onCreateWorktree,
98
+ onSelectRun,
99
+ }: {
100
+ pr: PrListItem;
101
+ currentBranch: string | null;
102
+ worktree: WorktreeState;
103
+ busy: boolean;
104
+ confirming: boolean;
105
+ worktreeBusy: boolean;
106
+ onConfirm: (number: number) => void;
107
+ onCancel: () => void;
108
+ onCheckout: (pr: PrListItem) => void;
109
+ onCreateWorktree: (pr: PrListItem) => void;
110
+ onSelectRun: (slug: string) => void;
111
+ }) {
112
+ const isCurrent = currentBranch === pr.headRefName;
113
+ const runSlug = pr.runSlug;
114
+ const review = pr.reviewDecision ? REVIEW_LABELS[pr.reviewDecision] : undefined;
115
+ return (
116
+ <article class={`pr${isCurrent ? " current" : ""}`}>
117
+ <div class="pr-head">
118
+ <GitPullRequest size={14} class="pr-icon" />
119
+ <span class="pr-number">#{pr.number}</span>
120
+ {pr.isDraft && <span class="tag">draft</span>}
121
+ {isCurrent && <span class="pr-current">atual</span>}
122
+ <span class="spacer" />
123
+ {pr.updatedAt && (
124
+ <span class="run-when">
125
+ <Clock size={12} />
126
+ {formatRunTime(pr.updatedAt)}
127
+ </span>
128
+ )}
129
+ </div>
130
+ <p class="pr-title">{pr.title}</p>
131
+ <p class="pr-meta">
132
+ <span class="pr-branch">{pr.headRefName}</span>
133
+ <span class="pr-arrow">→</span>
134
+ <span>{pr.baseRefName || "—"}</span>
135
+ <span>· {pr.author}</span>
136
+ </p>
137
+ {review && <p class={`pr-review pr-review-${pr.reviewDecision}`}>{review}</p>}
138
+ {pr.worktreePath && (
139
+ <p class="pr-worktree" title={pr.worktreePath}>
140
+ Worktree: <code>{pr.worktreePath}</code>
141
+ </p>
142
+ )}
143
+ <div class="pr-actions">
144
+ {runSlug && (
145
+ <button
146
+ type="button"
147
+ class="pr-btn"
148
+ title="Abrir a run local desta branch"
149
+ onClick={() => onSelectRun(runSlug)}
150
+ >
151
+ Abrir run
152
+ </button>
153
+ )}
154
+ {!pr.worktreePath && (
155
+ <button
156
+ type="button"
157
+ class="pr-btn"
158
+ disabled={busy || worktreeBusy}
159
+ title={`Criar um worktree dedicado para ${pr.headRefName} (revisão paralela)`}
160
+ onClick={() => onCreateWorktree(pr)}
161
+ >
162
+ {worktreeBusy ? (
163
+ <Spinner size={12} class="icon spin" />
164
+ ) : (
165
+ "Worktree"
166
+ )}
167
+ </button>
168
+ )}
169
+ {pr.url && (
170
+ <a
171
+ class="pr-btn"
172
+ href={pr.url}
173
+ target="_blank"
174
+ rel="noreferrer"
175
+ title="Abrir a PR no GitHub"
176
+ >
177
+ <GitHub size={12} /> GitHub
178
+ </a>
179
+ )}
180
+ {confirming ? (
181
+ <>
182
+ <button
183
+ type="button"
184
+ class="pr-btn danger"
185
+ disabled={busy}
186
+ title="Confirmar checkout da PR"
187
+ onClick={() => onCheckout(pr)}
188
+ >
189
+ {busy ? <Spinner size={12} class="icon spin" /> : "Confirmar"}
190
+ </button>
191
+ <button
192
+ type="button"
193
+ class="pr-btn"
194
+ disabled={busy}
195
+ onClick={onCancel}
196
+ >
197
+ Cancelar
198
+ </button>
199
+ </>
200
+ ) : (
201
+ <button
202
+ type="button"
203
+ class="pr-btn"
204
+ disabled={!worktree.clean || busy}
205
+ title={
206
+ worktree.clean
207
+ ? `Fazer checkout da branch ${pr.headRefName}`
208
+ : "Há alterações não commitadas — faça commit ou stash antes"
209
+ }
210
+ onClick={() => onConfirm(pr.number)}
211
+ >
212
+ Checkout
213
+ </button>
214
+ )}
215
+ </div>
216
+ </article>
217
+ );
218
+ }
219
+
220
+ export function Sidebar({
221
+ runs,
222
+ slug,
223
+ onSelect,
224
+ onRefreshRuns,
225
+ }: {
226
+ runs: RunSummary[];
227
+ slug: string | null;
228
+ onSelect: (slug: string) => void;
229
+ onRefreshRuns: () => void;
230
+ }) {
231
+ const [view, setView] = useState<SidebarView>("runs");
232
+ const [data, setData] = useState<PrListResponse | null>(null);
233
+ const [loading, setLoading] = useState(false);
234
+ const [error, setError] = useState("");
235
+ const [notice, setNotice] = useState("");
236
+ const [busy, setBusy] = useState<number | null>(null);
237
+ const [worktreeBusy, setWorktreeBusy] = useState<number | null>(null);
238
+ const [confirming, setConfirming] = useState<number | null>(null);
239
+ const requested = useRef(false);
240
+
241
+ const loadPrs = useCallback(async () => {
242
+ setLoading(true);
243
+ setError("");
244
+ try {
245
+ setData(await api<PrListResponse>("/api/prs"));
246
+ } catch (err) {
247
+ setError(String(err));
248
+ } finally {
249
+ setLoading(false);
250
+ }
251
+ }, []);
252
+
253
+ useEffect(() => {
254
+ if (view !== "prs" || requested.current) return;
255
+ requested.current = true;
256
+ void loadPrs();
257
+ }, [view, loadPrs]);
258
+
259
+ const checkout = useCallback(
260
+ async (pr: PrListItem) => {
261
+ setBusy(pr.number);
262
+ setError("");
263
+ setNotice("");
264
+ try {
265
+ const result = await api<CheckoutResponse>("/api/prs/checkout", {
266
+ method: "POST",
267
+ headers: { "content-type": "application/json" },
268
+ body: JSON.stringify({ number: pr.number }),
269
+ });
270
+ if (!result.ok) {
271
+ if (result.reason === "dirty") {
272
+ setData((current) =>
273
+ current
274
+ ? {
275
+ ...current,
276
+ worktree: {
277
+ ...current.worktree,
278
+ clean: false,
279
+ changes:
280
+ result.changes?.length ??
281
+ current.worktree.changes,
282
+ sample:
283
+ result.changes ?? current.worktree.sample,
284
+ },
285
+ }
286
+ : current,
287
+ );
288
+ }
289
+ setError(
290
+ result.reason === "dirty"
291
+ ? `Checkout bloqueado: ${result.detail}`
292
+ : result.detail,
293
+ );
294
+ return;
295
+ }
296
+ setConfirming(null);
297
+ setNotice(
298
+ `Checkout de #${pr.number} concluído${
299
+ result.branch ? ` — ${result.branch}` : ""
300
+ }.`,
301
+ );
302
+ await loadPrs();
303
+ onRefreshRuns();
304
+ if (pr.runSlug) onSelect(pr.runSlug);
305
+ } catch (err) {
306
+ setError(String(err));
307
+ } finally {
308
+ setBusy(null);
309
+ }
310
+ },
311
+ [loadPrs, onRefreshRuns, onSelect],
312
+ );
313
+ const createWorktree = useCallback(
314
+ async (pr: PrListItem) => {
315
+ setWorktreeBusy(pr.number);
316
+ setError("");
317
+ setNotice("");
318
+ try {
319
+ const result = await api<PrWorktreeResponse>("/api/prs/worktree", {
320
+ method: "POST",
321
+ headers: { "content-type": "application/json" },
322
+ body: JSON.stringify({ number: pr.number }),
323
+ });
324
+ if (!result.ok) {
325
+ setError(result.detail);
326
+ return;
327
+ }
328
+ setNotice(
329
+ `Worktree de #${pr.number} em ${result.path}. ` +
330
+ `Rode \`houndsman --pr ${pr.number}\` para revisar em paralelo.`,
331
+ );
332
+ await loadPrs();
333
+ } catch (err) {
334
+ setError(String(err));
335
+ } finally {
336
+ setWorktreeBusy(null);
337
+ }
338
+ },
339
+ [loadPrs],
340
+ );
341
+ return (
342
+ <aside class="sidebar">
343
+ <div class="brand">
344
+ <img class="logo" src="/assets/logo.png" alt="" />
345
+ <div>
346
+ <h1>Houndsman</h1>
347
+ <p class="sub">Revisões e triagem</p>
348
+ </div>
349
+ </div>
350
+
351
+ <div class="side-tabs" role="tablist" aria-label="Navegação lateral">
352
+ <button
353
+ type="button"
354
+ role="tab"
355
+ aria-selected={view === "runs"}
356
+ class={`side-tab${view === "runs" ? " active" : ""}`}
357
+ onClick={() => setView("runs")}
358
+ >
359
+ <ListIcon size={13} /> Execuções
360
+ <span class="side-count">{runs.length}</span>
361
+ </button>
362
+ <button
363
+ type="button"
364
+ role="tab"
365
+ aria-selected={view === "prs"}
366
+ class={`side-tab${view === "prs" ? " active" : ""}`}
367
+ onClick={() => setView("prs")}
368
+ >
369
+ <GitPullRequest size={13} /> PR's
370
+ </button>
371
+ </div>
372
+
373
+ {view === "runs" ? (
374
+ <nav class="runs" aria-label="Execuções">
375
+ {runs.length === 0 && <p class="empty">Nenhuma run encontrada.</p>}
376
+ {runs.map((run) => (
377
+ <RunItem
378
+ key={run.slug}
379
+ run={run}
380
+ active={run.slug === slug}
381
+ onSelect={onSelect}
382
+ />
383
+ ))}
384
+ </nav>
385
+ ) : (
386
+ <>
387
+ <div class="pr-toolbar">
388
+ <p class="section-label">PRs abertas</p>
389
+ <button
390
+ type="button"
391
+ class="icon-btn"
392
+ title="Atualizar lista de PRs"
393
+ disabled={loading}
394
+ onClick={() => void loadPrs()}
395
+ >
396
+ <Refresh
397
+ size={13}
398
+ class={loading ? "icon spin" : undefined}
399
+ />
400
+ </button>
401
+ </div>
402
+
403
+ {error && <p class="error">{error}</p>}
404
+ {notice && <p class="pr-notice">{notice}</p>}
405
+ {data && !data.available && (
406
+ <p class="empty">
407
+ GitHub indisponível
408
+ {data.reason ? `: ${data.reason}` : "."}
409
+ </p>
410
+ )}
411
+ {data && !data.worktree.available && (
412
+ <p class="empty">Não foi possível ler o estado do git.</p>
413
+ )}
414
+ {data?.worktree.available && !data.worktree.clean && (
415
+ <p class="pr-warning">
416
+ {data.worktree.changes} alteração(ões) não commitada(s) —
417
+ checkout bloqueado até limpar a árvore.
418
+ </p>
419
+ )}
420
+ {data?.currentBranch && (
421
+ <p class="pr-current-branch">
422
+ Branch atual: <code>{data.currentBranch}</code>
423
+ </p>
424
+ )}
425
+
426
+ <nav class="prs" aria-label="Pull requests abertas">
427
+ {loading && !data && <p class="empty">Carregando PRs…</p>}
428
+ {data?.available && data.prs.length === 0 && (
429
+ <p class="empty">Nenhuma PR aberta.</p>
430
+ )}
431
+ {data?.prs.map((pr) => (
432
+ <PrItem
433
+ key={pr.number}
434
+ pr={pr}
435
+ currentBranch={data.currentBranch}
436
+ worktree={data.worktree}
437
+ busy={busy !== null}
438
+ confirming={confirming === pr.number}
439
+ worktreeBusy={worktreeBusy === pr.number}
440
+ onConfirm={setConfirming}
441
+ onCancel={() => setConfirming(null)}
442
+ onCheckout={checkout}
443
+ onCreateWorktree={createWorktree}
444
+ onSelectRun={onSelect}
445
+ />
446
+ ))}
447
+ </nav>
448
+ </>
449
+ )}
450
+ </aside>
451
+ );
452
+ }
@@ -2,8 +2,9 @@
2
2
  * Modal "Enviar revisão": publica os findings selecionados como comentários na
3
3
  * Pull Request (`POST /submit`, via `gh`).
4
4
  *
5
- * Filtra por severidade e estado de execução, mostra uma prévia curta do que
6
- * será disparado e o resultado (com o link do comentário geral, quando houver).
5
+ * Filtra por severidade, decisão (a executar) e estado de execução, mostra uma
6
+ * prévia curta do que será disparado e o resultado (com o link do comentário
7
+ * geral, quando houver).
7
8
  * Fail-open: sem `gh`/PR o servidor devolve `available:false` e o modal só
8
9
  * informa — nada é postado.
9
10
  */
@@ -11,8 +12,14 @@
11
12
  import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
12
13
  import { GitHub, Send, Spinner } from "./icons";
13
14
  import { api } from "./utils/api";
14
- import { EXEC_STATES, SEVERITY_LABELS, SEVERITY_ORDER } from "./utils/constants";
15
+ import {
16
+ DECISION_LABELS,
17
+ EXEC_STATES,
18
+ SEVERITY_LABELS,
19
+ SEVERITY_ORDER,
20
+ } from "./utils/constants";
15
21
  import type {
22
+ Decision,
16
23
  ExecState,
17
24
  Severity,
18
25
  SubmitOutcome,
@@ -27,6 +34,9 @@ const EXEC_LABELS: Record<ExecState, string> = {
27
34
  stale: "Stale",
28
35
  };
29
36
 
37
+ /** Decisões elegíveis ao envio: `ignore` fica de fora por definição. */
38
+ const SUBMIT_DECISIONS: Decision[] = ["execute", "pending"];
39
+
30
40
  export function SubmitModal({
31
41
  slug,
32
42
  branch,
@@ -42,6 +52,7 @@ export function SubmitModal({
42
52
  }) {
43
53
  const [preview, setPreview] = useState<SubmitPreview | null>(null);
44
54
  const [severities, setSeverities] = useState<Severity[]>([]);
55
+ const [decisions, setDecisions] = useState<Decision[]>([]);
45
56
  const [execStates, setExecStates] = useState<ExecState[]>([]);
46
57
  const [includeSent, setIncludeSent] = useState(false);
47
58
  const [loading, setLoading] = useState(false);
@@ -86,6 +97,7 @@ export function SubmitModal({
86
97
  if (open) return;
87
98
  setPreview(null);
88
99
  setSeverities([]);
100
+ setDecisions([]);
89
101
  setExecStates([]);
90
102
  setIncludeSent(false);
91
103
  setLoading(false);
@@ -106,6 +118,12 @@ export function SubmitModal({
106
118
  ? current.filter((item) => item !== value)
107
119
  : [...current, value],
108
120
  );
121
+ const toggleDecision = (value: Decision) =>
122
+ setDecisions((current) =>
123
+ current.includes(value)
124
+ ? current.filter((item) => item !== value)
125
+ : [...current, value],
126
+ );
109
127
 
110
128
  const selected = useMemo(
111
129
  () =>
@@ -113,9 +131,14 @@ export function SubmitModal({
113
131
  (candidate) =>
114
132
  (severities.length === 0 ||
115
133
  severities.includes(candidate.severity)) &&
134
+ // Servidor antigo ainda em memória pode não mandar `decision`:
135
+ // nesse caso não escondemos o candidato do filtro.
136
+ (decisions.length === 0 ||
137
+ !candidate.decision ||
138
+ decisions.includes(candidate.decision)) &&
116
139
  (execStates.length === 0 || execStates.includes(candidate.execState)),
117
140
  ),
118
- [preview, severities, execStates],
141
+ [preview, severities, decisions, execStates],
119
142
  );
120
143
 
121
144
  const toSend = useMemo(
@@ -140,6 +163,7 @@ export function SubmitModal({
140
163
  headers: { "content-type": "application/json" },
141
164
  body: JSON.stringify({
142
165
  severities: severities.length > 0 ? severities : undefined,
166
+ decisions: decisions.length > 0 ? decisions : undefined,
143
167
  execStates: execStates.length > 0 ? execStates : undefined,
144
168
  includeSent,
145
169
  }),
@@ -155,7 +179,7 @@ export function SubmitModal({
155
179
  } finally {
156
180
  setPosting(false);
157
181
  }
158
- }, [slug, severities, execStates, includeSent, onSubmitted]);
182
+ }, [slug, severities, decisions, execStates, includeSent, onSubmitted]);
159
183
 
160
184
  if (!open) return null;
161
185
 
@@ -239,6 +263,38 @@ export function SubmitModal({
239
263
  </div>
240
264
  </div>
241
265
 
266
+ <div class="submit-field">
267
+ <span class="submit-label">Decisão</span>
268
+ <div class="chips">
269
+ <button
270
+ type="button"
271
+ class={`chip${decisions.length === 0 ? " active" : ""}`}
272
+ onClick={() => setDecisions([])}
273
+ >
274
+ Todas
275
+ </button>
276
+ {SUBMIT_DECISIONS.map((decision) => (
277
+ <button
278
+ type="button"
279
+ key={decision}
280
+ title={
281
+ decision === "execute"
282
+ ? "Findings marcados como Executar (a executar)"
283
+ : "Findings ainda não triados"
284
+ }
285
+ class={`chip dec-${decision}${
286
+ decisions.includes(decision)
287
+ ? " active"
288
+ : ""
289
+ }`}
290
+ onClick={() => toggleDecision(decision)}
291
+ >
292
+ {DECISION_LABELS[decision]}
293
+ </button>
294
+ ))}
295
+ </div>
296
+ </div>
297
+
242
298
  <div class="submit-field">
243
299
  <span class="submit-label">Execução</span>
244
300
  <div class="chips">
@@ -120,6 +120,16 @@ export const Alert = (p: IconProps) => (
120
120
  </Svg>
121
121
  );
122
122
 
123
+ /** Lixeira — excluir a run. */
124
+ export const Trash = (p: IconProps) => (
125
+ <Svg {...p}>
126
+ <path d="M4 7h16" />
127
+ <path d="M9 7V4h6v3" />
128
+ <path d="m6.5 7 1 13h9l1-13" />
129
+ <path d="M10 11v6M14 11v6" />
130
+ </Svg>
131
+ );
132
+
123
133
  export const ListIcon = (p: IconProps) => (
124
134
  <Svg {...p}>
125
135
  <path d="M9 6h12M9 12h12M9 18h12" />
@@ -158,3 +168,22 @@ export const Send = (p: IconProps) => (
158
168
  <path d="M22 2 15 22l-4-9-9-4z" />
159
169
  </Svg>
160
170
  );
171
+
172
+ /** Pull request — aba de PRs da sidebar. */
173
+ export const GitPullRequest = (p: IconProps) => (
174
+ <Svg {...p}>
175
+ <circle cx="6" cy="6" r="2.4" />
176
+ <circle cx="6" cy="18" r="2.4" />
177
+ <circle cx="18" cy="18" r="2.4" />
178
+ <path d="M6 8.4v7.2" />
179
+ <path d="M18 15.6V12a4 4 0 0 0-4-4h-2.5" />
180
+ <path d="m13.5 5.5-2.5 2.5 2.5 2.5" />
181
+ </Svg>
182
+ );
183
+
184
+ /** Balão de conversa — aba de comentários. */
185
+ export const MessageSquare = (p: IconProps) => (
186
+ <Svg {...p}>
187
+ <path d="M21 15a3 3 0 0 1-3 3H8l-5 3V6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3z" />
188
+ </Svg>
189
+ );
@@ -2,7 +2,7 @@
2
2
  * Entry do cliente (Preact): monta o `<App />` no `#app`.
3
3
  *
4
4
  * Bundle gerado em memória por `Bun.build` (`client-build.ts`). Os componentes
5
- * vivem em arquivos irmãos nesta pasta (`App.tsx`, `RunList.tsx`, ...).
5
+ * vivem em arquivos irmãos nesta pasta (`App.tsx`, `Sidebar.tsx`, ...).
6
6
  */
7
7
 
8
8
  import { render } from "preact";
@@ -245,4 +245,6 @@ export const TAB_CAPTIONS = {
245
245
  "Triagem dos achados da revisão: decida Executar, Ignorar ou deixe Pendente — o comentário orienta o auto-fix. Agente: houndsman findings list --decision pending --json · findings done <id>.",
246
246
  artifacts:
247
247
  "Artefatos persistidos da run: relatórios, briefs de auditoria, manifesto, classificação, Jev, contexto da PR e verificação. JSON com destaque de sintaxe; markdown renderizado com highlight de código.",
248
+ comments:
249
+ "Comentários da PR vinculada à branch: threads de revisão agrupadas por arquivo e comentários gerais. Carregados sob demanda ao abrir esta aba.",
248
250
  } as const;
@@ -1,8 +1,10 @@
1
1
  /**
2
- * Helpers de apresentação: data/hora das runs e link para abrir o arquivo no
3
- * VS Code (`vscode://file/...`).
2
+ * Helpers de apresentação: data/hora das runs e links para abrir o arquivo no
3
+ * editor detectado pelo servidor (`vscode://` ou `jetbrains://`).
4
4
  */
5
5
 
6
+ import type { EditorContext } from "./types";
7
+
6
8
  /** Data/hora curta em pt-BR — ex.: `14 de out., 17:48`. */
7
9
  export function formatRunTime(iso?: string): string {
8
10
  if (!iso) return "";
@@ -38,3 +40,46 @@ export function vscodeFileLink(file: string, line?: number | null): string | nul
38
40
  );
39
41
  return `vscode://file/${encodeURI(normalized)}${line ? `:${line}` : ""}`;
40
42
  }
43
+
44
+ /**
45
+ * Link `jetbrains://<produto>/navigate/reference?project=<nome>&path=<arquivo>`.
46
+ * O IDE espera o caminho absoluto com a linha no fim (`arquivo:linha`).
47
+ */
48
+ export function jetbrainsFileLink(
49
+ file: string,
50
+ line: number | null | undefined,
51
+ project: string,
52
+ product: string,
53
+ ): string | null {
54
+ const path = file.replace(/\\/g, "/");
55
+ if (!/^([a-zA-Z]:\/|\/)/.test(path)) return null;
56
+ const target = `${path}${line ? `:${line}` : ""}`;
57
+ return `jetbrains://${product}/navigate/reference?project=${encodeURIComponent(
58
+ project,
59
+ )}&path=${encodeURIComponent(target)}`;
60
+ }
61
+
62
+ /** Monta o link de código conforme o editor detectado (null = sem link). */
63
+ export function editorFileLink(
64
+ file: string,
65
+ line: number | null | undefined,
66
+ editor: EditorContext,
67
+ ): string | null {
68
+ if (editor.kind === "vscode") return vscodeFileLink(file, line);
69
+ if (editor.kind === "jetbrains") {
70
+ return jetbrainsFileLink(
71
+ file,
72
+ line,
73
+ editor.project ?? "",
74
+ editor.product ?? "idea",
75
+ );
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /** Rótulo do editor para tooltips. */
81
+ export function editorLabel(editor: EditorContext): string {
82
+ if (editor.kind === "jetbrains") return "JetBrains";
83
+ if (editor.kind === "vscode") return "VS Code";
84
+ return "editor";
85
+ }