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.
- package/.env +7 -0
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +124 -48
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +161 -10
- package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
- package/apps/dashboard/app/api/usage/route.ts +17 -0
- package/apps/dashboard/app/api/work/[id]/route.ts +35 -0
- package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
- package/apps/dashboard/app/connect-claude/page.tsx +24 -0
- package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
- package/apps/dashboard/app/demo/gates/page.tsx +42 -42
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +6 -2
- package/apps/dashboard/app/install-claude/page.tsx +9 -7
- package/apps/dashboard/app/layout.tsx +17 -5
- package/apps/dashboard/app/login/page.tsx +250 -0
- package/apps/dashboard/app/page.tsx +11 -9
- package/apps/dashboard/app/settings/page.tsx +4 -2
- package/apps/dashboard/app/signup/page.tsx +245 -0
- package/apps/dashboard/app/subscribe/page.tsx +11 -0
- package/apps/dashboard/app/welcome/page.tsx +24 -1
- package/apps/dashboard/app/work/[id]/page.tsx +34 -50
- package/apps/dashboard/components/AppShell.tsx +95 -55
- package/apps/dashboard/components/CardMenu.tsx +56 -13
- package/apps/dashboard/components/ClaudePanel.tsx +301 -582
- package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +210 -0
- package/apps/dashboard/components/CopyableId.tsx +3 -3
- package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
- package/apps/dashboard/components/DragContext.tsx +75 -65
- package/apps/dashboard/components/DraggableCard.tsx +6 -46
- package/apps/dashboard/components/DropZone.tsx +2 -2
- package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
- package/apps/dashboard/components/EditableTitle.tsx +26 -6
- package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
- package/apps/dashboard/components/EpicGroup.tsx +329 -0
- package/apps/dashboard/components/GateCard.tsx +100 -16
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
- package/apps/dashboard/components/InstallClaudeScreen.tsx +140 -51
- package/apps/dashboard/components/JettyLoader.tsx +38 -0
- package/apps/dashboard/components/KanbanBoard.tsx +147 -766
- package/apps/dashboard/components/KanbanCard.tsx +506 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
- package/apps/dashboard/components/MainNav.tsx +20 -54
- package/apps/dashboard/components/MessageBlock.tsx +391 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -15
- package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +11 -21
- package/apps/dashboard/components/ProjectSwitcher.tsx +36 -8
- package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +265 -301
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +97 -74
- package/apps/dashboard/components/ReviewFooter.tsx +141 -0
- package/apps/dashboard/components/SessionList.tsx +19 -18
- package/apps/dashboard/components/SubscribeContent.tsx +206 -0
- package/apps/dashboard/components/TestTree.tsx +15 -14
- package/apps/dashboard/components/TipCard.tsx +177 -0
- package/apps/dashboard/components/Toast.tsx +5 -5
- package/apps/dashboard/components/TypeIcon.tsx +56 -0
- package/apps/dashboard/components/UpgradeBanner.tsx +30 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
- package/apps/dashboard/components/WelcomeScreen.tsx +25 -27
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
- package/apps/dashboard/components/WorkItemTree.tsx +9 -28
- package/apps/dashboard/components/settings/AccountSection.tsx +169 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
- package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +408 -105
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
- package/apps/dashboard/contexts/UsageContext.tsx +155 -0
- package/apps/dashboard/contexts/usageHelpers.js +9 -0
- package/apps/dashboard/electron/ipc-handlers.js +281 -88
- package/apps/dashboard/electron/main.js +691 -131
- package/apps/dashboard/electron/preload.js +25 -4
- package/apps/dashboard/electron/session-manager.js +163 -0
- package/apps/dashboard/electron-builder.config.js +3 -5
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/lib/backlog-parser.ts +50 -0
- package/apps/dashboard/lib/claude-process-manager.ts +50 -11
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/db-bridge.ts +33 -0
- package/apps/dashboard/lib/db.ts +136 -20
- package/apps/dashboard/lib/kanban-utils.ts +70 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/session-state-machine.ts +3 -0
- package/apps/dashboard/lib/session-stream-manager.ts +144 -38
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/tests.ts +3 -1
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next.config.js +35 -14
- package/apps/dashboard/package.json +6 -3
- package/apps/dashboard/public/bug-icon.svg +9 -0
- package/apps/dashboard/public/buoy-icon.svg +9 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.svg +9 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.svg +14 -0
- package/apps/dashboard/public/star-icon.svg +9 -0
- package/apps/dashboard/public/wrench-icon.svg +9 -0
- package/apps/dashboard/scripts/upload-to-r2.js +89 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
- package/apps/update-server/package.json +16 -0
- package/apps/update-server/schema.sql +31 -0
- package/apps/update-server/src/index.ts +1085 -0
- package/apps/update-server/tsconfig.json +16 -0
- package/apps/update-server/wrangler.toml +35 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +54 -116
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/migrations/027-plan-at-creation-column.js +33 -0
- package/lib/migrations/028-ready-for-review-column.js +27 -0
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +13 -6
- package/lib/seed-onboarding.js +101 -69
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +129 -16
- package/lib/work-tracking/index.js +86 -46
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +39 -28
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +131 -68
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/epic-planning/SKILL.md +68 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +83 -73
- package/skills-templates/production-mode/SKILL.md +49 -49
- package/skills-templates/request-routing/SKILL.md +27 -14
- package/skills-templates/simple-improvement/SKILL.md +68 -44
- package/skills-templates/speed-mode/SKILL.md +209 -128
- package/skills-templates/stable-mode/SKILL.md +105 -94
- package/templates/bdd-guidance.md +139 -0
- package/templates/bdd-scaffolding/wait.js +18 -0
- package/templates/bdd-scaffolding/world.js +19 -0
- package/.jettypod-backup/work.db +0 -0
- package/apps/dashboard/app/access-code/page.tsx +0 -110
- package/lib/discovery-checkpoint.js +0 -123
- 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 {
|
|
9
|
+
import { useSessionState, useSessionActions, useSessionPersistence } from '../contexts/ClaudeSessionContext';
|
|
8
10
|
import { useConnectionStatus } from '../contexts/ConnectionStatusContext';
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
}
|
|
353
|
+
const handleOpenSession = useCallback(async (id: string) => {
|
|
354
|
+
await switchSession(id);
|
|
355
|
+
setClaudePanelOpen(true);
|
|
356
|
+
}, [switchSession, setClaudePanelOpen]);
|
|
431
357
|
|
|
432
|
-
|
|
433
|
-
|
|
358
|
+
const handleCloseSession = useCallback((id: string) => {
|
|
359
|
+
closeSession(id);
|
|
360
|
+
}, [closeSession]);
|
|
434
361
|
|
|
435
|
-
|
|
436
|
-
|
|
362
|
+
const handleRestart = useCallback((id: number) => {
|
|
363
|
+
const found = findItemById(dataRef.current, id);
|
|
364
|
+
if (!found) return;
|
|
437
365
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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:
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
}
|