jettypod 4.4.120 → 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 (208) hide show
  1. package/.env +2 -1
  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 +54 -49
  8. package/apps/dashboard/app/demo/gates/page.tsx +3 -5
  9. package/apps/dashboard/app/design-system/page.tsx +1 -1
  10. package/apps/dashboard/app/globals.css +74 -2
  11. package/apps/dashboard/app/install-claude/page.tsx +3 -5
  12. package/apps/dashboard/app/login/page.tsx +17 -20
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +60 -12
  15. package/apps/dashboard/app/signup/page.tsx +14 -17
  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 +12 -15
  19. package/apps/dashboard/app/work/[id]/page.tsx +90 -75
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +70 -61
  22. package/apps/dashboard/components/CardMenu.tsx +0 -1
  23. package/apps/dashboard/components/ClaudePanel.tsx +541 -283
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -4
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +1 -5
  26. package/apps/dashboard/components/CopyableId.tsx +1 -2
  27. package/apps/dashboard/components/DetailReviewActions.tsx +11 -20
  28. package/apps/dashboard/components/DragContext.tsx +132 -62
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +5 -6
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +6 -12
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +0 -1
  34. package/apps/dashboard/components/ElapsedTimer.tsx +15 -3
  35. package/apps/dashboard/components/EpicGroup.tsx +100 -70
  36. package/apps/dashboard/components/GateCard.tsx +0 -1
  37. package/apps/dashboard/components/GateChoiceCard.tsx +1 -2
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +1 -5
  39. package/apps/dashboard/components/JettyLoader.tsx +0 -1
  40. package/apps/dashboard/components/KanbanBoard.tsx +319 -173
  41. package/apps/dashboard/components/KanbanCard.tsx +341 -107
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +0 -1
  44. package/apps/dashboard/components/MainNav.tsx +24 -25
  45. package/apps/dashboard/components/MessageBlock.tsx +93 -16
  46. package/apps/dashboard/components/ModeStartCard.tsx +0 -1
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +0 -1
  48. package/apps/dashboard/components/PlaceholderCard.tsx +0 -1
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +20 -20
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +47 -26
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +308 -223
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +303 -160
  53. package/apps/dashboard/components/ReviewFooter.tsx +12 -14
  54. package/apps/dashboard/components/SessionList.tsx +0 -1
  55. package/apps/dashboard/components/SubscribeContent.tsx +40 -11
  56. package/apps/dashboard/components/TestTree.tsx +1 -2
  57. package/apps/dashboard/components/TipCard.tsx +2 -4
  58. package/apps/dashboard/components/Toast.tsx +0 -1
  59. package/apps/dashboard/components/TypeIcon.tsx +7 -8
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +5 -17
  62. package/apps/dashboard/components/WelcomeScreen.tsx +2 -6
  63. package/apps/dashboard/components/WorkItemHeader.tsx +0 -1
  64. package/apps/dashboard/components/WorkItemTree.tsx +2 -4
  65. package/apps/dashboard/components/settings/AccountSection.tsx +27 -13
  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 +20 -73
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +137 -26
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +0 -1
  72. package/apps/dashboard/components/ui/Button.tsx +1 -1
  73. package/apps/dashboard/components/ui/Input.tsx +1 -1
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +611 -358
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +0 -1
  77. package/apps/dashboard/contexts/UsageContext.tsx +62 -31
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  81. package/apps/dashboard/index.html +73 -0
  82. package/apps/dashboard/lib/data-bridge.ts +722 -0
  83. package/apps/dashboard/lib/db.ts +69 -1302
  84. package/apps/dashboard/lib/environment-config.ts +173 -0
  85. package/apps/dashboard/lib/environment-verification.ts +119 -0
  86. package/apps/dashboard/lib/kanban-utils.ts +226 -26
  87. package/apps/dashboard/lib/proof-run.ts +495 -0
  88. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  89. package/apps/dashboard/lib/service-recovery.ts +326 -0
  90. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  91. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  92. package/apps/dashboard/lib/session-stream-manager.ts +253 -122
  93. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  94. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  95. package/apps/dashboard/lib/tauri.ts +106 -0
  96. package/apps/dashboard/lib/utils.ts +3 -3
  97. package/apps/dashboard/next-env.d.ts +1 -1
  98. package/apps/dashboard/package.json +21 -33
  99. package/apps/dashboard/public/bug-icon.png +0 -0
  100. package/apps/dashboard/public/buoy-icon.png +0 -0
  101. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  102. package/apps/dashboard/public/pier-icon.png +0 -0
  103. package/apps/dashboard/public/star-icon.png +0 -0
  104. package/apps/dashboard/public/wrench-icon.png +0 -0
  105. package/apps/dashboard/scripts/tauri-build.js +228 -0
  106. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  107. package/apps/dashboard/src/main.tsx +12 -0
  108. package/apps/dashboard/src/router.tsx +107 -0
  109. package/apps/dashboard/src/vite-env.d.ts +1 -0
  110. package/apps/dashboard/tsconfig.json +7 -12
  111. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  112. package/apps/dashboard/vite.config.ts +33 -0
  113. package/apps/update-server/src/index.ts +167 -30
  114. package/claude-hooks/global-guardrails.js +14 -13
  115. package/crates/jettypod-cli/Cargo.toml +19 -0
  116. package/crates/jettypod-cli/src/commands.rs +1249 -0
  117. package/crates/jettypod-cli/src/main.rs +595 -0
  118. package/crates/jettypod-core/Cargo.toml +26 -0
  119. package/crates/jettypod-core/build.rs +98 -0
  120. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  121. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  122. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  123. package/crates/jettypod-core/src/auth.rs +294 -0
  124. package/crates/jettypod-core/src/config.rs +397 -0
  125. package/crates/jettypod-core/src/db/mod.rs +507 -0
  126. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  127. package/crates/jettypod-core/src/db/startup.rs +101 -0
  128. package/crates/jettypod-core/src/db/validate.rs +149 -0
  129. package/crates/jettypod-core/src/error.rs +76 -0
  130. package/crates/jettypod-core/src/git.rs +458 -0
  131. package/crates/jettypod-core/src/lib.rs +20 -0
  132. package/crates/jettypod-core/src/sessions.rs +625 -0
  133. package/crates/jettypod-core/src/skills.rs +556 -0
  134. package/crates/jettypod-core/src/work.rs +1086 -0
  135. package/crates/jettypod-core/src/worktree.rs +628 -0
  136. package/crates/jettypod-core/src/ws.rs +767 -0
  137. package/cucumber-test.cjs +6 -0
  138. package/jettypod.js +96 -4
  139. package/lib/bdd-preflight.js +96 -0
  140. package/lib/merge-lock.js +111 -253
  141. package/lib/migrations/030-rejection-round-columns.js +54 -0
  142. package/lib/migrations/031-session-isolation-index.js +17 -0
  143. package/lib/work-commands/index.js +58 -16
  144. package/lib/work-tracking/index.js +108 -8
  145. package/package.json +1 -1
  146. package/skills-templates/bug-mode/SKILL.md +43 -1
  147. package/skills-templates/chore-mode/SKILL.md +40 -1
  148. package/skills-templates/design-system-selection/SKILL.md +273 -0
  149. package/skills-templates/epic-planning/SKILL.md +14 -0
  150. package/skills-templates/feature-planning/SKILL.md +90 -1
  151. package/skills-templates/production-mode/SKILL.md +20 -0
  152. package/skills-templates/simple-improvement/SKILL.md +39 -2
  153. package/skills-templates/speed-mode/SKILL.md +10 -15
  154. package/skills-templates/stable-mode/SKILL.md +47 -0
  155. package/apps/dashboard/README.md +0 -36
  156. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -446
  157. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  158. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -280
  159. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  160. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -525
  161. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  162. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  163. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  164. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  165. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  166. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  167. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  168. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  169. package/apps/dashboard/app/api/tests/route.ts +0 -9
  170. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  171. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  172. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  173. package/apps/dashboard/app/api/usage/route.ts +0 -17
  174. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  175. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  176. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  177. package/apps/dashboard/app/api/work/[id]/route.ts +0 -35
  178. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -63
  179. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  180. package/apps/dashboard/app/layout.tsx +0 -55
  181. package/apps/dashboard/components/UpgradeBanner.tsx +0 -30
  182. package/apps/dashboard/electron/ipc-handlers.js +0 -1026
  183. package/apps/dashboard/electron/main.js +0 -2306
  184. package/apps/dashboard/electron/preload.js +0 -125
  185. package/apps/dashboard/electron/session-manager.js +0 -163
  186. package/apps/dashboard/electron-builder.config.js +0 -357
  187. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  188. package/apps/dashboard/lib/backlog-parser.ts +0 -50
  189. package/apps/dashboard/lib/claude-process-manager.ts +0 -529
  190. package/apps/dashboard/lib/db-bridge.ts +0 -283
  191. package/apps/dashboard/lib/prototypes.ts +0 -202
  192. package/apps/dashboard/lib/test-results-db.ts +0 -307
  193. package/apps/dashboard/lib/tests.ts +0 -282
  194. package/apps/dashboard/next.config.js +0 -66
  195. package/apps/dashboard/postcss.config.mjs +0 -7
  196. package/apps/dashboard/public/bug-icon.svg +0 -9
  197. package/apps/dashboard/public/buoy-icon.svg +0 -9
  198. package/apps/dashboard/public/file.svg +0 -1
  199. package/apps/dashboard/public/globe.svg +0 -1
  200. package/apps/dashboard/public/in-flight-seagull.svg +0 -9
  201. package/apps/dashboard/public/next.svg +0 -1
  202. package/apps/dashboard/public/pier-icon.svg +0 -14
  203. package/apps/dashboard/public/star-icon.svg +0 -9
  204. package/apps/dashboard/public/vercel.svg +0 -1
  205. package/apps/dashboard/public/window.svg +0 -1
  206. package/apps/dashboard/public/wrench-icon.svg +0 -9
  207. package/apps/dashboard/scripts/download-node.js +0 -104
  208. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
