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
@@ -1,9 +1,13 @@
1
- 'use client';
2
1
 
3
2
  import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
4
3
  import { useWebSocket, type WebSocketMessage } from '../hooks/useWebSocket';
5
- import { useClaudeSession } from '../contexts/ClaudeSessionContext';
6
- import type { TestDashboardData, TestFeature } from '@/lib/tests';
4
+ import { useSessionActions } from '../contexts/ClaudeSessionContext';
5
+ import type { TestDashboardData, TestFeature } from '@/lib/db';
6
+ import { Input } from '@/components/ui/Input';
7
+ import { Button } from '@/components/ui/Button';
8
+ import { dataBridge } from '@/lib/data-bridge';
9
+ import { invoke, listen } from '@/lib/tauri';
10
+ import { getWebSocketUrl } from '@/lib/utils';
7
11
 
8
12
  interface RealTimeTestsWrapperProps {
9
13
  initialData: TestDashboardData;
@@ -41,7 +45,6 @@ type StepStatus = 'pending' | 'running' | 'passed' | 'failed';
41
45
  export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps) {
42
46
  const [data, setData] = useState<TestDashboardData>(initialData);
43
47
  const [selectedFeatureId, setSelectedFeatureId] = useState<string | null>(() => {
44
- if (typeof window === 'undefined') return null;
45
48
  return localStorage.getItem(SELECTED_FEATURE_KEY);
46
49
  });
47
50
  const [searchText, setSearchText] = useState('');
@@ -53,27 +56,35 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
53
56
  const [elapsedDisplay, setElapsedDisplay] = useState<Record<string, number>>({});
54
57
 
55
58
  const elapsedStartRef = useRef<Record<string, number>>({});
59
+ const activeProcessesRef = useRef<Set<number>>(new Set());
60
+ const unlistenersRef = useRef<(() => void)[]>([]);
56
61
  const allFeaturesRef = useRef<TestFeature[]>([]);
57
- const { createFixScenarioSession } = useClaudeSession();
62
+ const projectRootRef = useRef<string | null>(null);
63
+ const { createFixScenarioSession } = useSessionActions();
58
64
 
59
- const refreshUndefinedSteps = useCallback(() => {
60
- fetch('/api/tests/undefined')
61
- .then(res => res.json())
62
- .then(setUndefinedStepsMap)
63
- .catch(() => {});
65
+ // Get project root on mount (needed for spawn_process cwd)
66
+ useEffect(() => {
67
+ dataBridge.getProjectRoot().then(root => {
68
+ projectRootRef.current = root;
69
+ });
64
70
  }, []);
65
71
 
66
- // Fetch undefined steps asynchronously (slow operation, don't block page load)
72
+ // Kill active test processes and remove event listeners on unmount
67
73
  useEffect(() => {
68
- refreshUndefinedSteps();
69
- }, [refreshUndefinedSteps]);
74
+ return () => {
75
+ for (const pid of activeProcessesRef.current) {
76
+ invoke('kill_process', { pid }).catch(() => {});
77
+ }
78
+ activeProcessesRef.current.clear();
79
+ for (const unlisten of unlistenersRef.current) unlisten();
80
+ unlistenersRef.current = [];
81
+ };
82
+ }, []);
70
83
 
71
84
  const refreshData = useCallback(async () => {
72
- const response = await fetch('/api/tests');
73
- const newData = await response.json();
85
+ const newData = await dataBridge.getTestDashboardData();
74
86
  setData(newData);
75
- refreshUndefinedSteps();
76
- }, [refreshUndefinedSteps]);
87
+ }, []);
77
88
 
78
89
  const handleMessage = useCallback(async (message: WebSocketMessage) => {
79
90
  if (message.type === 'test_change') {
@@ -81,8 +92,10 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
81
92
  }
82
93
  }, [refreshData]);
83
94
 
84
- // Elapsed timer tick
95
+ // Elapsed timer tick — only runs when scenarios are actively running
96
+ const hasRunning = runningScenarios.size > 0;
85
97
  useEffect(() => {
98
+ if (!hasRunning) return;
86
99
  const interval = setInterval(() => {
87
100
  const starts = elapsedStartRef.current;
88
101
  if (Object.keys(starts).length === 0) return;
@@ -94,7 +107,7 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
94
107
  );
95
108
  }, 1000);
96
109
  return () => clearInterval(interval);
97
- }, []);
110
+ }, [hasRunning]);
98
111
 
