@payconductor/houndsman 0.7.0 → 0.10.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 (88) 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/worktree-command.ts +118 -0
  8. package/src/cli/help.ts +12 -2
  9. package/src/cli/index.ts +14 -4
  10. package/src/core/ai/ai-client.ts +21 -0
  11. package/src/core/ai/ai-sdk-model-provider.ts +43 -18
  12. package/src/core/ai/features/technical-summarizer.ts +43 -0
  13. package/src/core/ai/index.ts +4 -1
  14. package/src/core/ai/prompts/context.ts +3 -3
  15. package/src/core/ai/prompts/technical.ts +30 -0
  16. package/src/core/ai/provider-capabilities.ts +45 -0
  17. package/src/core/ai/schemas.ts +19 -3
  18. package/src/core/ai/types.ts +3 -1
  19. package/src/core/ast/service.ts +7 -2
  20. package/src/core/brief.ts +26 -4
  21. package/src/core/commands/context.ts +9 -1
  22. package/src/core/commands/findings.ts +11 -5
  23. package/src/core/commands/review.ts +13 -0
  24. package/src/core/commands/scaffold.ts +9 -2
  25. package/src/core/config.ts +30 -1
  26. package/src/core/defaults.ts +7 -0
  27. package/src/core/engine/houndsman-engine.ts +26 -3
  28. package/src/core/engine/review-scope.ts +17 -22
  29. package/src/core/engine/review-session.ts +25 -2
  30. package/src/core/engine/review-workspace.ts +204 -0
  31. package/src/core/git-runner.ts +8 -1
  32. package/src/core/jev/jev-detector.ts +10 -2
  33. package/src/core/orchestrator.ts +176 -15
  34. package/src/core/paths.ts +3 -0
  35. package/src/core/pr-context.ts +11 -6
  36. package/src/core/project-config.ts +21 -1
  37. package/src/core/renderers/resume-renderer.ts +17 -34
  38. package/src/core/report-builder.ts +6 -6
  39. package/src/core/services/git-repository.ts +46 -0
  40. package/src/core/services/pr-resolver.ts +147 -0
  41. package/src/core/services/worktree-manager.ts +293 -0
  42. package/src/core/storage/file/file-storage.ts +14 -1
  43. package/src/core/storage/paths.ts +30 -1
  44. package/src/core/storage/sqlite/mappers.ts +4 -0
  45. package/src/core/storage/sqlite/schema.ts +13 -3
  46. package/src/core/storage/sqlite/sqlite-storage.ts +28 -8
  47. package/src/core/storage/types.ts +13 -0
  48. package/src/core/storage/work-area.ts +20 -1
  49. package/src/core/technical-summary.ts +398 -0
  50. package/src/core/types.ts +2 -0
  51. package/src/harness/agent/agent-factory.ts +1 -0
  52. package/src/harness/agent/review-agent.ts +11 -1
  53. package/src/harness/config/harness-config.ts +6 -1
  54. package/src/harness/hunt/hunt-options.ts +17 -0
  55. package/src/harness/hunt/hunt-session.ts +7 -20
  56. package/src/index.ts +33 -0
  57. package/src/server/client/App.tsx +51 -12
  58. package/src/server/client/DeleteRunModal.tsx +163 -0
  59. package/src/server/client/PrsSection.tsx +296 -0
  60. package/src/server/client/ResumePanel.tsx +17 -20
  61. package/src/server/client/RunsSection.tsx +73 -0
  62. package/src/server/client/Sidebar.tsx +171 -358
  63. package/src/server/client/icons.tsx +10 -0
  64. package/src/server/client/sidebar-sections.tsx +73 -0
  65. package/src/server/client/utils/constants.ts +7 -2
  66. package/src/server/client/utils/sidebar-prefs.ts +64 -0
  67. package/src/server/client/utils/types.ts +27 -0
  68. package/src/server/index.ts +113 -406
  69. package/src/server/pr-inbox.ts +15 -4
  70. package/src/server/run-repository.ts +103 -2
  71. package/src/server/styles.css +454 -0
  72. package/src/skill/README.md +11 -3
  73. package/src/skill/SKILL.md +44 -10
  74. package/src/skill/hunt.md +33 -7
  75. package/src/tests/__snapshots__/baseline.test.ts.snap +99 -24
  76. package/src/tests/ai-core.test.ts +71 -0
  77. package/src/tests/ai-features.test.ts +36 -0
  78. package/src/tests/baseline.test.ts +4 -1
  79. package/src/tests/cli.test.ts +2 -1
  80. package/src/tests/harness-agent.test.ts +19 -0
  81. package/src/tests/review-resume.test.ts +36 -8
  82. package/src/tests/review-scope.test.ts +67 -0
  83. package/src/tests/review-scripts.test.ts +2 -2
  84. package/src/tests/server.test.ts +54 -1
  85. package/src/tests/sidebar-sections.test.ts +94 -0
  86. package/src/tests/technical-summary.test.ts +176 -0
  87. package/src/tests/worktree.test.ts +404 -0
  88. package/assets/logo-legacy.png +0 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Seção `Execuções` da sidebar: lista das runs revisadas, com status, data e
