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,948 @@
1
+ import { useState, useRef, useEffect, useCallback } from 'react';
2
+ import { Button } from '@/components/ui/Button';
3
+ import { useSessionActions, useSessionState } from '@/contexts/ClaudeSessionContext';
4
+ import { getRegistry } from '@/lib/stream-manager-registry';
5
+ import type { StreamState } from '@/lib/session-stream-manager';
6
+ import { dataBridge } from '@/lib/data-bridge';
7
+ import { invoke } from '@/lib/tauri';
8
+ import type { EnvironmentConfig, ServiceDefinition, ServiceCategory, ContextItem } from '@/lib/environment-config';
9
+ import { validateCategory, validateFaIcon } from '@/lib/environment-config';
10
+ import { runVerification } from '@/lib/environment-verification';
11
+ import type { VerificationResult } from '@/lib/environment-verification';
12
+ import type { ServiceStatus } from '@/lib/proof-run';
13
+
14
+ /** Extended service with optional role from Claude analysis */
15
+ interface ProposedService extends ServiceDefinition {
16
+ role?: string;
17
+ }
18
+
19
+ interface ProjectStackSectionProps {
20
+ initialConfig: EnvironmentConfig | null;
21
+ onConfigSaved?: (config: EnvironmentConfig) => void;
22
+ }
23
+
24
+ /** Group services by their launch order */
25
+ function groupByOrder<T extends { order: number }>(services: T[]): Map<number, T[]> {
26
+ const groups = new Map<number, T[]>();
27
+ const sorted = [...services].sort((a, b) => a.order - b.order);
28
+ for (const svc of sorted) {
29
+ const existing = groups.get(svc.order);
30
+ if (existing) existing.push(svc);
31
+ else groups.set(svc.order, [svc]);
32
+ }
33
+ return groups;
34
+ }
35
+
36
+ /** QA-launchable categories (Zone 1) */
37
+ const LAUNCH_CATEGORIES: ServiceCategory[] = ['infrastructure', 'server'];
38
+ /** Context categories (Zone 2) */
39
+ const CONTEXT_CATEGORIES: ServiceCategory[] = ['managed', 'test-runner', 'build-tool'];
40
+
41
+ /** Split services into launch zone and context zone */
42
+ function splitByZone(services: ServiceDefinition[]) {
43
+ const launch = services.filter(s => LAUNCH_CATEGORIES.includes(s.category));
44
+ const context = services.filter(s => CONTEXT_CATEGORIES.includes(s.category));
45
+ return { launch, context };
46
+ }
47
+
48
+ /** Default FA icon per category */
49
+ const DEFAULT_ICONS: Record<ServiceCategory, string> = {
50
+ server: 'fa-server',
51
+ infrastructure: 'fa-cubes',
52
+ managed: 'fa-cloud',
53
+ 'test-runner': 'fa-flask',
54
+ 'build-tool': 'fa-hammer',
55
+ };
56
+
57
+ /** Category display labels */
58
+ const CATEGORY_LABELS: Record<ServiceCategory, string> = {
59
+ server: 'Dev Server',
60
+ infrastructure: 'Infrastructure',
61
+ managed: 'Managed Service',
62
+ 'test-runner': 'Test Runner',
63
+ 'build-tool': 'Build Tool',
64
+ };
65
+
66
+ function faIcon(svc: { faIcon?: string; category: ServiceCategory }): string {
67
+ return svc.faIcon || DEFAULT_ICONS[svc.category] || 'fa-gear';
68
+ }
69
+
70
+ export function ProjectStackSection({ initialConfig, onConfigSaved }: ProjectStackSectionProps) {
71
+ const { createSkillSession, createFixServiceSession } = useSessionActions();
72
+ const { sessions } = useSessionState();
73
+ const sessionsRef = useRef(sessions);
74
+ useEffect(() => { sessionsRef.current = sessions; }, [sessions]);
75
+ const [config, setConfig] = useState<EnvironmentConfig | null>(initialConfig);
76
+ const [uiState, setUiState] = useState<'empty' | 'analyzing' | 'proposal' | 'verifying' | 'verification-failed' | 'configured'>(
77
+ initialConfig ? 'configured' : 'empty'
78
+ );
79
+ const [proposedServices, setProposedServices] = useState<ProposedService[]>([]);
80
+ const [analysisMessages, setAnalysisMessages] = useState<string[]>([]);
81
+ const [verificationServices, setVerificationServices] = useState<ServiceStatus[]>([]);
82
+ const [verificationResult, setVerificationResult] = useState<VerificationResult | null>(null);
83
+ const [expandedService, setExpandedService] = useState<string | null>(null);
84
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
85
+ const [connectionStatus, setConnectionStatus] = useState<Record<string, 'testing' | 'connected' | 'failed'>>({});
86
+ const [testingConnections, setTestingConnections] = useState(false);
87
+
88
+ const handleTestAllConnections = async (services: Array<{ name: string; port?: number; connectionUrl?: string }>) => {
89
+ setTestingConnections(true);
90
+ const newStatus: Record<string, 'testing'> = {};
91
+ for (const svc of services) newStatus[svc.name] = 'testing';
92
+ setConnectionStatus(prev => ({ ...prev, ...newStatus }));
93
+
94
+ await Promise.all(services.map(async (svc) => {
95
+ try {
96
+ // Extract host and port from connectionUrl or fall back to localhost:port
97
+ let host = 'localhost';
98
+ let port = svc.port;
99
+ if (svc.connectionUrl) {
100
+ try {
101
+ const parsed = new URL(svc.connectionUrl);
102
+ host = parsed.hostname || 'localhost';
103
+ if (parsed.port) port = parseInt(parsed.port, 10);
104
+ } catch {
105
+ // Not a valid URL — treat as host:port or just use defaults
106
+ }
107
+ }
108
+ if (!port) {
109
+ setConnectionStatus(prev => ({ ...prev, [svc.name]: 'failed' }));
110
+ return;
111
+ }
112
+ const ok = await invoke<boolean>('test_tcp_connection', { host, port, timeoutMs: 3000 });
113
+ setConnectionStatus(prev => ({ ...prev, [svc.name]: ok ? 'connected' : 'failed' }));
114
+ } catch {
115
+ setConnectionStatus(prev => ({ ...prev, [svc.name]: 'failed' }));
116
+ }
117
+ }));
118
+ setTestingConnections(false);
119
+ };
120
+ const isAnalyzing = useRef(false);
121
+
122
+ // On mount: reconnect to in-progress analysis, or fetch saved config
123
+ useEffect(() => {
124
+ // Check if there's an active analysis session still running
125
+ for (const [id, session] of sessions) {
126
+ if (session.title === 'Project Stack Analysis' && (session.status === 'streaming' || session.status === 'connecting')) {
127
+ // Reconnect: show analyzing state and wait for completion
128
+ setUiState('analyzing');
129
+ setAnalysisMessages(['Claude is analyzing your codebase...']);
130
+ isAnalyzing.current = true;
131
+
132
+ waitForSession(id).then(() => {
133
+ setAnalysisMessages(prev => [...prev, 'Parsing results...']);
134
+ const services = parseServicesFromMessages(id);
135
+ if (services && services.length > 0) {
136
+ setProposedServices(services);
137
+ setUiState('proposal');
138
+ } else {
139
+ setErrorMessage("We couldn't find any services in your project. Make sure your project has a package.json or config files, then try again.");
140
+ setUiState('empty');
141
+ }
142
+ }).catch(() => {
143
+ setErrorMessage('Analysis failed. Please try again.');
144
+ setUiState('empty');
145
+ }).finally(() => {
146
+ isAnalyzing.current = false;
147
+ });
148
+ return; // Found active session, don't check saved config
149
+ }
150
+ }
151
+
152
+ // No active analysis — check for saved config if initialConfig was stale
153
+ if (!initialConfig) {
154
+ dataBridge.getEnvironmentConfig().then(freshConfig => {
155
+ if (freshConfig && freshConfig.services.length > 0) {
156
+ setConfig(freshConfig);
157
+ setUiState('configured');
158
+ }
159
+ });
160
+ }
161
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
162
+
163
+ const waitForSession = useCallback((sessionId: string, timeoutMs = 300000): Promise<void> => {
164
+ return new Promise((resolve, reject) => {
165
+ const registry = getRegistry();
166
+ let settled = false;
167
+
168
+ const settle = (fn: () => void) => {
169
+ if (settled) return;
170
+ settled = true;
171
+ registry.off('stateChange', handler);
172
+ clearInterval(pollTimer);
173
+ clearTimeout(timeoutTimer);
174
+ fn();
175
+ };
176
+
177
+ // Check status from stream manager directly (source of truth, no React delay)
178
+ const checkStatus = () => {
179
+ const manager = registry.get(sessionId);
180
+ if (manager?.status === 'done') { settle(() => resolve()); return true; }
181
+ if (manager?.status === 'error') { settle(() => reject(new Error('Session failed'))); return true; }
182
+ // Fallback: check React state
183
+ const session = sessionsRef.current.get(sessionId);
184
+ if (session?.status === 'done') { settle(() => resolve()); return true; }
185
+ if (session?.status === 'error') { settle(() => reject(new Error(session.error || 'Session failed'))); return true; }
186
+ return false;
187
+ };
188
+
189
+ // Check immediately
190
+ if (checkStatus()) return;
191
+
192
+ // Listen for registry events (primary path)
193
+ const handler = (changedId: string, state: StreamState) => {
194
+ if (changedId !== sessionId) return;
195
+ if (state.status === 'done') settle(() => resolve());
196
+ else if (state.status === 'error') settle(() => reject(new Error(state.error || 'Session failed')));
197
+ };
198
+ registry.on('stateChange', handler);
199
+
200
+ // Poll stream manager status every 1s as backup (catches missed events)
201
+ const pollTimer = setInterval(() => { checkStatus(); }, 1000);
202
+
203
+ // Timeout
204
+ const timeoutTimer = setTimeout(() => {
205
+ if (!checkStatus()) {
206
+ settle(() => reject(new Error('Analysis timed out.')));
207
+ }
208
+ }, timeoutMs);
209
+ });
210
+ }, []);
211
+
212
+ const parseServicesFromMessages = useCallback((sessionId: string): ProposedService[] | null => {
213
+ // Read directly from stream manager (source of truth, no React state delay)
214
+ const registry = getRegistry();
215
+ const manager = registry.get(sessionId);
216
+ const messages = manager?.messages ?? [];
217
+ if (messages.length === 0) return null;
218
+
219
+ // Look through assistant messages for a JSON block
220
+ for (const msg of [...messages].reverse()) {
221
+ if (msg.type !== 'assistant' || !msg.content) continue;
222
+
223
+ // Try to extract JSON from code block first
224
+ const codeBlockMatch = msg.content.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
225
+ const jsonStr = codeBlockMatch ? codeBlockMatch[1].trim() : msg.content.trim();
226
+
227
+ try {
228
+ const parsed = JSON.parse(jsonStr);
229
+ if (parsed?.services && Array.isArray(parsed.services)) {
230
+ return parsed.services.map((s: Record<string, unknown>, i: number) => ({
231
+ name: String(s.name || `Service ${i + 1}`),
232
+ command: String(s.command || ''),
233
+ port: Number(s.port) || 0,
234
+ healthCheck: String(s.healthCheck || 'http'),
235
+ readyPattern: String(s.readyPattern || ''),
236
+ optional: Boolean(s.optional),
237
+ order: Number(s.order) || i + 1,
238
+ role: s.role ? String(s.role) : undefined,
239
+ category: validateCategory(s.category),
240
+ faIcon: validateFaIcon(s.faIcon),
241
+ }));
242
+ }
243
+ } catch {
244
+ // Not valid JSON, try next message
245
+ }
246
+ }
247
+ return null;
248
+ }, []);
249
+
250
+ const handleAnalyze = async () => {
251
+ if (isAnalyzing.current) return;
252
+ isAnalyzing.current = true;
253
+ setUiState('analyzing');
254
+ setAnalysisMessages([]);
255
+ setErrorMessage(null);
256
+
257
+ try {
258
+ const analysisPrompt = `Analyze this project's codebase to identify all services and processes needed for development and testing.
259
+
260
+ Look at:
261
+ - package.json (scripts, workspaces)
262
+ - docker-compose files
263
+ - Procfile, Makefile
264
+ - Any configuration files
265
+
266
+ For each service found, determine the startup command, port, how to check if it's running, and startup order.
267
+
268
+ Classify each service into one of these categories:
269
+ - "server" — Dev servers that serve the app (Next.js, Vite, Express API servers)
270
+ - "infrastructure" — Local infrastructure that must start before servers (Docker containers, local databases, Redis)
271
+ - "managed" — External managed services not started locally (Supabase, AWS RDS, Firebase, Stripe)
272
+ - "test-runner" — Test frameworks (Jest, Vitest, Playwright, Cypress)
273
+ - "build-tool" — Build tools and bundlers (Webpack, Turbopack, esbuild, TypeScript compiler)
274
+
275
+ Pick a FontAwesome icon class for each service (e.g. "fa-server", "fa-database", "fa-cloud", "fa-flask", "fa-hammer").
276
+
277
+ Respond with ONLY a JSON code block in this exact format (no other text before or after):
278
+
279
+ \`\`\`json
280
+ {
281
+ "services": [
282
+ {
283
+ "name": "Human-friendly name",
284
+ "command": "shell command to start",
285
+ "port": 3000,
286
+ "healthCheck": "http or tcp or ws or stdout",
287
+ "readyPattern": "stdout text when ready or empty string",
288
+ "optional": false,
289
+ "order": 1,
290
+ "role": "What this service does",
291
+ "category": "server",
292
+ "faIcon": "fa-server"
293
+ }
294
+ ]
295
+ }
296
+ \`\`\``;
297
+
298
+ setAnalysisMessages(['Starting Claude analysis...']);
299
+
300
+ const sessionId = await createSkillSession('analyze-project-stack', 'Project Stack Analysis', analysisPrompt);
301
+ if (!sessionId) {
302
+ throw new Error('Failed to create analysis session');
303
+ }
304
+
305
+ setAnalysisMessages(prev => [...prev, 'Claude is reading your code...']);
306
+
307
+ // Wait for Claude to finish (up to 2 minutes)
308
+ await waitForSession(sessionId);
309
+
310
+ setAnalysisMessages(prev => [...prev, 'Parsing results...']);
311
+
312
+ // Parse services from Claude's response
313
+ const services = parseServicesFromMessages(sessionId);
314
+ if (services && services.length > 0) {
315
+ setProposedServices(services);
316
+ setUiState('proposal');
317
+ } else {
318
+ // Fallback: check if Claude wrote directly to config
319
+ const updatedConfig = await dataBridge.getEnvironmentConfig();
320
+ if (updatedConfig && updatedConfig.services.length > 0) {
321
+ setProposedServices(updatedConfig.services);
322
+ setUiState('proposal');
323
+ } else {
324
+ setErrorMessage("We couldn't find any services in your project. Make sure your project has a package.json or config files, then try again.");
325
+ setUiState('empty');
326
+ }
327
+ }
328
+ } catch (err) {
329
+ const message = err instanceof Error ? err.message : 'Something went wrong analyzing your project. Please try again.';
330
+ setErrorMessage(message);
331
+ setUiState('empty');
332
+ } finally {
333
+ isAnalyzing.current = false;
334
+ }
335
+ };
336
+
337
+ const handleAccept = async () => {
338
+ const { launch, context } = splitByZone(proposedServices);
339
+ const newConfig: EnvironmentConfig = {
340
+ services: launch.map(svc => ({
341
+ name: svc.name,
342
+ command: svc.command,
343
+ port: svc.port,
344
+ healthCheck: svc.healthCheck,
345
+ readyPattern: svc.readyPattern,
346
+ optional: svc.optional ?? false,
347
+ order: svc.order,
348
+ category: svc.category || 'server',
349
+ faIcon: svc.faIcon,
350
+ })),
351
+ contextItems: context.map(svc => ({
352
+ name: svc.name,
353
+ category: svc.category as ContextItem['category'],
354
+ faIcon: svc.faIcon,
355
+ command: svc.command || undefined,
356
+ })),
357
+ readyWhen: 'required',
358
+ teardownOnClose: true,
359
+ };
360
+ try {
361
+ const success = await dataBridge.setEnvironmentConfig(newConfig);
362
+ if (!success) throw new Error('Save returned false');
363
+ setConfig(newConfig);
364
+ onConfigSaved?.(newConfig);
365
+
366
+ // Verify the environment actually launches
367
+ setUiState('verifying');
368
+ setVerificationServices([]);
369
+ setVerificationResult(null);
370
+ const projectRoot = await dataBridge.getProjectRoot();
371
+ const result = await runVerification(newConfig, projectRoot, (update) => {
372
+ setVerificationServices([...update.services]);
373
+ if (update.result) setVerificationResult(update.result);
374
+ });
375
+
376
+ if (result.allPassed) {
377
+ setUiState('configured');
378
+ } else {
379
+ setVerificationResult(result);
380
+ setUiState('verification-failed');
381
+ }
382
+ } catch {
383
+ setErrorMessage('Failed to save your configuration. Please try again.');
384
+ }
385
+ };
386
+
387
+ const handleRemoveService = (serviceName: string) => {
388
+ setProposedServices(prev => prev.filter(s => s.name !== serviceName));
389
+ };
390
+
391
+ const handleRescan = () => {
392
+ handleAnalyze();
393
+ };
394
+
395
+ // ── Empty state ──
396
+ if (uiState === 'empty') {
397
+ return (
398
+ <section id="project-stack">
399
+ <div className="text-center py-16">
400
+ <svg viewBox="0 0 120 120" fill="none" className="w-28 h-28 mx-auto mb-6">
401
+ <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"/>
402
+ <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"/>
403
+ <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"/>
404
+ <circle cx="35" cy="75" r="4" className="fill-zinc-300 dark:fill-zinc-600"/>
405
+ <circle cx="60" cy="55" r="4" className="fill-zinc-300 dark:fill-zinc-600"/>
406
+ <circle cx="85" cy="65" r="4" className="fill-zinc-300 dark:fill-zinc-600"/>
407
+ <path d="M39 75 L56 55" className="stroke-zinc-300 dark:stroke-zinc-600" strokeWidth="1.5" strokeDasharray="4 3"/>
408
+ <path d="M64 55 L81 65" className="stroke-zinc-300 dark:stroke-zinc-600" strokeWidth="1.5" strokeDasharray="4 3"/>
409
+ </svg>
410
+ <h3 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-2">
411
+ What does your project need to run?
412
+ </h3>
413
+ <p className="text-sm text-zinc-500 dark:text-zinc-400 mb-7 max-w-md mx-auto leading-relaxed">
414
+ Every project has pieces that work together — a website, an API, a database.
415
+ Claude can figure out what yours needs so QA testing just works.
416
+ </p>
417
+ {errorMessage && (
418
+ <div className="mb-5 mx-auto max-w-md rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3">
419
+ <p className="text-sm text-red-600 dark:text-red-400">{errorMessage}</p>
420
+ </div>
421
+ )}
422
+ <Button onClick={handleAnalyze}>
423
+ Let Claude figure it out
424
+ </Button>
425
+ <p className="text-xs text-zinc-400 dark:text-zinc-600 mt-4">
426
+ Claude will analyze your codebase. You&apos;ll review everything before it&apos;s saved.
427
+ </p>
428
+ </div>
429
+ </section>
430
+ );
431
+ }
432
+
433
+ // ── Analyzing state ──
434
+ if (uiState === 'analyzing') {
435
+ return (
436
+ <section id="project-stack">
437
+ <div className="text-center py-12">
438
+ <div className="w-16 h-16 rounded-full mx-auto mb-6 flex items-center justify-center bg-blue-100 dark:bg-blue-950" style={{ animation: 'pulse 2s ease-in-out infinite' }}>
439
+ <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-blue-500 dark:text-blue-400">
440
+ <circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
441
+ </svg>
442
+ </div>
443
+ <h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100 mb-1">
444
+ Learning about your project...
445
+ </h3>
446
+ <p className="text-sm text-zinc-500 dark:text-zinc-400 mb-8">
447
+ Claude is reading your code to understand how everything fits together
448
+ </p>
449
+ <div className="max-w-sm mx-auto text-left space-y-3">
450
+ {analysisMessages.map((msg, i) => (
451
+ <div key={i} className="flex items-center gap-3 text-sm">
452
+ <span className="text-green-500 flex-shrink-0">✓</span>
453
+ <span className="text-zinc-700 dark:text-zinc-300">{msg}</span>
454
+ </div>
455
+ ))}
456
+ </div>
457
+ </div>
458
+ </section>
459
+ );
460
+ }
461
+
462
+ // ── Proposal state ──
463
+ if (uiState === 'proposal') {
464
+ const { launch, context } = splitByZone(proposedServices);
465
+ const launchGroups = groupByOrder(launch);
466
+ const launchSteps = [...launchGroups.keys()];
467
+ const managed = context.filter(s => s.category === 'managed');
468
+ const runners = context.filter(s => s.category === 'test-runner');
469
+ const buildTools = context.filter(s => s.category === 'build-tool');
470
+
471
+ return (
472
+ <section id="project-stack">
473
+ {/* Intro card */}
474
+ <div className="rounded-xl p-5 mb-6 border" style={{ background: 'linear-gradient(135deg, #eff6ff 0%, #eef2ff 100%)', borderColor: '#bfdbfe' }}>
475
+ <div className="dark:hidden">
476
+ <h2 className="text-base font-semibold text-zinc-900 mb-1">Here&apos;s what makes your project run</h2>
477
+ <p className="text-sm text-zinc-600 leading-relaxed">
478
+ Claude found {proposedServices.length} piece{proposedServices.length !== 1 ? 's' : ''} in your stack.
479
+ Look them over — you can remove anything that doesn&apos;t belong.
480
+ </p>
481
+ </div>
482
+ </div>
483
+ <div className="hidden dark:block rounded-xl p-5 mb-6 border" style={{ background: 'linear-gradient(135deg, #172554 0%, #1e1b4b 100%)', borderColor: '#1e40af' }}>
484
+ <h2 className="text-base font-semibold text-zinc-100 mb-1">Here&apos;s what makes your project run</h2>
485
+ <p className="text-sm text-zinc-400 leading-relaxed">
486
+ Claude found {proposedServices.length} piece{proposedServices.length !== 1 ? 's' : ''} in your stack.
487
+ Look them over — you can remove anything that doesn&apos;t belong.
488
+ </p>
489
+ </div>
490
+
491
+ {proposedServices.length === 0 ? (
492
+ <div className="text-center py-8">
493
+ <p className="text-sm text-amber-600 dark:text-amber-400 mb-4">
494
+ You&apos;ve removed all services. At least one service is needed to save your project stack.
495
+ </p>
496
+ </div>
497
+ ) : (
498
+ <>
499
+ {/* Zone 1: QA Launch */}
500
+ {launch.length > 0 && (
501
+ <div className="mb-8">
502
+ <div className="flex items-center gap-2 mb-4">
503
+ <i className="fa-solid fa-rocket text-xs text-blue-500" />
504
+ <h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">JettyPod starts these for QA</h3>
505
+ </div>
506
+ <div className="ml-1">
507
+ {launchSteps.map((order, stepIdx) => {
508
+ const services = launchGroups.get(order)!;
509
+ const isLast = stepIdx === launchSteps.length - 1;
510
+ return (
511
+ <div key={order} className="flex gap-4">
512
+ <div className="flex flex-col items-center w-5 flex-shrink-0">
513
+ <div className="w-2.5 h-2.5 rounded-full bg-blue-500 mt-5 flex-shrink-0" />
514
+ {!isLast && <div className="w-0.5 flex-1 bg-zinc-200 dark:bg-zinc-700" />}
515
+ </div>
516
+ <div className="flex-1 pb-4">
517
+ <div className="text-xs font-medium text-zinc-400 dark:text-zinc-500 uppercase tracking-wide mb-2">
518
+ {stepIdx === 0 ? 'Starts first' : `Step ${stepIdx + 1}`}{services.length > 1 ? ' — in parallel' : ''}
519
+ </div>
520
+ <div className="space-y-2">
521
+ {services.map(svc => (
522
+ <div key={svc.name} className="p-4 rounded-xl border border-dashed border-blue-300 dark:border-blue-800 bg-white dark:bg-zinc-800/50 hover:border-blue-400 dark:hover:border-blue-600 transition-colors">
523
+ <div className="flex items-start justify-between">
524
+ <div className="flex items-center gap-3">
525
+ <div className="w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 bg-blue-50 dark:bg-blue-950 text-blue-500 dark:text-blue-400">
526
+ <i className={`fa-solid ${faIcon(svc)}`} />
527
+ </div>
528
+ <div>
529
+ <div className="text-base font-semibold text-zinc-900 dark:text-zinc-100">{svc.name}</div>
530
+ {(svc as ProposedService).role && <div className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{(svc as ProposedService).role}</div>}
531
+ </div>
532
+ </div>
533
+ <div className="flex gap-1">
534
+ <button onClick={() => setExpandedService(expandedService === svc.name ? null : svc.name)} className="px-3 py-1 text-xs text-zinc-500 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded-lg cursor-pointer">Details</button>
535
+ <button onClick={() => handleRemoveService(svc.name)} className="px-3 py-1 text-xs text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg cursor-pointer">Remove</button>
536
+ </div>
537
+ </div>
538
+ <div className="flex gap-1.5 mt-2 flex-wrap">
539
+ <span className="text-xs px-2.5 py-0.5 rounded-full bg-zinc-100 text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400">{CATEGORY_LABELS[svc.category]}</span>
540
+ <span className="text-xs px-2.5 py-0.5 rounded-full bg-zinc-100 text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400">{svc.command}</span>
541
+ </div>
542
+ {expandedService === svc.name && (
543
+ <div className="mt-3 pt-3 border-t border-zinc-200 dark:border-zinc-700 grid grid-cols-2 gap-2 text-xs">
544
+ <div><span className="text-zinc-400">Port: </span><span className="font-mono text-zinc-600 dark:text-zinc-400">{svc.port}</span></div>
545
+ <div><span className="text-zinc-400">Check: </span><span className="font-mono text-zinc-600 dark:text-zinc-400">{svc.healthCheck}</span></div>
546
+ {svc.readyPattern && <div className="col-span-2"><span className="text-zinc-400">Ready signal: </span><span className="font-mono text-zinc-600 dark:text-zinc-400">&quot;{svc.readyPattern}&quot;</span></div>}
547
+ </div>
548
+ )}
549
+ </div>
550
+ ))}
551
+ </div>
552
+ </div>
553
+ </div>
554
+ );
555
+ })}
556
+ {/* Ready for QA indicator */}
557
+ <div className="flex gap-4">
558
+ <div className="flex flex-col items-center w-5 flex-shrink-0">
559
+ <div className="w-3 h-3 rounded-full bg-green-500 mt-1 flex-shrink-0 ring-4 ring-green-500/20" />
560
+ </div>
561
+ <div className="text-xs font-semibold text-green-600 dark:text-green-400 uppercase tracking-wide">Ready for QA</div>
562
+ </div>
563
+ </div>
564
+ </div>
565
+ )}
566
+
567
+ {/* Zone 2: Context */}
568
+ {context.length > 0 && (
569
+ <div className="mb-6">
570
+ <div className="flex items-center gap-2 mb-4">
571
+ <i className="fa-solid fa-layer-group text-xs text-zinc-400" />
572
+ <h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Also part of your stack</h3>
573
+ </div>
574
+
575
+ {/* Managed services */}
576
+ {managed.length > 0 && (
577
+ <div className="mb-4">
578
+ <div className="flex items-center justify-between mb-2">
579
+ <div className="text-xs font-medium text-zinc-400 dark:text-zinc-500 uppercase tracking-wide">Managed Services</div>
580
+ <button onClick={() => handleTestAllConnections(managed.map(s => ({ name: s.name, port: s.port })))} disabled={testingConnections} className="px-3 py-1.5 text-xs text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-950/50 hover:bg-purple-100 dark:hover:bg-purple-900/50 rounded-lg cursor-pointer disabled:opacity-50">{testingConnections ? 'Testing...' : 'Test Connections'}</button>
581
+ </div>
582
+ <div className="space-y-2">
583
+ {managed.map(svc => (
584
+ <div key={svc.name} className="p-4 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800/50">
585
+ <div className="flex items-center justify-between">
586
+ <div className="flex items-center gap-3">
587
+ <div className="w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 bg-purple-50 dark:bg-purple-950 text-purple-500 dark:text-purple-400">
588
+ <i className={`fa-solid ${faIcon(svc)}`} />
589
+ </div>
590
+ <div>
591
+ <div className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{svc.name}</div>
592
+ {connectionStatus[svc.name] && (
593
+ <div className="flex items-center gap-1.5 mt-0.5">
594
+ <span className={`w-2 h-2 rounded-full ${connectionStatus[svc.name] === 'connected' ? 'bg-green-500' : connectionStatus[svc.name] === 'failed' ? 'bg-red-500' : 'bg-amber-400 animate-pulse'}`} />
595
+ <span className="text-xs text-zinc-400">
596
+ {connectionStatus[svc.name] === 'connected' ? 'Connected' : connectionStatus[svc.name] === 'failed' ? 'Connection failed' : 'Testing...'}
597
+ </span>
598
+ </div>
599
+ )}
600
+ </div>
601
+ </div>
602
+ <button onClick={() => handleRemoveService(svc.name)} className="px-3 py-1 text-xs text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg cursor-pointer">Remove</button>
603
+ </div>
604
+ </div>
605
+ ))}
606
+ </div>
607
+ </div>
608
+ )}
609
+
610
+ {/* Test runners */}
611
+ {runners.length > 0 && (
612
+ <div className="mb-4">
613
+ <div className="text-xs font-medium text-zinc-400 dark:text-zinc-500 uppercase tracking-wide mb-2">Test Runners</div>
614
+ <div className="grid grid-cols-2 gap-2">
615
+ {runners.map(svc => (
616
+ <div key={svc.name} className="p-3 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800/50 flex items-center justify-between">
617
+ <div className="flex items-center gap-2.5">
618
+ <div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-amber-50 dark:bg-amber-950 text-amber-500 dark:text-amber-400 text-sm">
619
+ <i className={`fa-solid ${faIcon(svc)}`} />
620
+ </div>
621
+ <span className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{svc.name}</span>
622
+ </div>
623
+ <div className="flex gap-1">
624
+ <button className="px-2.5 py-1 text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/50 hover:bg-amber-100 dark:hover:bg-amber-900/50 rounded-lg cursor-pointer">Run</button>
625
+ <button onClick={() => handleRemoveService(svc.name)} className="px-2 py-1 text-xs text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg cursor-pointer">&times;</button>
626
+ </div>
627
+ </div>
628
+ ))}
629
+ </div>
630
+ </div>
631
+ )}
632
+
633
+ {/* Build tools */}
634
+ {buildTools.length > 0 && (
635
+ <div className="mb-4">
636
+ <div className="text-xs font-medium text-zinc-400 dark:text-zinc-500 uppercase tracking-wide mb-2">Build Tools</div>
637
+ <div className="flex gap-2 flex-wrap">
638
+ {buildTools.map(svc => (
639
+ <div key={svc.name} className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 text-sm text-zinc-600 dark:text-zinc-400">
640
+ <i className={`fa-solid ${faIcon(svc)} text-xs`} />
641
+ <span>{svc.name}</span>
642
+ <button onClick={() => handleRemoveService(svc.name)} className="text-xs text-red-400 hover:text-red-500 cursor-pointer ml-1">&times;</button>
643
+ </div>
644
+ ))}
645
+ </div>
646
+ </div>
647
+ )}
648
+ </div>
649
+ )}
650
+ </>
651
+ )}
652
+
653
+ {launch.length === 0 && proposedServices.length > 0 && (
654
+ <div className="mb-4 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 px-4 py-3">
655
+ <p className="text-sm text-amber-600 dark:text-amber-400">
656
+ No launchable services found. QA needs at least one server or infrastructure service to start.
657
+ </p>
658
+ </div>
659
+ )}
660
+ {errorMessage && (
661
+ <div className="mb-4 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3">
662
+ <p className="text-sm text-red-600 dark:text-red-400">{errorMessage}</p>
663
+ </div>
664
+ )}
665
+ <div className="flex gap-3 mt-4">
666
+ <Button onClick={handleAccept} disabled={proposedServices.length === 0 || launch.length === 0}>
667
+ Looks good, save it
668
+ </Button>
669
+ <Button onClick={handleRescan} variant="ghost">
670
+ Scan again
671
+ </Button>
672
+ </div>
673
+ </section>
674
+ );
675
+ }
676
+
677
+ // ── Verifying state ──
678
+ if (uiState === 'verifying') {
679
+ return (
680
+ <section id="project-stack">
681
+ <div className="text-center py-12">
682
+ <div className="w-16 h-16 rounded-full mx-auto mb-6 flex items-center justify-center bg-green-100 dark:bg-green-950" style={{ animation: 'pulse 2s ease-in-out infinite' }}>
683
+ <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-green-500 dark:text-green-400">
684
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>
685
+ </svg>
686
+ </div>
687
+ <h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100 mb-1">
688
+ Verifying your environment...
689
+ </h3>
690
+ <p className="text-sm text-zinc-500 dark:text-zinc-400 mb-8">
691
+ Starting each service to make sure everything launches correctly
692
+ </p>
693
+ <div className="max-w-sm mx-auto text-left space-y-3">
694
+ {verificationServices.map(svc => (
695
+ <div key={svc.name} className="flex items-center gap-3 text-sm">
696
+ {svc.status === 'running' ? (
697
+ <span className="text-green-500 flex-shrink-0">✓</span>
698
+ ) : svc.status === 'crashed' ? (
699
+ <span className="text-red-500 flex-shrink-0">✗</span>
700
+ ) : (
701
+ <span className="text-amber-400 flex-shrink-0 animate-pulse">●</span>
702
+ )}
703
+ <span className="text-zinc-700 dark:text-zinc-300">{svc.name}</span>
704
+ {svc.port && <span className="text-zinc-400 text-xs">:{svc.port}</span>}
705
+ </div>
706
+ ))}
707
+ </div>
708
+ </div>
709
+ </section>
710
+ );
711
+ }
712
+
713
+ // ── Verification failed state ──
714
+ if (uiState === 'verification-failed' && verificationResult) {
715
+ return (
716
+ <section id="project-stack">
717
+ <div className="py-8">
718
+ <div className="rounded-xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950/30 p-6 mb-6">
719
+ <div className="flex items-start gap-3 mb-4">
720
+ <div className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 bg-red-100 dark:bg-red-900/50 text-red-500">
721
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
722
+ <circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>
723
+ </svg>
724
+ </div>
725
+ <div>
726
+ <h3 className="text-base font-semibold text-red-800 dark:text-red-300 mb-1">
727
+ Some services didn&apos;t start
728
+ </h3>
729
+ <p className="text-sm text-red-600 dark:text-red-400">
730
+ Fix the issues below, then re-scan to try again.
731
+ </p>
732
+ </div>
733
+ </div>
734
+ <div className="space-y-2">
735
+ {verificationResult.services.map(svc => (
736
+ <div key={svc.name} className="flex items-center gap-3 text-sm">
737
+ {svc.passed ? (
738
+ <span className="text-green-500 flex-shrink-0">✓</span>
739
+ ) : (
740
+ <span className="text-red-500 flex-shrink-0">✗</span>
741
+ )}
742
+ <span className={svc.passed ? 'text-zinc-700 dark:text-zinc-300' : 'text-red-700 dark:text-red-300 font-medium'}>
743
+ {svc.name}
744
+ </span>
745
+ {svc.port && <span className="text-zinc-400 text-xs">:{svc.port}</span>}
746
+ {svc.error && <span className="text-red-500 dark:text-red-400 text-xs ml-auto">{svc.error}</span>}
747
+ </div>
748
+ ))}
749
+ </div>
750
+ </div>
751
+ <div className="flex gap-3">
752
+ <Button onClick={handleRescan}>
753
+ Re-scan project
754
+ </Button>
755
+ <Button
756
+ onClick={() => {
757
+ const failedServices = verificationResult.services
758
+ .filter(s => !s.passed)
759
+ .map(s => ({ name: s.name, port: s.port }));
760
+ if (failedServices.length > 0) {
761
+ createFixServiceSession(failedServices);
762
+ }
763
+ }}
764
+ variant="secondary"
765
+ >
766
+ Fix with Claude
767
+ </Button>
768
+ <Button onClick={() => setUiState('configured')} variant="ghost">
769
+ Proceed anyway
770
+ </Button>
771
+ </div>
772
+ </div>
773
+ </section>
774
+ );
775
+ }
776
+
777
+ // ── Configured state ──
778
+ if (uiState === 'configured' && config) {
779
+ const { launch, context } = splitByZone(config.services);
780
+ const launchGroups = groupByOrder(launch);
781
+ const launchSteps = [...launchGroups.keys()];
782
+ const managed = context.filter(s => s.category === 'managed');
783
+ const runners = context.filter(s => s.category === 'test-runner');
784
+ const buildTools = context.filter(s => s.category === 'build-tool');
785
+ const contextItems = config.contextItems || [];
786
+ const managedContext = contextItems.filter(c => c.category === 'managed');
787
+ const runnerContext = contextItems.filter(c => c.category === 'test-runner');
788
+ const buildContext = contextItems.filter(c => c.category === 'build-tool');
789
+
790
+ return (
791
+ <section id="project-stack">
792
+ <div className="flex items-center justify-between mb-2">
793
+ <h2 className="text-lg font-medium text-zinc-900 dark:text-zinc-100">
794
+ Your Project Stack
795
+ </h2>
796
+ <Button onClick={handleRescan} variant="secondary" size="sm">
797
+ Re-scan project
798
+ </Button>
799
+ </div>
800
+ <p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6 leading-relaxed">
801
+ These services start up automatically when you run a QA test.
802
+ </p>
803
+
804
+ {/* Zone 1: QA Launch Sequence */}
805
+ {launch.length > 0 && (
806
+ <div className="mb-8">
807
+ <div className="flex items-center gap-2 mb-4">
808
+ <i className="fa-solid fa-rocket text-xs text-blue-500" />
809
+ <h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">JettyPod starts these for QA</h3>
810
+ </div>
811
+ <div className="ml-1">
812
+ {launchSteps.map((order, stepIdx) => {
813
+ const services = launchGroups.get(order)!;
814
+ const isLast = stepIdx === launchSteps.length - 1;
815
+ return (
816
+ <div key={order} className="flex gap-4">
817
+ <div className="flex flex-col items-center w-5 flex-shrink-0">
818
+ <div className="w-2.5 h-2.5 rounded-full bg-green-500 mt-5 flex-shrink-0" />
819
+ {!isLast && <div className="w-0.5 flex-1 bg-zinc-200 dark:bg-zinc-700" />}
820
+ </div>
821
+ <div className="flex-1 pb-4">
822
+ <div className="text-xs font-medium text-zinc-400 dark:text-zinc-500 uppercase tracking-wide mb-2">
823
+ Step {stepIdx + 1}{services.length > 1 ? ' — starts together' : ''}
824
+ </div>
825
+ <div className="flex gap-2 flex-wrap">
826
+ {services.map(svc => (
827
+ <div key={svc.name} className="flex-1 min-w-[200px] p-4 rounded-xl bg-zinc-50 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700">
828
+ <div className="flex items-start justify-between">
829
+ <div className="flex items-center gap-3">
830
+ <div className="w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 bg-blue-50 dark:bg-blue-950 text-blue-500 dark:text-blue-400">
831
+ <i className={`fa-solid ${faIcon(svc)}`} />
832
+ </div>
833
+ <div>
834
+ <div className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{svc.name}</div>
835
+ <span className="text-xs text-zinc-400">{CATEGORY_LABELS[svc.category]}</span>
836
+ </div>
837
+ </div>
838
+ <button onClick={() => setExpandedService(expandedService === svc.name ? null : svc.name)} className="text-xs text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 cursor-pointer">
839
+ {expandedService === svc.name ? 'Hide' : 'Details'}
840
+ </button>
841
+ </div>
842
+ {expandedService === svc.name && (
843
+ <div className="mt-3 pt-3 border-t border-zinc-200 dark:border-zinc-700 space-y-1 text-xs">
844
+ <div><span className="text-zinc-400">Command: </span><code className="font-mono text-zinc-600 dark:text-zinc-400">{svc.command}</code></div>
845
+ <div><span className="text-zinc-400">Port: </span><span className="text-zinc-600 dark:text-zinc-400">{svc.port}</span></div>
846
+ <div><span className="text-zinc-400">Check: </span><span className="text-zinc-600 dark:text-zinc-400">{svc.healthCheck}</span></div>
847
+ </div>
848
+ )}
849
+ </div>
850
+ ))}
851
+ </div>
852
+ </div>
853
+ </div>
854
+ );
855
+ })}
856
+ {/* Ready for QA indicator */}
857
+ <div className="flex gap-4">
858
+ <div className="flex flex-col items-center w-5 flex-shrink-0">
859
+ <div className="w-3 h-3 rounded-full bg-green-500 mt-1 flex-shrink-0 ring-4 ring-green-500/20" />
860
+ </div>
861
+ <div className="text-xs font-semibold text-green-600 dark:text-green-400 uppercase tracking-wide">Ready for QA</div>
862
+ </div>
863
+ </div>
864
+ </div>
865
+ )}
866
+
867
+ {/* Zone 2: Context */}
868
+ {(context.length > 0 || contextItems.length > 0) && (
869
+ <div className="mb-6">
870
+ <div className="flex items-center gap-2 mb-4">
871
+ <i className="fa-solid fa-layer-group text-xs text-zinc-400" />
872
+ <h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Also part of your stack</h3>
873
+ </div>
874
+
875
+ {/* Managed services (from services + contextItems) */}
876
+ {(managed.length > 0 || managedContext.length > 0) && (
877
+ <div className="mb-4">
878
+ <div className="flex items-center justify-between mb-2">
879
+ <div className="text-xs font-medium text-zinc-400 dark:text-zinc-500 uppercase tracking-wide">Managed Services</div>
880
+ <button onClick={() => handleTestAllConnections([...managed, ...managedContext].map(s => ({ name: s.name, port: 'port' in s ? (s as ServiceDefinition).port : undefined, connectionUrl: 'connectionUrl' in s ? (s as ContextItem).connectionUrl : undefined })))} disabled={testingConnections} className="px-3 py-1.5 text-xs text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-950/50 hover:bg-purple-100 dark:hover:bg-purple-900/50 rounded-lg cursor-pointer disabled:opacity-50">{testingConnections ? 'Testing...' : 'Test Connections'}</button>
881
+ </div>
882
+ <div className="space-y-2">
883
+ {[...managed, ...managedContext].map(svc => (
884
+ <div key={svc.name} className="p-4 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800/50">
885
+ <div className="flex items-center gap-3">
886
+ <div className="w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 bg-purple-50 dark:bg-purple-950 text-purple-500 dark:text-purple-400">
887
+ <i className={`fa-solid ${faIcon(svc as ServiceDefinition)}`} />
888
+ </div>
889
+ <div>
890
+ <div className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{svc.name}</div>
891
+ {connectionStatus[svc.name] && (
892
+ <div className="flex items-center gap-1.5 mt-0.5">
893
+ <span className={`w-2 h-2 rounded-full ${connectionStatus[svc.name] === 'connected' ? 'bg-green-500' : connectionStatus[svc.name] === 'failed' ? 'bg-red-500' : 'bg-amber-400 animate-pulse'}`} />
894
+ <span className="text-xs text-zinc-400">
895
+ {connectionStatus[svc.name] === 'connected' ? 'Connected' : connectionStatus[svc.name] === 'failed' ? 'Connection failed' : 'Testing...'}
896
+ </span>
897
+ </div>
898
+ )}
899
+ </div>
900
+ </div>
901
+ </div>
902
+ ))}
903
+ </div>
904
+ </div>
905
+ )}
906
+
907
+ {/* Test runners */}
908
+ {(runners.length > 0 || runnerContext.length > 0) && (
909
+ <div className="mb-4">
910
+ <div className="text-xs font-medium text-zinc-400 dark:text-zinc-500 uppercase tracking-wide mb-2">Test Runners</div>
911
+ <div className="grid grid-cols-2 gap-2">
912
+ {[...runners, ...runnerContext].map(svc => (
913
+ <div key={svc.name} className="p-3 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800/50 flex items-center justify-between">
914
+ <div className="flex items-center gap-2.5">
915
+ <div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-amber-50 dark:bg-amber-950 text-amber-500 dark:text-amber-400 text-sm">
916
+ <i className={`fa-solid ${faIcon(svc as ServiceDefinition)}`} />
917
+ </div>
918
+ <span className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{svc.name}</span>
919
+ </div>
920
+ <button className="px-2.5 py-1 text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/50 hover:bg-amber-100 dark:hover:bg-amber-900/50 rounded-lg cursor-pointer">Run</button>
921
+ </div>
922
+ ))}
923
+ </div>
924
+ </div>
925
+ )}
926
+
927
+ {/* Build tools */}
928
+ {(buildTools.length > 0 || buildContext.length > 0) && (
929
+ <div className="mb-4">
930
+ <div className="text-xs font-medium text-zinc-400 dark:text-zinc-500 uppercase tracking-wide mb-2">Build Tools</div>
931
+ <div className="flex gap-2 flex-wrap">
932
+ {[...buildTools, ...buildContext].map(svc => (
933
+ <div key={svc.name} className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 text-sm text-zinc-600 dark:text-zinc-400">
934
+ <i className={`fa-solid ${faIcon(svc as ServiceDefinition)} text-xs`} />
935
+ <span>{svc.name}</span>
936
+ </div>
937
+ ))}
938
+ </div>
939
+ </div>
940
+ )}
941
+ </div>
942
+ )}
943
+ </section>
944
+ );
945
+ }
946
+
947
+ return null;
948
+ }