jettypod 4.4.118 → 4.4.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/.env +4 -3
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. package/docs/bdd-guidance.md +0 -390
@@ -0,0 +1,722 @@
1
+ /**
2
+ * Data Bridge — provides all data operations via Tauri IPC.
3
+ * Low-level invoke/listen/isTauri are in lib/tauri.ts.
4
+ */
5
+
6
+ import { invoke } from './tauri';
7
+ import type { TestDashboardData, PrototypeDashboardData } from './db';
8
+ import type { EnvironmentConfig, ContextItem } from './environment-config';
9
+ import { validateCategory, validateFaIcon } from './environment-config';
10
+
11
+ // Work item types matching Tauri IPC response format
12
+ // Note: Rust WorkItemResponse uses #[serde(rename = "type")] so JSON key is "type"
13
+ export interface WorkItemData {
14
+ id: number;
15
+ type: string;
16
+ title: string;
17
+ description: string | null;
18
+ status: string;
19
+ mode: string | null;
20
+ phase: string | null;
21
+ parent_id: number | null;
22
+ branch_name: string | null;
23
+ scenario_file: string | null;
24
+ created_at: string;
25
+ completed_at: string | null;
26
+ ready_for_review: number;
27
+ display_order: number;
28
+ rejection_count: number;
29
+ rejection_reason: string | null;
30
+ rejection_round: number | null;
31
+ rejection_history: string | null;
32
+ needs_discovery: number;
33
+ qa_steps: string | null;
34
+ }
35
+
36
+ export interface DecisionData {
37
+ id: number;
38
+ work_item_id: number;
39
+ work_item_title: string;
40
+ aspect: string;
41
+ decision: string;
42
+ rationale: string | null;
43
+ created_at: string;
44
+ }
45
+
46
+ // Kanban types (matching what components expect)
47
+ export interface InFlightItem {
48
+ id: number;
49
+ type: string;
50
+ title: string;
51
+ status: string;
52
+ mode: string | null;
53
+ phase: string | null;
54
+ parent_id: number | null;
55
+ parent_title: string | null;
56
+ parent_type: string | null;
57
+ branch_name: string | null;
58
+ ready_for_review: number;
59
+ display_order: number;
60
+ description: string | null;
61
+ rejection_count: number;
62
+ rejection_reason: string | null;
63
+ needs_discovery: number;
64
+ created_at: string;
65
+ completed_at: string | null;
66
+ rejection_round: number | null;
67
+ rejection_history: string | null;
68
+ // Aliases for backward compatibility with components
69
+ epicTitle: string | null;
70
+ epic_id: number | null;
71
+ // Children nested under feature cards (populated by transformToKanbanData)
72
+ chores?: InFlightItem[];
73
+ bugs?: InFlightItem[];
74
+ conversational?: number;
75
+ scenario_file?: string | null;
76
+ }
77
+
78
+ export interface KanbanGroup {
79
+ epicId: number | null;
80
+ epicTitle: string | null;
81
+ items: InFlightItem[];
82
+ }
83
+
84
+ export interface KanbanData {
85
+ inFlight: InFlightItem[];
86
+ backlog: Map<string, KanbanGroup>;
87
+ done: Map<string, KanbanGroup>;
88
+ /** O(1) item lookup by ID — built once during transform */
89
+ itemMap: Map<number, InFlightItem>;
90
+ /** O(1) status lookup by ID — built once during transform */
91
+ statusMap: Map<number, string>;
92
+ }
93
+
94
+ // Project config (matches Rust ProjectConfig serialization)
95
+ export interface ProjectConfig {
96
+ name: string;
97
+ stage: string;
98
+ bundles: string[];
99
+ project_state: string;
100
+ project_discovery: {
101
+ status: string;
102
+ prototypes: unknown[];
103
+ winner: string | null;
104
+ rationale: string | null;
105
+ started_date: string | null;
106
+ completed_date: string | null;
107
+ };
108
+ mainBranch?: string | null;
109
+ [key: string]: unknown;
110
+ }
111
+
112
+ // ---- Helper: transform work items into kanban format ----
113
+
114
+ function toInFlightItem(item: WorkItemData, parentMap: Map<number, WorkItemData>): InFlightItem {
115
+ const parent = item.parent_id ? parentMap.get(item.parent_id) : null;
116
+ return {
117
+ id: item.id,
118
+ type: item.type,
119
+ title: item.title,
120
+ status: item.status,
121
+ mode: item.mode,
122
+ phase: item.phase,
123
+ parent_id: item.parent_id,
124
+ parent_title: parent?.title ?? null,
125
+ parent_type: parent?.type ?? null,
126
+ branch_name: item.branch_name,
127
+ ready_for_review: item.ready_for_review,
128
+ display_order: item.display_order,
129
+ description: item.description,
130
+ rejection_count: item.rejection_count,
131
+ rejection_reason: item.rejection_reason,
132
+ needs_discovery: item.needs_discovery,
133
+ created_at: item.created_at,
134
+ completed_at: item.completed_at,
135
+ rejection_round: item.rejection_round ?? null,
136
+ rejection_history: item.rejection_history ?? null,
137
+ // Backward compatibility aliases
138
+ epicTitle: parent?.title ?? null,
139
+ epic_id: item.parent_id,
140
+ };
141
+ }
142
+
143
+ function groupByEpic(items: InFlightItem[], sort: 'display_order' | 'completed_at_desc' = 'display_order'): Map<string, KanbanGroup> {
144
+ const groups = new Map<string, KanbanGroup>();
145
+ for (const item of items) {
146
+ const key = item.parent_id ? String(item.parent_id) : 'ungrouped';
147
+ const existing = groups.get(key);
148
+ if (existing) {
149
+ existing.items.push(item);
150
+ } else {
151
+ groups.set(key, {
152
+ epicId: item.parent_id,
153
+ epicTitle: item.parent_title,
154
+ items: [item],
155
+ });
156
+ }
157
+ }
158
+ const compareFn = sort === 'completed_at_desc'
159
+ ? (a: InFlightItem, b: InFlightItem) => (b.completed_at ?? '').localeCompare(a.completed_at ?? '')
160
+ : (a: InFlightItem, b: InFlightItem) => (a.display_order ?? a.id * 10) - (b.display_order ?? b.id * 10);
161
+ for (const group of groups.values()) {
162
+ group.items.sort(compareFn);
163
+ }
164
+ return groups;
165
+ }
166
+
167
+ export function transformToKanbanData(allItems: WorkItemData[]): KanbanData {
168
+ // Build parent lookup map
169
+ const parentMap = new Map<number, WorkItemData>();
170
+ for (const item of allItems) {
171
+ parentMap.set(item.id, item);
172
+ }
173
+
174
+ // Identify feature IDs so we can nest their children instead of showing them as separate cards
175
+ const featureIds = new Set<number>();
176
+ for (const item of allItems) {
177
+ if (item.type === 'feature') featureIds.add(item.id);
178
+ }
179
+
180
+ // Collect children of features separately — they'll be nested, not shown as cards
181
+ const featureChildren: InFlightItem[] = [];
182
+
183
+ // Separate into categories
184
+ // In-flight: non-epic items with status 'in_progress' or 'blocked'
185
+ // Backlog: non-epic items with status 'backlog' or 'todo'
186
+ // Done: non-epic items with status 'done' or 'cancelled'
187
+ const inFlightItems: InFlightItem[] = [];
188
+ const backlogItems: InFlightItem[] = [];
189
+ const doneItems: InFlightItem[] = [];
190
+
191
+ for (const item of allItems) {
192
+ // Skip epics — they're containers, not kanban cards
193
+ if (item.type === 'epic') continue;
194
+
195
+ const inflightItem = toInFlightItem(item, parentMap);
196
+
197
+ // If this item's parent is a feature, collect it for nesting rather than
198
+ // showing as a separate card
199
+ if (item.parent_id && featureIds.has(item.parent_id)) {
200
+ featureChildren.push(inflightItem);
201
+ continue;
202
+ }
203
+
204
+ if (item.status === 'in_progress' || item.status === 'blocked') {
205
+ inFlightItems.push(inflightItem);
206
+ } else if (item.status === 'done' || item.status === 'cancelled') {
207
+ doneItems.push(inflightItem);
208
+ } else {
209
+ // backlog, todo
210
+ backlogItems.push(inflightItem);
211
+ }
212
+ }
213
+
214
+ // Sort in-flight by display_order (fall back to id * 10 for null — matches midpoint gap)
215
+ inFlightItems.sort((a, b) => (a.display_order ?? a.id * 10) - (b.display_order ?? b.id * 10));
216
+
217
+ const backlog = groupByEpic(backlogItems);
218
+ const done = groupByEpic(doneItems, 'completed_at_desc');
219
+
220
+ // Build lookup maps once during transform — consumers use these instead of
221
+ // scanning all items with O(N) loops on every access.
222
+ const itemMap = new Map<number, InFlightItem>();
223
+ const statusMap = new Map<number, string>();
224
+ for (const item of inFlightItems) {
225
+ itemMap.set(item.id, item);
226
+ statusMap.set(item.id, item.status);
227
+ }
228
+ for (const group of backlog.values()) {
229
+ for (const item of group.items) {
230
+ itemMap.set(item.id, item);
231
+ statusMap.set(item.id, item.status);
232
+ }
233
+ }
234
+ for (const group of done.values()) {
235
+ for (const item of group.items) {
236
+ itemMap.set(item.id, item);
237
+ statusMap.set(item.id, item.status);
238
+ }
239
+ }
240
+
241
+ // Nest feature children (chores/bugs) onto their parent feature items
242
+ // Also add children to itemMap/statusMap so they're findable
243
+ for (const child of featureChildren) {
244
+ const parent = itemMap.get(child.parent_id!);
245
+ if (parent) {
246
+ if (child.type === 'bug') {
247
+ if (!parent.bugs) parent.bugs = [];
248
+ parent.bugs.push(child);
249
+ } else {
250
+ if (!parent.chores) parent.chores = [];
251
+ parent.chores.push(child);
252
+ }
253
+ }
254
+ itemMap.set(child.id, child);
255
+ statusMap.set(child.id, child.status);
256
+ }
257
+
258
+ return {
259
+ inFlight: inFlightItems,
260
+ backlog,
261
+ done,
262
+ itemMap,
263
+ statusMap,
264
+ };
265
+ }
266
+
267
+ // ---- Project root cache (static value, never changes in a session) ----
268
+
269
+ let projectRootCache: string | null | undefined = undefined; // undefined = not fetched yet
270
+
271
+ // ---- Kanban cache ----
272
+
273
+ let kanbanCache: KanbanData | null = null;
274
+ let lastRawFingerprint: string | null = null;
275
+ let lastKanbanResult: KanbanData | null = null;
276
+
277
+ /** Timestamp of the last local mutation. WebSocket db_change events that arrive
278
+ * within a short window after a local mutation are skipped because the caller
279
+ * already refreshed the data. */
280
+ let lastLocalMutationTs = 0;
281
+
282
+ /** How long (ms) after a local mutation to suppress WebSocket-triggered refetches. */
283
+ const LOCAL_MUTATION_SUPPRESS_MS = 1500;
284
+
285
+ /** Returns true if a local mutation happened recently enough that an incoming
286
+ * WebSocket db_change is almost certainly an echo of our own write. */
287
+ export function isLocalMutationRecent(): boolean {
288
+ return Date.now() - lastLocalMutationTs < LOCAL_MUTATION_SUPPRESS_MS;
289
+ }
290
+
291
+ /** Mark that a local mutation is in flight — suppresses WS-triggered refetches
292
+ * without clearing the cache. Call this BEFORE the IPC so the suppression
293
+ * window covers the entire round-trip. invalidateKanbanCache() extends the
294
+ * window after the IPC completes. */
295
+ export function markLocalMutation(): void {
296
+ lastLocalMutationTs = Date.now();
297
+ }
298
+
299
+ /** Clear the kanban cache so the next getKanbanData() fetches fresh from DB. */
300
+ export function invalidateKanbanCache(): void {
301
+ kanbanCache = null;
302
+ lastLocalMutationTs = Date.now();
303
+ }
304
+
305
+ /** Apply a field-level update to the cached kanban data without full invalidation.
306
+ * Returns true if the item was found and updated, false otherwise. */
307
+ function applyCacheUpdate(id: number, updates: Partial<Pick<InFlightItem, 'title' | 'description'>>): boolean {
308
+ if (!kanbanCache) return false;
309
+ const item = kanbanCache.itemMap.get(id);
310
+ if (!item) return false;
311
+
312
+ // Apply updates in place
313
+ if (updates.title !== undefined) {
314
+ item.title = updates.title;
315
+ // Update fingerprint to match new state
316
+ if (lastRawFingerprint) {
317
+ lastRawFingerprint = lastRawFingerprint.replace(
318
+ new RegExp(`${id}:${item.status}:${item.display_order}:[^;]*;`),
319
+ `${id}:${item.status}:${item.display_order}:${updates.title};`
320
+ );
321
+ }
322
+ }
323
+ if (updates.description !== undefined) {
324
+ item.description = updates.description;
325
+ }
326
+ lastLocalMutationTs = Date.now();
327
+ return true;
328
+ }
329
+
330
+ // ---- Delta update support ----
331
+
332
+ /** Fetch a single work item by ID and patch it into the kanban cache.
333
+ * Returns the updated KanbanData if the cache was patched, or null if a full
334
+ * refetch is needed (item not in cache, status changed requiring column move,
335
+ * or cache is cold). */
336
+ export async function patchKanbanItem(rowid: number): Promise<KanbanData | null> {
337
+ if (!kanbanCache) return null;
338
+
339
+ const rawItem = await invoke<WorkItemData | null>('db_get_work_item', { id: rowid });
340
+ if (!rawItem) return null;
341
+
342
+ // Skip epics — they're containers, not kanban cards
343
+ if (rawItem.type === 'epic') return kanbanCache;
344
+
345
+ const existing = kanbanCache.itemMap.get(rowid);
346
+ if (!existing) {
347
+ // Item not in cache (new item or cache stale) — caller should full refetch
348
+ return null;
349
+ }
350
+
351
+ // If status changed, the item needs to move between columns — that's complex
352
+ // enough that a full refetch is cleaner and these are infrequent events
353
+ if (rawItem.status !== existing.status) {
354
+ return null;
355
+ }
356
+
357
+ // Build parent lookup from cache for the toInFlightItem-style transform
358
+ const parentTitle = existing.parent_title;
359
+ const parentType = existing.parent_type;
360
+
361
+ // Patch the item in place — same column, just updated fields
362
+ const patched: InFlightItem = {
363
+ ...existing,
364
+ title: rawItem.title,
365
+ description: rawItem.description,
366
+ mode: rawItem.mode,
367
+ phase: rawItem.phase,
368
+ branch_name: rawItem.branch_name,
369
+ ready_for_review: rawItem.ready_for_review,
370
+ display_order: rawItem.display_order,
371
+ rejection_count: rawItem.rejection_count,
372
+ rejection_reason: rawItem.rejection_reason,
373
+ needs_discovery: rawItem.needs_discovery,
374
+ scenario_file: rawItem.scenario_file,
375
+ completed_at: rawItem.completed_at,
376
+ parent_title: parentTitle,
377
+ parent_type: parentType,
378
+ };
379
+
380
+ // Update fingerprint to match new state
381
+ if (lastRawFingerprint) {
382
+ const oldPattern = `${rowid}:${existing.status}:${existing.display_order}:${existing.title};`;
383
+ const newPattern = `${rowid}:${patched.status}:${patched.display_order}:${patched.title};`;
384
+ lastRawFingerprint = lastRawFingerprint.replace(oldPattern, newPattern);
385
+ }
386
+
387
+ // Replace item in all data structures
388
+ const newItemMap = new Map(kanbanCache.itemMap);
389
+ newItemMap.set(rowid, patched);
390
+
391
+ const statusColumn = existing.status === 'in_progress' || existing.status === 'blocked'
392
+ ? 'inFlight' as const
393
+ : existing.status === 'done' || existing.status === 'cancelled'
394
+ ? 'done' as const
395
+ : 'backlog' as const;
396
+
397
+ let newInFlight = kanbanCache.inFlight;
398
+ let newBacklog = kanbanCache.backlog;
399
+ let newDone = kanbanCache.done;
400
+
401
+ if (statusColumn === 'inFlight') {
402
+ newInFlight = kanbanCache.inFlight.map(i => i.id === rowid ? patched : i);
403
+ } else {
404
+ const groups = statusColumn === 'backlog' ? kanbanCache.backlog : kanbanCache.done;
405
+ const newGroups = new Map<string, KanbanGroup>();
406
+ for (const [key, group] of groups) {
407
+ if (group.items.some(i => i.id === rowid)) {
408
+ newGroups.set(key, { ...group, items: group.items.map(i => i.id === rowid ? patched : i) });
409
+ } else {
410
+ newGroups.set(key, group);
411
+ }
412
+ }
413
+ if (statusColumn === 'backlog') newBacklog = newGroups;
414
+ else newDone = newGroups;
415
+ }
416
+
417
+ const result: KanbanData = {
418
+ inFlight: newInFlight,
419
+ backlog: newBacklog,
420
+ done: newDone,
421
+ itemMap: newItemMap,
422
+ statusMap: kanbanCache.statusMap,
423
+ };
424
+
425
+ kanbanCache = result;
426
+ lastKanbanResult = result;
427
+ return result;
428
+ }
429
+
430
+ // ---- Page data prefetch cache ----
431
+ // Caches promises with a short TTL so hover-triggered fetches are reused by the
432
+ // page component mount. After 10s the cache expires and the next access fetches fresh.
433
+
434
+ const PREFETCH_TTL = 10_000;
435
+ const pageCache = new Map<string, { promise: Promise<any>; ts: number }>();
436
+
437
+ function getCachedOrFetch<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
438
+ const cached = pageCache.get(key);
439
+ if (cached && Date.now() - cached.ts < PREFETCH_TTL) return cached.promise as Promise<T>;
440
+ const promise = fetcher();
441
+ pageCache.set(key, { promise, ts: Date.now() });
442
+ return promise;
443
+ }
444
+
445
+ export interface ContextDocument {
446
+ type: 'file' | 'text';
447
+ name: string;
448
+ content?: string;
449
+ path?: string;
450
+ }
451
+
452
+ export interface SettingsPrefetchData {
453
+ files: string[];
454
+ selected: string | null;
455
+ branch: string;
456
+ claudeModel: string | null;
457
+ designSystemDir: string | null;
458
+ contextDocuments: ContextDocument[];
459
+ environmentConfig: EnvironmentConfig | null;
460
+ }
461
+
462
+ export const prefetch = {
463
+ backlog(): Promise<KanbanData> {
464
+ return getCachedOrFetch('backlog', () => dataBridge.getKanbanData());
465
+ },
466
+ tests(): Promise<TestDashboardData> {
467
+ return getCachedOrFetch('tests', () => dataBridge.getTestDashboardData());
468
+ },
469
+ prototypes(): Promise<PrototypeDashboardData> {
470
+ return getCachedOrFetch('prototypes', () => dataBridge.getPrototypeDashboardData());
471
+ },
472
+ workItem(id: number): Promise<{ item: WorkItemData | null; children: WorkItemData[]; decisions: DecisionData[] }> {
473
+ return getCachedOrFetch(`workItem:${id}`, async () => {
474
+ const [item, children, decisions] = await Promise.all([
475
+ dataBridge.getWorkItem(id),
476
+ dataBridge.getChildren(id),
477
+ dataBridge.getDecisions(id),
478
+ ]);
479
+ return { item, children, decisions };
480
+ });
481
+ },
482
+ settings(): Promise<SettingsPrefetchData> {
483
+ return getCachedOrFetch('settings', async () => {
484
+ const [files, selected, branch, claudeModel, designSystemDir, contextDocuments, environmentConfig] = await Promise.all([
485
+ dataBridge.discoverEnvFiles(),
486
+ dataBridge.getSelectedEnvFile(),
487
+ dataBridge.getMainBranch(),
488
+ dataBridge.getClaudeModel(),
489
+ dataBridge.getDesignSystemDir(),
490
+ dataBridge.getContextDocuments(),
491
+ dataBridge.getEnvironmentConfig(),
492
+ ]);
493
+ return { files, selected, branch, claudeModel, designSystemDir, contextDocuments, environmentConfig };
494
+ });
495
+ },
496
+ };
497
+
498
+ // ---- Public API ----
499
+
500
+ export const dataBridge = {
501
+ // Kanban / work items
502
+ async getKanbanData(): Promise<KanbanData> {
503
+ if (kanbanCache) return kanbanCache;
504
+ // Fetch all active items + the 25 most recent done/cancelled for the Done
505
+ // column. This avoids loading 1,000+ completed items while still showing
506
+ // recent completions on the board.
507
+ const items = await invoke<WorkItemData[]>('db_get_tree', {
508
+ includeCompleted: true,
509
+ completedLimit: 25,
510
+ });
511
+ // Lightweight fingerprint — only hash id:status:order:title per item instead of
512
+ // full JSON.stringify. O(N) string concat is much cheaper than O(N) serialization.
513
+ let fingerprint = '';
514
+ for (const item of items) {
515
+ fingerprint += `${item.id}:${item.status}:${item.display_order}:${item.title};`;
516
+ }
517
+ if (fingerprint === lastRawFingerprint && lastKanbanResult) {
518
+ kanbanCache = lastKanbanResult;
519
+ return kanbanCache;
520
+ }
521
+ lastRawFingerprint = fingerprint;
522
+ kanbanCache = transformToKanbanData(items);
523
+ lastKanbanResult = kanbanCache;
524
+ return kanbanCache;
525
+ },
526
+
527
+ async getWorkItem(id: number): Promise<WorkItemData | null> {
528
+ try {
529
+ return await invoke<WorkItemData>('db_get_work_item', { id });
530
+ } catch {
531
+ return null;
532
+ }
533
+ },
534
+
535
+ async getChildren(parentId: number): Promise<WorkItemData[]> {
536
+ return invoke<WorkItemData[]>('db_get_children', { parentId });
537
+ },
538
+
539
+ async getDecisions(workItemId: number): Promise<DecisionData[]> {
540
+ return invoke<DecisionData[]>('db_get_decisions', { workItemId });
541
+ },
542
+
543
+ async getDecision(id: number): Promise<DecisionData | null> {
544
+ try {
545
+ return await invoke<DecisionData>('db_get_decision', { id });
546
+ } catch {
547
+ return null;
548
+ }
549
+ },
550
+
551
+ // Mutations (all invalidate kanban cache)
552
+ async updateStatus(id: number, status: string, rejectionReason?: string): Promise<boolean> {
553
+ const result = await invoke<boolean>('db_update_work_item_status', { id, status });
554
+ if (rejectionReason && status === 'in_progress') {
555
+ await invoke<boolean>('db_increment_rejection', { id, reason: rejectionReason });
556
+ }
557
+ invalidateKanbanCache();
558
+ return result;
559
+ },
560
+
561
+ async updateTitle(id: number, title: string): Promise<boolean> {
562
+ const result = await invoke<boolean>('db_update_work_item_title', { id, title });
563
+ // Optimistic: update cached item in place instead of full invalidation
564
+ if (!applyCacheUpdate(id, { title })) invalidateKanbanCache();
565
+ return result;
566
+ },
567
+
568
+ async updateDescription(id: number, description: string): Promise<boolean> {
569
+ const result = await invoke<boolean>('db_update_work_item_description', { id, description });
570
+ // Optimistic: update cached item in place instead of full invalidation
571
+ if (!applyCacheUpdate(id, { description })) invalidateKanbanCache();
572
+ return result;
573
+ },
574
+
575
+ async setQaSteps(id: number, qaSteps: string): Promise<boolean> {
576
+ return invoke<boolean>('db_set_work_item_qa_steps', { id, qaSteps });
577
+ },
578
+
579
+ async updateDisplayOrders(orders: [number, number][]): Promise<boolean> {
580
+ const result = await invoke<boolean>('db_update_display_orders', { orders });
581
+ invalidateKanbanCache();
582
+ return result;
583
+ },
584
+
585
+ // Project (cached — project root never changes in a session)
586
+ async hasProject(): Promise<boolean> {
587
+ const root = await this.getProjectRoot();
588
+ return root !== null;
589
+ },
590
+
591
+ async getProjectRoot(): Promise<string | null> {
592
+ if (projectRootCache !== undefined) return projectRootCache;
593
+ projectRootCache = await invoke<string | null>('db_get_project_root');
594
+ return projectRootCache;
595
+ },
596
+
597
+ // Settings
598
+ async getEnvVars(file: string): Promise<Array<{ key: string; value: string }>> {
599
+ return invoke<Array<{ key: string; value: string }>>('db_get_env_vars', { file });
600
+ },
601
+
602
+ async discoverEnvFiles(): Promise<string[]> {
603
+ return invoke<string[]>('db_discover_env_files');
604
+ },
605
+
606
+ async getSelectedEnvFile(): Promise<string | null> {
607
+ return invoke<string | null>('db_get_selected_env_file');
608
+ },
609
+
610
+ async getMainBranch(): Promise<string> {
611
+ return invoke<string>('db_get_main_branch');
612
+ },
613
+
614
+ async getClaudeModel(): Promise<string | null> {
615
+ return invoke<string | null>('db_get_claude_model');
616
+ },
617
+
618
+ async setClaudeModel(model: string): Promise<boolean> {
619
+ return invoke<boolean>('db_set_claude_model', { model });
620
+ },
621
+
622
+ async resetClaudeModel(): Promise<boolean> {
623
+ return invoke<boolean>('db_reset_claude_model');
624
+ },
625
+
626
+ async getDesignSystemDir(): Promise<string | null> {
627
+ return invoke<string | null>('db_get_design_system_dir');
628
+ },
629
+
630
+ async setDesignSystemDir(dir: string): Promise<boolean> {
631
+ return invoke<boolean>('db_set_design_system_dir', { dir });
632
+ },
633
+
634
+ async resetDesignSystemDir(): Promise<boolean> {
635
+ return invoke<boolean>('db_reset_design_system_dir');
636
+ },
637
+
638
+ // Context Documents
639
+ async getContextDocuments(): Promise<ContextDocument[]> {
640
+ return invoke<ContextDocument[]>('db_get_context_documents');
641
+ },
642
+
643
+ async addContextDocument(doc: ContextDocument): Promise<boolean> {
644
+ return invoke<boolean>('db_add_context_document', { doc });
645
+ },
646
+
647
+ async updateContextDocument(index: number, doc: ContextDocument): Promise<boolean> {
648
+ return invoke<boolean>('db_update_context_document', { index, doc });
649
+ },
650
+
651
+ async removeContextDocument(index: number): Promise<boolean> {
652
+ return invoke<boolean>('db_remove_context_document', { index });
653
+ },
654
+
655
+ // Environment Config
656
+ async getEnvironmentConfig(): Promise<EnvironmentConfig | null> {
657
+ try {
658
+ const fullConfig = await invoke<Record<string, unknown>>('db_get_config');
659
+ const env = fullConfig?.environment as Record<string, unknown> | undefined;
660
+ if (!env || typeof env !== 'object' || !Array.isArray(env.services) || env.services.length === 0) {
661
+ return null;
662
+ }
663
+ const contextItems = Array.isArray(env.contextItems)
664
+ ? (env.contextItems as ContextItem[]).map(item => ({
665
+ name: item.name,
666
+ category: item.category,
667
+ faIcon: item.faIcon,
668
+ command: item.command,
669
+ connectionUrl: item.connectionUrl,
670
+ }))
671
+ : [];
672
+
673
+ return {
674
+ services: (env.services as EnvironmentConfig['services']).map((svc, i) => ({
675
+ name: svc.name,
676
+ command: svc.command,
677
+ port: svc.port,
678
+ healthCheck: svc.healthCheck || 'http',
679
+ readyPattern: svc.readyPattern,
680
+ optional: svc.optional ?? false,
681
+ order: svc.order ?? i + 1,
682
+ category: validateCategory(svc.category),
683
+ faIcon: validateFaIcon(svc.faIcon),
684
+ })),
685
+ contextItems,
686
+ readyWhen: (env.readyWhen as EnvironmentConfig['readyWhen']) || 'required',
687
+ teardownOnClose: env.teardownOnClose !== false,
688
+ };
689
+ } catch {
690
+ return null;
691
+ }
692
+ },
693
+
694
+ async setEnvironmentConfig(config: EnvironmentConfig): Promise<boolean> {
695
+ try {
696
+ const result = await invoke<boolean>('db_set_environment_config', {
697
+ environment: config,
698
+ });
699
+ if (result) pageCache.delete('settings');
700
+ return result;
701
+ } catch {
702
+ return false;
703
+ }
704
+ },
705
+
706
+ // Tests & Prototypes
707
+ async getTestDashboardData(): Promise<TestDashboardData> {
708
+ return invoke<TestDashboardData>('db_get_test_dashboard_data');
709
+ },
710
+
711
+ async ingestTestResults(): Promise<number> {
712
+ return invoke<number>('ingest_test_results');
713
+ },
714
+
715
+ async getProjectRoot(): Promise<string | null> {
716
+ return invoke<string | null>('db_get_project_root');
717
+ },
718
+
719
+ async getPrototypeDashboardData(): Promise<PrototypeDashboardData> {
720
+ return invoke<PrototypeDashboardData>('db_get_prototype_dashboard_data');
721
+ },
722
+ };