jettypod 4.4.116 → 4.4.120

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