3
+ * contagem de findings. Selecionar uma run troca apenas o conteúdo principal.
4
+ */
5
+
6
+ import { BranchIcon, Clock, Sparkles } from "./icons";
7
+ import type { SidebarSectionContext } from "./sidebar-sections";
8
+ import { describeRunStatus } from "./utils/constants";
9
+ import { formatRunTime } from "./utils/format";
10
+ import type { RunSummary } from "./utils/types";
11
+
12
+ function RunItem({
13
+ run,
14
+ active,
15
+ onSelect,
16
+ }: {
17
+ run: RunSummary;
18
+ active: boolean;
19
+ onSelect: (slug: string) => void;
20
+ }) {
21
+ const status = describeRunStatus(run);
22
+ const fixReady = status?.kind === "fix_done";
23
+ return (
24
+ <button
25
+ type="button"
26
+ title={`Abrir a run da branch ${run.branch}`}
27
+ class={`run${active ? " active" : ""}`}
28
+ onClick={() => onSelect(run.slug)}
29
+ >
30
+ <span class="run-top">
31
+ <BranchIcon size={14} class="run-icon" />
32
+ <span class="run-branch">{run.branch}</span>
33
+ </span>
34
+ <span class="run-meta">
35
+ <span class="run-when">
36
+ <Clock size={12} />
37
+ {formatRunTime(run.updatedAt) || "sem data"}
38
+ </span>
39
+ <span
40
+ class="run-count"
41
+ title={`${run.summary.present} de ${run.summary.total} findings presentes`}
42
+ >
43
+ {run.summary.present}/{run.summary.total}
44
+ </span>
45
+ </span>
46
+ {fixReady ? (
47
+ <span class="run-badge">
48
+ <Sparkles size={12} /> Correção pronta
49
+ </span>
50
+ ) : (
51
+ status && (
52
+ <span class={`run-status status-${status.kind}`}>{status.label}</span>
53
+ )
54
+ )}
55
+ </button>
56
+ );
57
+ }
58
+
59
+ export function RunsSection({ runs, slug, onSelect }: SidebarSectionContext) {
60
+ return (
61
+ <nav class="runs" aria-label="Execuções">
62
+ {runs.length === 0 && <p class="empty">Nenhuma run encontrada.</p>}
63
+ {runs.map((run) => (
64
+ <RunItem
65
+ key={run.slug}
66
+ run={run}
67
+ active={run.slug === slug}
68
+ onSelect={onSelect}
69
+ />
70
+ ))}
71
+ </nav>
72
+ );
73
+ }
@@ -1,196 +1,30 @@
1
1
  /**
2
- * Sidebar: logo do houndsman + abas `Execuções` e `PR's`.
2
+ * Sidebar do servidor: rail vertical de seções (padrão VS Code) + painel
3
+ * expansível. A navegação vem do registry `sidebar-sections.tsx`; a rail fica
4
+ * sempre visível, inclusive com o painel recolhido.
3
5
  *
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.
6
+ * Largura, colapso e seção ativa são preferências locais (`sidebar-prefs.ts`).
7
7
  */
