jettypod 4.4.116 → 4.4.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/.env +7 -0
  2. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +124 -48
  3. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
  4. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +161 -10
  5. package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
  6. package/apps/dashboard/app/api/usage/route.ts +17 -0
  7. package/apps/dashboard/app/api/work/[id]/route.ts +35 -0
  8. package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
  9. package/apps/dashboard/app/connect-claude/page.tsx +24 -0
  10. package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
  11. package/apps/dashboard/app/demo/gates/page.tsx +42 -42
  12. package/apps/dashboard/app/design-system/page.tsx +868 -0
  13. package/apps/dashboard/app/globals.css +6 -2
  14. package/apps/dashboard/app/install-claude/page.tsx +9 -7
  15. package/apps/dashboard/app/layout.tsx +17 -5
  16. package/apps/dashboard/app/login/page.tsx +250 -0
  17. package/apps/dashboard/app/page.tsx +11 -9
  18. package/apps/dashboard/app/settings/page.tsx +4 -2
  19. package/apps/dashboard/app/signup/page.tsx +245 -0
  20. package/apps/dashboard/app/subscribe/page.tsx +11 -0
  21. package/apps/dashboard/app/welcome/page.tsx +24 -1
  22. package/apps/dashboard/app/work/[id]/page.tsx +34 -50
  23. package/apps/dashboard/components/AppShell.tsx +95 -55
  24. package/apps/dashboard/components/CardMenu.tsx +56 -13
  25. package/apps/dashboard/components/ClaudePanel.tsx +301 -582
  26. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
  27. package/apps/dashboard/components/ConnectClaudeScreen.tsx +210 -0
  28. package/apps/dashboard/components/CopyableId.tsx +3 -3
  29. package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
  30. package/apps/dashboard/components/DragContext.tsx +75 -65
  31. package/apps/dashboard/components/DraggableCard.tsx +6 -46
  32. package/apps/dashboard/components/DropZone.tsx +2 -2
  33. package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
  34. package/apps/dashboard/components/EditableTitle.tsx +26 -6
  35. package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
  36. package/apps/dashboard/components/EpicGroup.tsx +329 -0
  37. package/apps/dashboard/components/GateCard.tsx +100 -16
  38. package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
  39. package/apps/dashboard/components/InstallClaudeScreen.tsx +140 -51
  40. package/apps/dashboard/components/JettyLoader.tsx +38 -0
  41. package/apps/dashboard/components/KanbanBoard.tsx +147 -766
  42. package/apps/dashboard/components/KanbanCard.tsx +506 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
  44. package/apps/dashboard/components/MainNav.tsx +20 -54
  45. package/apps/dashboard/components/MessageBlock.tsx +391 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -15
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +11 -21
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +36 -8
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +265 -301
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +97 -74
  53. package/apps/dashboard/components/ReviewFooter.tsx +141 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -18
  55. package/apps/dashboard/components/SubscribeContent.tsx +206 -0
  56. package/apps/dashboard/components/TestTree.tsx +15 -14
  57. package/apps/dashboard/components/TipCard.tsx +177 -0
  58. package/apps/dashboard/components/Toast.tsx +5 -5
  59. package/apps/dashboard/components/TypeIcon.tsx +56 -0
  60. package/apps/dashboard/components/UpgradeBanner.tsx +30 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
  62. package/apps/dashboard/components/WelcomeScreen.tsx +25 -27
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
  64. package/apps/dashboard/components/WorkItemTree.tsx +9 -28
  65. package/apps/dashboard/components/settings/AccountSection.tsx +169 -0
  66. package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
  67. package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
  68. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
  69. package/apps/dashboard/components/ui/Button.tsx +104 -0
  70. package/apps/dashboard/components/ui/Input.tsx +78 -0
  71. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +408 -105
  72. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
  73. package/apps/dashboard/contexts/UsageContext.tsx +155 -0
  74. package/apps/dashboard/contexts/usageHelpers.js +9 -0
  75. package/apps/dashboard/electron/ipc-handlers.js +281 -88
  76. package/apps/dashboard/electron/main.js +691 -131
  77. package/apps/dashboard/electron/preload.js +25 -4
  78. package/apps/dashboard/electron/session-manager.js +163 -0
  79. package/apps/dashboard/electron-builder.config.js +3 -5
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/lib/backlog-parser.ts +50 -0
  83. package/apps/dashboard/lib/claude-process-manager.ts +50 -11
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/db-bridge.ts +33 -0
  86. package/apps/dashboard/lib/db.ts +136 -20
  87. package/apps/dashboard/lib/kanban-utils.ts +70 -0
  88. package/apps/dashboard/lib/run-migrations.js +27 -2
  89. package/apps/dashboard/lib/session-state-machine.ts +3 -0
  90. package/apps/dashboard/lib/session-stream-manager.ts +144 -38
  91. package/apps/dashboard/lib/shadows.ts +7 -0
  92. package/apps/dashboard/lib/tests.ts +3 -1
  93. package/apps/dashboard/lib/utils.ts +6 -0
  94. package/apps/dashboard/next.config.js +35 -14
  95. package/apps/dashboard/package.json +6 -3
  96. package/apps/dashboard/public/bug-icon.svg +9 -0
  97. package/apps/dashboard/public/buoy-icon.svg +9 -0
  98. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  99. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  100. package/apps/dashboard/public/in-flight-seagull.svg +9 -0
  101. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  102. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  103. package/apps/dashboard/public/jettypod_logo.png +0 -0
  104. package/apps/dashboard/public/pier-icon.svg +14 -0
  105. package/apps/dashboard/public/star-icon.svg +9 -0
  106. package/apps/dashboard/public/wrench-icon.svg +9 -0
  107. package/apps/dashboard/scripts/upload-to-r2.js +89 -0
  108. package/apps/dashboard/scripts/ws-server.js +191 -0
  109. package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
  110. package/apps/update-server/package.json +16 -0
  111. package/apps/update-server/schema.sql +31 -0
  112. package/apps/update-server/src/index.ts +1085 -0
  113. package/apps/update-server/tsconfig.json +16 -0
  114. package/apps/update-server/wrangler.toml +35 -0
  115. package/cucumber.js +9 -3
  116. package/docs/COMMAND_REFERENCE.md +34 -0
  117. package/hooks/post-checkout +32 -75
  118. package/hooks/post-merge +111 -10
  119. package/jest.setup.js +1 -0
  120. package/jettypod.js +54 -116
  121. package/lib/chore-taxonomy.js +33 -10
  122. package/lib/database.js +36 -16
  123. package/lib/db-watcher.js +1 -1
  124. package/lib/git-hooks/pre-commit +1 -1
  125. package/lib/jettypod-backup.js +27 -4
  126. package/lib/migrations/027-plan-at-creation-column.js +33 -0
  127. package/lib/migrations/028-ready-for-review-column.js +27 -0
  128. package/lib/migrations/029-remove-autoincrement.js +307 -0
  129. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  130. package/lib/migrations/index.js +47 -4
  131. package/lib/schema.js +13 -6
  132. package/lib/seed-onboarding.js +101 -69
  133. package/lib/update-command/index.js +9 -175
  134. package/lib/work-commands/index.js +129 -16
  135. package/lib/work-tracking/index.js +86 -46
  136. package/lib/worktree-diagnostics.js +16 -16
  137. package/lib/worktree-facade.js +1 -1
  138. package/lib/worktree-manager.js +8 -8
  139. package/lib/worktree-reconciler.js +5 -5
  140. package/package.json +9 -2
  141. package/scripts/ndjson-to-cucumber-json.js +152 -0
  142. package/scripts/postinstall.js +25 -0
  143. package/skills-templates/bug-mode/SKILL.md +39 -28
  144. package/skills-templates/bug-planning/SKILL.md +25 -29
  145. package/skills-templates/chore-mode/SKILL.md +131 -68
  146. package/skills-templates/chore-mode/verification.js +51 -10
  147. package/skills-templates/chore-planning/SKILL.md +47 -18
  148. package/skills-templates/epic-planning/SKILL.md +68 -48
  149. package/skills-templates/external-transition/SKILL.md +47 -47
  150. package/skills-templates/feature-planning/SKILL.md +83 -73
  151. package/skills-templates/production-mode/SKILL.md +49 -49
  152. package/skills-templates/request-routing/SKILL.md +27 -14
  153. package/skills-templates/simple-improvement/SKILL.md +68 -44
  154. package/skills-templates/speed-mode/SKILL.md +209 -128
  155. package/skills-templates/stable-mode/SKILL.md +105 -94
  156. package/templates/bdd-guidance.md +139 -0
  157. package/templates/bdd-scaffolding/wait.js +18 -0
  158. package/templates/bdd-scaffolding/world.js +19 -0
  159. package/.jettypod-backup/work.db +0 -0
  160. package/apps/dashboard/app/access-code/page.tsx +0 -110
  161. package/lib/discovery-checkpoint.js +0 -123
  162. package/skills-templates/project-discovery/SKILL.md +0 -372
