jettypod 4.4.118 → 4.4.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/.env +4 -3
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. package/docs/bdd-guidance.md +0 -390
@@ -1,22 +1,20 @@
1
- 'use client';
2
-
3
- import { useState, useCallback, useEffect, useRef } from 'react';
1
+ import { useState, useCallback, useEffect, useRef, useMemo, startTransition } from 'react';
2
+ import { useSearchParams, useNavigate } from 'react-router-dom';
4
3
  import { KanbanBoard } from './KanbanBoard';
4
+ import { OnboardingWelcome } from './OnboardingWelcome';
5
5
  import { useToast } from './Toast';
6
6
  import { useWebSocket, type WebSocketMessage } from '../hooks/useWebSocket';
7
- import { useClaudeSession } from '../contexts/ClaudeSessionContext';
7
+ import { useSessionState, useSessionActions, useSessionPersistence } from '../contexts/ClaudeSessionContext';
8
8
  import { useConnectionStatus } from '../contexts/ConnectionStatusContext';
9
9
  import { useUsage } from '../contexts/UsageContext';
10
- import { UpgradeBanner } from './UpgradeBanner';
11
- import type { InFlightItem, KanbanGroup, WorkItem } from '@/lib/db';
12
- import { UndoStack, type UndoAction } from '@/lib/undoStack';
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
-
15
- interface KanbanData {
16
- inFlight: InFlightItem[];
17
- backlog: Map<string, KanbanGroup>;
18
- done: Map<string, KanbanGroup>;
19
- }
14
+ import { getWebSocketUrl } from '@/lib/utils';
15
+ import { type KanbanData, findItemById, getOnboardingItems, buildStatusMap, buildModeMap, applyStatusChange, applyTitleChange, applyOrderChange, applyEpicAssign } from '@/lib/kanban-utils';
16
+ import { useKanbanUndo } from '../hooks/useKanbanUndo';
17
+ import { useKanbanAnimation } from '../hooks/useKanbanAnimation';
20
18
 