@@ -1,7 +1,5 @@
1
- 'use client';
2
-
3
- import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
4
- import { useSearchParams, useRouter } from 'next/navigation';
1
+ import { useState, useCallback, useEffect, useRef, useMemo, startTransition } from 'react';
2
+ import { useSearchParams, useNavigate } from 'react-router-dom';
5
3
  import { KanbanBoard } from './KanbanBoard';
6
4
  import { OnboardingWelcome } from './OnboardingWelcome';
7
5
  import { useToast } from './Toast';
@@ -10,9 +8,11 @@ import { useSessionState, useSessionActions, useSessionPersistence } from '../co
10
8
  import { useConnectionStatus } from '../contexts/ConnectionStatusContext';
11
9
  import { useUsage } from '../contexts/UsageContext';
12
10
  import type { InFlightItem, KanbanGroup } from '@/lib/db';
11
+ import { invoke } from '@/lib/tauri';
12
+ import { dataBridge, invalidateKanbanCache, isLocalMutationRecent, markLocalMutation, patchKanbanItem } from '@/lib/data-bridge';
13
13
  import { getRegistry } from '@/lib/stream-manager-registry';
14
14
  import { getWebSocketUrl } from '@/lib/utils';
15
- import { type KanbanData, findItemById, getOnboardingItems, buildStatusMap, buildModeMap } from '@/lib/kanban-utils';
15
+ import { type KanbanData, findItemById, getOnboardingItems, buildStatusMap, buildModeMap, applyStatusChange, applyTitleChange, applyOrderChange, applyEpicAssign } from '@/lib/kanban-utils';
16
16
  import { useKanbanUndo } from '../hooks/useKanbanUndo';
