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