21
19
  interface RealTimeKanbanWrapperProps {
22
20
  initialData: {
@@ -28,133 +26,81 @@ interface RealTimeKanbanWrapperProps {
28
26
  projectPath?: string;
29
27
  }
30
28
 
31
- // Helper to find item by ID in the kanban data
32
- function findItemById(data: KanbanData, id: number): { item: InFlightItem; status: string } | null {
33
- // Check in-flight (status: in_progress)
34
- const inFlightItem = data.inFlight.find(item => item.id === id);
35
- if (inFlightItem) return { item: inFlightItem, status: 'in_progress' };
36
-
37
- // Check backlog groups
38
- for (const group of data.backlog.values()) {
39
- const backlogItem = group.items.find(item => item.id === id);
40
- if (backlogItem) return { item: backlogItem as InFlightItem, status: 'backlog' };
41
- }
42
-
43
- // Check done groups
44
- for (const group of data.done.values()) {
45
- const doneItem = group.items.find(item => item.id === id);
46
- if (doneItem) return { item: doneItem as InFlightItem, status: 'done' };
47
- }
48
-
49
- return null;
50
- }
51
-
52
29
  // Component uses context providers from AppShell (ToastProvider, ClaudeSessionProvider)
53
30
  // DO NOT wrap with duplicate providers - it creates isolated context state
54
31
  export function RealTimeKanbanWrapper({ initialData, isBlank, projectPath }: RealTimeKanbanWrapperProps) {
32
+ const [searchParams] = useSearchParams();
33
+ const navigate = useNavigate();
55
34
  const { showToast } = useToast();
56
35
  const { allowed: usageAllowed } = useUsage();
57
- const [data, setData] = useState<KanbanData>(() => ({
58
- inFlight: initialData.inFlight,
59
- backlog: new Map(initialData.backlog),
60
- done: new Map(initialData.done),
61
- }));
62
- const [statusError, setStatusError] = useState<string | null>(null);
63
-
64
- // Use ClaudeSessionContext for session state and actions
65
- // Note: ClaudePanel is rendered by AppShell, which handles most session UI
66
- // This component only needs session state for KanbanBoard integration
67
- const {
68
- setClaudePanelOpen,
69
- sessions,
70
- setSessions,
71
- standaloneSessions,
72
- openSession,
73
- switchSession,
74
- createAddToBacklogSession,
75
- createWelcomeSession,
76
- } = useClaudeSession();
77
-
78
- // Auto-open welcome session for blank projects (once per project lifetime)
79
- const welcomeTriggeredRef = useRef(false);
80
- useEffect(() => {
81
- if (isBlank && !welcomeTriggeredRef.current) {
82
- try {
83
- const welcomeKey = `jettypod-welcome-shown-${projectPath || 'default'}`;
84
- if (localStorage.getItem(welcomeKey)) return;
85
- welcomeTriggeredRef.current = true;
86
- localStorage.setItem(welcomeKey, 'true');
87
- } catch {
88
- // localStorage unavailable (private browsing, full storage) — proceed anyway
89
- welcomeTriggeredRef.current = true;
90
- }
91
- createWelcomeSession().catch((err) => {
92
- console.error('[RealTimeKanbanWrapper] Welcome session failed:', err);
93
- });
94
- }
95
- }, [isBlank, createWelcomeSession]);
96
-
97
- // Undo/redo stack - created once per component instance
98
- const [undoStack] = useState(() => new UndoStack());
99
- const [undoRedoVersion, setUndoRedoVersion] = useState(0); // Force re-render on stack changes
100
-
101
- // External completion animation state
102
- const [externalAnimatingItemId, setExternalAnimatingItemId] = useState<number | null>(null);
103
- const pendingDataRef = useRef<KanbanData | null>(null);
104
-
105
- // Track items animated internally so WebSocket handler skips them
106
- const lastInternallyAnimatedIdRef = useRef<number | null>(null);
107
-
108
- // Build a status map from kanban data (item id -> status string)
109
- const buildStatusMap = useCallback((kanbanData: KanbanData): Map<number, string> => {
110
- const map = new Map<number, string>();
111
- for (const item of kanbanData.inFlight) {
112
- map.set(item.id, item.status);
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);
113
46
  }
114
- for (const group of kanbanData.backlog.values()) {
47
+ for (const group of backlog.values()) {
115
48
  for (const item of group.items) {
116
- map.set(item.id, item.status);
49
+ itemMap.set(item.id, item as InFlightItem);
50
+ statusMap.set(item.id, item.status);
117
51
  }
118
52
  }
119
- for (const group of kanbanData.done.values()) {
53
+ for (const group of done.values()) {
120
54
  for (const item of group.items) {
121
- map.set(item.id, item.status);
55
+ itemMap.set(item.id, item as InFlightItem);
56
+ statusMap.set(item.id, item.status);
122
57
  }
123
58
  }
124
- return map;
125
- }, []);
59
+ return { inFlight, backlog, done, itemMap, statusMap };
60
+ });
61
+ const [statusError, setStatusError] = useState<string | null>(null);
126
62
 
127
- // Build a mode map from kanban data (feature id -> mode) for detecting mode transitions
128
- const buildModeMap = useCallback((kanbanData: KanbanData): Map<number, string | null> => {
129
- const map = new Map<number, string | null>();
130
- const collectFeatures = (items: WorkItem[]) => {
131
- for (const item of items) {
132
- if (item.type === 'feature') {
133
- map.set(item.id, item.mode);
134
- }
135
- }
136
- };
137
- collectFeatures(kanbanData.inFlight);
138
- for (const group of kanbanData.backlog.values()) collectFeatures(group.items);
139
- for (const group of kanbanData.done.values()) collectFeatures(group.items);
140
- return map;
141
- }, []);
63
+ // Ref to latest data lets callbacks read current data without closing over it,
64
+ // keeping function references stable across data changes (preserves memo on cards)
65
+ const dataRef = useRef(data);
66
+ useEffect(() => { dataRef.current = data; }, [data]);
67
+
68
+ // Use ClaudeSessionContext for session state and actions
69
+ const { sessions, standaloneSessions } = useSessionState();
70
+ const { setClaudePanelOpen, openSession, switchSession, closeSession, createAddToBacklogSession, sendMessage } = useSessionActions();
71
+ const { setSessions } = useSessionPersistence();
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
+
77
+ // Onboarding state: show OnboardingWelcome instead of KanbanBoard for blank projects
78
+ const [showOnboarding, setShowOnboarding] = useState(!!isBlank);
79
+ const onboardingItems = useMemo(() => getOnboardingItems(data), [data]);
80
+
81
+ // Animation state
82
+ const {
83
+ externalAnimatingItemId,
84
+ setExternalAnimatingItemId,
85
+ pendingDataRef,
86
+ lastInternallyAnimatedIdRef,
87
+ handleExternalAnimationComplete: rawHandleExternalAnimationComplete,
88
+ } = useKanbanAnimation();
89
+
90
+ // Wrap animation complete to apply pending data
91
+ const handleExternalAnimationComplete = useCallback(() => {
92
+ const pending = rawHandleExternalAnimationComplete();
93
+ if (pending) {
94
+ startTransition(() => setData(pending));
95
+ }
96
+ }, [rawHandleExternalAnimationComplete]);
142
97
 
143
98
  // Track previous mode per feature to detect transitions
144
- // Initialized lazily on first use (ref starts null, populated on first db_change)
145
99
  const previousModeMapRef = useRef<Map<number, string | null> | null>(null);
146
100
 
147
- const refreshData = useCallback(async (): Promise<KanbanData> => {
148
- const kanbanResponse = await fetch('/api/kanban');
149
- const newData = await kanbanResponse.json();
150
- const kanbanData: KanbanData = {
151
- inFlight: newData.inFlight,
152
- backlog: new Map(newData.backlog),
153
- done: new Map(newData.done),
154
- };
155
- setData(kanbanData);
156
- return kanbanData;
157
- }, []);
101
+ // Debounce + abort for WS-triggered kanban fetches
102
+ const dbChangeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
103
+ const kanbanFetchAbortRef = useRef<AbortController | null>(null);
158
104
 
159
105
  // Sync session titles with work item titles from kanban data
160
106
  const syncSessionTitles = useCallback((kanbanData: KanbanData) => {
@@ -163,10 +109,8 @@ export function RealTimeKanbanWrapper({ initialData, isBlank, projectPath }: Rea
163
109
  let hasChanges = false;
164
110
 
165
111
  for (const [sessionId, session] of updated.entries()) {
166
- // Skip standalone sessions (they're not tied to work items)
167
112
  if (standaloneSessions.some(s => s.id === sessionId)) continue;
168
113
 
169
- // Find the work item in kanban data
170
114
  const workItemId = parseInt(sessionId, 10);
171
115
  if (isNaN(workItemId)) continue;
172
116
 
@@ -181,322 +125,370 @@ export function RealTimeKanbanWrapper({ initialData, isBlank, projectPath }: Rea
181
125
  });
182
126
  }, [standaloneSessions]);
183
127
 
184
- const handleMessage = useCallback(async (message: WebSocketMessage) => {
185
- if (message.type === 'db_change') {
186
- // Snapshot current statuses before fetching new data
187
- const oldStatusMap = buildStatusMap(data);
188
-
189
- // Snapshot the internally-animated ID BEFORE the async fetch.
190
- // handleStatusChange may clear this ref while our fetch is in flight,
191
- // so we must capture it synchronously when the message arrives.
192
- const internallyAnimatedId = lastInternallyAnimatedIdRef.current;
193
-
194
- // Fetch new data without applying it yet
195
- const kanbanResponse = await fetch('/api/kanban');
196
- const rawData = await kanbanResponse.json();
197
- const newKanbanData: KanbanData = {
198
- inFlight: rawData.inFlight,
199
- backlog: new Map(rawData.backlog),
200
- done: new Map(rawData.done),
201
- };
202
- const newStatusMap = buildStatusMap(newKanbanData);
203
-
204
- // Detect feature mode changes and inject gate cards into active sessions
205
- const newModeMap = buildModeMap(newKanbanData);
206
- if (previousModeMapRef.current) {
207
- const registry = getRegistry();
208
- for (const [featureId, newMode] of newModeMap) {
209
- const oldMode = previousModeMapRef.current.get(featureId);
210
- if (newMode && newMode !== oldMode) {
211
- // Feature mode changed — inject gate into its session if active
212
- const sessionId = String(featureId);
213
- const streamManager = registry.get(sessionId);
214
- if (streamManager) {
215
- streamManager.injectGate(`mode-${newMode}-start`);
216
- }
217
- }
218
- }
219
- }
220
- previousModeMapRef.current = newModeMap;
221
-
222
- // Check if any item transitioned to done externally
223
- let newlyDoneItemId: number | null = null;
224
- for (const [id, newStatus] of newStatusMap) {
225
- const oldStatus = oldStatusMap.get(id);
226
- if (newStatus === 'done' && oldStatus && oldStatus !== 'done') {
227
- newlyDoneItemId = id;
228
- break; // Animate one at a time
229
- }
230
- }
231
-
232
- if (newlyDoneItemId !== null && externalAnimatingItemId === null) {
233
- // Skip if this item was just animated internally (UI-driven completion).
234
- // We check the snapshot taken before the async fetch, not the current ref,
235
- // because handleStatusChange clears the ref after refreshData completes —
236
- // which can happen while our fetch is still in flight.
237
- if (internallyAnimatedId === newlyDoneItemId) {
238
- setData(newKanbanData);
239
- syncSessionTitles(newKanbanData);
240
- return;
241
- }
242
- // Hold new data, play animation on the old data first
243
- pendingDataRef.current = newKanbanData;
244
- setExternalAnimatingItemId(newlyDoneItemId);
245
- syncSessionTitles(newKanbanData);
246
- } else {
247
- // No external completion to animate - apply data immediately
248
- setData(newKanbanData);
249
- syncSessionTitles(newKanbanData);
250
- }
251
- }
252
- }, [data, buildStatusMap, buildModeMap, syncSessionTitles, externalAnimatingItemId]);
253
-
254
- 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]);
128
+ // Undo/redo use ref to break circular dependency with handleStatusChange
129
+ const pushActionRef = useRef<ReturnType<typeof useKanbanUndo>['pushAction']>(() => {});
262
130
 
263
131
  const handleStatusChange = useCallback(async (id: number, newStatus: string, skipUndo = false): Promise<{ success: boolean; notFound?: boolean }> => {
264
132
  setStatusError(null);
265
133
 
266
- // Find the item to get its current status and title before the change
267
- const found = findItemById(data, id);
134
+ const found = findItemById(dataRef.current, id);
268
135
  const previousStatus = found?.status;
269
136
  const itemTitle = found?.item.title ?? `Item #${id}`;
270
137
 
271
- try {
272
- // Track internally-animated items so WebSocket handler skips the duplicate animation
273
- if (newStatus === 'done') {
274
- lastInternallyAnimatedIdRef.current = id;
275
- }
138
+ if (newStatus === 'done') {
139
+ lastInternallyAnimatedIdRef.current = id;
140
+ }
276
141
 
277
- const response = await fetch(`/api/work/${id}/status`, {
278
- method: 'PATCH',
279
- headers: { 'Content-Type': 'application/json' },
280
- 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(),
281
154
  });
282
- if (!response.ok) {
283
- if (response.status === 404) {
284
- setStatusError('Item no longer exists');
285
- await refreshData();
286
- return { success: false, notFound: true };
287
- } else {
288
- setStatusError('Failed to update status');
289
- await refreshData();
290
- return { success: false };
291
- }
292
- }
155
+ }
293
156
 
294
- // Push to undo stack if this is a user-initiated change (not undo/redo)
295
- if (!skipUndo && previousStatus && previousStatus !== newStatus) {
296
- undoStack.push({
297
- type: 'status_change',
298
- itemId: id,
299
- itemTitle,
300
- before: previousStatus,
301
- after: newStatus,
302
- });
303
- setUndoRedoVersion(v => v + 1); // Trigger re-render
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 };
304
165
  }
305
-
306
- await refreshData();
307
- // Clear the internal animation guard after data is refreshed.
308
- // This ensures all WebSocket db_change messages from this write are ignored.
309
166
  if (newStatus === 'done') {
310
167
  lastInternallyAnimatedIdRef.current = null;
311
168
  }
312
169
  return { success: true };
313
- } catch {
314
- 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}`);
315
176
  return { success: false };
316
177
  }
317
- }, [refreshData, data, undoStack]);
178
+ }, [lastInternallyAnimatedIdRef]);
318
179
 
319
- const handleReject = useCallback(async (id: number, reason: string) => {
320
- setStatusError(null);
180
+ const { pushAction, handleUndo, handleRedo, canUndo, canRedo } = useKanbanUndo({
181
+ onStatusChange: handleStatusChange,
182
+ showToast,
183
+ });
184
+ useEffect(() => { pushActionRef.current = pushAction; }, [pushAction]);
321
185
 
322
- const found = findItemById(data, id);
323
- const previousStatus = found?.status;
324
- const itemTitle = found?.item.title ?? `Item #${id}`;
186
+ // Full refetch handler — shared between db_change and db_delta fallback
187
+ const doFullRefetch = useCallback(async () => {
188
+ if (isLocalMutationRecent()) return;
189
+
190
+ const oldStatusMap = buildStatusMap(dataRef.current);
191
+ const internallyAnimatedId = lastInternallyAnimatedIdRef.current;
192
+
193
+ invalidateKanbanCache();
325
194
 
195
+ let newKanbanData: KanbanData;
326
196
  try {
327
- const response = await fetch(`/api/work/${id}/status`, {
328
- method: 'PATCH',
329
- headers: { 'Content-Type': 'application/json' },
330
- body: JSON.stringify({ status: 'in_progress', rejectionReason: reason }),
331
- });
332
- if (!response.ok) {
333
- setStatusError('Failed to reject item');
334
- return;
335
- }
197
+ newKanbanData = await dataBridge.getKanbanData();
198
+ } catch (e: unknown) {
199
+ console.error('Failed to refresh kanban data:', e);
200
+ return;
201
+ }
336
202
 
337
- // Push to undo stack
338
- if (previousStatus && previousStatus !== 'in_progress') {
339
- undoStack.push({
340
- type: 'status_change',
341
- itemId: id,
342
- itemTitle,
343
- before: previousStatus,
344
- after: 'in_progress',
345
- });
346
- setUndoRedoVersion(v => v + 1);
203
+ if (newKanbanData === dataRef.current) return;
204
+
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`);
218
+ }
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;
347
231
  }
348
-
349
- await refreshData();
350
- showToast(`Rejected: "${itemTitle}" — ${reason}`);
351
- } catch {
352
- setStatusError('Failed to reject item');
353
232
  }
354
- }, [refreshData, data, undoStack, showToast]);
355
233
 
356
- const handleOrderChange = useCallback(async (id: number, newOrder: number) => {
357
- try {
358
- const response = await fetch(`/api/work/${id}/order`, {
359
- method: 'PATCH',
360
- headers: { 'Content-Type': 'application/json' },
361
- body: JSON.stringify({ display_order: newOrder }),
362
- });
363
- if (!response.ok) {
364
- throw new Error('Failed to update order');
234
+ if (newlyDoneItemId !== null && externalAnimatingItemId === null) {
235
+ if (internallyAnimatedId === newlyDoneItemId) {
236
+ startTransition(() => setData(newKanbanData));
237
+ syncSessionTitles(newKanbanData);
238
+ return;
365
239
  }
366
- await refreshData();
367
- } catch (error) {
368
- const errorMessage = error instanceof Error ? error.message : 'Failed to reorder item';
369
- showToast(errorMessage, 'error');
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]);
248
+
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();
271
+ }
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;
370
282
  }
371
- }, [refreshData, showToast]);
372
283
 
373
- const handleEpicAssign = useCallback(async (id: number, epicId: number | null) => {
374
- try {
375
- await fetch(`/api/work/${id}/epic`, {
376
- method: 'PATCH',
377
- headers: { 'Content-Type': 'application/json' },
378
- body: JSON.stringify({ epic_id: epicId }),
379
- });
380
- await refreshData();
381
- } catch {
382
- showToast('Failed to assign epic', 'error');
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();
291
+ }, 150);
383
292
  }
384
- }, [refreshData, showToast]);
293
+ }, [syncSessionTitles, doFullRefetch]);
385
294
 
386
- // Error handler for drag-and-drop operations
387
- const handleDragError = useCallback((message: string) => {
388
- showToast(message, 'error');
389
- }, [showToast]);
295
+ const handleTitleSave = useCallback(async (id: number, newTitle: string) => {
296
+ const previousTitle = dataRef.current.itemMap.get(id)?.title ?? '';
390
297
 
391
- // Helper to format status for display
392
- const formatStatus = (status: string): string => {
393
- switch (status) {
394
- case 'in_progress': return 'In Flight';
395
- case 'backlog': return 'Backlog';
396
- case 'done': return 'Done';
397
- default: return status;
398
- }
399
- };
298
+ // Optimistic: update UI immediately, suppress WS refetches during IPC
299
+ startTransition(() => setData(prev => applyTitleChange(prev, id, newTitle)));
300
+ markLocalMutation();
400
301
 
401
- // Undo the most recent status change
402
- const handleUndo = useCallback(async (): Promise<UndoAction | null> => {
403
- const action = undoStack.undo();
404
- if (!action) return null;
405
-
406
- // Revert the status change (skipUndo=true to prevent adding to undo stack)
407
- const result = await handleStatusChange(action.itemId, action.before, true);
408
- setUndoRedoVersion(v => v + 1);
409
-
410
- if (result.notFound) {
411
- // Item was deleted - show error toast (statusError already set by handleStatusChange)
412
- // The action is already removed from undo stack and won't be in redo stack
413
- // because we didn't push it back - just leave it removed
414
- showToast(`Item no longer exists: "${action.itemTitle}" was deleted`, 'error');
415
- return null;
302
+ try {
303
+ await dataBridge.updateTitle(id, newTitle);
304
+ } catch {
305
+ // Rollback
306
+ startTransition(() => setData(prev => applyTitleChange(prev, id, previousTitle)));
416
307
  }
308
+ }, []);
309
+
310
+ const handleReject = useCallback(async (id: number, reason: string) => {
311
+ setStatusError(null);
417
312
 
418
- if (!result.success) {
419
- // Other error - push action back to undo stack so user can retry
420
- undoStack.push({
421
- type: action.type,
422
- itemId: action.itemId,
423
- itemTitle: action.itemTitle,
424
- before: action.after, // Swap because we want to retry undoing
425
- after: action.before,
313
+ const found = findItemById(dataRef.current, id);
314
+ const previousStatus = found?.status;
315
+ const itemTitle = found?.item.title ?? `Item #${id}`;
316
+
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(),
426
329
  });
427
- return null;
428
330
  }
429
331
 
430
- // Show success toast notification
431
- showToast(`Undone: "${action.itemTitle}" moved back to ${formatStatus(action.before)}`);
332
+ showToast(`Rejected: "${itemTitle}" ${reason}`);
432
333
 
433
- return action;
434
- }, [undoStack, handleStatusChange, showToast]);
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);
435
338
 
