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