8
8
 
9
9
  import { useCallback, useEffect, useRef, useState } from "preact/hooks";
10
+ import { ChevronRight } from "./icons";
10
11
  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
- RunSummary,
28
- WorktreeState,
29
- } from "./utils/types";
30
-
31
- type SidebarView = "runs" | "prs";
12
+ getSidebarSection,
13
+ resolveSidebarSectionId,
14
+ SIDEBAR_SECTIONS,
15
+ type SidebarSectionContext,
16
+ } from "./sidebar-sections";
17
+ import {
18
+ clampSidebarWidth,
19
+ readSidebarPrefs,
20
+ SIDEBAR_MAX_WIDTH,
21
+ SIDEBAR_MIN_WIDTH,
22
+ saveSidebarPrefs,
23
+ } from "./utils/sidebar-prefs";
24
+ import type { RunSummary } from "./utils/types";
32
25
 
33
- const REVIEW_LABELS: Record<string, string> = {
34
- APPROVED: "aprovada",
35
- CHANGES_REQUESTED: "mudanças",
36
- REVIEW_REQUIRED: "revisão pendente",
37
- };
38
-
39
- function RunItem({
40
- run,
41
- active,
42
- onSelect,
43
- }: {
44
- run: RunSummary;
45
- active: boolean;
46
- onSelect: (slug: string) => void;
47
- }) {
48
- const status = describeRunStatus(run);
49
- const fixReady = status?.kind === "fix_done";
50
- return (
51
- <button
52
- type="button"
53
- title={`Abrir a run da branch ${run.branch}`}
54
- class={`run${active ? " active" : ""}`}
55
- onClick={() => onSelect(run.slug)}
56
- >
57
- <span class="run-top">
58
- <BranchIcon size={14} class="run-icon" />
59
- <span class="run-branch">{run.branch}</span>
60
- </span>
61
- <span class="run-meta">
62
- <span class="run-when">
63
- <Clock size={12} />
64
- {formatRunTime(run.updatedAt) || "sem data"}
65
- </span>
66
- <span
67
- class="run-count"
68
- title={`${run.summary.present} de ${run.summary.total} findings presentes`}
69
- >
70
- {run.summary.present}/{run.summary.total}
71
- </span>
72
- </span>
73
- {fixReady ? (
74
- <span class="run-badge">
75
- <Sparkles size={12} /> Correção pronta
76
- </span>
77
- ) : (
78
- status && (
79
- <span class={`run-status status-${status.kind}`}>{status.label}</span>
80
- )
81
- )}
82
- </button>
83
- );
84
- }
85
-
86
- function PrItem({
87
- pr,
88
- currentBranch,
89
- worktree,
90
- busy,
91
- confirming,
92
- onConfirm,
93
- onCancel,
94
- onCheckout,
95
- onSelectRun,
96
- }: {
97
- pr: PrListItem;
98
- currentBranch: string | null;
99
- worktree: WorktreeState;
100
- busy: boolean;
101
- confirming: boolean;
102
- onConfirm: (number: number) => void;
103
- onCancel: () => void;
104
- onCheckout: (pr: PrListItem) => void;
105
- onSelectRun: (slug: string) => void;
106
- }) {
107
- const isCurrent = currentBranch === pr.headRefName;
108
- const runSlug = pr.runSlug;
109
- const review = pr.reviewDecision ? REVIEW_LABELS[pr.reviewDecision] : undefined;
110
- return (
111
- <article class={`pr${isCurrent ? " current" : ""}`}>
112
- <div class="pr-head">
113
- <GitPullRequest size={14} class="pr-icon" />
114
- <span class="pr-number">#{pr.number}</span>
115
- {pr.isDraft && <span class="tag">draft</span>}
116
- {isCurrent && <span class="pr-current">atual</span>}
117
- <span class="spacer" />
118
- {pr.updatedAt && (
119
- <span class="run-when">
120
- <Clock size={12} />
121
- {formatRunTime(pr.updatedAt)}
122
- </span>
123
- )}
124
- </div>
125
- <p class="pr-title">{pr.title}</p>
126
- <p class="pr-meta">
127
- <span class="pr-branch">{pr.headRefName}</span>
128
- <span class="pr-arrow">→</span>
129
- <span>{pr.baseRefName || "—"}</span>
130
- <span>· {pr.author}</span>
131
- </p>
132
- {review && <p class={`pr-review pr-review-${pr.reviewDecision}`}>{review}</p>}
133
- <div class="pr-actions">
134
- {runSlug && (
135
- <button
136
- type="button"
137
- class="pr-btn"
138
- title="Abrir a run local desta branch"
139
- onClick={() => onSelectRun(runSlug)}
140
- >
141
- Abrir run
142
- </button>
143
- )}
144
- {pr.url && (
145
- <a
146
- class="pr-btn"
147
- href={pr.url}
148
- target="_blank"
149
- rel="noreferrer"
150
- title="Abrir a PR no GitHub"
151
- >
152
- <GitHub size={12} /> GitHub
153
- </a>
154
- )}
155
- {confirming ? (
156
- <>
157
- <button
158
- type="button"
159
- class="pr-btn danger"
160
- disabled={busy}
161
- title="Confirmar checkout da PR"
162
- onClick={() => onCheckout(pr)}
163
- >
164
- {busy ? <Spinner size={12} class="icon spin" /> : "Confirmar"}
165
- </button>
166
- <button
167
- type="button"
168
- class="pr-btn"
169
- disabled={busy}
170
- onClick={onCancel}
171
- >
172
- Cancelar
173
- </button>
174
- </>
175
- ) : (
176
- <button
177
- type="button"
178
- class="pr-btn"
179
- disabled={!worktree.clean || busy}
180
- title={
181
- worktree.clean
182
- ? `Fazer checkout da branch ${pr.headRefName}`
183
- : "Há alterações não commitadas — faça commit ou stash antes"
184
- }
185
- onClick={() => onConfirm(pr.number)}
186
- >
187
- Checkout
188
- </button>
189
- )}
190
- </div>
191
- </article>
192
- );
193
- }
26
+ /** Passo do redimensionamento por teclado (Shift amplia). */
27
+ const KEYBOARD_STEP = 16;
194
28
 