436
- // Redo the most recently undone status change
437
- const handleRedo = useCallback(async (): Promise<UndoAction | null> => {
438
- const action = undoStack.redo();
439
- if (!action) return null;
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 });
345
+ }
346
+ sendMessage(reason);
347
+ }, 0);
440
348
 
441
- // Re-apply the status change (skipUndo=true to prevent adding to undo stack)
442
- const result = await handleStatusChange(action.itemId, action.after, true);
443
- setUndoRedoVersion(v => v + 1);
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;
357
+ }
444
358
 
445
- if (result.notFound) {
446
- // Item was deleted - show error toast
447
- showToast(`Item no longer exists: "${action.itemTitle}" was deleted`, 'error');
448
- return null;
359
+ // Load existing conversation + persisted rejection gate from DB into stream manager.
360
+ // Fire-and-forget don'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
380
+ }
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}`);
449
388
  }
389
+ }, [pushAction, showToast, openSession, sendMessage]);
450
390
 
451
- if (!result.success) {
452
- // Other error - push action back to redo stack so user can retry
453
- // Note: This is a simplification; in production you might want more sophisticated retry logic
454
- return null;
455
- }
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);
456
396
 
457
- // Show success toast notification
458
- showToast(`Redone: "${action.itemTitle}" moved to ${formatStatus(action.after)}`);
397
+ // Optimistic: update order immediately, suppress WS refetches during IPC
398
+ startTransition(() => setData(prev => applyOrderChange(prev, id, newOrder)));
399
+ markLocalMutation();
400
+
401
+ try {
402
+ await dataBridge.updateDisplayOrders([[id, newOrder]]);
403
+ } catch (error) {
404
+ // Rollback
405
+ startTransition(() => setData(prev => applyOrderChange(prev, id, previousOrder)));
406
+ const errorMessage = error instanceof Error ? error.message : 'Failed to reorder item';
407
+ showToast(errorMessage, 'error');
408
+ }
409
+ }, [showToast]);
459
410
 
460
- return action;
461
- }, [undoStack, handleStatusChange, showToast]);
411
+ const handleEpicAssign = useCallback(async (id: number, epicId: number | null) => {
412
+ const previousEpicId = dataRef.current.itemMap.get(id)?.parent_id ?? null;
462
413
 
463
- // Expose undo stack state
464
- const canUndo = undoStack.canUndo();
465
- const canRedo = undoStack.canRedo();
414
+ // Optimistic: move between epic groups immediately, suppress WS refetches during IPC
415
+ startTransition(() => setData(prev => applyEpicAssign(prev, id, epicId)));
416
+ markLocalMutation();
466
417
 
467
- // Called when external completion animation finishes - apply the pending data
468
- const handleExternalAnimationComplete = useCallback(() => {
469
- setExternalAnimatingItemId(null);
470
- if (pendingDataRef.current) {
471
- setData(pendingDataRef.current);
472
- pendingDataRef.current = null;
418
+ try {
419
+ await invoke('db_assign_epic', { id, epicId });
420
+ invalidateKanbanCache();
421
+ } catch {
422
+ // Rollback
423
+ startTransition(() => setData(prev => applyEpicAssign(prev, id, previousEpicId)));
424
+ showToast('Failed to assign epic', 'error');
473
425
  }
474
- }, []);
426
+ }, [showToast]);
475
427
 
476
- // Claude panel handlers - multi-session support
428
+ const handleDragError = useCallback((message: string) => {
429
+ showToast(message, 'error');
430
+ }, [showToast]);
431
+
432
+ // Claude panel handlers
477
433
  const handleTriggerClaude = useCallback((id: number, title: string, type: string, conversational?: boolean, description?: string | null) => {
478
- // Use context's openSession - handles existing session check, creation, and streaming
479
434
  openSession(String(id), title, type, conversational, description);
480
435
  }, [openSession]);
481
436
 
482
- // Open an existing session (for card icon click)
483
437
  const handleOpenSession = useCallback(async (id: string) => {
484
- // Use context's switchSession to load content, then open panel
485
438
  await switchSession(id);
486
439
  setClaudePanelOpen(true);
487
440
  }, [switchSession, setClaudePanelOpen]);
488
441
 
489
- // Add to backlog handler - uses context's specialized method
442
+ const handleCloseSession = useCallback((id: string) => {
443
+ closeSession(id);
444
+ }, [closeSession]);
445
+
446
+ const handleRestart = useCallback((id: number) => {
447
+ const found = findItemById(dataRef.current, id);
448
+ if (!found) return;
449
+
450
+ const sessionId = String(id);
451
+ const itemTitle = found.item.title ?? `Item #${id}`;
452
+ const itemType = found.item.type ?? 'chore';
453
+ const reason = found.item.rejection_reason;
454
+ if (!reason) return;
455
+
456
+ openSession(sessionId, itemTitle, itemType);
457
+
458
+ setTimeout(() => {
459
+ const registry = getRegistry();
460
+ const streamManager = registry.get(sessionId);
461
+ if (streamManager) {
462
+ streamManager.injectGate('rejection', { reason });
463
+ }
464
+ sendMessage(reason);
465
+ }, 0);
466
+ }, [openSession, sendMessage]);
467
+
490
468
  const handleAddToBacklog = useCallback(async () => {
491
469
  await createAddToBacklogSession();
492
470
  }, [createAddToBacklogSession]);