99
112
  const runScenario = useCallback(async (featureFile: string, scenarioTitle: string) => {
100
113
  const key = `${featureFile}::${scenarioTitle}`;
@@ -114,155 +127,250 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
114
127
  elapsedStartRef.current = { ...elapsedStartRef.current, [key]: Date.now() };
115
128
  setElapsedDisplay(prev => ({ ...prev, [key]: 0 }));
116
129
 
117
- return new Promise<void>((resolve) => {
118
- const params = new URLSearchParams({ featureFile, scenarioTitle });
119
- const eventSource = new EventSource(`/api/tests/run/stream?${params}`);
120
-
121
- const steps = scenario?.steps || [];
122
-
123
- eventSource.addEventListener('step_start', (e: MessageEvent) => {
124
- const eventData = JSON.parse(e.data);
125
- const stepIndex = steps.findIndex(s => stripKeyword(s) === eventData.step);
126
- if (stepIndex >= 0) {
127
- setStepProgress(prev => {
128
- const arr = [...(prev[key] || [])];
129
- arr[stepIndex] = 'running';
130
- return { ...prev, [key]: arr };
131
- });
130
+ const steps = scenario?.steps || [];
131
+
132
+ // Cucumber NDJSON message parsing state
133
+ const pickleSteps = new Map<string, { text: string }>();
134
+ const testCaseSteps = new Map<string, { id: string; pickleStepId?: string }[]>();
135
+ let activeTestCaseId: string | null = null;
136
+ let outputBuffer = '';
137
+
138
+ function resolveStepText(testStepId: string): string | null {
139
+ if (!activeTestCaseId) return null;
140
+ const tcSteps = testCaseSteps.get(activeTestCaseId);
141
+ if (!tcSteps) return null;
142
+ const testStep = tcSteps.find(s => s.id === testStepId);
143
+ if (!testStep?.pickleStepId) return null;
144
+ const ps = pickleSteps.get(testStep.pickleStepId);
145
+ return ps ? ps.text : null;
146
+ }
147
+
148
+ function processLine(line: string) {
149
+ const trimmed = line.trim();
150
+ if (!trimmed || !trimmed.startsWith('{')) return;
151
+ let msg: Record<string, any>;
152
+ try { msg = JSON.parse(trimmed); } catch { return; }
153
+
154
+ if (msg.pickle) {
155
+ for (const s of msg.pickle.steps) pickleSteps.set(s.id, s);
156
+ }
157
+ if (msg.testCase) {
158
+ testCaseSteps.set(msg.testCase.id, msg.testCase.testSteps);
159
+ }
160
+ if (msg.testCaseStarted) {
161
+ activeTestCaseId = msg.testCaseStarted.testCaseId;
162
+ }
163
+ if (msg.testStepStarted) {
164
+ const stepText = resolveStepText(msg.testStepStarted.testStepId);
165
+ if (stepText) {
166
+ const stepIndex = steps.findIndex(s => stripKeyword(s) === stepText);
167
+ if (stepIndex >= 0) {
168
+ setStepProgress(prev => {
169
+ const arr = [...(prev[key] || [])];
170
+ arr[stepIndex] = 'running';
171
+ return { ...prev, [key]: arr };
172
+ });
173
+ }
132
174
  }
133
- });
134
-
135
- eventSource.addEventListener('step_end', (e: MessageEvent) => {
136
- const eventData = JSON.parse(e.data);
137
- const stepIndex = steps.findIndex(s => stripKeyword(s) === eventData.step);
138
- if (stepIndex >= 0) {
139
- setStepProgress(prev => {
140
- const arr = [...(prev[key] || [])];
141
- arr[stepIndex] = eventData.result === 'passed' ? 'passed' : 'failed';
142
- return { ...prev, [key]: arr };
143
- });
175
+ }
176
+ if (msg.testStepFinished) {
177
+ const stepText = resolveStepText(msg.testStepFinished.testStepId);
178
+ if (stepText) {
179
+ const status = msg.testStepFinished.testStepResult.status.toLowerCase();
180
+ const stepIndex = steps.findIndex(s => stripKeyword(s) === stepText);
181
+ if (stepIndex >= 0) {
182
+ setStepProgress(prev => {
183
+ const arr = [...(prev[key] || [])];
184
+ arr[stepIndex] = status === 'passed' ? 'passed' : 'failed';
185
+ return { ...prev, [key]: arr };
186
+ });
187
+ }
144
188
  }
145
- });
189
+ }
190
+ }
146
191
 
192
+ return new Promise<void>(async (resolve) => {
147
193
  function cleanup() {
148
- eventSource.close();
149
- // Stop timer
150
194
  const { [key]: _, ...restStarts } = elapsedStartRef.current;
151
195
  elapsedStartRef.current = restStarts;
152
- setElapsedDisplay(prev => {
153
- const { [key]: _, ...rest } = prev;
154
- return rest;
155
- });
156
- // Clear step progress
157
- setStepProgress(prev => {
158
- const { [key]: _, ...rest } = prev;
159
- return rest;
160
- });
161
- setRunningScenarios(prev => {
162
- const next = new Set(prev);
163
- next.delete(key);
164
- return next;
165
- });
196
+ setElapsedDisplay(prev => { const { [key]: _, ...rest } = prev; return rest; });
197
+ setStepProgress(prev => { const { [key]: _, ...rest } = prev; return rest; });
198
+ setRunningScenarios(prev => { const next = new Set(prev); next.delete(key); return next; });
166
199
  }
167
200
 
168
- eventSource.addEventListener('test_complete', async () => {
169
- cleanup();
170
- await refreshData();
171
- resolve();
172
- });
173
-
174
- eventSource.onerror = async () => {
201
+ try {
202
+ const escapedTitle = scenarioTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
203
+ const resultsPath = projectRootRef.current
204
+ ? `${projectRootRef.current}/cucumber-results.json`
205
+ : 'cucumber-results.json';
206
+
207
+ // Spawn cucumber-js first, then set up listeners
208
+ const pid = await invoke<number>('spawn_process', {
209
+ command: 'npx',
210
+ args: [
211
+ 'cucumber-js', featureFile,
212
+ '--name', `^${escapedTitle}$`,
213
+ '--format', 'message',
214
+ '--format', `json:${resultsPath}`,
215
+ ],
216
+ label: 'test-runner',
217
+ kind: null,
218
+ cwd: projectRootRef.current,
219
+ env: null,
220
+ });
221
+ activeProcessesRef.current.add(pid);
222
+
223
+ // Listen for stdout lines to parse NDJSON
224
+ const outputUnlisten = await listen<{ pid: number; lines: string[] }>(
225
+ 'process-output-batch',
226
+ (event) => {
227
+ if (event.payload.pid !== pid) return;
228
+ for (const line of event.payload.lines) {
229
+ outputBuffer += line + '\n';
230
+ const bufLines = outputBuffer.split('\n');
231
+ outputBuffer = bufLines.pop() || '';
232
+ for (const l of bufLines) processLine(l);
233
+ }
234
+ },
235
+ );
236
+ unlistenersRef.current.push(outputUnlisten);
237
+
238
+ // Listen for process exit
239
+ const exitUnlisten = await listen<{ pid: number; code: number | null }>(
240
+ 'process-exit',
241
+ async (event) => {
242
+ if (event.payload.pid !== pid) return;
243
+ activeProcessesRef.current.delete(pid);
244
+ // Process remaining buffer
245
+ if (outputBuffer.trim()) processLine(outputBuffer);
246
+ // Ingest results into SQLite and refresh
247
+ try { await dataBridge.ingestTestResults(); } catch { /* best effort */ }
248
+ cleanup();
249
+ await refreshData();
250
+ resolve();
251
+ },
252
+ );
253
+ unlistenersRef.current.push(exitUnlisten);
254
+ } catch {
175
255
  cleanup();
176
256
  await refreshData();
177
257
  resolve();
178
- };
258
+ }
179
259
  });
180
260
  }, [refreshData]);
181
261
 
182
262
  const runAllScenarios = useCallback(async (feature: TestFeature) => {
183
263
  setRunningAll(true);
184
264
 
185
- return new Promise<void>((resolve) => {
186
- const params = new URLSearchParams({ featureFile: feature.featureFile });
187
- const eventSource = new EventSource(`/api/tests/run/stream?${params}`);
188
-
189
- // Track which scenario is currently active
190
- let activeScenarioKey: string | null = null;
191
-
192
- eventSource.addEventListener('scenario_start', (e: MessageEvent) => {
193
- const { scenario } = JSON.parse(e.data);
194
- const key = `${feature.featureFile}::${scenario}`;
195
- activeScenarioKey = key;
196
-
197
- // Find matching scenario for step count
198
- const matchedScenario = feature.scenarios.find(s => s.title === scenario);
199
- setRunningScenarios(prev => new Set(prev).add(key));
200
- if (matchedScenario?.steps?.length) {
201
- setStepProgress(prev => ({
202
- ...prev,
203
- [key]: matchedScenario.steps.map(() => 'pending' as StepStatus),
204
- }));
265
+ // Cucumber NDJSON message parsing state
266
+ const pickleSteps = new Map<string, { text: string }>();
267
+ const pickleNames = new Map<string, string>();
268
+ const testCaseSteps = new Map<string, { id: string; pickleStepId?: string }[]>();
269
+ const testCasePickleIds = new Map<string, string>();
270
+ const testCaseStartedIds = new Map<string, string>();
271
+ let activeTestCaseId: string | null = null;
272
+ let outputBuffer = '';
273
+
274
+ function getScenarioName(testCaseId: string): string | null {
275
+ const pickleId = testCasePickleIds.get(testCaseId);
276
+ return pickleId ? pickleNames.get(pickleId) ?? null : null;
277
+ }
278
+
279
+ function resolveStepText(testStepId: string): string | null {
280
+ if (!activeTestCaseId) return null;
281
+ const tcSteps = testCaseSteps.get(activeTestCaseId);
282
+ if (!tcSteps) return null;
283
+ const testStep = tcSteps.find(s => s.id === testStepId);
284
+ if (!testStep?.pickleStepId) return null;
285
+ const ps = pickleSteps.get(testStep.pickleStepId);
286
+ return ps ? ps.text : null;
287
+ }
288
+
289
+ function processLine(line: string) {
290
+ const trimmed = line.trim();
291
+ if (!trimmed || !trimmed.startsWith('{')) return;
292
+ let msg: Record<string, any>;
293
+ try { msg = JSON.parse(trimmed); } catch { return; }
294
+
295
+ if (msg.pickle) {
296
+ pickleNames.set(msg.pickle.id, msg.pickle.name);
297
+ for (const s of msg.pickle.steps) pickleSteps.set(s.id, s);
298
+ }
299
+ if (msg.testCase) {
300
+ testCaseSteps.set(msg.testCase.id, msg.testCase.testSteps);
301
+ testCasePickleIds.set(msg.testCase.id, msg.testCase.pickleId);
302
+ }
303
+ if (msg.testCaseStarted) {
304
+ const tcs = msg.testCaseStarted;
305
+ activeTestCaseId = tcs.testCaseId;
306
+ testCaseStartedIds.set(tcs.id, tcs.testCaseId);
307
+ const scenario = getScenarioName(tcs.testCaseId);
308
+ if (scenario) {
309
+ const key = `${feature.featureFile}::${scenario}`;
310
+ const matchedScenario = feature.scenarios.find(s => s.title === scenario);
311
+ setRunningScenarios(prev => new Set(prev).add(key));
312
+ if (matchedScenario?.steps?.length) {
313
+ setStepProgress(prev => ({
314
+ ...prev,
315
+ [key]: matchedScenario.steps.map(() => 'pending' as StepStatus),
316
+ }));
317
+ }
318
+ elapsedStartRef.current = { ...elapsedStartRef.current, [key]: Date.now() };
319
+ setElapsedDisplay(prev => ({ ...prev, [key]: 0 }));
205
320
  }
206
- elapsedStartRef.current = { ...elapsedStartRef.current, [key]: Date.now() };
207
- setElapsedDisplay(prev => ({ ...prev, [key]: 0 }));
208
- });
209
-
210
- eventSource.addEventListener('step_start', (e: MessageEvent) => {
211
- const eventData = JSON.parse(e.data);
212
- const key = eventData.scenario ? `${feature.featureFile}::${eventData.scenario}` : activeScenarioKey;
213
- if (!key) return;
214
- const matchedScenario = feature.scenarios.find(s => s.title === (eventData.scenario || activeScenarioKey?.split('::')[1]));
215
- const steps = matchedScenario?.steps || [];
216
- const stepIndex = steps.findIndex(s => stripKeyword(s) === eventData.step);
217
- if (stepIndex >= 0) {
218
- setStepProgress(prev => {
219
- const arr = [...(prev[key] || [])];
220
- arr[stepIndex] = 'running';
221
- return { ...prev, [key]: arr };
222
- });
321
+ }
322
+ if (msg.testStepStarted) {
323
+ const stepText = resolveStepText(msg.testStepStarted.testStepId);
324
+ const scenario = activeTestCaseId ? getScenarioName(activeTestCaseId) : null;
325
+ if (stepText && scenario) {
326
+ const key = `${feature.featureFile}::${scenario}`;
327
+ const matchedScenario = feature.scenarios.find(s => s.title === scenario);
328
+ const steps = matchedScenario?.steps || [];
329
+ const stepIndex = steps.findIndex(s => stripKeyword(s) === stepText);
330
+ if (stepIndex >= 0) {
331
+ setStepProgress(prev => {
332
+ const arr = [...(prev[key] || [])];
333
+ arr[stepIndex] = 'running';
334
+ return { ...prev, [key]: arr };
335
+ });
336
+ }
223
337
  }
224
- });
225
-
226
- eventSource.addEventListener('step_end', (e: MessageEvent) => {
227
- const eventData = JSON.parse(e.data);
228
- const key = eventData.scenario ? `${feature.featureFile}::${eventData.scenario}` : activeScenarioKey;
229
- if (!key) return;
230
- const matchedScenario = feature.scenarios.find(s => s.title === (eventData.scenario || activeScenarioKey?.split('::')[1]));
231
- const steps = matchedScenario?.steps || [];
232
- const stepIndex = steps.findIndex(s => stripKeyword(s) === eventData.step);
233
- if (stepIndex >= 0) {
234
- setStepProgress(prev => {
235
- const arr = [...(prev[key] || [])];
236
- arr[stepIndex] = eventData.result === 'passed' ? 'passed' : 'failed';
237
- return { ...prev, [key]: arr };
238
- });
338
+ }
339
+ if (msg.testStepFinished) {
340
+ const stepText = resolveStepText(msg.testStepFinished.testStepId);
341
+ const scenario = activeTestCaseId ? getScenarioName(activeTestCaseId) : null;
342
+ if (stepText && scenario) {
343
+ const key = `${feature.featureFile}::${scenario}`;
344
+ const status = msg.testStepFinished.testStepResult.status.toLowerCase();
345
+ const matchedScenario = feature.scenarios.find(s => s.title === scenario);
346
+ const steps = matchedScenario?.steps || [];
347
+ const stepIndex = steps.findIndex(s => stripKeyword(s) === stepText);
348
+ if (stepIndex >= 0) {
349
+ setStepProgress(prev => {
350
+ const arr = [...(prev[key] || [])];
351
+ arr[stepIndex] = status === 'passed' ? 'passed' : 'failed';
352
+ return { ...prev, [key]: arr };
353
+ });
354
+ }
239
355
  }
240
- });
241
-
242
- eventSource.addEventListener('scenario_end', (e: MessageEvent) => {
243
- const { scenario } = JSON.parse(e.data);
244
- const key = `${feature.featureFile}::${scenario}`;
245
- // Stop timer and clean up for this scenario
246
- const { [key]: _, ...restStarts } = elapsedStartRef.current;
247
- elapsedStartRef.current = restStarts;
248
- setElapsedDisplay(prev => {
249
- const { [key]: _, ...rest } = prev;
250
- return rest;
251
- });
252
- setStepProgress(prev => {
253
- const { [key]: _, ...rest } = prev;
254
- return rest;
255
- });
256
- setRunningScenarios(prev => {
257
- const next = new Set(prev);
258
- next.delete(key);
259
- return next;
260
- });
261
- });
356
+ }
357
+ if (msg.testCaseFinished) {
358
+ const tcf = msg.testCaseFinished;
359
+ const testCaseId = testCaseStartedIds.get(tcf.testCaseStartedId);
360
+ const scenario = testCaseId ? getScenarioName(testCaseId) : null;
361
+ if (scenario) {
362
+ const key = `${feature.featureFile}::${scenario}`;
363
+ const { [key]: _, ...restStarts } = elapsedStartRef.current;
364
+ elapsedStartRef.current = restStarts;
365
+ setElapsedDisplay(prev => { const { [key]: _, ...rest } = prev; return rest; });
366
+ setStepProgress(prev => { const { [key]: _, ...rest } = prev; return rest; });
367
+ setRunningScenarios(prev => { const next = new Set(prev); next.delete(key); return next; });
368
+ }
369
+ }
370
+ }
262
371
 
372
+ return new Promise<void>(async (resolve) => {
263
373
  function cleanup() {
264
- eventSource.close();
265
- // Clean up any remaining scenario state
266
374
  for (const scenario of feature.scenarios) {
267
375
  const key = `${feature.featureFile}::${scenario.title}`;
268
376
  const { [key]: _, ...restStarts } = elapsedStartRef.current;
@@ -274,26 +382,63 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
274
382
  setRunningAll(false);
275
383
  }
276
384
 
277
- eventSource.addEventListener('test_complete', async () => {
278
- cleanup();
279
- await refreshData();
280
- resolve();
281
- });
282
-
283
- eventSource.onerror = async () => {
385
+ try {
386
+ const resultsPath = projectRootRef.current
387
+ ? `${projectRootRef.current}/cucumber-results.json`
388
+ : 'cucumber-results.json';
389
+
390
+ // Spawn cucumber-js first, then set up listeners
391
+ const pid = await invoke<number>('spawn_process', {
392
+ command: 'npx',
393
+ args: [
394
+ 'cucumber-js', feature.featureFile,
395
+ '--format', 'message',
396
+ '--format', `json:${resultsPath}`,
397
+ ],
398
+ label: 'test-runner',
399
+ kind: null,
400
+ cwd: projectRootRef.current,
401
+ env: null,
402
+ });
403
+ activeProcessesRef.current.add(pid);
404
+
405
+ const outputUnlisten = await listen<{ pid: number; lines: string[] }>(
406
+ 'process-output-batch',
407
+ (event) => {
408
+ if (event.payload.pid !== pid) return;
409
+ for (const line of event.payload.lines) {
410
+ outputBuffer += line + '\n';
411
+ const bufLines = outputBuffer.split('\n');
412
+ outputBuffer = bufLines.pop() || '';
413
+ for (const l of bufLines) processLine(l);
414
+ }
415
+ },
416
+ );
417
+ unlistenersRef.current.push(outputUnlisten);
418
+
419
+ const exitUnlisten = await listen<{ pid: number; code: number | null }>(
420
+ 'process-exit',
421
+ async (event) => {
422
+ if (event.payload.pid !== pid) return;
423
+ activeProcessesRef.current.delete(pid);
424
+ if (outputBuffer.trim()) processLine(outputBuffer);
425
+ try { await dataBridge.ingestTestResults(); } catch { /* best effort */ }
426
+ cleanup();
427
+ await refreshData();
428
+ resolve();
429
+ },
430
+ );
431
+ unlistenersRef.current.push(exitUnlisten);
432
+ } catch {
284
433
  cleanup();
285
434
  await refreshData();
286
435
  resolve();
287
- };
436
+ }
288
437
  });
289
438
  }, [refreshData]);
290
439
 
291
- const wsUrl = typeof window !== 'undefined'
292
- ? `ws://${window.location.hostname}:8080`
293
- : 'ws://localhost:8080';
294
-
295
440
  useWebSocket({
296
- url: wsUrl,
441
+ url: getWebSocketUrl(),
297
442
  onMessage: handleMessage,
298
443
  });
299
444
 
@@ -348,43 +493,43 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
348
493
  <div className="flex-shrink-0 px-6 py-4 border-b border-zinc-200">
349
494
  <div className="flex gap-4">
350
495
  <div className="flex items-center gap-2 px-4 py-2 bg-zinc-100 rounded-lg">
351
- <span className="text-sm text-zinc-500">Total</span>
496
+ <span className="text-base text-zinc-500">Total</span>
352
497
  <span className="font-mono font-semibold text-zinc-900">
353
498
  {data.summary.total}
354
499
  </span>
355
500
  </div>
356
501
  <button
357
502
  onClick={() => setStatusFilter(statusFilter === 'pass' ? null : 'pass')}
358
- className={`flex items-center gap-2 px-4 py-2 bg-green-50 rounded-lg cursor-pointer transition-all ${
503
+ className={`flex items-center gap-2 px-4 py-2 bg-green-50 rounded-lg cursor-pointer transition-[color,background-color] duration-200 ease-out ${
359
504
  statusFilter === 'pass' ? 'ring-2 ring-green-500' : 'hover:bg-green-100'
360
505
  }`}
361
506
  data-testid="status-badge-passing"
362
507
  >
363
- <span className="text-sm text-green-600">Passing</span>
508
+ <span className="text-base text-green-600">Passing</span>
364
509
  <span className="font-mono font-semibold text-green-700">
365
510
  {data.summary.passing}
366
511
  </span>
367
512
  </button>
368
513
  <button
369
514
  onClick={() => setStatusFilter(statusFilter === 'fail' ? null : 'fail')}
370
- className={`flex items-center gap-2 px-4 py-2 bg-red-50 rounded-lg cursor-pointer transition-all ${
515
+ className={`flex items-center gap-2 px-4 py-2 bg-red-50 rounded-lg cursor-pointer transition-[color,background-color] duration-200 ease-out ${
371
516
  statusFilter === 'fail' ? 'ring-2 ring-red-500' : 'hover:bg-red-100'
372
517
  }`}
373
518
  data-testid="status-badge-failing"
374
519
  >
375
- <span className="text-sm text-red-600">Failing</span>
520
+ <span className="text-base text-red-600">Failing</span>
376
521
  <span className="font-mono font-semibold text-red-700">
377
522
  {data.summary.failing}
378
523
  </span>
379
524
  </button>
380
525
  <button
381
526
  onClick={() => setStatusFilter(statusFilter === 'pending' ? null : 'pending')}
382
- className={`flex items-center gap-2 px-4 py-2 bg-amber-50 rounded-lg cursor-pointer transition-all ${
527
+ className={`flex items-center gap-2 px-4 py-2 bg-amber-50 rounded-lg cursor-pointer transition-[color,background-color] duration-200 ease-out ${
383
528
  statusFilter === 'pending' ? 'ring-2 ring-amber-500' : 'hover:bg-amber-100'
384
529
  }`}
385
530
  data-testid="status-badge-pending"
386
531
  >
387
- <span className="text-sm text-amber-600">Pending</span>
532
+ <span className="text-base text-amber-600">Pending</span>
388
533
  <span className="font-mono font-semibold text-amber-700">
389
534
  {data.summary.pending}
390
535
  </span>
@@ -392,7 +537,7 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
392
537
  {statusFilter && (
393
538
  <button
394
539
  onClick={() => setStatusFilter(null)}
395
- className="flex items-center gap-1 px-3 py-2 text-sm text-zinc-500 hover:text-zinc-700 rounded-lg hover:bg-zinc-100 transition-colors"
540
+ className="flex items-center gap-1 px-3 py-2 text-base text-zinc-500 hover:text-zinc-700 rounded-lg hover:bg-zinc-100 transition-colors duration-200 ease-out"
396
541
  data-testid="clear-status-filter"
397
542
  >
398
543
  <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
@@ -402,7 +547,7 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
402
547
  </button>
403
548
  )}
404
549
  {data.summary.lastRun && (
405
- <div className="flex items-center gap-2 px-4 py-2 text-sm text-zinc-500">
550
+ <div className="flex items-center gap-2 px-4 py-2 text-base text-zinc-500">
406
551
  Last run: {parseUtcDate(data.summary.lastRun).toLocaleString()}
407
552
  </div>
408
553
  )}
@@ -414,13 +559,13 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
414
559
  {/* Left Sidebar - Feature List */}
415
560
  <div className="w-80 flex-shrink-0 border-r border-zinc-200 flex flex-col overflow-hidden">
416
561
  {/* Search */}
417
- <div className="p-3 border-b border-zinc-200">
418
- <input
562
+ <div className="p-4 border-b border-zinc-200">
563
+ <Input
419
564
  type="text"
420
565
  placeholder="Search features..."
421
566
  value={searchText}
422
567
  onChange={(e) => setSearchText(e.target.value)}
423
- className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-zinc-300 focus:border-transparent placeholder:text-zinc-400"
568
+ size="sm"
424
569
  />
425
570
  </div>
426
571
 
@@ -430,17 +575,17 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
430
575
  <div className="flex flex-col items-center justify-center h-full px-6 text-center">
431
576
  {statusFilter && searchText ? (
432
577
  <>
433
- <span className="text-zinc-400 text-sm">No {statusFilter === 'pass' ? 'passing' : statusFilter === 'fail' ? 'failing' : 'pending'} features matching &ldquo;{searchText}&rdquo;</span>
578
+ <span className="text-zinc-400 text-base">No {statusFilter === 'pass' ? 'passing' : statusFilter === 'fail' ? 'failing' : 'pending'} features matching &ldquo;{searchText}&rdquo;</span>
434
579
  <div className="flex gap-2 mt-2">
435
580
  <button
436
581
  onClick={() => setStatusFilter(null)}
437
- className="text-xs text-zinc-500 hover:text-zinc-700 underline"
582
+ className="text-base text-zinc-500 hover:text-zinc-700 underline"
438
583
  >
439
584
  Clear filter
440
585
  </button>
441
586
  <button
442
587
  onClick={() => setSearchText('')}
443
- className="text-xs text-zinc-500 hover:text-zinc-700 underline"
588
+ className="text-base text-zinc-500 hover:text-zinc-700 underline"
444
589
  >
445
590
  Clear search
446
591
  </button>
@@ -448,28 +593,28 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
448
593
  </>
449
594
  ) : statusFilter ? (
450
595
  <>
451
- <span className="text-zinc-400 text-sm">No {statusFilter === 'pass' ? 'passing' : statusFilter === 'fail' ? 'failing' : 'pending'} features</span>
596
+ <span className="text-zinc-400 text-base">No {statusFilter === 'pass' ? 'passing' : statusFilter === 'fail' ? 'failing' : 'pending'} features</span>
452
597
  <button
453
598
  onClick={() => setStatusFilter(null)}
454
- className="mt-2 text-xs text-zinc-500 hover:text-zinc-700 underline"
599
+ className="mt-2 text-base text-zinc-500 hover:text-zinc-700 underline"
455
600
  >
456
601
  Clear filter
457
602
  </button>
458
603
  </>
459
604
  ) : searchText ? (
460
605
  <>
461
- <span className="text-zinc-400 text-sm">No results for &ldquo;{searchText}&rdquo;</span>
606
+ <span className="text-zinc-400 text-base">No results for &ldquo;{searchText}&rdquo;</span>
462
607
  <button
463
608
  onClick={() => setSearchText('')}
464
- className="mt-2 text-xs text-zinc-500 hover:text-zinc-700 underline"
609
+ className="mt-2 text-base text-zinc-500 hover:text-zinc-700 underline"
465
610
  >
466
611
  Clear search
467
612
  </button>
468
613
  </>
469
614
  ) : (
470
615
  <>
471
- <span className="text-zinc-400 text-sm">No features found</span>
472
- <span className="text-zinc-300 text-xs mt-1">Add .feature files to get started</span>
616
+ <span className="text-zinc-400 text-base">No features found</span>
617
+ <span className="text-zinc-300 text-base mt-1">Add .feature files to get started</span>
473
618
  </>
474
619
  )}
475
620
  </div>
@@ -484,21 +629,21 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
484
629
  <button
485
630
  key={feature.id}
486
631
  onClick={() => setSelectedFeatureId(feature.id)}
487
- className={`w-full text-left px-4 py-3 border-b border-zinc-100 hover:bg-zinc-50 transition-colors ${
632
+ className={`w-full text-left px-4 py-3 border-b border-zinc-100 hover:bg-zinc-50 transition-colors duration-200 ease-out ${
488
633
  isSelected ? 'bg-zinc-100 border-l-2 border-l-zinc-900' : ''
489
634
  }`}
490
635
  >
491
- <div className="flex items-center justify-between gap-2">
492
- <span className={`text-sm font-medium truncate ${
636
+ <div className="flex items-center justify-between gap-3">
637
+ <span className={`text-base font-medium truncate ${
493
638
  status === 'fail' ? 'text-red-700' : 'text-zinc-900'
494
639
  }`}>
495
640
  {feature.title}
496
641
  </span>
497
- <span className={`flex-shrink-0 text-xs font-mono px-2 py-0.5 rounded-full ${statusColors[status]}`}>
642
+ <span className={`flex-shrink-0 text-xs font-mono px-2.5 py-1 rounded-full ${statusColors[status]}`}>
498
643
  {passingCount}/{totalCount}
499
644
  </span>
500
645
  </div>
501
- <div className="text-xs text-zinc-400 mt-0.5 truncate">
646
+ <div className="text-base text-zinc-400 mt-0.5 truncate">
502
647
  {feature.featureFile}
503
648
  </div>
504
649
  </button>
@@ -510,42 +655,33 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
510
655
  {/* Right Panel - Detail */}
511
656
  <div className="flex-1 overflow-y-auto">
512
657
  {selectedFeature ? (
513
- <div className="p-6">
514
- <div className="mb-6">
515
- <div className="flex items-start justify-between gap-4">
658
+ <div className="p-8">
659
+ <div className="mb-8">
660
+ <div className="flex items-start justify-between gap-5">
516
661
  <div>
517
662
  <h2 className="text-lg font-semibold text-zinc-900">{selectedFeature.title}</h2>
518
- <p className="text-sm text-zinc-500 font-mono mt-1">{selectedFeature.featureFile}</p>
663
+ <p className="text-base text-zinc-500 font-mono mt-1">{selectedFeature.featureFile}</p>
519
664
  {selectedFeature.description && (
520
- <p className="text-sm text-zinc-600 mt-2">{selectedFeature.description}</p>
665
+ <p className="text-base text-zinc-600 mt-2">{selectedFeature.description}</p>
521
666
  )}
522
667
  </div>
523
- <button
668
+ <Button
524
669
  onClick={() => {
525
670
  if (!runningAll && runningScenarios.size === 0) {
526
671
  runAllScenarios(selectedFeature);
527
672
  }
528
673
  }}
529
674
  disabled={runningAll || runningScenarios.size > 0}
530
- className={`flex-shrink-0 px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
531
- runningAll || runningScenarios.size > 0
532
- ? 'text-zinc-400 bg-zinc-50 cursor-not-allowed'
533
- : 'text-white bg-zinc-900 hover:bg-zinc-800'
534
- }`}
675
+ loading={runningAll}
676
+ size="sm"
677
+ className="flex-shrink-0"
535
678
  >
536
- {runningAll ? (
537
- <span className="flex items-center gap-2">
538
- <span className="inline-block w-3.5 h-3.5 border-2 border-zinc-300 border-t-white rounded-full animate-spin" />
539
- Running All...
540
- </span>
541
- ) : (
542
- 'Run All'
543
- )}
544
- </button>
679
+ {runningAll ? 'Running All...' : 'Run All'}
680
+ </Button>
545
681
  </div>
546
682
  </div>
547
683
 
548
- <div className="space-y-4">
684
+ <div className="space-y-5">
549
685
  {selectedFeature.scenarios.map(scenario => {
550
686
  const scenarioKey = `${selectedFeature.featureFile}::${scenario.title}`;
551
687
  const isRunning = runningScenarios.has(scenarioKey);
@@ -554,32 +690,32 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
554
690
  return (
555
691
  <div
556
692
  key={scenario.id}
557
- className={`rounded-lg border ${
693
+ className={`rounded-lg ${
558
694
  isRunning
559
- ? 'border-blue-200 bg-blue-50'
695
+ ? 'border-2 border-[#819D9F]/30 bg-[#e8f0f0]'
560
696
  : scenario.status === 'fail'
561
- ? 'border-red-200 bg-red-50'
697
+ ? 'border-2 border-red-200 bg-red-50'
562
698
  : scenario.status === 'pending'
563
- ? 'border-amber-200 bg-amber-50'
564
- : 'border-zinc-200 bg-white'
699
+ ? 'border-2 border-amber-200 bg-amber-50'
700
+ : 'bg-white'
565
701
  }`}
566
702
  >
567
- <div className="flex items-center justify-between p-4">
568
- <div className="flex items-center gap-2">
569
- <span className="text-sm">
703
+ <div className="flex items-center justify-between p-5">
704
+ <div className="flex items-center gap-3">
705
+ <span className="text-base">
570
706
  {isRunning ? '🔄' : scenario.status === 'pass' ? '✅' : scenario.status === 'fail' ? '❌' : '⏳'}
571
707
  </span>
572
- <span className={`text-sm font-medium ${
573
- isRunning ? 'text-blue-700' : scenario.status === 'fail' ? 'text-red-700' : 'text-zinc-900'
708
+ <span className={`text-base font-medium ${
709
+ isRunning ? 'text-[#5a7d7f]' : scenario.status === 'fail' ? 'text-red-700' : 'text-zinc-900'
574
710
  }`}>
575
711
  {scenario.title}
576
712
  </span>
577
713
  </div>
578
- <div className="flex items-center gap-2">
714
+ <div className="flex items-center gap-3">
579
715
  {isRunning && elapsed !== undefined ? (
580
- <span className="text-xs font-mono text-blue-500">{elapsed}s</span>
716
+ <span className="text-xs font-mono text-[#819D9F]">{elapsed}s</span>
581
717
  ) : (
582
- <div className="flex items-center gap-2">
718
+ <div className="flex items-center gap-3">
583
719
  <span className="text-xs font-mono text-zinc-400">{scenario.duration}</span>
584
720
  {scenario.lastRun && (
585
721
  <span className="text-xs text-zinc-400" title={parseUtcDate(scenario.lastRun).toLocaleString()}>
@@ -596,7 +732,7 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
596
732
  }
597
733
  }}
598
734
  disabled={isRunning}
599
- className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
735
+ className={`px-3 py-1.5 text-base font-medium rounded transition-colors duration-200 ease-out ${
600
736
  isRunning
601
737
  ? 'text-zinc-400 bg-zinc-50 cursor-not-allowed'
602
738
  : 'text-zinc-600 bg-zinc-100 hover:bg-zinc-200'
@@ -604,7 +740,7 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
604
740
  >
605
741
  {isRunning ? (
606
742
  <span className="flex items-center gap-1">
607
- <span className="inline-block w-3 h-3 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin" />
743
+ <span className="inline-block w-3 h-3 border-2 border-[#819D9F]/50 border-t-[#5a7d7f] rounded-full animate-spin" />
608
744
  Running...
609
745
  </span>
610
746
  ) : (
@@ -616,8 +752,8 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
616
752
 
617
753
  {/* Gherkin Steps */}
618
754
  {scenario.steps && scenario.steps.length > 0 && (
619
- <div className="px-4 pb-3 border-t border-zinc-100">
620
- <div className="mt-3 space-y-1">
755
+ <div className="px-5 pb-4 border-t border-zinc-100">
756
+ <div className="mt-4 space-y-1.5">
621
757
  {scenario.steps.map((step: string, i: number) => {
622
758
  const isFailedStep = scenario.failedStep && step.includes(scenario.failedStep.replace(/^(Given |When |Then |And |But )/, ''));
623
759
  const scenarioUndefined = undefinedStepsMap[scenario.title] || [];
@@ -626,9 +762,9 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
626
762
  return (
627
763
  <div
628
764
  key={i}
629
- className={`text-xs font-mono py-1 px-2 rounded flex items-center gap-1.5 ${
765
+ className={`text-xs font-mono py-1.5 px-3 rounded flex items-center gap-2 ${
630
766
  stepStatus === 'running'
631
- ? 'bg-blue-100 text-blue-900 border-l-2 border-blue-500'
767
+ ? 'bg-[#e8f0f0] text-zinc-900 border-l-2 border-[#819D9F]'
632
768
  : stepStatus === 'passed'
633
769
  ? 'bg-green-50 text-green-800 border-l-2 border-green-400'
634
770
  : stepStatus === 'failed'
@@ -644,7 +780,7 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
644
780
  >
645
781
  {stepStatus === 'running' && (
646
782
  <span className="flex-shrink-0">
647
- <span className="inline-block w-2.5 h-2.5 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin" />
783
+ <span className="inline-block w-2.5 h-2.5 border-2 border-[#819D9F]/50 border-t-[#5a7d7f] rounded-full animate-spin" />
648
784
  </span>
649
785
  )}
650
786
  {stepStatus === 'passed' && (
@@ -673,18 +809,18 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
673
809
 
674
810
  {/* Error Details */}
675
811
  {scenario.status === 'fail' && scenario.error && (
676
- <div className="px-4 pb-4">
812
+ <div className="px-5 pb-5">
677
813
  {scenario.failedStep && (
678
- <div className="mb-2 text-xs font-medium text-red-700">
814
+ <div className="mb-3 text-base font-medium text-red-700">
679
815
  Failed at: {scenario.failedStep}
680
816
  </div>
681
817
  )}
682
- <div className="p-3 bg-red-100 rounded border border-red-200">
818
+ <div className="p-4 bg-red-100 rounded border-2 border-red-200">
683
819
  <pre className="text-xs text-red-800 whitespace-pre-wrap font-mono">
684
820
  {scenario.error}
685
821
  </pre>
686
822
  </div>
687
- <button
823
+ <Button
688
824
  onClick={(e) => {
689
825
  e.stopPropagation();
690
826
  createFixScenarioSession(
@@ -695,10 +831,12 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
695
831
  scenario.steps
696
832
  );
697
833
  }}
698
- className="mt-3 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors text-violet-700 bg-violet-100 hover:bg-violet-200 border border-violet-200"
834
+ className="mt-4"
835
+ variant="accent"
836
+ size="sm"
699
837
  >
700
838
  Fix with Claude
701
- </button>
839
+ </Button>
702
840
  </div>
703
841
  )}
704
842
  </div>
@@ -710,11 +848,11 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
710
848
  <div className="flex flex-col items-center justify-center h-full text-center px-6">
711
849
  {allFeatures.length === 0 ? (
712
850
  <>
713
- <span className="text-zinc-400 text-sm">No test features available</span>
714
- <span className="text-zinc-300 text-xs mt-1">Create BDD feature files in the features/ directory to see test results here.</span>
851
+ <span className="text-zinc-400 text-base">No test features available</span>
852
+ <span className="text-zinc-300 text-base mt-1">Create BDD feature files in the features/ directory to see test results here.</span>
715
853
  </>
716
854
  ) : (
717
- <span className="text-zinc-400 text-sm">Select a feature from the sidebar to view its scenarios</span>
855
+ <span className="text-zinc-400 text-base">Select a feature from the sidebar to view its scenarios</span>
718
856
  )}
719
857
  </div>
720
858
  )}