@@ -0,0 +1,329 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback, useRef, useEffect } from 'react';
4
+ import Link from 'next/link';
5
+ import { AnimatePresence } from 'framer-motion';
6
+ import { useDroppable } from '@dnd-kit/core';
7
+ import type { WorkItem, InFlightItem } from '@/lib/db';
8
+ import type { Session } from '../contexts/ClaudeSessionContext';
9
+ import { KanbanCard } from './KanbanCard';
10
+ import { useDragContext } from './DragContext';
11
+ import { DraggableCard } from './DraggableCard';
12
+ import { PlaceholderCard } from './PlaceholderCard';
13
+ import { TypeIcon } from './TypeIcon';
14
+
15
+ // Safe bounds for display_order to prevent overflow
16
+ export const MIN_DISPLAY_ORDER = 0;
17
+ export const MAX_DISPLAY_ORDER = Number.MAX_SAFE_INTEGER - 1000;
18
+ export const DISPLAY_ORDER_INCREMENT = 10;
19
+
20
+ export interface EpicGroupProps {
21
+ epicId: number | null;
22
+ epicTitle: string | null;
23
+ items: WorkItem[];
24
+ isInFlight?: boolean;
25
+ inFlightItems?: InFlightItem[];
26
+ isDraggable?: boolean;
27
+ onTitleSave?: (id: number, newTitle: string) => Promise<void>;
28
+ onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
29
+ onReject?: (id: number, reason: string) => Promise<void>;
30
+ onRestart?: (id: number) => void;
31
+ onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
32
+ onOrderChange?: (id: number, newOrder: number) => Promise<void>;
33
+ onTriggerClaude?: (id: number, title: string, type: string, conversational?: boolean, description?: string | null) => void;
34
+ activeSessions?: Map<string, Session>;
35
+ onOpenSession?: (id: string) => void;
36
+ onCloseSession?: (id: string) => void;
37
+ onError?: (message: string) => void;
38
+ usageAllowed?: boolean;
39
+ // Animation state lifted to board level
40
+ animatingItemId?: number | null;
41
+ onAnimationComplete?: () => void;
42
+ }
43
+
44
+ export function EpicGroup({ epicId, epicTitle, items, isInFlight = false, inFlightItems, isDraggable = true, onTitleSave, onStatusChange, onReject, onRestart, onEpicAssign, onOrderChange, onTriggerClaude, activeSessions, onOpenSession, onCloseSession, onError, usageAllowed = true, animatingItemId, onAnimationComplete }: EpicGroupProps) {
45
+ const containerRef = useRef<HTMLDivElement>(null);
46
+ const { isDragging, draggedItem, activeEpicZone, activeDropZone, registerEpicDropZone, unregisterEpicDropZone, getCardPositions } = useDragContext();
47
+
48
+ // Local pointer tracking - only this component needs pointer Y for insertion preview.
49
+ // Using local state avoids re-rendering every context consumer at 60fps.
50
+ const [pointerY, setPointerY] = useState(0);
51
+ useEffect(() => {
52
+ if (!isDragging) return;
53
+ const onPointerMove = (e: PointerEvent) => { setPointerY(e.clientY); };
54
+ window.addEventListener('pointermove', onPointerMove);
55
+ return () => window.removeEventListener('pointermove', onPointerMove);
56
+ }, [isDragging]);
57
+
58
+ // Use @dnd-kit's useDroppable for epic zone collision detection
59
+ const zoneId = epicId !== null ? `epic-${epicId}` : undefined;
60
+ const { setNodeRef } = useDroppable({
61
+ id: zoneId || 'ungrouped',
62
+ disabled: epicId === null, // Don't use droppable for ungrouped section
63
+ data: { epicId },
64
+ });
65
+
66
+ // Combine refs
67
+ const setRefs = useCallback((node: HTMLDivElement | null) => {
68
+ if (epicId !== null) {
69
+ setNodeRef(node);
70
+ }
71
+ (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
72
+ }, [epicId, setNodeRef]);
73
+
74
+ // Use ref for items to avoid re-registering drop zone when items change
75
+ const itemsRef = useRef(items);
76
+ itemsRef.current = items;
77
+
78
+ // Use ref for callbacks to keep drop zone registration stable
79
+ const onOrderChangeRef = useRef(onOrderChange);
80
+ onOrderChangeRef.current = onOrderChange;
81
+
82
+ // Use ref for error handler to keep reorder handler stable
83
+ const onErrorRef = useRef(onError);
84
+ onErrorRef.current = onError;
85
+
86
+ // Stable reorder handler that reads from refs
87
+ const handleEpicReorder = useCallback(async (itemId: number, pointerY: number) => {
88
+ if (!onOrderChangeRef.current) {
89
+ return;
90
+ }
91
+
92
+ const currentItems = itemsRef.current.filter(item => item.id !== itemId);
93
+ if (currentItems.length === 0) {
94
+ return;
95
+ }
96
+
97
+ // Read fresh positions from DOM (not stale cache)
98
+ const allPositions = getCardPositions();
99
+ const itemIds = new Set(currentItems.map(item => item.id));
100
+ const cardPositions = allPositions
101
+ .filter(pos => itemIds.has(pos.id))
102
+ .map(pos => ({
103
+ id: pos.id,
104
+ midY: (pos.rect.top + pos.rect.bottom) / 2,
105
+ }))
106
+ .sort((a, b) => a.midY - b.midY);
107
+
108
+ if (cardPositions.length === 0) {
109
+ return;
110
+ }
111
+
112
+ // Find insertion index based on pointer Y
113
+ let insertIndex = cardPositions.length;
114
+ for (let i = 0; i < cardPositions.length; i++) {
115
+ if (pointerY < cardPositions[i].midY) {
116
+ insertIndex = i;
117
+ break;
118
+ }
119
+ }
120
+
121
+ // Map visual positions to items for display_order midpoint calculation
122
+ const itemMap = new Map(currentItems.map(item => [item.id, item]));
123
+ const visualOrder = cardPositions.map(pos => itemMap.get(pos.id)!).filter(Boolean);
124
+
125
+ // Calculate proper midpoint display_order between surrounding items
126
+ let newOrder: number;
127
+ if (visualOrder.length === 0) {
128
+ newOrder = DISPLAY_ORDER_INCREMENT;
129
+ } else if (insertIndex === 0) {
130
+ const firstOrder = visualOrder[0].display_order ?? visualOrder[0].id;
131
+ newOrder = firstOrder - DISPLAY_ORDER_INCREMENT;
132
+ } else if (insertIndex >= visualOrder.length) {
133
+ const lastOrder = visualOrder[visualOrder.length - 1].display_order ?? visualOrder[visualOrder.length - 1].id;
134
+ newOrder = lastOrder + DISPLAY_ORDER_INCREMENT;
135
+ } else {
136
+ const before = visualOrder[insertIndex - 1].display_order ?? visualOrder[insertIndex - 1].id;
137
+ const after = visualOrder[insertIndex].display_order ?? visualOrder[insertIndex].id;
138
+ newOrder = Math.floor((before + after) / 2);
139
+ }
140
+
141
+ newOrder = Math.max(MIN_DISPLAY_ORDER, Math.min(MAX_DISPLAY_ORDER, newOrder));
142
+
143
+ try {
144
+ await onOrderChangeRef.current(itemId, newOrder);
145
+ } catch (error) {
146
+ const errorMessage = error instanceof Error ? error.message : 'Failed to reorder item. Please try again.';
147
+ onErrorRef.current?.(errorMessage);
148
+ }
149
+ }, [getCardPositions]);
150
+
151
+ // Register as epic drop zone - stable registration that doesn't change with items
152
+ useEffect(() => {
153
+ if (!containerRef.current || !onEpicAssign || epicId === null) return;
154
+
155
+ const zoneId = `epic-${epicId}`;
156
+ registerEpicDropZone(zoneId, {
157
+ epicId,
158
+ element: containerRef.current,
159
+ onEpicAssign,
160
+ onReorder: handleEpicReorder,
161
+ });
162
+
163
+ return () => {
164
+ unregisterEpicDropZone(zoneId);
165
+ };
166
+ }, [epicId, onEpicAssign, handleEpicReorder, registerEpicDropZone, unregisterEpicDropZone]);
167
+
168
+ // Check if this epic zone is the active drop target
169
+ const isActiveTarget = activeEpicZone === `epic-${epicId}`;
170
+
171
+ // Check if the dragged item is from a different epic or same epic
172
+ const draggedItemEpicId = draggedItem ? (draggedItem.parent_id || draggedItem.epic_id) : null;
173
+ const isDifferentEpic = isDragging && draggedItem && draggedItemEpicId !== epicId;
174
+ const isSameEpic = isDragging && draggedItem && draggedItemEpicId === epicId;
175
+
176
+ // Show highlight when dragging an item from different epic over this group (indigo)
177
+ const showHighlight = isActiveTarget && isDifferentEpic;
178
+ // Show reorder highlight when dragging within same epic (purple)
179
+ const showReorderHighlight = isActiveTarget && isSameEpic;
180
+
181
+ // For ungrouped section (epicId === null)
182
+ const isUngroupedSection = epicId === null;
183
+ // Check if cursor is over this ungrouped section (not over any epic zone, but over backlog drop zone)
184
+ const isOverUngroupedSection = isUngroupedSection && !activeEpicZone && activeDropZone;
185
+
186
+ // Render the ungrouped zone when dragging from an epic (provides drop target), but only highlight when cursor is over it
187
+ const shouldRenderUngroupedZone = isUngroupedSection && isDragging && draggedItemEpicId !== null;
188
+ const showRemoveFromEpicZone = isOverUngroupedSection && isDragging && draggedItemEpicId !== null;
189
+
190
+ // Show reorder for ungrouped section when dragging an ungrouped card and cursor is over it
191
+ const showUngroupedReorder = isOverUngroupedSection && isDragging && draggedItemEpicId === null;
192
+
193
+ // Calculate insertion preview for this group - only for the active zone
194
+ const showPreview = (showReorderHighlight || showRemoveFromEpicZone || showHighlight || showUngroupedReorder) && draggedItem;
195
+ let insertAfterItemId: number | null | undefined = undefined; // undefined = no preview, null = at beginning
196
+
197
+ if (showPreview && draggedItem) {
198
+ const allPositions = getCardPositions();
199
+ const itemIds = new Set(items.map(item => item.id));
200
+ const groupPositions = allPositions
201
+ .filter(pos => itemIds.has(pos.id) && pos.id !== draggedItem.id)
202
+ .map(pos => ({
203
+ id: pos.id,
204
+ midY: (pos.rect.top + pos.rect.bottom) / 2,
205
+ }))
206
+ .sort((a, b) => a.midY - b.midY);
207
+
208
+ // Find which card the pointer is after
209
+ insertAfterItemId = null; // Default to beginning
210
+ for (const pos of groupPositions) {
211
+ if (pointerY > pos.midY) {
212
+ insertAfterItemId = pos.id;
213
+ } else {
214
+ break;
215
+ }
216
+ }
217
+ }
218
+
219
+ if (items.length === 0 && !showHighlight && !showReorderHighlight && !shouldRenderUngroupedZone) return null;
220
+
221
+ // Standalone done items (single item, no epic) use tighter spacing
222
+ const isStandaloneItem = !epicTitle && items.length === 1;
223
+
224
+ return (
225
+ <div
226
+ ref={setRefs}
227
+ className={`${isStandaloneItem ? 'mb-2' : 'mb-6 p-3 -mx-3'} rounded-lg transition-[color,background-color,box-shadow] duration-200 ease-out ${
228
+ showHighlight
229
+ ? 'ring-2 ring-indigo-400 bg-indigo-100/50 dark:bg-indigo-900/30'
230
+ : showReorderHighlight
231
+ ? 'ring-2 ring-purple-400 bg-purple-100/50 dark:bg-purple-900/30'
232
+ : showRemoveFromEpicZone
233
+ ? 'ring-2 ring-orange-400 bg-orange-100/50 dark:bg-orange-900/30'
234
+ : ''
235
+ }`}
236
+ data-epic-id={epicId}
237
+ >
238
+ {epicTitle && (
239
+ <div className="flex items-center gap-3 mb-3">
240
+ <Link
241
+ href={`/work/${epicId}`}
242
+ className="group/epic flex items-center gap-1.5 text-base font-medium text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300"
243
+ >
244
+ <TypeIcon type="epic" />
245
+ <span className="group-hover/epic:underline">{epicTitle}</span>
246
+ </Link>
247
+ {isInFlight && (
248
+ <span
249
+ className="relative group/inflight text-xs px-2 py-1 rounded bg-[#e8f0f0] text-[#5a7d7f] dark:bg-[#819D9F]/20 dark:text-[#a3bfc0] cursor-default"
250
+ >
251
+ in flight
252
+ {inFlightItems && inFlightItems.length > 0 && (
253
+ <span className="pointer-events-none absolute left-1/2 -translate-x-1/2 top-full mt-2 z-50 hidden group-hover/inflight:block w-max max-w-xs">
254
+ <span className="block rounded-lg bg-zinc-800 dark:bg-zinc-700 text-zinc-100 text-xs px-3 py-2 shadow-lg">
255
+ {inFlightItems.map(item => (
256
+ <span key={item.id} className="flex items-center gap-1 py-0.5 truncate">
257
+ <TypeIcon type={item.type} /> {item.title}
258
+ </span>
259
+ ))}
260
+ </span>
261
+ </span>
262
+ )}
263
+ </span>
264
+ )}
265
+ {showHighlight && (
266
+ <span className="text-xs px-2 py-1 rounded bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300">
267
+ drop to assign
268
+ </span>
269
+ )}
270
+ {showReorderHighlight && (
271
+ <span className="text-xs px-2 py-1 rounded bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300">
272
+ reorder
273
+ </span>
274
+ )}
275
+ </div>
276
+ )}
277
+ {/* Ungrouped section header - shown when dragging from epic */}
278
+ {isUngroupedSection && showRemoveFromEpicZone && items.length === 0 && (
279
+ <div className="flex items-center gap-3 py-4">
280
+ <span className="text-base font-medium text-orange-600 dark:text-orange-400">
281
+ Drop here to remove from epic
282
+ </span>
283
+ </div>
284
+ )}
285
+ {isUngroupedSection && items.length > 0 && isDraggable && showRemoveFromEpicZone && (
286
+ <div className="flex items-center gap-3 mb-3">
287
+ <span className="text-xs px-2 py-1 rounded bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300">
288
+ drop to remove from epic
289
+ </span>
290
+ </div>
291
+ )}
292
+ <div className="space-y-3">
293
+ {/* Placeholder at the beginning (insertAfterItemId === null) */}
294
+ <AnimatePresence>
295
+ {insertAfterItemId === null && (
296
+ <PlaceholderCard key="placeholder-start" />
297
+ )}
298
+ </AnimatePresence>
299
+ {items.map((item) => (
300
+ <div key={item.id}>
301
+ <DraggableCard item={item} disabled={!isDraggable}>
302
+ <KanbanCard
303
+ item={item}
304
+ onTitleSave={onTitleSave}
305
+ onStatusChange={onStatusChange}
306
+ onReject={onReject}
307
+ onRestart={onRestart}
308
+ onTriggerClaude={onTriggerClaude}
309
+ hasActiveSession={activeSessions?.has(String(item.id))}
310
+ onOpenSession={onOpenSession}
311
+ onCloseSession={onCloseSession}
312
+ usageAllowed={usageAllowed}
313
+ isCompletingAnimation={animatingItemId === item.id}
314
+ onAnimationComplete={onAnimationComplete}
315
+ isHighlighted={false}
316
+ />
317
+ </DraggableCard>
318
+ {/* Placeholder after this card */}
319
+ <AnimatePresence>
320
+ {insertAfterItemId === item.id && (
321
+ <PlaceholderCard key={`placeholder-${item.id}`} />
322
+ )}
323
+ </AnimatePresence>
324
+ </div>
325
+ ))}
326
+ </div>
327
+ </div>
328
+ );
329
+ }
@@ -5,6 +5,8 @@ import type { ClaudeMessage } from '../lib/session-stream-manager';
5
5
  import { GateChoiceCard } from './GateChoiceCard';
6
6
  import type { ChoiceOption } from './GateChoiceCard';
7
7
  import { ModeStartCard, isModeStartGate } from './ModeStartCard';
8
+ import { TipCard } from './TipCard';
9
+ import { TypeIcon } from './TypeIcon';
8
10
 
9
11
  // Gate display configuration matching JettyPod design system
10
12
  const GATE_CONFIG: Record<string, {
@@ -89,11 +91,11 @@ const GATE_CONFIG: Record<string, {
89
91
  label: 'Saving Changes',
90
92
  bg: 'bg-white',
91
93
  darkBg: 'dark:bg-zinc-800',
92
- border: 'border-blue-200',
93
- darkBorder: 'dark:border-blue-800',
94
+ border: 'border-[#819D9F]/30',
95
+ darkBorder: 'dark:border-[#819D9F]/30',
94
96
  text: 'text-zinc-700',
95
97
  darkText: 'dark:text-zinc-300',
96
- fileMono: 'text-blue-600',
98
+ fileMono: 'text-[#5a7d7f]',
97
99
  },
98
100
  'complete': {
99
101
  emoji: '✨',
@@ -117,6 +119,39 @@ const GATE_CONFIG: Record<string, {
117
119
  darkText: 'dark:text-zinc-300',
118
120
  fileMono: 'text-indigo-600',
119
121
  },
122
+ 'work-item-card': {
123
+ emoji: '📋',
124
+ label: 'Added to Backlog',
125
+ bg: 'bg-white',
126
+ darkBg: 'dark:bg-zinc-800',
127
+ border: 'border-emerald-200',
128
+ darkBorder: 'dark:border-emerald-800',
129
+ text: 'text-zinc-700',
130
+ darkText: 'dark:text-zinc-300',
131
+ fileMono: 'text-emerald-600',
132
+ },
133
+ 'tip': {
134
+ emoji: '💡',
135
+ label: 'Tip',
136
+ bg: 'bg-teal-50',
137
+ darkBg: 'dark:bg-teal-900/20',
138
+ border: 'border-teal-200',
139
+ darkBorder: 'dark:border-teal-800',
140
+ text: 'text-zinc-700',
141
+ darkText: 'dark:text-zinc-300',
142
+ fileMono: 'text-teal-600',
143
+ },
144
+ 'rejection': {
145
+ emoji: '❌',
146
+ label: 'Work Rejected',
147
+ bg: 'bg-red-50',
148
+ darkBg: 'dark:bg-red-900/20',
149
+ border: 'border-red-200',
150
+ darkBorder: 'dark:border-red-800',
151
+ text: 'text-zinc-700',
152
+ darkText: 'dark:text-zinc-300',
153
+ fileMono: 'text-red-600',
154
+ },
120
155
  };
121
156
 
122
157
  const DEFAULT_CONFIG = {
@@ -131,9 +166,7 @@ const DEFAULT_CONFIG = {
131
166
  fileMono: 'text-zinc-500',
132
167
  };
133
168
 
134
- // Multi-layer shadow matching kanban card elevation
135
- const CARD_SHADOW = '0 1px 2px rgba(0,0,0,0.03), 0 2px 4px rgba(0,0,0,0.03), 0 4px 8px rgba(0,0,0,0.02)';
136
- const CARD_SHADOW_ACTIVE = '0 2px 4px rgba(0,0,0,0.04), 0 4px 8px rgba(0,0,0,0.04), 0 8px 16px rgba(0,0,0,0.03)';
169
+ import { shadow } from '@/lib/shadows';
137
170
 
138
171
  /**
139
172
  * Render a human-friendly description based on gate type and data
@@ -151,7 +184,8 @@ function getGateDescription(gateType: string, data: Record<string, unknown>): st
151
184
  };
152
185
  return routeLabels[route || ''] || `Routing to ${route || 'workflow'}`;
153
186
  }
154
- case 'work-created': {
187
+ case 'work-created':
188
+ case 'work-item-card': {
155
189
  const title = data.title as string | undefined;
156
190
  const id = data.id as number | undefined;
157
191
  return title ? `#${id || '?'} ${title}` : 'Work item created';
@@ -183,6 +217,10 @@ function getGateDescription(gateType: string, data: Record<string, unknown>): st
183
217
  const question = data.question as string | undefined;
184
218
  return question || 'A decision is needed';
185
219
  }
220
+ case 'rejection': {
221
+ const reason = data.reason as string | undefined;
222
+ return reason || 'Work was rejected';
223
+ }
186
224
  default:
187
225
  return (data.message as string) || gateType;
188
226
  }
@@ -193,9 +231,10 @@ interface GateCardProps {
193
231
  isLatest?: boolean;
194
232
  onAnswerQuestion?: (optionId: string, optionLabel: string) => void;
195
233
  answeredQuestionId?: string | null;
234
+ onStartWorkItem?: (id: number, title: string, type: string) => void;
196
235
  }
197
236
 
198
- export function GateCard({ message, isLatest = false, onAnswerQuestion, answeredQuestionId }: GateCardProps) {
237
+ export function GateCard({ message, isLatest = false, onAnswerQuestion, answeredQuestionId, onStartWorkItem }: GateCardProps) {
199
238
  const gateType = message.gateType || 'unknown';
200
239
  const gateData = message.gateData || {};
201
240
  const config = GATE_CONFIG[gateType] || DEFAULT_CONFIG;
@@ -207,6 +246,51 @@ export function GateCard({ message, isLatest = false, onAnswerQuestion, answered
207
246
  return <ModeStartCard gateType={gateType} />;
208
247
  }
209
248
 
249
+ // Tip gates render as dismissible guidance cards
250
+ if (gateType === 'tip') {
251
+ const tipId = (gateData.id as string) || `tip-${message.timestamp}`;
252
+ const icon = (gateData.icon as string) || '💡';
253
+ const title = (gateData.title as string) || 'Tip';
254
+ const body = (gateData.body as string) || '';
255
+ return <TipCard tipId={tipId} icon={icon} title={title} body={body} />;
256
+ }
257
+
258
+ // Work item card gates render as mini kanban cards with Start button
259
+ if (gateType === 'work-item-card') {
260
+ const itemId = gateData.id as number;
261
+ const itemTitle = gateData.title as string;
262
+ const itemType = (gateData.type as string) || 'feature';
263
+ return (
264
+ <div
265
+ className="bg-white dark:bg-zinc-800 border-2 border-emerald-200 dark:border-emerald-800 rounded-xl p-4 transition-shadow duration-200 ease-out"
266
+ style={{ boxShadow: shadow.sm }}
267
+ data-testid="gate-work-item-card"
268
+ >
269
+ <div className="flex items-center justify-between gap-3">
270
+ <div className="flex items-center gap-3 min-w-0">
271
+ <span className="text-base flex-shrink-0"><TypeIcon type={itemType} /></span>
272
+ <div className="min-w-0">
273
+ <span className="text-xs font-medium text-zinc-400 dark:text-zinc-500">
274
+ #{itemId} · {itemType}
275
+ </span>
276
+ <p className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 truncate">
277
+ {itemTitle}
278
+ </p>
279
+ </div>
280
+ </div>
281
+ {onStartWorkItem && (
282
+ <button
283
+ onClick={() => onStartWorkItem(itemId, itemTitle, itemType)}
284
+ className="flex-shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg bg-[#819D9F] hover:bg-[#6b8587] text-white transition-colors duration-150"
285
+ >
286
+ Start
287
+ </button>
288
+ )}
289
+ </div>
290
+ </div>
291
+ );
292
+ }
293
+
210
294
  // Question gates render as interactive choice cards
211
295
  if (gateType === 'question') {
212
296
  const question = (gateData.question as string) || 'A decision is needed';
@@ -238,24 +322,24 @@ export function GateCard({ message, isLatest = false, onAnswerQuestion, answered
238
322
  <div
239
323
  className={`
240
324
  ${config.bg} ${config.darkBg}
241
- border ${config.border} ${config.darkBorder}
242
- rounded-xl p-3 transition-all duration-200
243
- ${isLatest ? 'ring-2 ring-blue-400/50 ring-offset-1' : ''}
325
+ border-2 ${config.border} ${config.darkBorder}
326
+ rounded-xl p-4 transition-shadow duration-200 ease-out
327
+ ${isLatest ? 'ring-2 ring-[#819D9F]/50 ring-offset-1' : ''}
244
328
  `}
245
- style={{ boxShadow: isLatest ? CARD_SHADOW_ACTIVE : CARD_SHADOW }}
329
+ style={{ boxShadow: isLatest ? shadow.md : shadow.sm }}
246
330
  data-testid={`gate-card-${gateType}`}
247
331
  >
248
- <div className="flex items-start gap-3">
332
+ <div className="flex items-start gap-4">
249
333
  <span className="text-base flex-shrink-0 mt-0.5">{config.emoji}</span>
250
334
  <div className="flex-1 min-w-0">
251
- <span className={`text-xs font-semibold ${config.text} ${config.darkText}`}>
335
+ <span className={`text-base font-semibold ${config.text} ${config.darkText}`}>
252
336
  {config.label}
253
337
  </span>
254
- <p className={`text-sm mt-0.5 ${config.text} ${config.darkText}`}>
338
+ <p className={`text-base mt-1 ${config.text} ${config.darkText}`}>
255
339
  {description}
256
340
  </p>
257
341
  {hasFiles && (
258
- <div className="mt-2 space-y-0.5">
342
+ <div className="mt-3 space-y-1">
259
343
  {(gateData.files as string[]).map((file, i) => (
260
344
  <div key={i} className={`text-xs font-mono truncate ${config.fileMono}`}>
261
345
  {file}
@@ -2,9 +2,7 @@
2
2
 
3
3
  import { useState } from 'react';
4
4
 
5
- // Multi-layer shadow matching kanban card system
6
- const CARD_SHADOW = '0 1px 2px rgba(0,0,0,0.03), 0 2px 4px rgba(0,0,0,0.03), 0 4px 8px rgba(0,0,0,0.02)';
7
- const CARD_SHADOW_HOVER = '0 2px 4px rgba(0,0,0,0.04), 0 4px 8px rgba(0,0,0,0.04), 0 8px 16px rgba(0,0,0,0.03), 0 12px 24px rgba(129,157,159,0.08)';
5
+ import { shadow } from '@/lib/shadows';
8
6
 
9
7
  export interface ChoiceOption {
10
8
  id: string;
@@ -32,22 +30,22 @@ export function GateChoiceCard({
32
30
 
33
31
  return (
34
32
  <div
35
- className="bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 transition-all duration-200"
36
- style={{ boxShadow: CARD_SHADOW }}
33
+ className="bg-white dark:bg-zinc-800 rounded-xl p-4 transition-shadow duration-200 ease-out"
34
+ style={{ boxShadow: shadow.sm }}
37
35
  data-testid="gate-choice-card"
38
36
  >
39
- <div className="flex items-start gap-3">
37
+ <div className="flex items-start gap-4">
40
38
  <span className="text-base flex-shrink-0 mt-0.5">💬</span>
41
39
  <div className="flex-1 min-w-0">
42
- <span className="text-xs font-semibold text-zinc-700 dark:text-zinc-300">
40
+ <span className="text-base font-semibold text-zinc-700 dark:text-zinc-300">
43
41
  Input Needed
44
42
  </span>
45
- <p className="text-sm mt-0.5 text-zinc-700 dark:text-zinc-300">
43
+ <p className="text-base mt-1 text-zinc-700 dark:text-zinc-300">
46
44
  {question}
47
45
  </p>
48
46
 
49
47
  {/* Option cards */}
50
- <div className="mt-3 space-y-2">
48
+ <div className="mt-4 space-y-3">
51
49
  {options.map((option) => {
52
50
  const isSelected = selectedId === option.id;
53
51
  const isHovered = hoveredId === option.id;
@@ -60,35 +58,35 @@ export function GateChoiceCard({
60
58
  onMouseLeave={() => setHoveredId(null)}
61
59
  disabled={disabled && !isSelected}
62
60
  className={`
63
- w-full text-left rounded-xl border p-3 transition-all duration-200
61
+ w-full text-left rounded-xl border-2 p-4 transition-[color,background-color,border-color,box-shadow,transform] duration-200 ease-out
64
62
  ${isSelected
65
- ? 'border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 ring-2 ring-blue-400/30'
63
+ ? 'border-[#819D9F] dark:border-[#819D9F] bg-[#e8f0f0] dark:bg-[#819D9F]/20 ring-2 ring-[#819D9F]/30'
66
64
  : 'border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-600'
67
65
  }
68
66
  ${disabled && !isSelected ? 'opacity-50 cursor-default' : 'cursor-pointer'}
69
67
  ${!disabled && !isSelected ? 'hover:-translate-y-0.5' : ''}
70
68
  `}
71
69
  style={{
72
- boxShadow: isSelected ? CARD_SHADOW_HOVER : isHovered && !disabled ? CARD_SHADOW_HOVER : CARD_SHADOW,
70
+ boxShadow: isSelected ? shadow.lg : isHovered && !disabled ? shadow.lg : shadow.sm,
73
71
  }}
74
72
  data-testid={`choice-option-${option.id}`}
75
73
  >
76
- <div className="flex items-start gap-3">
74
+ <div className="flex items-start gap-4">
77
75
  {option.emoji && (
78
76
  <span className="text-base flex-shrink-0">{option.emoji}</span>
79
77
  )}
80
78
  <div className="flex-1 min-w-0">
81
- <div className="flex items-center gap-2">
82
- <span className={`text-sm font-medium ${isSelected ? 'text-blue-700 dark:text-blue-300' : 'text-zinc-900 dark:text-zinc-100'}`}>
79
+ <div className="flex items-center gap-3">
80
+ <span className={`text-base font-medium ${isSelected ? 'text-[#5a7d7f] dark:text-[#a3bfc0]' : 'text-zinc-900 dark:text-zinc-100'}`}>
83
81
  {option.label}
84
82
  </span>
85
83
  {isSelected && (
86
- <svg className="w-4 h-4 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
84
+ <svg className="w-4 h-4 text-[#819D9F] flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
87
85
  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
88
86
  </svg>
89
87
  )}
90
88
  </div>
91
- <p className={`text-xs mt-0.5 ${isSelected ? 'text-blue-600/70 dark:text-blue-400/70' : 'text-zinc-500 dark:text-zinc-400'}`}>
89
+ <p className={`text-base mt-1 ${isSelected ? 'text-[#5a7d7f]/70 dark:text-[#a3bfc0]/70' : 'text-zinc-500 dark:text-zinc-400'}`}>
92
90
  {option.description}
93
91
  </p>
94
92
  </div>