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