17
17
  import { useKanbanAnimation } from '../hooks/useKanbanAnimation';
18
18
 
@@ -29,27 +29,51 @@ interface RealTimeKanbanWrapperProps {
29
29
  // Component uses context providers from AppShell (ToastProvider, ClaudeSessionProvider)
30
30
  // DO NOT wrap with duplicate providers - it creates isolated context state
31
31
  export function RealTimeKanbanWrapper({ initialData, isBlank, projectPath }: RealTimeKanbanWrapperProps) {
32
- const searchParams = useSearchParams();
33
- const router = useRouter();
32
+ const [searchParams] = useSearchParams();
33
+ const navigate = useNavigate();
34
34
  const { showToast } = useToast();
35
35
  const { allowed: usageAllowed } = useUsage();
36
- const [data, setData] = useState<KanbanData>(() => ({
37
- inFlight: initialData.inFlight,
38
- backlog: new Map(initialData.backlog),
39
- done: new Map(initialData.done),
40
- }));
36
+ const [data, setData] = useState<KanbanData>(() => {
37
+ const inFlight = initialData.inFlight;
38
+ const backlog = new Map(initialData.backlog);
39
+ const done = new Map(initialData.done);
40
+ // Build lookup maps for initial data
41
+ const itemMap = new Map<number, InFlightItem>();
42
+ const statusMap = new Map<number, string>();
43
+ for (const item of inFlight) {
44
+ itemMap.set(item.id, item);
45
+ statusMap.set(item.id, item.status);
46
+ }
47
+ for (const group of backlog.values()) {
48
+ for (const item of group.items) {
49
+ itemMap.set(item.id, item as InFlightItem);
50
+ statusMap.set(item.id, item.status);
51
+ }
52
+ }
53
+ for (const group of done.values()) {
54
+ for (const item of group.items) {
55
+ itemMap.set(item.id, item as InFlightItem);
56
+ statusMap.set(item.id, item.status);
57
+ }
58
+ }
59
+ return { inFlight, backlog, done, itemMap, statusMap };
60
+ });
41
61
  const [statusError, setStatusError] = useState<string | null>(null);
42
62
 
43
63
  // Ref to latest data — lets callbacks read current data without closing over it,
44
64
  // keeping function references stable across data changes (preserves memo on cards)
45
65
  const dataRef = useRef(data);
46
- dataRef.current = data;
66
+ useEffect(() => { dataRef.current = data; }, [data]);
47
67
 
48
68
  // Use ClaudeSessionContext for session state and actions
49
69
  const { sessions, standaloneSessions } = useSessionState();
50
70
  const { setClaudePanelOpen, openSession, switchSession, closeSession, createAddToBacklogSession, sendMessage } = useSessionActions();
51
71
  const { setSessions } = useSessionPersistence();
52
72
 
73
+ // Stable set of active session IDs — only changes when sessions are added/removed,
74
+ // not when session metadata updates. Prevents busting React.memo on all EpicGroups.
75
+ const activeSessionIds = useMemo(() => new Set(sessions.keys()), [sessions]);
76
+
53
77
  // Onboarding state: show OnboardingWelcome instead of KanbanBoard for blank projects
54
78
  const [showOnboarding, setShowOnboarding] = useState(!!isBlank);
55
79
  const onboardingItems = useMemo(() => getOnboardingItems(data), [data]);
@@ -67,7 +91,7 @@ export function RealTimeKanbanWrapper({ initialData, isBlank, projectPath }: Rea
67
91
  const handleExternalAnimationComplete = useCallback(() => {
68
92
  const pending = rawHandleExternalAnimationComplete();
69
93
  if (pending) {
70
- setData(pending);
94
+ startTransition(() => setData(pending));
71
95
  }
72
96
  }, [rawHandleExternalAnimationComplete]);
73
97
 
@@ -78,18 +102,6 @@ export function RealTimeKanbanWrapper({ initialData, isBlank, projectPath }: Rea
78
102
  const dbChangeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
79
103
  const kanbanFetchAbortRef = useRef<AbortController | null>(null);
80
104
 
81
- const refreshData = useCallback(async (): Promise<KanbanData> => {
82
- const kanbanResponse = await fetch('/api/kanban');
83
- const newData = await kanbanResponse.json();
84
- const kanbanData: KanbanData = {
85
- inFlight: newData.inFlight,
86
- backlog: new Map(newData.backlog),
87
- done: new Map(newData.done),
88
- };
89
- setData(kanbanData);
90
- return kanbanData;
91
- }, []);
92
-
93
105
  // Sync session titles with work item titles from kanban data
94
106
  const syncSessionTitles = useCallback((kanbanData: KanbanData) => {
95
107
  setSessions(prev => {
@@ -123,142 +135,177 @@ export function RealTimeKanbanWrapper({ initialData, isBlank, projectPath }: Rea
123
135
  const previousStatus = found?.status;
124
136
  const itemTitle = found?.item.title ?? `Item #${id}`;
125
137
 
126
- try {
127
- if (newStatus === 'done') {
128
- lastInternallyAnimatedIdRef.current = id;
129
- }
138
+ if (newStatus === 'done') {
139
+ lastInternallyAnimatedIdRef.current = id;
140
+ }
130
141
 
131
- const response = await fetch(`/api/work/${id}/status`, {
132
- method: 'PATCH',
133
- headers: { 'Content-Type': 'application/json' },
134
- body: JSON.stringify({ status: newStatus }),
142
+ // Optimistic: update UI immediately, suppress WS refetches during IPC
143
+ startTransition(() => setData(prev => applyStatusChange(prev, id, newStatus)));
144
+ markLocalMutation();
145
+
146
+ if (!skipUndo && previousStatus && previousStatus !== newStatus) {
147
+ pushActionRef.current({
148
+ type: 'status_change',
149
+ itemId: id,
150
+ itemTitle,
151
+ before: previousStatus,
152
+ after: newStatus,
153
+ timestamp: Date.now(),
135
154
  });
136
- if (!response.ok) {
137
- if (response.status === 404) {
138
- setStatusError('Item no longer exists');
139
- await refreshData();
140
- return { success: false, notFound: true };
141
- } else {
142
- setStatusError('Failed to update status');
143
- await refreshData();
144
- return { success: false };
145
- }
146
- }
155
+ }
147
156
 
148
- if (!skipUndo && previousStatus && previousStatus !== newStatus) {
149
- pushActionRef.current({
150
- type: 'status_change',
151
- itemId: id,
152
- itemTitle,
153
- before: previousStatus,
154
- after: newStatus,
155
- timestamp: Date.now(),
156
- });
157
+ // Fire IPC in background
158
+ try {
159
+ const success = await dataBridge.updateStatus(id, newStatus);
160
+ if (!success) {
161
+ // Rollback with reverse mutation
162
+ startTransition(() => setData(prev => applyStatusChange(prev, id, previousStatus ?? 'backlog')));
163
+ setStatusError('Failed to update status');
164
+ return { success: false };
157
165
  }
158
-
159
- await refreshData();
160
166
  if (newStatus === 'done') {
161
167
  lastInternallyAnimatedIdRef.current = null;
162
168
  }
163
169
  return { success: true };
164
- } catch {
165
- setStatusError('Failed to update status');
170
+ } catch (err) {
171
+ // Rollback with reverse mutation
172
+ startTransition(() => setData(prev => applyStatusChange(prev, id, previousStatus ?? 'backlog')));
173
+ const msg = err instanceof Error ? err.message : String(err);
174
+ console.error('Status update failed:', msg);
175
+ setStatusError(`Failed to update status: ${msg}`);
166
176
  return { success: false };
167
177
  }
168
- }, [refreshData, lastInternallyAnimatedIdRef]);
178
+ }, [lastInternallyAnimatedIdRef]);
169
179
 
170
180
  const { pushAction, handleUndo, handleRedo, canUndo, canRedo } = useKanbanUndo({
171
181
  onStatusChange: handleStatusChange,
172
182
  showToast,
173
183
  });
174
- pushActionRef.current = pushAction;
184
+ useEffect(() => { pushActionRef.current = pushAction; }, [pushAction]);
175
185
 
176
- const handleMessage = useCallback((message: WebSocketMessage) => {
177
- if (message.type === 'db_change') {
178
- // Debounce: clear any pending fetch, coalesce rapid db_change events
179
- if (dbChangeTimerRef.current) {
180
- clearTimeout(dbChangeTimerRef.current);
181
- }
186
+ // Full refetch handler shared between db_change and db_delta fallback
187
+ const doFullRefetch = useCallback(async () => {
188
+ if (isLocalMutationRecent()) return;
182
189
 
183
- dbChangeTimerRef.current = setTimeout(async () => {
184
- // Abort any in-flight fetch from a previous db_change
185
- if (kanbanFetchAbortRef.current) {
186
- kanbanFetchAbortRef.current.abort();
187
- }
188
- const abortController = new AbortController();
189
- kanbanFetchAbortRef.current = abortController;
190
+ const oldStatusMap = buildStatusMap(dataRef.current);
191
+ const internallyAnimatedId = lastInternallyAnimatedIdRef.current;
190
192
 
191
- const oldStatusMap = buildStatusMap(dataRef.current);
192
- const internallyAnimatedId = lastInternallyAnimatedIdRef.current;
193
+ invalidateKanbanCache();
193
194
 
194
- let rawData;
195
- try {
196
- const kanbanResponse = await fetch('/api/kanban', { signal: abortController.signal });
197
- rawData = await kanbanResponse.json();
198
- } catch (e: unknown) {
199
- if (e instanceof DOMException && e.name === 'AbortError') return;
200
- throw e;
201
- }
195
+ let newKanbanData: KanbanData;
196
+ try {
197
+ newKanbanData = await dataBridge.getKanbanData();
198
+ } catch (e: unknown) {
199
+ console.error('Failed to refresh kanban data:', e);
200
+ return;
201
+ }
202
202
 
203
- const newKanbanData: KanbanData = {
204
- inFlight: rawData.inFlight,
205
- backlog: new Map(rawData.backlog),
206
- done: new Map(rawData.done),
207
- };
208
- const newStatusMap = buildStatusMap(newKanbanData);
203
+ if (newKanbanData === dataRef.current) return;
209
204
 
210
- // Detect feature mode changes and inject gate cards into active sessions
211
- const newModeMap = buildModeMap(newKanbanData);
212
- if (previousModeMapRef.current) {
213
- const registry = getRegistry();
214
- for (const [featureId, newMode] of newModeMap) {
215
- const oldMode = previousModeMapRef.current.get(featureId);
216
- if (newMode && newMode !== oldMode) {
217
- const sessionId = String(featureId);
218
- const streamManager = registry.get(sessionId);
219
- if (streamManager) {
220
- streamManager.injectGate(`mode-${newMode}-start`);
221
- }
222
- }
223
- }
224
- }
225
- previousModeMapRef.current = newModeMap;
226
-
227
- // Check if any item transitioned to done externally
228
- let newlyDoneItemId: number | null = null;
229
- for (const [id, newStatus] of newStatusMap) {
230
- const oldStatus = oldStatusMap.get(id);
231
- if (newStatus === 'done' && oldStatus && oldStatus !== 'done') {
232
- newlyDoneItemId = id;
233
- break;
205
+ const newStatusMap = buildStatusMap(newKanbanData);
206
+
207
+ // Detect feature mode changes and inject gate cards into active sessions
208
+ const newModeMap = buildModeMap(newKanbanData);
209
+ if (previousModeMapRef.current) {
210
+ const registry = getRegistry();
211
+ for (const [featureId, newMode] of newModeMap) {
212
+ const oldMode = previousModeMapRef.current.get(featureId);
213
+ if (newMode && newMode !== oldMode) {
214
+ const sessionId = String(featureId);
215
+ const streamManager = registry.get(sessionId);
216
+ if (streamManager) {
217
+ streamManager.injectGate(`mode-${newMode}-start`);
234
218
  }
235
219
  }
220
+ }
221
+ }
222
+ previousModeMapRef.current = newModeMap;
223
+
224
+ // Check if any item transitioned to done externally
225
+ let newlyDoneItemId: number | null = null;
226
+ for (const [id, newStatus] of newStatusMap) {
227
+ const oldStatus = oldStatusMap.get(id);
228
+ if (newStatus === 'done' && oldStatus && oldStatus !== 'done') {
229
+ newlyDoneItemId = id;
230
+ break;
231
+ }
232
+ }
233
+
234
+ if (newlyDoneItemId !== null && externalAnimatingItemId === null) {
235
+ if (internallyAnimatedId === newlyDoneItemId) {
236
+ startTransition(() => setData(newKanbanData));
237
+ syncSessionTitles(newKanbanData);
238
+ return;
239
+ }
240
+ pendingDataRef.current = newKanbanData;
241
+ setExternalAnimatingItemId(newlyDoneItemId);
242
+ syncSessionTitles(newKanbanData);
243
+ } else {
244
+ startTransition(() => setData(newKanbanData));
245
+ syncSessionTitles(newKanbanData);
246
+ }
247
+ }, [syncSessionTitles, externalAnimatingItemId, lastInternallyAnimatedIdRef, pendingDataRef, setExternalAnimatingItemId]);
236
248
 
237
- if (newlyDoneItemId !== null && externalAnimatingItemId === null) {
238
- if (internallyAnimatedId === newlyDoneItemId) {
239
- setData(newKanbanData);
240
- syncSessionTitles(newKanbanData);
241
- return;
249
+ const handleMessage = useCallback((message: WebSocketMessage) => {
250
+ // ---- Delta updates (from SQLite update_hook via Tauri IPC writes) ----
251
+ if (message.type === 'db_delta' && message.table === 'work_items' && message.rowid != null) {
252
+ if (isLocalMutationRecent()) return;
253
+
254
+ // For updates to existing items, try a targeted single-item patch.
255
+ // For inserts/deletes or non-work_items tables, fall back to full refetch.
256
+ if (message.action === 'update') {
257
+ // Debounce rapid deltas (e.g., batch display_order updates)
258
+ if (dbChangeTimerRef.current) clearTimeout(dbChangeTimerRef.current);
259
+ dbChangeTimerRef.current = setTimeout(async () => {
260
+ if (isLocalMutationRecent()) return;
261
+ const patched = await patchKanbanItem(message.rowid!);
262
+ if (patched) {
263
+ // Successfully patched — apply without full refetch
264
+ if (patched !== dataRef.current) {
265
+ startTransition(() => setData(patched));
266
+ syncSessionTitles(patched);
267
+ }
268
+ } else {
269
+ // Patch failed (status changed, item not in cache) — full refetch
270
+ await doFullRefetch();
242
271
  }
243
- pendingDataRef.current = newKanbanData;
244
- setExternalAnimatingItemId(newlyDoneItemId);
245
- syncSessionTitles(newKanbanData);
246
- } else {
247
- setData(newKanbanData);
248
- syncSessionTitles(newKanbanData);
249
- }
272
+ }, 50);
273
+ } else {
274
+ // Insert or delete — full refetch (these are infrequent)
275
+ if (dbChangeTimerRef.current) clearTimeout(dbChangeTimerRef.current);
276
+ dbChangeTimerRef.current = setTimeout(() => {
277
+ if (isLocalMutationRecent()) return;
278
+ doFullRefetch();
279
+ }, 150);
280
+ }
281
+ return;
282
+ }
283
+
284
+ // ---- Full refetch fallback (from file mtime polling — external writes) ----
285
+ if (message.type === 'db_change') {
286
+ if (isLocalMutationRecent()) return;
287
+ if (dbChangeTimerRef.current) clearTimeout(dbChangeTimerRef.current);
288
+ dbChangeTimerRef.current = setTimeout(() => {
289
+ if (isLocalMutationRecent()) return;
290
+ doFullRefetch();
250
291
  }, 150);
251
292
  }
252
- }, [syncSessionTitles, externalAnimatingItemId, lastInternallyAnimatedIdRef, pendingDataRef, setExternalAnimatingItemId]);
293
+ }, [syncSessionTitles, doFullRefetch]);
253
294
 
254
295
  const handleTitleSave = useCallback(async (id: number, newTitle: string) => {
255
- await fetch(`/api/work/${id}/title`, {
256
- method: 'PATCH',
257
- headers: { 'Content-Type': 'application/json' },
258
- body: JSON.stringify({ title: newTitle }),
259
- });
260
- await refreshData();
261
- }, [refreshData]);
296
+ const previousTitle = dataRef.current.itemMap.get(id)?.title ?? '';
297
+
298
+ // Optimistic: update UI immediately, suppress WS refetches during IPC
299
+ startTransition(() => setData(prev => applyTitleChange(prev, id, newTitle)));
300
+ markLocalMutation();
301
+
302
+ try {
303
+ await dataBridge.updateTitle(id, newTitle);
304
+ } catch {
305
+ // Rollback
306
+ startTransition(() => setData(prev => applyTitleChange(prev, id, previousTitle)));
307
+ }
308
+ }, []);
262
309
 
263
310
  const handleReject = useCallback(async (id: number, reason: string) => {
264
311
  setStatusError(null);
@@ -267,79 +314,116 @@ export function RealTimeKanbanWrapper({ initialData, isBlank, projectPath }: Rea
267
314
  const previousStatus = found?.status;
268
315
  const itemTitle = found?.item.title ?? `Item #${id}`;
269
316
 
270
- try {
271
- const response = await fetch(`/api/work/${id}/status`, {
272
- method: 'PATCH',
273
- headers: { 'Content-Type': 'application/json' },
274
- body: JSON.stringify({ status: 'in_progress', rejectionReason: reason }),
317
+ // Optimistic: move to in_progress with rejection info immediately, suppress WS refetches during IPC
318
+ startTransition(() => setData(prev => applyStatusChange(prev, id, 'in_progress', { reason })));
319
+ markLocalMutation();
320
+
321
+ if (previousStatus && previousStatus !== 'in_progress') {
322
+ pushAction({
323
+ type: 'status_change',
324
+ itemId: id,
325
+ itemTitle,
326
+ before: previousStatus,
327
+ after: 'in_progress',
328
+ timestamp: Date.now(),
275
329
  });
276
- if (!response.ok) {
277
- setStatusError('Failed to reject item');
278
- return;
330
+ }
331
+
332
+ showToast(`Rejected: "${itemTitle}" — ${reason}`);
333
+
334
+ // Open chat session for the rejected item so Claude can address the rejection
335
+ const sessionId = String(id);
336
+ const itemType = found?.item.type ?? 'chore';
337
+ openSession(sessionId, itemTitle, itemType, false, null, true);
338
+
339
+ // Inject rejection gate and send reason to Claude
340
+ setTimeout(() => {
341
+ const registry = getRegistry();
342
+ const streamManager = registry.get(sessionId);
343
+ if (streamManager) {
344
+ streamManager.injectGate('rejection', { reason });
279
345
  }
346
+ sendMessage(reason);
347
+ }, 0);
280
348
 
281
- if (previousStatus && previousStatus !== 'in_progress') {
282
- pushAction({
283
- type: 'status_change',
284
- itemId: id,
285
- itemTitle,
286
- before: previousStatus,
287
- after: 'in_progress',
288
- timestamp: Date.now(),
289
- });
349
+ // Fire IPC in background
350
+ try {
351
+ const success = await dataBridge.updateStatus(id, 'in_progress', reason);
352
+ if (!success) {
353
+ // Rollback
354
+ startTransition(() => setData(prev => applyStatusChange(prev, id, previousStatus ?? 'backlog')));
355
+ setStatusError('Failed to reject item');
356
+ return;
290
357
  }
291
358
 
292
- await refreshData();
293
- showToast(`Rejected: "${itemTitle}"${reason}`);
294
-
295
- // Open chat session for the rejected item so Claude can address the rejection
296
- const sessionId = String(id);
297
- const itemType = found?.item.type ?? 'chore';
298
- openSession(sessionId, itemTitle, itemType, false, null, true);
299
-
300
- // Inject rejection gate card on next tick
301
- // (after React state updates from openSession have been committed)
302
- setTimeout(() => {
303
- const registry = getRegistry();
304
- const streamManager = registry.get(sessionId);
305
- if (streamManager) {
306
- streamManager.injectGate('rejection', { reason });
359
+ // Load existing conversation + persisted rejection gate from DB into stream manager.
360
+ // Fire-and-forgetdon't block the rejection flow on content loading.
361
+ void (async () => {
362
+ try {
363
+ const registry = getRegistry();
364
+ const mgr = registry.get(sessionId);
365
+ if (mgr && mgr.status !== 'streaming' && mgr.messages.length === 0) {
366
+ const content = await invoke<string>('db_get_session_content', { id: parseInt(sessionId, 10) });
367
+ if (content) {
368
+ try {
369
+ const parsed = JSON.parse(content);
370
+ if (Array.isArray(parsed) && parsed.length > 0) {
371
+ mgr.setMessages(parsed);
372
+ }
373
+ } catch {
374
+ // Content wasn't JSON array — ignore
375
+ }
376
+ }
377
+ }
378
+ } catch {
379
+ // Content loading failed — gate was still persisted to DB, will show on next refresh
307
380
  }
308
- }, 0);
309
- } catch {
310
- setStatusError('Failed to reject item');
381
+ })();
382
+ } catch (err) {
383
+ // Rollback
384
+ startTransition(() => setData(prev => applyStatusChange(prev, id, previousStatus ?? 'backlog')));
385
+ const msg = err instanceof Error ? err.message : String(err);
386
+ console.error('Reject failed:', msg);
387
+ setStatusError(`Failed to reject item: ${msg}`);
311
388
  }
312
- }, [refreshData, pushAction, showToast, openSession]);
389
+ }, [pushAction, showToast, openSession, sendMessage]);
313
390
 
314
391
  const handleOrderChange = useCallback(async (id: number, newOrder: number) => {
392
+ const item = dataRef.current.itemMap.get(id);
393
+ // Use effective display_order for rollback — null maps to id*10 in sort
394
+ // comparators, so use that same value to preserve original position on failure.
395
+ const previousOrder = item?.display_order ?? (item ? item.id * 10 : 0);
396
+
397
+ // Optimistic: update order immediately, suppress WS refetches during IPC
398
+ startTransition(() => setData(prev => applyOrderChange(prev, id, newOrder)));
399
+ markLocalMutation();
400
+
315
401
  try {
316
- const response = await fetch(`/api/work/${id}/order`, {
317
- method: 'PATCH',
318
- headers: { 'Content-Type': 'application/json' },
319
- body: JSON.stringify({ display_order: newOrder }),
320
- });
321
- if (!response.ok) {
322
- throw new Error('Failed to update order');
323
- }
324
- await refreshData();
402
+ await dataBridge.updateDisplayOrders([[id, newOrder]]);
325
403
  } catch (error) {
404
+ // Rollback
405
+ startTransition(() => setData(prev => applyOrderChange(prev, id, previousOrder)));
326
406
  const errorMessage = error instanceof Error ? error.message : 'Failed to reorder item';
327
407
  showToast(errorMessage, 'error');
328
408
  }
329
- }, [refreshData, showToast]);
409
+ }, [showToast]);
330
410
 
331
411
  const handleEpicAssign = useCallback(async (id: number, epicId: number | null) => {
412
+ const previousEpicId = dataRef.current.itemMap.get(id)?.parent_id ?? null;
413
+
414
+ // Optimistic: move between epic groups immediately, suppress WS refetches during IPC
415
+ startTransition(() => setData(prev => applyEpicAssign(prev, id, epicId)));
416
+ markLocalMutation();
417
+
332
418
  try {
333
- await fetch(`/api/work/${id}/epic`, {
334
- method: 'PATCH',
335
- headers: { 'Content-Type': 'application/json' },
336
- body: JSON.stringify({ epic_id: epicId }),
337
- });
338
- await refreshData();
419
+ await invoke('db_assign_epic', { id, epicId });
420
+ invalidateKanbanCache();
339
421
  } catch {
422
+ // Rollback
423
+ startTransition(() => setData(prev => applyEpicAssign(prev, id, previousEpicId)));
340
424
  showToast('Failed to assign epic', 'error');
341
425
  }
342
- }, [refreshData, showToast]);
426
+ }, [showToast]);
343
427
 
344
428
  const handleDragError = useCallback((message: string) => {
345
429
  showToast(message, 'error');
@@ -377,8 +461,9 @@ export function RealTimeKanbanWrapper({ initialData, isBlank, projectPath }: Rea
377
461
  if (streamManager) {
378
462
  streamManager.injectGate('rejection', { reason });
379
463
  }
464
+ sendMessage(reason);
380
465
  }, 0);
381
- }, [openSession]);
466
+ }, [openSession, sendMessage]);
382
467
 
383
468
  const handleAddToBacklog = useCallback(async () => {
384
469
  await createAddToBacklogSession();
@@ -421,7 +506,7 @@ export function RealTimeKanbanWrapper({ initialData, isBlank, projectPath }: Rea
421
506
  if (!rejectedId || !reason) return;
422
507
 
423
508
  // Clean the URL immediately
424
- router.replace('/', { scroll: false });
509
+ navigate('/', { replace: true });
425
510
 
426
511
  const found = findItemById(dataRef.current, parseInt(rejectedId, 10));
427
512
  const itemTitle = found?.item.title ?? `Item #${rejectedId}`;
@@ -435,11 +520,12 @@ export function RealTimeKanbanWrapper({ initialData, isBlank, projectPath }: Rea
435
520
  if (streamManager) {
436
521
  streamManager.injectGate('rejection', { reason });
437
522
  }
523
+ sendMessage(reason);
438
524
  }, 0);
439
- }, [searchParams, router, openSession]);
525
+ }, [searchParams, navigate, openSession, sendMessage]);
440
526
 
441
527
  return (
442
- <div className="h-full flex flex-col">
528
+ <div className="flex flex-col min-h-0">
443
529
  {statusError && (
444
530
  <div
445
531
  className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg flex items-center justify-between flex-shrink-0"
@@ -455,39 +541,38 @@ export function RealTimeKanbanWrapper({ initialData, isBlank, projectPath }: Rea
455
541
  </button>
456
542
  </div>
457
543
  )}
458
- <div className="flex-1 min-h-0">
459
- {showOnboarding ? (
460
- <OnboardingWelcome
461
- onboardingItems={onboardingItems}
462
- onStartChore={handleStartOnboardingChore}
463
- />
464
- ) : (
465
- <KanbanBoard
466
- inFlight={data.inFlight}
467
- backlog={data.backlog}
468
- done={data.done}
469
- onTitleSave={handleTitleSave}
470
- onStatusChange={handleStatusChange}
471
- onReject={handleReject}
472
- onRestart={handleRestart}
473
- onOrderChange={handleOrderChange}
474
- onEpicAssign={handleEpicAssign}
475
- onTriggerClaude={handleTriggerClaude}
476
- onOpenSession={handleOpenSession}
477
- onCloseSession={handleCloseSession}
478
- activeSessions={sessions}
479
- onUndo={handleUndo}
480
- onRedo={handleRedo}
481
- canUndo={canUndo}
482
- canRedo={canRedo}
483
- onError={handleDragError}
484
- onAddToBacklog={handleAddToBacklog}
485
- usageAllowed={usageAllowed}
486
- externalAnimatingItemId={externalAnimatingItemId}
487
- onExternalAnimationComplete={handleExternalAnimationComplete}
488
- />
489
- )}
490
- </div>
544
+ {showOnboarding ? (
545
+ <OnboardingWelcome
546
+ onboardingItems={onboardingItems}
547
+ onStartChore={handleStartOnboardingChore}
548
+ />
549
+ ) : (
550
+ <KanbanBoard
551
+ inFlight={data.inFlight}
552
+ backlog={data.backlog}
553
+ done={data.done}
554
+ itemStatusMap={data.statusMap}
555
+ onTitleSave={handleTitleSave}
556
+ onStatusChange={handleStatusChange}
557
+ onReject={handleReject}
558
+ onRestart={handleRestart}
559
+ onOrderChange={handleOrderChange}
560
+ onEpicAssign={handleEpicAssign}
561
+ onTriggerClaude={handleTriggerClaude}
562
+ onOpenSession={handleOpenSession}
563
+ onCloseSession={handleCloseSession}
564
+ activeSessionIds={activeSessionIds}
565
+ onUndo={handleUndo}
566
+ onRedo={handleRedo}
567
+ canUndo={canUndo}
568
+ canRedo={canRedo}
569
+ onError={handleDragError}
570
+ onAddToBacklog={handleAddToBacklog}
571
+ usageAllowed={usageAllowed}
572
+ externalAnimatingItemId={externalAnimatingItemId}
573
+ onExternalAnimationComplete={handleExternalAnimationComplete}
574
+ />
575
+ )}
491
576
  </div>
492
577
  );
493
578
  }