195
29
  export function Sidebar({
196
30
  runs,
@@ -203,194 +37,173 @@ export function Sidebar({
203
37
  onSelect: (slug: string) => void;
204
38
  onRefreshRuns: () => void;
205
39
  }) {
206
- const [view, setView] = useState<SidebarView>("runs");
207
- const [data, setData] = useState<PrListResponse | null>(null);
208
- const [loading, setLoading] = useState(false);
209
- const [error, setError] = useState("");
210
- const [notice, setNotice] = useState("");
211
- const [busy, setBusy] = useState<number | null>(null);
212
- const [confirming, setConfirming] = useState<number | null>(null);
213
- const requested = useRef(false);
40
+ const [width, setWidth] = useState(() => clampSidebarWidth(readSidebarPrefs().width));
41
+ const [collapsed, setCollapsed] = useState(() => readSidebarPrefs().collapsed);
42
+ const [active, setActive] = useState(() =>
43
+ resolveSidebarSectionId(readSidebarPrefs().section),
44
+ );
45
+ const drag = useRef<{
46
+ startX: number;
47
+ startWidth: number;
48
+ pointerId: number;
49
+ } | null>(null);
50
+
51
+ // Persistência com debounce: arrastar o divisor não escreve a cada pixel.
52
+ useEffect(() => {
53
+ const id = setTimeout(
54
+ () => saveSidebarPrefs({ width, collapsed, section: active }),
55
+ 180,
56
+ );
57
+ return () => clearTimeout(id);
58
+ }, [width, collapsed, active]);
214
59
 
215
- const loadPrs = useCallback(async () => {
216
- setLoading(true);
217
- setError("");
218
- try {
219
- setData(await api<PrListResponse>("/api/prs"));
220
- } catch (err) {
221
- setError(String(err));
222
- } finally {
223
- setLoading(false);
224
- }
60
+ const selectSection = useCallback((id: string) => {
61
+ setActive(id);
62
+ setCollapsed(false);
225
63
  }, []);
226
64
 
227
- useEffect(() => {
228
- if (view !== "prs" || requested.current) return;
229
- requested.current = true;
230
- void loadPrs();
231
- }, [view, loadPrs]);
65
+ const startResize = useCallback(
66
+ (event: PointerEvent) => {
67
+ if (collapsed || event.button !== 0) return;
68
+ event.preventDefault();
69
+ const target = event.currentTarget as HTMLElement;
70
+ target.setPointerCapture?.(event.pointerId);
71
+ drag.current = {
72
+ startX: event.clientX,
73
+ startWidth: width,
74
+ pointerId: event.pointerId,
75
+ };
76
+ document.body.classList.add("resizing-sidebar");
77
+ },
78
+ [collapsed, width],
79
+ );
232
80
 
233
- const checkout = useCallback(
234
- async (pr: PrListItem) => {
235
- setBusy(pr.number);
236
- setError("");
237
- setNotice("");
238
- try {
239
- const result = await api<CheckoutResponse>("/api/prs/checkout", {
240
- method: "POST",
241
- headers: { "content-type": "application/json" },
242
- body: JSON.stringify({ number: pr.number }),
243
- });
244
- if (!result.ok) {
245
- if (result.reason === "dirty") {
246
- setData((current) =>
247
- current
248
- ? {
249
- ...current,
250
- worktree: {
251
- ...current.worktree,
252
- clean: false,
253
- changes:
254
- result.changes?.length ??
255
- current.worktree.changes,
256
- sample:
257
- result.changes ?? current.worktree.sample,
258
- },
259
- }
260
- : current,
261
- );
262
- }
263
- setError(
264
- result.reason === "dirty"
265
- ? `Checkout bloqueado: ${result.detail}`
266
- : result.detail,
267
- );
268
- return;
269
- }
270
- setConfirming(null);
271
- setNotice(
272
- `Checkout de #${pr.number} concluído${
273
- result.branch ? ` — ${result.branch}` : ""
274
- }.`,
275
- );
276
- await loadPrs();
277
- onRefreshRuns();
278
- if (pr.runSlug) onSelect(pr.runSlug);
279
- } catch (err) {
280
- setError(String(err));
281
- } finally {
282
- setBusy(null);
283
- }
81
+ const moveResize = useCallback((event: PointerEvent) => {
82
+ const state = drag.current;
83
+ if (!state || event.pointerId !== state.pointerId) return;
84
+ setWidth(clampSidebarWidth(state.startWidth + event.clientX - state.startX));
85
+ }, []);
86
+
87
+ const endResize = useCallback((event: PointerEvent) => {
88
+ if (!drag.current) return;
89
+ drag.current = null;
90
+ (event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId);
91
+ document.body.classList.remove("resizing-sidebar");
92
+ }, []);
93
+
94
+ const resizeByKeyboard = useCallback(
95
+ (event: KeyboardEvent) => {
96
+ const step = event.shiftKey ? KEYBOARD_STEP * 2 : KEYBOARD_STEP;
97
+ let next: number | null = null;
98
+ if (event.key === "ArrowLeft") next = width - step;
99
+ if (event.key === "ArrowRight") next = width + step;
100
+ if (event.key === "Home") next = SIDEBAR_MIN_WIDTH;
101
+ if (event.key === "End") next = SIDEBAR_MAX_WIDTH;
102
+ if (next === null) return;
103
+ event.preventDefault();
104
+ setWidth(clampSidebarWidth(next));
284
105
  },
285
- [loadPrs, onRefreshRuns, onSelect],
106
+ [width],
286
107
  );
287
108
 
288
- return (
289
- <aside class="sidebar">
290
- <div class="brand">
291
- <img class="logo" src="/assets/logo.png" alt="" />
292
- <div>
293
- <h1>Houndsman</h1>
294
- <p class="sub">Revisões e triagem</p>
295
- </div>
296
- </div>
109
+ const section = getSidebarSection(active);
110
+ const context: SidebarSectionContext = {
111
+ runs,
112
+ slug,
113
+ onSelect,
114
+ onRefreshRuns,
115
+ active: false,
116
+ };
297
117
 
298
- <div class="side-tabs" role="tablist" aria-label="Navegação lateral">
299
- <button
300
- type="button"
301
- role="tab"
302
- aria-selected={view === "runs"}
303
- class={`side-tab${view === "runs" ? " active" : ""}`}
304
- onClick={() => setView("runs")}
305
- >
306
- <ListIcon size={13} /> Execuções
307
- <span class="side-count">{runs.length}</span>
308
- </button>
118
+ return (
119
+ <aside
120
+ class={`sidebar${collapsed ? " collapsed" : ""}`}
121
+ style={`--side-width:${width}px`}
122
+ >
123
+ <nav class="side-rail" aria-label="Seções do Houndsman">
124
+ {SIDEBAR_SECTIONS.map((item) => {
125
+ const SectionIcon = item.icon;
126
+ const count = item.badge?.(context);
127
+ const isActive = item.id === active;
128
+ return (
129
+ <button
130
+ key={item.id}
131
+ type="button"
132
+ class={`rail-btn${isActive ? " active" : ""}`}
133
+ title={item.label}
134
+ aria-label={item.label}
135
+ aria-current={isActive ? "page" : undefined}
136
+ aria-controls={`side-section-${item.id}`}
137
+ onClick={() => selectSection(item.id)}
138
+ >
139
+ <SectionIcon size={20} />
140
+ {typeof count === "number" && count > 0 && (
141
+ <span class="rail-badge">
142
+ {count > 99 ? "99+" : count}
143
+ </span>
144
+ )}
145
+ </button>
146
+ );
147
+ })}
148
+ <span class="rail-spacer" />
309
149
  <button
310
150
  type="button"
311
- role="tab"
312
- aria-selected={view === "prs"}
313
- class={`side-tab${view === "prs" ? " active" : ""}`}
314
- onClick={() => setView("prs")}
151
+ class="rail-btn rail-toggle"
152
+ title={collapsed ? "Expandir painel" : "Recolher painel"}
153
+ aria-label={collapsed ? "Expandir painel" : "Recolher painel"}
154
+ aria-expanded={!collapsed}
155
+ onClick={() => setCollapsed((current) => !current)}
315
156
  >
316
- <GitPullRequest size={13} /> PR's
157
+ <ChevronRight size={18} class={collapsed ? undefined : "icon flip"} />
317
158
  </button>
318
- </div>
159
+ </nav>
319
160
 
320
- {view === "runs" ? (
321
- <nav class="runs" aria-label="Execuções">
322
- {runs.length === 0 && <p class="empty">Nenhuma run encontrada.</p>}
323
- {runs.map((run) => (
324
- <RunItem
325
- key={run.slug}
326
- run={run}
327
- active={run.slug === slug}
328
- onSelect={onSelect}
329
- />
330
- ))}
331
- </nav>
332
- ) : (
333
- <>
334
- <div class="pr-toolbar">
335
- <p class="section-label">PRs abertas</p>
336
- <button
337
- type="button"
338
- class="icon-btn"
339
- title="Atualizar lista de PRs"
340
- disabled={loading}
341
- onClick={() => void loadPrs()}
342
- >
343
- <Refresh
344
- size={13}
345
- class={loading ? "icon spin" : undefined}
346
- />
347
- </button>
161
+ <div class="side-panel">
162
+ <header class="side-head">
163
+ <div class="brand">
164
+ <img class="logo" src="/assets/logo.png" alt="" />
165
+ <div>
166
+ <h1>Houndsman</h1>
167
+ <p class="sub">Revisões e triagem</p>
168
+ </div>
169
+ </div>
170
+ <div class="side-title">
171
+ <h2>{section.title}</h2>
172
+ {section.hint && <p class="side-hint">{section.hint}</p>}
348
173
  </div>
174
+ </header>
349
175
 
350
- {error && <p class="error">{error}</p>}
351
- {notice && <p class="pr-notice">{notice}</p>}
352
- {data && !data.available && (
353
- <p class="empty">
354
- GitHub indisponível
355
- {data.reason ? `: ${data.reason}` : "."}
356
- </p>
357
- )}
358
- {data && !data.worktree.available && (
359
- <p class="empty">Não foi possível ler o estado do git.</p>
360
- )}
361
- {data?.worktree.available && !data.worktree.clean && (
362
- <p class="pr-warning">
363
- {data.worktree.changes} alteração(ões) não commitada(s) —
364
- checkout bloqueado até limpar a árvore.
365
- </p>
366
- )}
367
- {data?.currentBranch && (
368
- <p class="pr-current-branch">
369
- Branch atual: <code>{data.currentBranch}</code>
370
- </p>
371
- )}
176
+ <div class="side-body">
177
+ {SIDEBAR_SECTIONS.map((item) => (
178
+ <section
179
+ key={item.id}
180
+ id={`side-section-${item.id}`}
181
+ class="side-section"
182
+ hidden={item.id !== active}
183
+ >
184
+ <item.Content {...context} active={item.id === active} />
185
+ </section>
186
+ ))}
187
+ </div>
188
+ </div>
372
189
 
373
- <nav class="prs" aria-label="Pull requests abertas">
374
- {loading && !data && <p class="empty">Carregando PRs…</p>}
375
- {data?.available && data.prs.length === 0 && (
376
- <p class="empty">Nenhuma PR aberta.</p>
377
- )}
378
- {data?.prs.map((pr) => (
379
- <PrItem
380
- key={pr.number}
381
- pr={pr}
382
- currentBranch={data.currentBranch}
383
- worktree={data.worktree}
384
- busy={busy !== null}
385
- confirming={confirming === pr.number}
386
- onConfirm={setConfirming}
387
- onCancel={() => setConfirming(null)}
388
- onCheckout={checkout}
389
- onSelectRun={onSelect}
390
- />
391
- ))}
392
- </nav>
393
- </>
190
+ {!collapsed && (
191
+ // biome-ignore lint/a11y/useSemanticElements: divisor arrastável, não conteúdo.
192
+ <div
193
+ class="side-resizer"
194
+ role="separator"
195
+ aria-orientation="vertical"
196
+ aria-label="Redimensionar painel lateral"
197
+ aria-valuemin={SIDEBAR_MIN_WIDTH}
198
+ aria-valuemax={SIDEBAR_MAX_WIDTH}
199
+ aria-valuenow={width}
200
+ tabIndex={0}
201
+ onPointerDown={startResize}
202
+ onPointerMove={moveResize}
203
+ onPointerUp={endResize}
204
+ onPointerCancel={endResize}
205
+ onKeyDown={resizeByKeyboard}
206
+ />
394
207
  )}
395
208
  </aside>
396
209
  );
@@ -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" />