jettypod 4.4.118 → 4.4.121

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 (240) hide show
  1. package/.env +4 -3
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. package/docs/bdd-guidance.md +0 -390
@@ -0,0 +1,1489 @@
1
+ import { useState, useEffect, useRef, useCallback } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { useParams, Link, useNavigate } from 'react-router-dom';
4
+ import { dataBridge } from '@/lib/data-bridge';
5
+ import type { WorkItemData } from '@/lib/data-bridge';
6
+ import type { TestScenario, TestFeature } from '@/lib/db';
7
+ import { shadow } from '@/lib/shadows';
8
+ import { ProofRunManager } from '@/lib/proof-run';
9
+ import type { ProofRunStatus } from '@/lib/proof-run';
10
+ import { loadEnvironmentConfig } from '@/lib/environment-config';
11
+ import type { EnvironmentConfig } from '@/lib/environment-config';
12
+ import { ScenarioRunner } from '@/lib/proof-scenario-runner';
13
+ import type { ScenarioRunnerStatus } from '@/lib/proof-scenario-runner';
14
+ import { useWebSocket } from '@/hooks/useWebSocket';
15
+ import type { WebSocketMessage } from '@/hooks/useWebSocket';
16
+ import { getWebSocketUrl } from '@/lib/utils';
17
+ import { openShell } from '@/lib/tauri';
18
+ import { ProjectStackSection } from '@/components/settings/ProjectStackSection';
19
+ import { useSessionActions } from '@/contexts/ClaudeSessionContext';
20
+
21
+ // ─── Types ───────────────────────────────────────────────────
22
+
23
+ interface ServiceStatus {
24
+ name: string;
25
+ port: number | null;
26
+ status: 'stopped' | 'starting' | 'running' | 'crashed';
27
+ startTime?: number;
28
+ }
29
+
30
+ interface ScenarioStep {
31
+ keyword: string;
32
+ text: string;
33
+ status: 'passed' | 'running' | 'pending' | 'failed';
34
+ duration?: number;
35
+ }
36
+
37
+ interface Scenario {
38
+ title: string;
39
+ status: 'passed' | 'running' | 'pending' | 'failed';
40
+ steps: ScenarioStep[];
41
+ duration?: number;
42
+ }
43
+
44
+ interface DbChange {
45
+ table: string;
46
+ op: string;
47
+ field: string;
48
+ from: string | null;
49
+ to: string;
50
+ time: string;
51
+ }
52
+
53
+ interface EventLogEntry {
54
+ time: string;
55
+ from: string;
56
+ to: string;
57
+ label: string;
58
+ color: string;
59
+ }
60
+
61
+ interface ChecklistItem {
62
+ id: string;
63
+ text: string;
64
+ detail?: string;
65
+ section: string;
66
+ status: 'pending' | 'passed' | 'failed';
67
+ scenarioRef?: string;
68
+ note?: string;
69
+ failReason?: string;
70
+ }
71
+
72
+ interface HistoricalScenario {
73
+ title: string;
74
+ status: 'pass' | 'fail' | 'pending';
75
+ lastRun: string | null;
76
+ ranBy: string;
77
+ duration: string;
78
+ steps: string[];
79
+ }
80
+
81
+ function formatRelativeTime(isoDate: string): string {
82
+ const now = Date.now();
83
+ const then = new Date(isoDate).getTime();
84
+ const diffMs = now - then;
85
+ const diffSec = Math.floor(diffMs / 1000);
86
+ const diffMin = Math.floor(diffSec / 60);
87
+ const diffHour = Math.floor(diffMin / 60);
88
+ const diffDay = Math.floor(diffHour / 24);
89
+
90
+ if (diffSec < 60) return 'just now';
91
+ if (diffMin < 60) return `${diffMin}m ago`;
92
+ if (diffHour < 24) return `${diffHour}h ago`;
93
+ if (diffDay < 7) return `${diffDay}d ago`;
94
+ return new Date(isoDate).toLocaleDateString();
95
+ }
96
+
97
+ // ─── Default state ──────────────────────────────────────────
98
+
99
+ const DEFAULT_SERVICES: ServiceStatus[] = [];
100
+
101
+ // ─── Shared primitives ──────────────────────────────────────
102
+
103
+ const STATUS_COLORS: Record<string, string> = {
104
+ passed: '#22c55e',
105
+ running: '#3b82f6',
106
+ pending: '#a1a1aa',
107
+ failed: '#ef4444',
108
+ starting: '#f59e0b',
109
+ stopped: '#a1a1aa',
110
+ crashed: '#ef4444',
111
+ };
112
+
113
+ function StatusIcon({ status, size = 16 }: { status: string; size?: number }) {
114
+ const color = STATUS_COLORS[status] || '#a1a1aa';
115
+
116
+ if (status === 'passed') {
117
+ return (
118
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none">
119
+ <circle cx="8" cy="8" r="7" fill={color} fillOpacity={0.12} />
120
+ <path d="M5 8l2 2 4-4" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
121
+ </svg>
122
+ );
123
+ }
124
+ if (status === 'failed') {
125
+ return (
126
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none">
127
+ <circle cx="8" cy="8" r="7" fill={color} fillOpacity={0.12} />
128
+ <path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
129
+ </svg>
130
+ );
131
+ }
132
+ if (status === 'running' || status === 'starting') {
133
+ return (
134
+ <span className="relative inline-flex" style={{ width: size, height: size }}>
135
+ <span
136
+ className="absolute inline-flex rounded-full opacity-30"
137
+ style={{
138
+ width: size, height: size,
139
+ backgroundColor: color,
140
+ animation: 'ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite',
141
+ }}
142
+ />
143
+ <span
144
+ className="relative inline-flex rounded-full"
145
+ style={{ width: size, height: size, backgroundColor: color }}
146
+ />
147
+ </span>
148
+ );
149
+ }
150
+ return (
151
+ <span
152
+ className="inline-flex rounded-full"
153
+ style={{
154
+ width: size, height: size,
155
+ backgroundColor: 'transparent',
156
+ border: `1.5px solid ${color}`,
157
+ }}
158
+ />
159
+ );
160
+ }
161
+
162
+ // ─── Environment Status Bar ─────────────────────────────────
163
+
164
+ function EnvironmentBar({ services, proofState, onLaunch, onFixWithClaude }: { services: ServiceStatus[]; proofState: ProofRunStatus['state']; onLaunch?: () => void; onFixWithClaude?: () => void }) {
165
+ const runningCount = services.filter(s => s.status === 'running').length;
166
+ const total = services.length;
167
+ const isIdle = proofState === 'idle';
168
+ const isLaunching = proofState === 'launching';
169
+ const isReady = proofState === 'healthy';
170
+ const isFailed = proofState === 'failed';
171
+ const hasCrashed = services.some(s => s.status === 'crashed');
172
+ const progress = total > 0 ? (runningCount / total) * 100 : 0;
173
+
174
+ // Determine dot color per service state
175
+ const dotColor = (status: string) => {
176
+ if (status === 'running') return '#22c55e';
177
+ if (status === 'starting') return '#f59e0b';
178
+ if (status === 'crashed') return '#ef4444';
179
+ return '#a1a1aa';
180
+ };
181
+
182
+ // Animated dot for launching/starting states
183
+ const shouldAnimate = (status: string) => status === 'starting';
184
+
185
+ return (
186
+ <div
187
+ className="bg-white dark:bg-zinc-800 rounded-xl overflow-hidden mb-4"
188
+ style={{ boxShadow: shadow.sm }}
189
+ data-testid="environment-bar"
190
+ >
191
+ {/* Progress bar during launch */}
192
+ {isLaunching && (
193
+ <div className="h-[3px] bg-zinc-100 dark:bg-zinc-700">
194
+ <div
195
+ className="h-full transition-all duration-500 ease-out"
196
+ style={{ width: `${progress}%`, backgroundColor: '#22c55e' }}
197
+ />
198
+ </div>
199
+ )}
200
+ <div className="flex items-center gap-4 px-5 py-3">
201
+ <span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Environment</span>
202
+ <div className="flex items-center gap-1.5 flex-1 flex-wrap">
203
+ {services.map(svc => (
204
+ <span
205
+ key={svc.name}
206
+ className="flex items-center gap-1.5 text-[12px] rounded-md px-2 py-0.5"
207
+ style={{
208
+ color: !isIdle && svc.status === 'running' ? '#52525b' : '#a1a1aa',
209
+ backgroundColor: 'rgba(0,0,0,0.03)',
210
+ }}
211
+ >
212
+ {!isIdle && (
213
+ <span className="relative inline-flex" style={{ width: 7, height: 7 }}>
214
+ {shouldAnimate(svc.status) && (
215
+ <span
216
+ className="absolute inline-flex rounded-full opacity-30"
217
+ style={{
218
+ width: 7, height: 7,
219
+ backgroundColor: dotColor(svc.status),
220
+ animation: 'ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite',
221
+ }}
222
+ />
223
+ )}
224
+ <span
225
+ className="relative inline-flex rounded-full"
226
+ style={{ width: 7, height: 7, backgroundColor: dotColor(svc.status) }}
227
+ data-testid={`svc-indicator-${svc.name.toLowerCase().replace(/\s+/g, '-')}`}
228
+ />
229
+ </span>
230
+ )}
231
+ {svc.name}
232
+ {svc.port && <span className="font-mono text-[11px]" style={{ color: '#819D9F' }}>:{svc.port}</span>}
233
+ </span>
234
+ ))}
235
+ </div>
236
+ {/* Right side: Open in App button when idle, status badges when active */}
237
+ {isIdle ? (
238
+ <button
239
+ className="px-4 py-2 text-[13px] font-medium rounded-xl transition-colors duration-200"
240
+ style={{ backgroundColor: '#819D9F', color: 'white' }}
241
+ onClick={onLaunch}
242
+ data-testid="open-in-app-button"
243
+ >
244
+ Open in App
245
+ </button>
246
+ ) : isLaunching ? (
247
+ <span className="flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: '#f59e0b' }} data-testid="env-status-badge">
248
+ <svg width="14" height="14" viewBox="0 0 16 16" className="animate-spin">
249
+ <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="2" strokeDasharray="32" strokeDashoffset="8" strokeLinecap="round" />
250
+ </svg>
251
+ Starting services
252
+ </span>
253
+ ) : isFailed ? (
254
+ <span className="flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: '#ef4444' }} data-testid="env-status-badge">
255
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
256
+ <circle cx="8" cy="8" r="7" fill="currentColor" fillOpacity={0.12} />
257
+ <path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
258
+ </svg>
259
+ Launch failed
260
+ </span>
261
+ ) : isReady && hasCrashed ? (
262
+ <div className="flex items-center gap-2">
263
+ <span className="flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: '#f59e0b' }} data-testid="env-status-badge">
264
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
265
+ <circle cx="8" cy="8" r="7" fill="currentColor" fillOpacity={0.12} />
266
+ <path d="M8 5v3M8 10v.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
267
+ </svg>
268
+ Ready (degraded)
269
+ </span>
270
+ <button
271
+ className="px-3 py-1.5 text-[12px] font-medium rounded-lg transition-colors duration-200"
272
+ style={{ backgroundColor: 'rgba(59,130,246,0.1)', color: '#3b82f6' }}
273
+ onClick={onFixWithClaude}
274
+ data-testid="fix-with-claude-button"
275
+ >
276
+ Fix with Claude
277
+ </button>
278
+ </div>
279
+ ) : isReady ? (
280
+ <span className="flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: '#22c55e' }} data-testid="env-ready-badge">
281
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
282
+ <circle cx="8" cy="8" r="7" fill="currentColor" fillOpacity={0.12} />
283
+ <path d="M5 8l2 2 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
284
+ </svg>
285
+ Ready for QA
286
+ </span>
287
+ ) : (
288
+ <span className="text-[12px] text-zinc-400">
289
+ {runningCount}/{total} services
290
+ </span>
291
+ )}
292
+ </div>
293
+ </div>
294
+ );
295
+ }
296
+
297
+ // ─── QA Checklist ───────────────────────────────────────────
298
+
299
+ function QAChecklist({
300
+ items,
301
+ onPass,
302
+ onFail,
303
+ onReset,
304
+ onResetAll,
305
+ onApprove,
306
+ onReject,
307
+ }: {
308
+ items: ChecklistItem[];
309
+ onPass: (id: string) => void;
310
+ onFail: (id: string, reason: string) => void;
311
+ onReset: (id: string) => void;
312
+ onResetAll: () => void;
313
+ onApprove: () => void;
314
+ onReject: () => void;
315
+ }) {
316
+ const [failReasonInputId, setFailReasonInputId] = useState<string | null>(null);
317
+ const [failReasonText, setFailReasonText] = useState('');
318
+ const completedCount = items.filter(i => i.status === 'passed').length;
319
+ const failedCount = items.filter(i => i.status === 'failed').length;
320
+ const hasFailed = failedCount > 0;
321
+ const sections = [...new Set(items.map(i => i.section))];
322
+
323
+ return (
324
+ <div
325
+ className="bg-white dark:bg-zinc-800 rounded-xl overflow-hidden flex flex-col"
326
+ style={{ boxShadow: shadow.md }}
327
+ data-testid="qa-checklist"
328
+ >
329
+ <div className="px-5 py-4 border-b border-zinc-100 dark:border-zinc-700 flex items-center justify-between">
330
+ <span className="text-[15px] font-semibold text-zinc-900 dark:text-zinc-100">QA Checklist</span>
331
+ <div className="flex items-center gap-3">
332
+ <span className="text-[12px] text-zinc-400" data-testid="checklist-progress">{completedCount} of {items.length} verified</span>
333
+ {items.some(i => i.status !== 'pending') && (
334
+ <button
335
+ className="px-2 py-1 text-[11px] font-medium rounded-md transition-colors duration-150"
336
+ style={{ color: '#a1a1aa' }}
337
+ onClick={onResetAll}
338
+ data-testid="reset-all-btn"
339
+ >
340
+ Reset All
341
+ </button>
342
+ )}
343
+ <button
344
+ className="px-3 py-1.5 text-[12px] font-semibold rounded-lg transition-all duration-200"
345
+ style={{
346
+ backgroundColor: hasFailed ? 'rgba(34,197,94,0.06)' : 'rgba(34,197,94,0.1)',
347
+ color: hasFailed ? '#a1a1aa' : '#22c55e',
348
+ cursor: hasFailed ? 'not-allowed' : 'pointer',
349
+ }}
350
+ onClick={() => { if (!hasFailed) onApprove(); }}
351
+ disabled={hasFailed}
352
+ data-testid="approve-btn"
353
+ >
354
+ Approve
355
+ </button>
356
+ <button
357
+ className="px-3 py-1.5 text-[12px] font-semibold rounded-lg transition-all duration-200"
358
+ style={{ backgroundColor: 'rgba(239,68,68,0.1)', color: '#ef4444' }}
359
+ onClick={onReject}
360
+ data-testid="reject-btn"
361
+ >
362
+ Reject
363
+ </button>
364
+ </div>
365
+ </div>
366
+
367
+ {/* Progress bar */}
368
+ <div className="h-1 bg-zinc-100 dark:bg-zinc-700">
369
+ <div
370
+ className="h-full rounded-r-full transition-all duration-500 ease-out"
371
+ style={{
372
+ width: items.length > 0 ? `${(completedCount / items.length) * 100}%` : '0%',
373
+ backgroundColor: '#22c55e',
374
+ }}
375
+ data-testid="checklist-progress-bar"
376
+ />
377
+ </div>
378
+
379
+ <div className="flex-1 overflow-auto">
380
+ {sections.map(section => (
381
+ <div key={section}>
382
+ <div
383
+ className="px-5 py-2 text-[11px] font-semibold text-zinc-400 uppercase tracking-wider bg-zinc-50 dark:bg-zinc-800/50 sticky top-0 z-10"
384
+ >
385
+ {section}
386
+ </div>
387
+ {items.filter(i => i.section === section).map(item => {
388
+ const isPassed = item.status === 'passed';
389
+ const isFailed = item.status === 'failed';
390
+ const isPending = item.status === 'pending';
391
+ return (
392
+ <div
393
+ key={item.id}
394
+ className="group flex items-start gap-3 px-5 py-3.5 border-b border-zinc-50 dark:border-zinc-700/30 transition-colors duration-150 hover:bg-zinc-50/50 dark:hover:bg-zinc-700/20"
395
+ style={{
396
+ ...(isPassed ? { opacity: 0.5 } : {}),
397
+ ...(isFailed ? { borderLeft: '3px solid #ef4444' } : {}),
398
+ }}
399
+ data-testid={`checklist-item-${item.id}`}
400
+ >
401
+ {/* Checkbox */}
402
+ <button
403
+ className="w-[22px] h-[22px] rounded-md flex items-center justify-center shrink-0 mt-0.5 transition-all duration-200"
404
+ style={{
405
+ backgroundColor: isPassed ? '#22c55e' : isFailed ? 'rgba(239,68,68,0.08)' : 'transparent',
406
+ border: isPassed ? '2px solid #22c55e' : isFailed ? '2px solid #ef4444' : '2px solid #e4e4e7',
407
+ }}
408
+ onClick={() => {
409
+ if (isPending) onPass(item.id);
410
+ }}
411
+ >
412
+ {isPassed && (
413
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
414
+ <path d="M3.5 7l2.5 2.5 4.5-4.5" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
415
+ </svg>
416
+ )}
417
+ {isFailed && (
418
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
419
+ <path d="M3 3l6 6M9 3l-6 6" stroke="#ef4444" strokeWidth="1.5" strokeLinecap="round" />
420
+ </svg>
421
+ )}
422
+ </button>
423
+
424
+ {/* Content */}
425
+ <div className="flex-1 min-w-0">
426
+ <div className="text-[14px] leading-relaxed text-zinc-800 dark:text-zinc-200" style={isPassed ? { textDecoration: 'line-through' } : undefined}>
427
+ {item.text}
428
+ </div>
429
+ {item.detail && (
430
+ <div className="text-[12px] text-zinc-400 mt-0.5">{item.detail}</div>
431
+ )}
432
+ {item.scenarioRef && (
433
+ <div className="flex items-center gap-1.5 mt-1.5 px-2.5 py-1 rounded-md inline-flex" style={{ backgroundColor: 'rgba(129,157,159,0.08)' }}>
434
+ <svg width="10" height="10" viewBox="0 0 16 16" fill="none">
435
+ <rect x="2" y="2" width="12" height="12" rx="3" stroke="#4A6365" strokeWidth="1.5" />
436
+ </svg>
437
+ <span className="text-[11px]" style={{ color: '#4A6365' }}>Scenario: {item.scenarioRef}</span>
438
+ </div>
439
+ )}
440
+ {/* Fail reason display */}
441
+ {isFailed && item.failReason && (
442
+ <div
443
+ className="mt-1.5 px-2.5 py-1.5 rounded-md text-[12px]"
444
+ style={{ backgroundColor: 'rgba(239,68,68,0.06)', color: '#ef4444' }}
445
+ data-testid={`fail-reason-${item.id}`}
446
+ >
447
+ {item.failReason}
448
+ </div>
449
+ )}
450
+ {/* Fail reason input */}
451
+ {failReasonInputId === item.id && (
452
+ <div className="mt-1.5 flex gap-1.5">
453
+ <input
454
+ type="text"
455
+ className="flex-1 px-2.5 py-1.5 text-[12px] rounded-md border border-red-300 dark:border-red-600 bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-200 outline-none focus:ring-1 focus:ring-red-400"
456
+ value={failReasonText}
457
+ onChange={e => setFailReasonText(e.target.value)}
458
+ onKeyDown={e => {
459
+ if (e.key === 'Enter' && failReasonText.trim()) {
460
+ onFail(item.id, failReasonText.trim());
461
+ setFailReasonInputId(null);
462
+ setFailReasonText('');
463
+ } else if (e.key === 'Escape') {
464
+ setFailReasonInputId(null);
465
+ setFailReasonText('');
466
+ }
467
+ }}
468
+ placeholder="Why did this fail?"
469
+ autoFocus
470
+ data-testid={`fail-reason-input-${item.id}`}
471
+ />
472
+ <button
473
+ className="px-2 py-1 text-[11px] font-medium rounded-md text-white transition-opacity duration-150"
474
+ style={{
475
+ backgroundColor: '#ef4444',
476
+ opacity: failReasonText.trim() ? 1 : 0.4,
477
+ cursor: failReasonText.trim() ? 'pointer' : 'not-allowed',
478
+ }}
479
+ disabled={!failReasonText.trim()}
480
+ onClick={() => {
481
+ if (failReasonText.trim()) {
482
+ onFail(item.id, failReasonText.trim());
483
+ setFailReasonInputId(null);
484
+ setFailReasonText('');
485
+ }
486
+ }}
487
+ data-testid={`fail-reason-confirm-${item.id}`}
488
+ >
489
+ Confirm
490
+ </button>
491
+ </div>
492
+ )}
493
+ </div>
494
+
495
+ {/* Actions */}
496
+ {isPending && (
497
+ <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-150 shrink-0">
498
+ <button
499
+ className="px-2.5 py-1 text-[11px] font-medium rounded-md border transition-colors duration-150"
500
+ style={{ color: '#22c55e', borderColor: 'rgba(34,197,94,0.3)', background: 'transparent' }}
501
+ onMouseEnter={e => { (e.target as HTMLElement).style.background = 'rgba(34,197,94,0.08)'; }}
502
+ onMouseLeave={e => { (e.target as HTMLElement).style.background = 'transparent'; }}
503
+ onClick={() => onPass(item.id)}
504
+ data-testid={`pass-btn-${item.id}`}
505
+ >
506
+ Pass
507
+ </button>
508
+ <button
509
+ className="px-2.5 py-1 text-[11px] font-medium rounded-md border transition-colors duration-150"
510
+ style={{ color: '#ef4444', borderColor: 'rgba(239,68,68,0.3)', background: 'transparent' }}
511
+ onMouseEnter={e => { (e.target as HTMLElement).style.background = 'rgba(239,68,68,0.08)'; }}
512
+ onMouseLeave={e => { (e.target as HTMLElement).style.background = 'transparent'; }}
513
+ onClick={() => { setFailReasonInputId(item.id); setFailReasonText(''); }}
514
+ data-testid={`fail-btn-${item.id}`}
515
+ >
516
+ Fail
517
+ </button>
518
+ </div>
519
+ )}
520
+ {/* Reset button for completed items */}
521
+ {!isPending && (
522
+ <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-150 shrink-0">
523
+ <button
524
+ className="px-2.5 py-1 text-[11px] font-medium rounded-md border transition-colors duration-150"
525
+ style={{ color: '#a1a1aa', borderColor: 'rgba(161,161,170,0.3)', background: 'transparent' }}
526
+ onMouseEnter={e => { (e.target as HTMLElement).style.background = 'rgba(161,161,170,0.08)'; }}
527
+ onMouseLeave={e => { (e.target as HTMLElement).style.background = 'transparent'; }}
528
+ onClick={() => onReset(item.id)}
529
+ data-testid={`reset-btn-${item.id}`}
530
+ >
531
+ Reset
532
+ </button>
533
+ </div>
534
+ )}
535
+ </div>
536
+ );
537
+ })}
538
+ </div>
539
+ ))}
540
+ </div>
541
+ </div>
542
+ );
543
+ }
544
+
545
+ // ─── Scenario Sidebar ───────────────────────────────────────
546
+
547
+ function ScenarioSidebar({
548
+ scenarios,
549
+ historicalScenarios,
550
+ }: {
551
+ scenarios: Scenario[];
552
+ historicalScenarios: HistoricalScenario[];
553
+ }) {
554
+ const hasLive = scenarios.length > 0;
555
+ const hasHistorical = historicalScenarios.length > 0;
556
+
557
+ if (!hasLive && !hasHistorical) {
558
+ return (
559
+ <div
560
+ className="bg-white dark:bg-zinc-800 rounded-xl overflow-hidden"
561
+ style={{ boxShadow: shadow.md }}
562
+ data-testid="scenario-sidebar"
563
+ >
564
+ <div className="px-4 py-3 border-b border-zinc-100 dark:border-zinc-700">
565
+ <span className="text-[13px] font-semibold text-zinc-900 dark:text-zinc-100">BDD Scenarios</span>
566
+ </div>
567
+ <div className="px-4 py-6 text-center text-[12px] text-zinc-400 italic">
568
+ No BDD scenarios linked to this work item
569
+ </div>
570
+ </div>
571
+ );
572
+ }
573
+
574
+ // Show live scenarios when running, otherwise show historical
575
+ const showLive = hasLive;
576
+ const displayScenarios = showLive ? scenarios : [];
577
+
578
+ // Find the most recent run timestamp across all historical scenarios
579
+ const lastRunTimestamp = historicalScenarios
580
+ .filter(s => s.lastRun)
581
+ .sort((a, b) => new Date(b.lastRun!).getTime() - new Date(a.lastRun!).getTime())[0]?.lastRun;
582
+
583
+ const lastRunner = historicalScenarios[0]?.ranBy || 'Claude';
584
+
585
+ const totalCount = showLive ? scenarios.length : historicalScenarios.length;
586
+
587
+ return (
588
+ <div
589
+ className="bg-white dark:bg-zinc-800 rounded-xl overflow-hidden"
590
+ style={{ boxShadow: shadow.md }}
591
+ data-testid="scenario-sidebar"
592
+ >
593
+ {/* Header */}
594
+ <div className="px-4 py-3 border-b border-zinc-100 dark:border-zinc-700">
595
+ <div className="flex items-center justify-between mb-1">
596
+ <span className="text-[13px] font-semibold text-zinc-900 dark:text-zinc-100">BDD Scenarios</span>
597
+ <span className="text-[11px] text-zinc-400">{totalCount} scenarios</span>
598
+ </div>
599
+ {lastRunTimestamp && !showLive && (
600
+ <div className="flex items-center gap-1.5 text-[11px] text-zinc-400" data-testid="scenario-last-run-info">
601
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none">
602
+ <circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.2" />
603
+ <path d="M8 4.5V8l2.5 1.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
604
+ </svg>
605
+ Last run {formatRelativeTime(lastRunTimestamp)} by {lastRunner}
606
+ </div>
607
+ )}
608
+ </div>
609
+
610
+ <div className="divide-y divide-zinc-50 dark:divide-zinc-700/30 max-h-[500px] overflow-auto">
611
+ {/* Live execution scenarios */}
612
+ {showLive && displayScenarios.map((scenario, i) => (
613
+ <div key={`live-${i}`} className="px-4 py-2.5">
614
+ <div className="flex items-center gap-1.5 mb-1.5">
615
+ <StatusIcon status={scenario.status} size={13} />
616
+ <span className="text-[12px] font-medium text-zinc-800 dark:text-zinc-200 flex-1 leading-snug">
617
+ {scenario.title}
618
+ </span>
619
+ </div>
620
+ <div className="flex items-center gap-1 ml-5">
621
+ {scenario.steps.map((step, si) => (
622
+ <div key={si} className="flex items-center gap-1">
623
+ <div
624
+ className="w-[5px] h-[5px] rounded-full"
625
+ style={{ backgroundColor: STATUS_COLORS[step.status] }}
626
+ />
627
+ {si < scenario.steps.length - 1 && (
628
+ <div className="w-2 h-[1px] bg-zinc-200 dark:bg-zinc-700" />
629
+ )}
630
+ </div>
631
+ ))}
632
+ </div>
633
+ {scenario.duration != null && (
634
+ <div className="mt-1 ml-5 font-mono text-[10px] text-zinc-400">{scenario.duration}ms</div>
635
+ )}
636
+ </div>
637
+ ))}
638
+
639
+ {/* Historical scenarios (shown when not running live) */}
640
+ {!showLive && historicalScenarios.map((scenario, i) => {
641
+ const statusColor = scenario.status === 'pass' ? '#22c55e' : scenario.status === 'fail' ? '#ef4444' : '#a1a1aa';
642
+ const statusForIcon = scenario.status === 'pass' ? 'passed' : scenario.status === 'fail' ? 'failed' : 'pending';
643
+
644
+ return (
645
+ <div key={`hist-${i}`} className="px-4 py-3" data-testid={`scenario-item-${i}`}>
646
+ <div className="flex items-center gap-1.5 mb-1">
647
+ <StatusIcon status={statusForIcon} size={13} />
648
+ <span className="text-[12px] font-medium text-zinc-800 dark:text-zinc-200 flex-1 leading-snug">
649
+ {scenario.title}
650
+ </span>
651
+ </div>
652
+
653
+ {/* Step dots */}
654
+ {scenario.steps.length > 0 && (
655
+ <div className="flex items-center gap-1 ml-5 mb-1.5">
656
+ {scenario.steps.map((_step, si) => (
657
+ <div key={si} className="flex items-center gap-1">
658
+ <div
659
+ className="w-[5px] h-[5px] rounded-full"
660
+ style={{ backgroundColor: statusColor }}
661
+ />
662
+ {si < scenario.steps.length - 1 && (
663
+ <div className="w-2 h-[1px] bg-zinc-200 dark:bg-zinc-700" />
664
+ )}
665
+ </div>
666
+ ))}
667
+ </div>
668
+ )}
669
+
670
+ {/* Meta: duration + last run */}
671
+ <div className="flex items-center gap-3 ml-5 text-[10px] text-zinc-400">
672
+ {scenario.duration && (
673
+ <span className="font-mono">{scenario.duration}</span>
674
+ )}
675
+ {scenario.lastRun && (
676
+ <span data-testid={`scenario-last-run-${i}`}>
677
+ {formatRelativeTime(scenario.lastRun)}
678
+ </span>
679
+ )}
680
+ </div>
681
+ </div>
682
+ );
683
+ })}
684
+ </div>
685
+ </div>
686
+ );
687
+ }
688
+
689
+ // ─── Inspector Drawer ───────────────────────────────────────
690
+
691
+ type InspectorTab = 'flow' | 'db' | 'logs';
692
+
693
+ const INSPECTOR_TABS: { id: InspectorTab; label: string }[] = [
694
+ { id: 'flow', label: 'Flow' },
695
+ { id: 'db', label: 'DB' },
696
+ { id: 'logs', label: 'Logs' },
697
+ ];
698
+
699
+ function InspectorDrawer({
700
+ dbChanges,
701
+ eventLog,
702
+ logs,
703
+ isOpen,
704
+ onToggle,
705
+ }: {
706
+ dbChanges: DbChange[];
707
+ eventLog: EventLogEntry[];
708
+ logs: string[];
709
+ isOpen: boolean;
710
+ onToggle: () => void;
711
+ }) {
712
+ const [tab, setTab] = useState<InspectorTab>('flow');
713
+
714
+ return (
715
+ <div
716
+ className="fixed bottom-0 left-0 right-0 bg-white dark:bg-zinc-800 border-t border-zinc-200 dark:border-zinc-700 transition-transform duration-300 ease-out z-50"
717
+ style={{
718
+ transform: isOpen ? 'translateY(0)' : 'translateY(calc(100% - 40px))',
719
+ borderRadius: '16px 16px 0 0',
720
+ boxShadow: '0 -4px 16px rgba(0,0,0,0.06)',
721
+ }}
722
+ data-testid="inspector-drawer"
723
+ >
724
+ {/* Handle */}
725
+ <button
726
+ className="w-full h-10 flex items-center justify-center gap-2 cursor-pointer"
727
+ onClick={onToggle}
728
+ data-testid="inspector-drawer-toggle"
729
+ >
730
+ <span className="w-8 h-1 bg-zinc-300 dark:bg-zinc-600 rounded-full" />
731
+ <span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Inspector</span>
732
+ {eventLog.length > 0 && (
733
+ <span className="text-[10px] px-1.5 py-0.5 rounded" style={{ backgroundColor: 'rgba(59,130,246,0.08)', color: '#3b82f6' }}>
734
+ {eventLog.length} events
735
+ </span>
736
+ )}
737
+ {dbChanges.length > 0 && (
738
+ <span className="text-[10px] px-1.5 py-0.5 rounded" style={{ backgroundColor: 'rgba(34,197,94,0.08)', color: '#22c55e' }}>
739
+ {dbChanges.length} DB ops
740
+ </span>
741
+ )}
742
+ <span className="w-8 h-1 bg-zinc-300 dark:bg-zinc-600 rounded-full" />
743
+ </button>
744
+
745
+ {/* Tabs */}
746
+ <div className="flex px-4 gap-0.5 border-b border-zinc-100 dark:border-zinc-700">
747
+ {INSPECTOR_TABS.map(t => (
748
+ <button
749
+ key={t.id}
750
+ onClick={() => setTab(t.id)}
751
+ className="px-3 py-2 text-[12px] font-medium transition-colors duration-200"
752
+ style={{
753
+ color: tab === t.id ? '#4A6365' : '#a1a1aa',
754
+ borderBottom: tab === t.id ? '2px solid #819D9F' : '2px solid transparent',
755
+ }}
756
+ data-testid={`inspector-tab-${t.id}`}
757
+ >
758
+ {t.label}
759
+ </button>
760
+ ))}
761
+ </div>
762
+
763
+ {/* Content */}
764
+ <div className="h-[260px] overflow-auto">
765
+ {/* Flow tab */}
766
+ {tab === 'flow' && (
767
+ <div data-testid="inspector-flow">
768
+ {eventLog.length === 0 ? (
769
+ <div className="px-4 py-8 text-center text-[12px] text-zinc-400 italic">
770
+ Event flow will appear during proof run
771
+ </div>
772
+ ) : (
773
+ eventLog.map((evt, i) => (
774
+ <div
775
+ key={i}
776
+ className="px-4 py-2 border-b border-zinc-50 dark:border-zinc-700/30 hover:bg-zinc-50 dark:hover:bg-zinc-700/30 transition-colors duration-200"
777
+ >
778
+ <div className="flex items-center gap-2 text-[12px]">
779
+ <span className="font-mono text-zinc-400 w-16 shrink-0">{evt.time}</span>
780
+ <span
781
+ className="font-mono text-[10px] font-semibold px-1.5 py-0.5 rounded shrink-0"
782
+ style={{ backgroundColor: 'rgba(59,130,246,0.08)', color: '#3b82f6' }}
783
+ >
784
+ {evt.from}
785
+ </span>
786
+ <svg width="12" height="6" viewBox="0 0 12 6" className="shrink-0">
787
+ <path d="M0 3h9M7 0.5l2.5 2.5-2.5 2.5" fill="none" stroke={evt.color} strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" />
788
+ </svg>
789
+ <span className="font-mono text-zinc-600 dark:text-zinc-300 flex-1">{evt.label}</span>
790
+ </div>
791
+ </div>
792
+ ))
793
+ )}
794
+ </div>
795
+ )}
796
+
797
+ {/* DB tab */}
798
+ {tab === 'db' && (
799
+ <div data-testid="inspector-db">
800
+ {dbChanges.length === 0 ? (
801
+ <div className="px-4 py-8 text-center text-[12px] text-zinc-400 italic">
802
+ DB changes will appear during proof run
803
+ </div>
804
+ ) : (
805
+ dbChanges.map((change, i) => (
806
+ <div key={i} className="px-4 py-2.5 border-b border-zinc-50 dark:border-zinc-700/30 hover:bg-zinc-50 dark:hover:bg-zinc-700/30 transition-colors duration-200">
807
+ <div className="flex items-center gap-2">
808
+ <span
809
+ className="font-mono text-[10px] font-bold px-1.5 py-0.5 rounded"
810
+ style={{
811
+ backgroundColor: change.op === 'INSERT' ? 'rgba(34,197,94,0.1)' : 'rgba(59,130,246,0.1)',
812
+ color: change.op === 'INSERT' ? '#22c55e' : '#3b82f6',
813
+ }}
814
+ >
815
+ {change.op}
816
+ </span>
817
+ <span className="font-mono text-[12px] text-zinc-700 dark:text-zinc-300">{change.table}</span>
818
+ <span className="font-mono text-zinc-400 ml-auto text-[10px]">{change.time}</span>
819
+ </div>
820
+ <div className="mt-1 ml-2 font-mono text-[11px]">
821
+ <span className="text-zinc-400">{change.field}: </span>
822
+ {change.from && <span className="text-red-400 line-through">{change.from}</span>}
823
+ {change.from && <span className="text-zinc-500"> &rarr; </span>}
824
+ <span className="text-green-500">{change.to}</span>
825
+ </div>
826
+ </div>
827
+ ))
828
+ )}
829
+ </div>
830
+ )}
831
+
832
+ {/* Logs tab */}
833
+ {tab === 'logs' && (
834
+ <div className="px-4 py-3 font-mono text-[11px] leading-relaxed text-zinc-500 space-y-0.5" data-testid="inspector-logs">
835
+ {logs.length === 0 ? (
836
+ <div className="py-5 text-center text-[12px] text-zinc-400 italic font-sans">
837
+ Service logs will appear during proof run
838
+ </div>
839
+ ) : (
840
+ logs.map((line, i) => (
841
+ <div key={i} dangerouslySetInnerHTML={{ __html: line }} />
842
+ ))
843
+ )}
844
+ </div>
845
+ )}
846
+ </div>
847
+ </div>
848
+ );
849
+ }
850
+
851
+ // ─── Checklist generation ────────────────────────────────────
852
+
853
+ interface QaStep {
854
+ section: string;
855
+ text: string;
856
+ detail?: string;
857
+ }
858
+
859
+ function parseQaSteps(qaStepsJson: string): ChecklistItem[] {
860
+ try {
861
+ const steps: QaStep[] = JSON.parse(qaStepsJson);
862
+ if (!Array.isArray(steps)) return [];
863
+ return steps.map((step, i) => ({
864
+ id: `qa-${i}`,
865
+ text: step.text,
866
+ detail: step.detail,
867
+ section: step.section || 'Core Functionality',
868
+ status: 'pending' as const,
869
+ }));
870
+ } catch {
871
+ return [];
872
+ }
873
+ }
874
+
875
+ function generateDefaultChecklist(): ChecklistItem[] {
876
+ return [
877
+ {
878
+ id: 'nav-1',
879
+ text: 'Navigate to the feature and verify it loads',
880
+ detail: 'Page renders without console errors',
881
+ section: 'Navigation & Access',
882
+ status: 'pending',
883
+ },
884
+ {
885
+ id: 'visual-1',
886
+ text: 'UI displays cleanly without layout issues',
887
+ detail: 'No overflow, alignment issues, or missing elements',
888
+ section: 'Visual Quality',
889
+ status: 'pending',
890
+ },
891
+ ];
892
+ }
893
+
894
+ // ─── localStorage persistence ───────────────────────────────
895
+
896
+ function getStorageKey(workItemId: string) {
897
+ return `qa-checklist-${workItemId}`;
898
+ }
899
+
900
+ interface StoredItemState {
901
+ status: ChecklistItem['status'];
902
+ note?: string;
903
+ failReason?: string;
904
+ }
905
+
906
+ function loadChecklistState(workItemId: string): Record<string, StoredItemState> | null {
907
+ const stored = localStorage.getItem(getStorageKey(workItemId));
908
+ if (!stored) return null;
909
+ try {
910
+ return JSON.parse(stored);
911
+ } catch {
912
+ return null;
913
+ }
914
+ }
915
+
916
+ function saveChecklistState(workItemId: string, items: ChecklistItem[]) {
917
+ const state: Record<string, StoredItemState> = {};
918
+ for (const item of items) {
919
+ if (item.status !== 'pending' || item.note) {
920
+ state[item.id] = { status: item.status };
921
+ if (item.note) state[item.id].note = item.note;
922
+ if (item.failReason) state[item.id].failReason = item.failReason;
923
+ }
924
+ }
925
+ localStorage.setItem(getStorageKey(workItemId), JSON.stringify(state));
926
+ }
927
+
928
+ function applyStoredState(items: ChecklistItem[], stored: Record<string, StoredItemState>): ChecklistItem[] {
929
+ return items.map(item => {
930
+ const saved = stored[item.id];
931
+ if (saved) {
932
+ // Handle legacy format (plain status string) and new format (object)
933
+ if (typeof saved === 'string') {
934
+ return { ...item, status: saved as ChecklistItem['status'] };
935
+ }
936
+ return { ...item, status: saved.status, ...(saved.note ? { note: saved.note } : {}), ...(saved.failReason ? { failReason: saved.failReason } : {}) };
937
+ }
938
+ return item;
939
+ });
940
+ }
941
+
942
+ // ─── Build checklist from work item ─────────────────────────
943
+
944
+ function buildChecklist(item: WorkItemData): ChecklistItem[] {
945
+ // Primary: use AI-generated QA steps if available
946
+ if (item.qa_steps) {
947
+ const steps = parseQaSteps(item.qa_steps);
948
+ if (steps.length > 0) return steps;
949
+ }
950
+
951
+ // Fallback: generic default checklist
952
+ return generateDefaultChecklist();
953
+ }
954
+
955
+ // ─── Main Page ──────────────────────────────────────────────
956
+
957
+ export default function ProofDashboardPage() {
958
+ const { id } = useParams<{ id: string }>();
959
+ const navigate = useNavigate();
960
+ const { createFixServiceSession } = useSessionActions();
961
+ const [item, setItem] = useState<WorkItemData | null>(null);
962
+ const [loading, setLoading] = useState(true);
963
+ const [services, setServices] = useState<ServiceStatus[]>(DEFAULT_SERVICES);
964
+ const [proofState, setProofState] = useState<ProofRunStatus['state']>('idle');
965
+ const [scenarios, setScenarios] = useState<Scenario[]>([]);
966
+ const [scenarioRunState, setScenarioRunState] = useState<ScenarioRunnerStatus['state']>('idle');
967
+ const [dbChanges, setDbChanges] = useState<DbChange[]>([]);
968
+ const [eventLog, setEventLog] = useState<EventLogEntry[]>([]);
969
+ const [logs, setLogs] = useState<string[]>([]);
970
+ const [checklist, setChecklist] = useState<ChecklistItem[]>([]);
971
+ const [historicalScenarios, setHistoricalScenarios] = useState<HistoricalScenario[]>([]);
972
+ const [inspectorOpen, setInspectorOpen] = useState(false);
973
+ const [envConfig, setEnvConfig] = useState<EnvironmentConfig | null | undefined>(undefined); // undefined = loading, null = not configured
974
+ const [showStackModal, setShowStackModal] = useState(false);
975
+ const managerRef = useRef<ProofRunManager | null>(null);
976
+ const scenarioRunnerRef = useRef<ScenarioRunner | null>(null);
977
+ const proofActiveRef = useRef(false);
978
+ const browserOpenedRef = useRef(false);
979
+
980
+ // Open the primary service in the browser when all services go healthy
981
+ useEffect(() => {
982
+ if (proofState === 'healthy' && envConfig && !browserOpenedRef.current) {
983
+ browserOpenedRef.current = true;
984
+ const primaryService = envConfig.services.find(
985
+ s => s.category === 'server' && s.healthCheck === 'http'
986
+ ) || envConfig.services.find(s => s.port);
987
+ if (primaryService) {
988
+ openShell(`http://localhost:${primaryService.port}`);
989
+ }
990
+ }
991
+ if (proofState === 'idle') {
992
+ browserOpenedRef.current = false;
993
+ }
994
+ }, [proofState, envConfig]);
995
+
996
+ // Persist checklist state to localStorage whenever it changes
997
+ useEffect(() => {
998
+ if (id && checklist.length > 0) {
999
+ saveChecklistState(id, checklist);
1000
+ }
1001
+ }, [id, checklist]);
1002
+
1003
+ // Checklist actions
1004
+ const handlePass = useCallback((itemId: string) => {
1005
+ setChecklist(prev => prev.map(i => i.id === itemId ? { ...i, status: 'passed' as const } : i));
1006
+ }, []);
1007
+
1008
+ const handleFail = useCallback((itemId: string, reason: string) => {
1009
+ setChecklist(prev => prev.map(i => i.id === itemId ? { ...i, status: 'failed' as const, failReason: reason } : i));
1010
+ }, []);
1011
+
1012
+ const handleReset = useCallback((itemId: string) => {
1013
+ setChecklist(prev => prev.map(i => i.id === itemId ? { ...i, status: 'pending' as const, failReason: undefined } : i));
1014
+ }, []);
1015
+
1016
+ const handleResetAll = useCallback(() => {
1017
+ setChecklist(prev => prev.map(i => ({ ...i, status: 'pending' as const, failReason: undefined })));
1018
+ }, []);
1019
+
1020
+ const handleApprove = useCallback(async () => {
1021
+ if (!id) return;
1022
+ const workItemId = parseInt(id, 10);
1023
+ await dataBridge.updateStatus(workItemId, 'done');
1024
+ setItem(prev => prev ? { ...prev, status: 'done' } : prev);
1025
+ }, [id]);
1026
+
1027
+ const handleReject = useCallback(async () => {
1028
+ if (!id) return;
1029
+ const workItemId = parseInt(id, 10);
1030
+ const failedItems = checklist.filter(i => i.status === 'failed' && i.failReason);
1031
+ const reasons = failedItems.map(i => i.failReason!).join('; ');
1032
+ await dataBridge.updateStatus(workItemId, 'in_progress', reasons || undefined);
1033
+ setItem(prev => prev ? { ...prev, status: 'in_progress' } : prev);
1034
+ // Navigate to kanban with rejection params so chat opens and reason is sent to Claude
1035
+ if (reasons) {
1036
+ navigate(`/?rejected=${workItemId}&reason=${encodeURIComponent(reasons)}`);
1037
+ }
1038
+ }, [id, checklist, navigate]);
1039
+
1040
+ // Wire WebSocket to DB and Flow tabs
1041
+ const handleWsMessage = useCallback((msg: WebSocketMessage) => {
1042
+ if (!proofActiveRef.current) return;
1043
+
1044
+ const time = new Date(msg.timestamp).toLocaleTimeString('en-US', {
1045
+ hour12: false,
1046
+ hour: '2-digit',
1047
+ minute: '2-digit',
1048
+ second: '2-digit',
1049
+ });
1050
+
1051
+ if (msg.type === 'db_delta' && msg.table && msg.action) {
1052
+ setDbChanges(prev => {
1053
+ const entry: DbChange = {
1054
+ table: msg.table!,
1055
+ op: msg.action!.toUpperCase(),
1056
+ field: `rowid:${msg.rowid ?? '?'}`,
1057
+ from: null,
1058
+ to: msg.action!,
1059
+ time,
1060
+ };
1061
+ const next = [...prev, entry];
1062
+ return next.length > 200 ? next.slice(-200) : next;
1063
+ });
1064
+
1065
+ setEventLog(prev => {
1066
+ const entry: EventLogEntry = {
1067
+ time,
1068
+ from: 'SQLite',
1069
+ to: msg.table!,
1070
+ label: `${msg.action!.toUpperCase()} rowid:${msg.rowid ?? '?'}`,
1071
+ color: msg.action === 'insert' ? '#22c55e' : msg.action === 'delete' ? '#ef4444' : '#3b82f6',
1072
+ };
1073
+ const next = [...prev, entry];
1074
+ return next.length > 200 ? next.slice(-200) : next;
1075
+ });
1076
+ } else if (msg.type === 'db_change') {
1077
+ setEventLog(prev => {
1078
+ const entry: EventLogEntry = {
1079
+ time,
1080
+ from: 'SQLite',
1081
+ to: 'App',
1082
+ label: 'db_change',
1083
+ color: '#3b82f6',
1084
+ };
1085
+ const next = [...prev, entry];
1086
+ return next.length > 200 ? next.slice(-200) : next;
1087
+ });
1088
+ } else if (msg.type === 'test_change') {
1089
+ setEventLog(prev => {
1090
+ const entry: EventLogEntry = {
1091
+ time,
1092
+ from: 'Tests',
1093
+ to: 'App',
1094
+ label: 'test results updated',
1095
+ color: '#22c55e',
1096
+ };
1097
+ const next = [...prev, entry];
1098
+ return next.length > 200 ? next.slice(-200) : next;
1099
+ });
1100
+ }
1101
+ }, []);
1102
+
1103
+ useWebSocket({ url: getWebSocketUrl(), onMessage: handleWsMessage });
1104
+
1105
+ const handleScenarioUpdate = useCallback((status: ScenarioRunnerStatus) => {
1106
+ setScenarios(status.scenarios);
1107
+ setLogs(status.logs);
1108
+ setScenarioRunState(status.state);
1109
+ }, []);
1110
+
1111
+ const handleProofUpdate = useCallback((status: ProofRunStatus) => {
1112
+ setProofState(status.state);
1113
+ setServices(status.services.map(s => ({
1114
+ name: s.name,
1115
+ port: s.port,
1116
+ status: s.status,
1117
+ startTime: s.startTime,
1118
+ })));
1119
+
1120
+ const now = new Date().toLocaleTimeString('en-US', {
1121
+ hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit',
1122
+ });
1123
+ for (const svc of status.services) {
1124
+ if (svc.status === 'running') {
1125
+ setEventLog(prev => {
1126
+ if (prev.some(e => e.from === svc.name && e.label === 'running')) return prev;
1127
+ return [...prev, {
1128
+ time: now,
1129
+ from: svc.name,
1130
+ to: 'Proof',
1131
+ label: 'running',
1132
+ color: '#22c55e',
1133
+ }];
1134
+ });
1135
+ }
1136
+ }
1137
+
1138
+ if (status.state === 'healthy') {
1139
+ proofActiveRef.current = true;
1140
+
1141
+ setEventLog(prev => [...prev, {
1142
+ time: now,
1143
+ from: 'Proof',
1144
+ to: 'Runner',
1145
+ label: 'all services healthy — starting scenarios',
1146
+ color: '#3b82f6',
1147
+ }]);
1148
+
1149
+ if (scenarioRunnerRef.current) {
1150
+ scenarioRunnerRef.current.run(item?.scenario_file || null);
1151
+ }
1152
+ }
1153
+ }, [item?.scenario_file]);
1154
+
1155
+ const isRunning = proofState === 'launching' || proofState === 'running' || scenarioRunState === 'running' || scenarios.some(s => s.status === 'running');
1156
+
1157
+ const initEnvironmentWithConfig = useCallback(async (config: EnvironmentConfig | null, projectCwd?: string | null) => {
1158
+ // Tear down any existing managers — await to ensure old processes are killed
1159
+ if (managerRef.current) {
1160
+ await managerRef.current.stopServices();
1161
+ managerRef.current.destroy();
1162
+ }
1163
+ if (scenarioRunnerRef.current) {
1164
+ scenarioRunnerRef.current.stop();
1165
+ scenarioRunnerRef.current.destroy();
1166
+ }
1167
+
1168
+ const manager = new ProofRunManager(undefined, handleProofUpdate, config, projectCwd);
1169
+ managerRef.current = manager;
1170
+
1171
+ const runner = new ScenarioRunner(handleScenarioUpdate);
1172
+ scenarioRunnerRef.current = runner;
1173
+ }, [handleProofUpdate, handleScenarioUpdate]);
1174
+
1175
+ useEffect(() => {
1176
+ let cancelled = false;
1177
+
1178
+ async function initEnvironment() {
1179
+ // Load user-configured environment and project root in parallel
1180
+ const [config, projectCwd] = await Promise.all([
1181
+ loadEnvironmentConfig(),
1182
+ dataBridge.getProjectRoot(),
1183
+ ]);
1184
+ if (cancelled) return;
1185
+
1186
+ setEnvConfig(config);
1187
+ await initEnvironmentWithConfig(config, projectCwd);
1188
+ }
1189
+
1190
+ initEnvironment();
1191
+
1192
+ return () => {
1193
+ cancelled = true;
1194
+ if (managerRef.current) {
1195
+ managerRef.current.stopServices();
1196
+ managerRef.current.destroy();
1197
+ }
1198
+ if (scenarioRunnerRef.current) {
1199
+ scenarioRunnerRef.current.stop();
1200
+ scenarioRunnerRef.current.destroy();
1201
+ }
1202
+ };
1203
+ }, [initEnvironmentWithConfig]);
1204
+
1205
+ useEffect(() => {
1206
+ async function loadData() {
1207
+ const workItemId = parseInt(id || '', 10);
1208
+ if (isNaN(workItemId)) {
1209
+ setLoading(false);
1210
+ return;
1211
+ }
1212
+
1213
+ try {
1214
+ const workItem = await dataBridge.getWorkItem(workItemId);
1215
+ setItem(workItem);
1216
+
1217
+ // Load checklist from stored QA steps or defaults
1218
+ let items = buildChecklist(workItem);
1219
+
1220
+ // Restore saved state from localStorage
1221
+ const stored = loadChecklistState(String(workItemId));
1222
+ if (stored) {
1223
+ items = applyStoredState(items, stored);
1224
+ }
1225
+
1226
+ setChecklist(items);
1227
+
1228
+ // Load historical test results if this work item has a scenario file
1229
+ if (workItem.scenario_file) {
1230
+ try {
1231
+ const testData = await dataBridge.getTestDashboardData();
1232
+ const allFeatures = [
1233
+ ...testData.epics.flatMap((e: { features: TestFeature[] }) => e.features),
1234
+ ...testData.standaloneFeatures,
1235
+ ];
1236
+ // Find the feature matching this work item's scenario file
1237
+ const matchingFeature = allFeatures.find(
1238
+ (f: TestFeature) => f.featureFile === workItem.scenario_file
1239
+ );
1240
+ if (matchingFeature) {
1241
+ const historical: HistoricalScenario[] = matchingFeature.scenarios.map(
1242
+ (s: TestScenario) => ({
1243
+ title: s.title,
1244
+ status: s.status,
1245
+ lastRun: s.lastRun,
1246
+ ranBy: 'Claude',
1247
+ duration: s.duration,
1248
+ steps: s.steps,
1249
+ })
1250
+ );
1251
+ setHistoricalScenarios(historical);
1252
+ }
1253
+ } catch {
1254
+ // Test data not available — that's fine, sidebar stays empty
1255
+ }
1256
+ }
1257
+ } catch {
1258
+ setItem(null);
1259
+ }
1260
+ setLoading(false);
1261
+ }
1262
+ loadData();
1263
+ }, [id]);
1264
+
1265
+ // When ProjectStackSection saves config from the modal, reinit ProofRunManager
1266
+ const handleConfigSaved = useCallback(async (config: EnvironmentConfig) => {
1267
+ setEnvConfig(config);
1268
+ setShowStackModal(false);
1269
+
1270
+ // Tear down old manager and create a new one with the saved config
1271
+ if (managerRef.current) {
1272
+ managerRef.current.stopServices();
1273
+ managerRef.current.destroy();
1274
+ }
1275
+ const projectCwd = await dataBridge.getProjectRoot();
1276
+ const manager = new ProofRunManager(undefined, handleProofUpdate, config, projectCwd);
1277
+ managerRef.current = manager;
1278
+ }, [handleProofUpdate]);
1279
+
1280
+ // Launch environment on user action (Open in App button)
1281
+ const handleLaunch = useCallback(() => {
1282
+ const manager = managerRef.current;
1283
+ if (manager) {
1284
+ manager.startServices();
1285
+ proofActiveRef.current = true;
1286
+ }
1287
+ }, []);
1288
+
1289
+ // Fix with Claude: open chat session with crashed service context
1290
+ const handleFixWithClaude = useCallback(() => {
1291
+ const crashedServices = services
1292
+ .filter(s => s.status === 'crashed')
1293
+ .map(s => ({ name: s.name, port: s.port }));
1294
+ if (crashedServices.length > 0) {
1295
+ createFixServiceSession(crashedServices);
1296
+ }
1297
+ }, [services, createFixServiceSession]);
1298
+
1299
+ if (loading) {
1300
+ return (
1301
+ <div className="h-full flex items-center justify-center">
1302
+ <div className="text-zinc-400">Loading QA environment...</div>
1303
+ </div>
1304
+ );
1305
+ }
1306
+
1307
+ if (!item) {
1308
+ return (
1309
+ <div className="h-full flex items-center justify-center">
1310
+ <div className="text-center">
1311
+ <div className="text-zinc-400 mb-3" data-testid="not-found-message">Work item not found</div>
1312
+ <Link
1313
+ to="/"
1314
+ viewTransition
1315
+ className="text-[13px] font-medium transition-colors duration-200"
1316
+ style={{ color: '#819D9F' }}
1317
+ data-testid="return-to-board-link"
1318
+ >
1319
+ &larr; Return to kanban board
1320
+ </Link>
1321
+ </div>
1322
+ </div>
1323
+ );
1324
+ }
1325
+
1326
+
1327
+ const completedCount = checklist.filter(i => i.status === 'passed').length;
1328
+
1329
+ // Gate: no environment config configured yet
1330
+ if (envConfig === null && item) {
1331
+ return (
1332
+ <div className="h-full overflow-auto" style={{ padding: '24px 32px' }}>
1333
+ {/* Header */}
1334
+ <div
1335
+ className="bg-white dark:bg-zinc-800 rounded-xl px-5 py-4 mb-6"
1336
+ style={{ boxShadow: shadow.md }}
1337
+ >
1338
+ <div className="flex items-center gap-3">
1339
+ <Link
1340
+ to={`/work/${item.id}`}
1341
+ viewTransition
1342
+ className="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors duration-200"
1343
+ >
1344
+ &larr;
1345
+ </Link>
1346
+ <div>
1347
+ <div className="text-[11px] text-zinc-400 font-mono">#{item.id} QA Environment</div>
1348
+ <span className="text-[16px] font-semibold text-zinc-900 dark:text-zinc-100">
1349
+ {item.title}
1350
+ </span>
1351
+ </div>
1352
+ </div>
1353
+ </div>
1354
+
1355
+ {/* Empty state */}
1356
+ <div className="flex items-center justify-center" style={{ minHeight: 400 }}>
1357
+ <div className="text-center max-w-md">
1358
+ <svg viewBox="0 0 120 120" fill="none" className="w-28 h-28 mx-auto mb-6">
1359
+ <rect x="20" y="60" width="30" height="40" rx="6" className="fill-zinc-100 stroke-zinc-300 dark:fill-zinc-800 dark:stroke-zinc-600" strokeWidth="2"/>
1360
+ <rect x="45" y="40" width="30" height="60" rx="6" className="fill-zinc-100 stroke-zinc-300 dark:fill-zinc-800 dark:stroke-zinc-600" strokeWidth="2"/>
1361
+ <rect x="70" y="50" width="30" height="50" rx="6" className="fill-zinc-100 stroke-zinc-300 dark:fill-zinc-800 dark:stroke-zinc-600" strokeWidth="2"/>
1362
+ <circle cx="35" cy="75" r="4" className="fill-zinc-300 dark:fill-zinc-600"/>
1363
+ <circle cx="60" cy="55" r="4" className="fill-zinc-300 dark:fill-zinc-600"/>
1364
+ <circle cx="85" cy="65" r="4" className="fill-zinc-300 dark:fill-zinc-600"/>
1365
+ <path d="M39 75 L56 55" className="stroke-zinc-300 dark:stroke-zinc-600" strokeWidth="1.5" strokeDasharray="4 3"/>
1366
+ <path d="M64 55 L81 65" className="stroke-zinc-300 dark:stroke-zinc-600" strokeWidth="1.5" strokeDasharray="4 3"/>
1367
+ </svg>
1368
+ <h3 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-2">
1369
+ Set up your project stack first
1370
+ </h3>
1371
+ <p className="text-sm text-zinc-500 dark:text-zinc-400 mb-7 leading-relaxed">
1372
+ QA needs to know what services your project runs — a dev server, a database, an API.
1373
+ Let Claude figure it out so testing just works.
1374
+ </p>
1375
+ <button
1376
+ className="px-5 py-2.5 text-[14px] font-medium rounded-xl transition-colors duration-200"
1377
+ style={{ backgroundColor: '#819D9F', color: 'white' }}
1378
+ onClick={() => setShowStackModal(true)}
1379
+ data-testid="setup-stack-button"
1380
+ >
1381
+ Set up project stack
1382
+ </button>
1383
+ </div>
1384
+ </div>
1385
+
1386
+ {/* Modal */}
1387
+ {showStackModal && createPortal(
1388
+ <div
1389
+ className="fixed inset-0 z-50 flex items-center justify-center"
1390
+ onClick={(e) => { if (e.target === e.currentTarget) setShowStackModal(false); }}
1391
+ >
1392
+ <div className="absolute inset-0 bg-black/40" />
1393
+ <div
1394
+ className="relative bg-white dark:bg-zinc-900 rounded-2xl w-full max-w-2xl max-h-[80vh] overflow-auto p-6"
1395
+ style={{ boxShadow: shadow.lg }}
1396
+ >
1397
+ <button
1398
+ className="absolute top-4 right-4 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 text-lg cursor-pointer"
1399
+ onClick={() => setShowStackModal(false)}
1400
+ >
1401
+ &times;
1402
+ </button>
1403
+ <ProjectStackSection initialConfig={null} onConfigSaved={handleConfigSaved} />
1404
+ </div>
1405
+ </div>,
1406
+ document.body
1407
+ )}
1408
+ </div>
1409
+ );
1410
+ }
1411
+
1412
+ return (
1413
+ <>
1414
+ <style>{`
1415
+ @keyframes ping {
1416
+ 75%, 100% { transform: scale(2); opacity: 0; }
1417
+ }
1418
+ `}</style>
1419
+
1420
+ <div className="h-full overflow-auto" style={{ padding: '24px 32px', paddingBottom: inspectorOpen ? 340 : 80 }}>
1421
+ {/* Header */}
1422
+ <div
1423
+ className="bg-white dark:bg-zinc-800 rounded-xl px-5 py-4 mb-4"
1424
+ style={{ boxShadow: shadow.md }}
1425
+ >
1426
+ <div className="flex items-center justify-between">
1427
+ <div className="flex items-center gap-3">
1428
+ <Link
1429
+ to={`/work/${item.id}`}
1430
+ viewTransition
1431
+ className="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors duration-200"
1432
+ >
1433
+ &larr;
1434
+ </Link>
1435
+ <div>
1436
+ <div className="text-[11px] text-zinc-400 font-mono">#{item.id} QA Environment</div>
1437
+ <span className="text-[16px] font-semibold text-zinc-900 dark:text-zinc-100">
1438
+ {item.title}
1439
+ </span>
1440
+ </div>
1441
+ {item.mode && (
1442
+ <span
1443
+ className="px-2 py-0.5 text-[11px] font-medium rounded-lg"
1444
+ style={{ backgroundColor: '#FCEEE6', color: '#9E4A1E' }}
1445
+ >
1446
+ {item.mode}
1447
+ </span>
1448
+ )}
1449
+ </div>
1450
+ <span className="text-[13px] text-zinc-500">{completedCount}/{checklist.length} verified</span>
1451
+ </div>
1452
+ </div>
1453
+
1454
+ {/* Environment Status Bar */}
1455
+ <EnvironmentBar services={services} proofState={proofState} onLaunch={handleLaunch} onFixWithClaude={handleFixWithClaude} />
1456
+
1457
+ {/* Main Content: Checklist + Scenario Sidebar */}
1458
+ <div className="flex gap-4" style={{ minHeight: 420 }}>
1459
+ {/* Checklist (Primary) */}
1460
+ <div className="flex-1">
1461
+ <QAChecklist
1462
+ items={checklist}
1463
+ onPass={handlePass}
1464
+ onFail={handleFail}
1465
+ onReset={handleReset}
1466
+ onResetAll={handleResetAll}
1467
+ onApprove={handleApprove}
1468
+ onReject={handleReject}
1469
+ />
1470
+ </div>
1471
+
1472
+ {/* Scenario Sidebar */}
1473
+ <div className="w-[320px] shrink-0">
1474
+ <ScenarioSidebar scenarios={scenarios} historicalScenarios={historicalScenarios} />
1475
+ </div>
1476
+ </div>
1477
+ </div>
1478
+
1479
+ {/* Inspector Drawer */}
1480
+ <InspectorDrawer
1481
+ dbChanges={dbChanges}
1482
+ eventLog={eventLog}
1483
+ logs={logs}
1484
+ isOpen={inspectorOpen}
1485
+ onToggle={() => setInspectorOpen(prev => !prev)}
1486
+ />
1487
+ </>
1488
+ );
1489
+ }