493
471
 
494
- const wsUrl = typeof window !== 'undefined'
495
- ? `ws://${window.location.hostname}:47808`
496
- : 'ws://localhost:47808';
472
+ // Onboarding: when user clicks start on first chore, transition to normal view
473
+ const handleStartOnboardingChore = useCallback(async (id: number, title: string) => {
474
+ await handleStatusChange(id, 'in_progress');
475
+ const found = findItemById(dataRef.current, id);
476
+ const type = found?.item.type ?? 'chore';
477
+ const description = found?.item.description ?? null;
478
+ openSession(String(id), title, type, true, description);
479
+ setShowOnboarding(false);
480
+ }, [handleStatusChange, openSession]);
481
+
482
+ // Cleanup debounce timer and in-flight fetch on unmount
483
+ useEffect(() => {
484
+ return () => {
485
+ if (dbChangeTimerRef.current) clearTimeout(dbChangeTimerRef.current);
486
+ if (kanbanFetchAbortRef.current) kanbanFetchAbortRef.current.abort();
487
+ };
488
+ }, []);
497
489
 
498
490
  const { isConnected, isReconnecting } = useWebSocket({
499
- url: wsUrl,
491
+ url: getWebSocketUrl(),
500
492
  onMessage: handleMessage,
501
493
  });
502
494
 
@@ -507,13 +499,33 @@ export function RealTimeKanbanWrapper({ initialData, isBlank, projectPath }: Rea
507
499
  setConnectionStatus(status);
508
500
  }, [isConnected, isReconnecting, setConnectionStatus]);
509
501
 
502
+ // Handle rejection from detail page — open chat session for the rejected item
503
+ useEffect(() => {
504
+ const rejectedId = searchParams.get('rejected');
505
+ const reason = searchParams.get('reason');
506
+ if (!rejectedId || !reason) return;
507
+
508
+ // Clean the URL immediately
509
+ navigate('/', { replace: true });
510
+
511
+ const found = findItemById(dataRef.current, parseInt(rejectedId, 10));
512
+ const itemTitle = found?.item.title ?? `Item #${rejectedId}`;
513
+ const itemType = found?.item.type ?? 'chore';
514
+
515
+ openSession(rejectedId, itemTitle, itemType, false, null, true);
516
+
517
+ setTimeout(() => {
518
+ const registry = getRegistry();
519
+ const streamManager = registry.get(rejectedId);
520
+ if (streamManager) {
521
+ streamManager.injectGate('rejection', { reason });
522
+ }
523
+ sendMessage(reason);
524
+ }, 0);
525
+ }, [searchParams, navigate, openSession, sendMessage]);
526
+
510
527
  return (
511
- <div className="h-full flex flex-col">
512
- {/* Usage Limit Banner */}
513
- <div className="px-4 pt-2">
514
- <UpgradeBanner />
515
- </div>
516
- {/* Status Error Display */}
528
+ <div className="flex flex-col min-h-0">
517
529
  {statusError && (
518
530
  <div
519
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"
@@ -529,19 +541,27 @@ export function RealTimeKanbanWrapper({ initialData, isBlank, projectPath }: Rea
529
541
  </button>
530
542
  </div>
531
543
  )}
532
- <div className="flex-1 min-h-0">
544
+ {showOnboarding ? (
545
+ <OnboardingWelcome
546
+ onboardingItems={onboardingItems}
547
+ onStartChore={handleStartOnboardingChore}
548
+ />
549
+ ) : (
533
550
  <KanbanBoard
534
551
  inFlight={data.inFlight}
535
552
  backlog={data.backlog}
536
553
  done={data.done}
554
+ itemStatusMap={data.statusMap}
537
555
  onTitleSave={handleTitleSave}
538
556
  onStatusChange={handleStatusChange}
539
557
  onReject={handleReject}
558
+ onRestart={handleRestart}
540
559
  onOrderChange={handleOrderChange}
541
560
  onEpicAssign={handleEpicAssign}
542
561
  onTriggerClaude={handleTriggerClaude}
543
562
  onOpenSession={handleOpenSession}
544
- activeSessions={sessions}
563
+ onCloseSession={handleCloseSession}
564
+ activeSessionIds={activeSessionIds}
545
565
  onUndo={handleUndo}
546
566
  onRedo={handleRedo}
547
567
  canUndo={canUndo}
@@ -551,10 +571,8 @@ export function RealTimeKanbanWrapper({ initialData, isBlank, projectPath }: Rea
551
571
  usageAllowed={usageAllowed}
552
572
  externalAnimatingItemId={externalAnimatingItemId}
553
573
  onExternalAnimationComplete={handleExternalAnimationComplete}
554
- isBlank={isBlank}
555
574
  />
556
- </div>
557
- {/* ClaudePanel is rendered by AppShell - DO NOT duplicate here */}
575
+ )}
558
576
  </div>
559
577
  );
560
578
  }