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
@@ -29,6 +29,12 @@ export type StreamStatus = 'idle' | 'connecting' | 'creating' | 'streaming' | 'd
29
29
  export interface SessionContext {
30
30
  workItemId: string;
31
31
  standalone: boolean;
32
+ conversational?: boolean;
33
+ }
34
+
35
+ export interface QueuedMessage {
36
+ message: string;
37
+ images?: AttachedImage[];
32
38
  }
33
39
 
34
40
  export interface StreamState {
@@ -40,11 +46,12 @@ export interface StreamState {
40
46
  isReconnecting: boolean;
41
47
  reconnectAttempt: number;
42
48
  narratedMode: boolean;
49
+ queuedMessage: QueuedMessage | null;
43
50
  }
44
51
 
45
52
  export interface StreamManagerCallbacks {
46
53
  onStateChange?: (state: StreamState) => void;
47
- onWorkItemCreated?: (workItemId: number, title: string) => void;
54
+ onWorkItemCreated?: (workItemId: number, title: string, sourceSessionId: string) => void;
48
55
  onGate?: (gate: ClaudeMessage) => void;
49
56
  onQuestion?: (gate: ClaudeMessage) => void;
50
57
  }
@@ -188,6 +195,23 @@ function transformClaudeMessage(parsed: any): ClaudeMessage | null {
188
195
  timestamp,
189
196
  };
190
197
 
198
+ // Claude CLI v2.1.49+ may emit streaming content block deltas
199
+ case 'content_block_delta': {
200
+ const delta = parsed.delta as { type?: string; text?: string } | undefined;
201
+ if (delta?.type === 'text_delta' && delta.text) {
202
+ return { type: 'assistant', content: delta.text, timestamp };
203
+ }
204
+ return null;
205
+ }
206
+
207
+ case 'content_block_start':
208
+ case 'content_block_stop':
209
+ case 'message_start':
210
+ case 'message_delta':
211
+ case 'message_stop':
212
+ // Streaming lifecycle events — no user-visible content
213
+ return null;
214
+
191
215
  default:
192
216
  return null;
193
217
  }
@@ -266,9 +290,14 @@ export class SessionStreamManager {
266
290
  private _isReconnecting: boolean = false;
267
291
  private _reconnectAttempt: number = 0;
268
292
  private _narratedMode: boolean = true;
293
+ private _userToggledNarratedMode: boolean = false;
269
294
  private _pendingQuestion: ClaudeMessage | null = null;
295
+ private _queuedMessage: QueuedMessage | null = null;
270
296
  private _isFirstMessage: boolean = true;
271
297
 
298
+ // Notification batching — coalesces rapid state changes into one callback per frame
299
+ private _notifyPending: boolean = false;
300
+
272
301
  // Control
273
302
  private abortController: AbortController | null = null;
274
303
  private creatingStatusTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -278,6 +307,7 @@ export class SessionStreamManager {
278
307
  // Last request (for retry/reconnect)
279
308
  private lastMessage: string | null = null;
280
309
  private lastImages: AttachedImage[] | undefined = undefined;
310
+ private _lastWasHidden: boolean = false;
281
311
 
282
312
  // Callbacks
283
313
  private callbacks: StreamManagerCallbacks;
@@ -327,11 +357,43 @@ export class SessionStreamManager {
327
357
  return this._pendingQuestion;
328
358
  }
329
359
 
360
+ get queuedMessage(): QueuedMessage | null {
361
+ return this._queuedMessage;
362
+ }
363
+
364
+ /**
365
+ * Queue a message to be sent after the current stream completes.
366
+ * Only one message can be queued at a time (last one wins).
367
+ */
368
+ queueMessage(message: string, images?: AttachedImage[]): void {
369
+ this._queuedMessage = { message, images };
370
+ this.notifyStateChange();
371
+ }
372
+
373
+ /**
374
+ * Clear a queued message (e.g., if user stops the stream).
375
+ */
376
+ clearQueuedMessage(): void {
377
+ this._queuedMessage = null;
378
+ this.notifyStateChange();
379
+ }
380
+
330
381
  setNarratedMode(enabled: boolean): void {
331
382
  this._narratedMode = enabled;
383
+ this._userToggledNarratedMode = true;
332
384
  this.notifyStateChange();
333
385
  }
334
386
 
387
+ /**
388
+ * Update narrated mode without firing notifyStateChange.
389
+ * Used when React state is the source of truth (e.g., user toggle)
390
+ * to keep the stream manager in sync without overwriting React state.
391
+ */
392
+ setNarratedModeQuiet(enabled: boolean): void {
393
+ this._narratedMode = enabled;
394
+ this._userToggledNarratedMode = true;
395
+ }
396
+
335
397
  /**
336
398
  * Answer a pending question gate by clearing it and sending the selection as a message
337
399
  */
@@ -351,6 +413,7 @@ export class SessionStreamManager {
351
413
  isReconnecting: this._isReconnecting,
352
414
  reconnectAttempt: this._reconnectAttempt,
353
415
  narratedMode: this._narratedMode,
416
+ queuedMessage: this._queuedMessage,
354
417
  };
355
418
  }
356
419
 
@@ -359,7 +422,20 @@ export class SessionStreamManager {
359
422
  // -------------------------------------------------------------------------
360
423
 
361
424
  private notifyStateChange(): void {
362
- this.callbacks.onStateChange?.(this.getState());
425
+ if (!this._notifyPending) {
426
+ this._notifyPending = true;
427
+ const notify = () => {
428
+ this._notifyPending = false;
429
+ this.callbacks.onStateChange?.(this.getState());
430
+ };
431
+ // Batch rapid state changes (e.g., multiple stream events per frame) into one callback.
432
+ // requestAnimationFrame syncs with display refresh (~16ms); queueMicrotask as fallback.
433
+ if (typeof requestAnimationFrame !== 'undefined') {
434
+ requestAnimationFrame(notify);
435
+ } else {
436
+ queueMicrotask(notify);
437
+ }
438
+ }
363
439
  }
364
440
 
365
441
  setMessages(messages: ClaudeMessage[]): void {
@@ -401,9 +477,16 @@ export class SessionStreamManager {
401
477
  // Check for gate markers in tool results and text content
402
478
  const gate = extractGateFromMessage(message);
403
479
  if (gate) {
480
+ // Skip if the previous gate has the same gateType (prevents duplicate consecutive cards)
481
+ const lastGate = [...this._messages].reverse().find(m => m.type === 'gate');
482
+ if (lastGate && lastGate.gateType === gate.gateType) {
483
+ return;
484
+ }
404
485
  // Add the gate message instead of (or in addition to) the raw message
405
486
  this._messages = [...this._messages, gate];
406
- this._narratedMode = true; // Auto-enable narrated mode on first gate
487
+ if (!this._userToggledNarratedMode) {
488
+ this._narratedMode = true; // Auto-enable narrated mode on first gate
489
+ }
407
490
  this.callbacks.onGate?.(gate);
408
491
 
409
492
  // Question gates pause the workflow for user input
@@ -471,18 +554,19 @@ export class SessionStreamManager {
471
554
  this.reconnectTimeout = setTimeout(() => {
472
555
  if (this.stopped || !this.lastMessage) return;
473
556
 
474
- // Remove the user message we added (will be re-added on retry)
475
- // Find and remove the last user message
476
- const lastUserIndex = this._messages.findLastIndex(m => m.type === 'user');
477
- if (lastUserIndex >= 0) {
478
- this._messages = this._messages.slice(0, lastUserIndex);
557
+ // Remove the user message we added (will be re-added on retry) — skip if hidden
558
+ if (!this._lastWasHidden) {
559
+ const lastUserIndex = this._messages.findLastIndex(m => m.type === 'user');
560
+ if (lastUserIndex >= 0) {
561
+ this._messages = this._messages.slice(0, lastUserIndex);
562
+ }
479
563
  }
480
564
 
481
565
  this._isReconnecting = false;
482
566
  this.notifyStateChange();
483
567
 
484
568
  // Retry the request
485
- this.sendMessage(this.lastMessage, this.lastImages);
569
+ this.sendMessage(this.lastMessage, this.lastImages, { hidden: this._lastWasHidden });
486
570
  }, delay);
487
571
  }
488
572
 
@@ -491,36 +575,48 @@ export class SessionStreamManager {
491
575
  // -------------------------------------------------------------------------
492
576
 
493
577
  /**
494
- * Send a message to Claude and stream the response
578
+ * Send a message to Claude and stream the response.
579
+ * When hidden is true, the user message is not added to visible messages —
580
+ * used for conversational sessions where Claude should speak first.
495
581
  */
496
- async sendMessage(message: string, images?: AttachedImage[]): Promise<void> {
582
+ async sendMessage(message: string, images?: AttachedImage[], options?: { hidden?: boolean }): Promise<void> {
583
+ const hidden = options?.hidden ?? false;
497
584
  this.stopped = false;
498
585
 
499
586
  // Store for potential retry/reconnect
500
587
  this.lastMessage = message;
501
588
  this.lastImages = images;
589
+ this._lastWasHidden = hidden;
590
+
591
+ // Add user message immediately (unless hidden — conversational system instructions)
592
+ if (!hidden) {
593
+ const userMessage: ClaudeMessage = {
594
+ type: 'user',
595
+ content: message,
596
+ timestamp: Date.now(),
597
+ };
598
+ this.addMessage(userMessage);
599
+ }
502
600
 
503
- // Add user message immediately
504
- const userMessage: ClaudeMessage = {
505
- type: 'user',
506
- content: message,
507
- timestamp: Date.now(),
508
- };
509
- this.addMessage(userMessage);
510
-
511
- // For first message in a new session, show "creating" status for 5 seconds
601
+ // For first message in a new session, show "creating" status
602
+ // Conversational sessions skip the 5s delay and go straight to streaming
512
603
  if (this._isFirstMessage) {
513
604
  this._isFirstMessage = false;
514
- this._status = 'creating';
515
- this.notifyStateChange();
516
-
517
- // Transition to streaming after 5 seconds
518
- this.creatingStatusTimeout = setTimeout(() => {
519
- if (this._status === 'creating') {
520
- this._status = 'streaming';
521
- this.notifyStateChange();
522
- }
523
- }, 5000);
605
+ if (this.sessionContext.conversational) {
606
+ this._status = 'streaming';
607
+ this.notifyStateChange();
608
+ } else {
609
+ this._status = 'creating';
610
+ this.notifyStateChange();
611
+
612
+ // Transition to streaming after 5 seconds
613
+ this.creatingStatusTimeout = setTimeout(() => {
614
+ if (this._status === 'creating') {
615
+ this._status = 'streaming';
616
+ this.notifyStateChange();
617
+ }
618
+ }, 5000);
619
+ }
524
620
  } else {
525
621
  // Set status to streaming
526
622
  this._status = 'streaming';
@@ -541,7 +637,6 @@ export class SessionStreamManager {
541
637
  },
542
638
  body: JSON.stringify({
543
639
  message,
544
- conversationHistory: this._messages.slice(0, -1), // Exclude the user message we just added
545
640
  images: images?.map(img => ({
546
641
  type: img.type,
547
642
  data: img.dataUrl,
@@ -618,7 +713,7 @@ export class SessionStreamManager {
618
713
  if (this.sessionContext.standalone && this.callbacks.onWorkItemCreated) {
619
714
  const created = checkMessageForWorkItemCreation(claudeMessage);
620
715
  if (created) {
621
- this.callbacks.onWorkItemCreated(created.id, created.title);
716
+ this.callbacks.onWorkItemCreated(created.id, created.title, this.sessionContext.workItemId);
622
717
  }
623
718
  }
624
719
  }
@@ -667,6 +762,7 @@ export class SessionStreamManager {
667
762
  this._status = 'idle';
668
763
  this._isReconnecting = false;
669
764
  this._reconnectAttempt = 0;
765
+ this._queuedMessage = null; // Clear queue on stop
670
766
  this.notifyStateChange();
671
767
  }
672
768
 
@@ -681,6 +777,7 @@ export class SessionStreamManager {
681
777
  this._canRetry = false;
682
778
  this._isReconnecting = false;
683
779
  this._reconnectAttempt = 0;
780
+ this._queuedMessage = null;
684
781
  this.lastMessage = null;
685
782
  this.lastImages = undefined;
686
783
  this.notifyStateChange();
@@ -700,16 +797,18 @@ export class SessionStreamManager {
700
797
  this._error = null;
701
798
  this._canRetry = false;
702
799
 
703
- // Remove the failed user message (will be re-added on retry)
704
- const lastUserIndex = this._messages.findLastIndex(m => m.type === 'user');
705
- if (lastUserIndex >= 0) {
706
- this._messages = this._messages.slice(0, lastUserIndex);
800
+ // Remove the failed user message (will be re-added on retry) — skip if hidden
801
+ if (!this._lastWasHidden) {
802
+ const lastUserIndex = this._messages.findLastIndex(m => m.type === 'user');
803
+ if (lastUserIndex >= 0) {
804
+ this._messages = this._messages.slice(0, lastUserIndex);
805
+ }
707
806
  }
708
807
 
709
808
  this.notifyStateChange();
710
809
 
711
810
  // Resend the last message
712
- this.sendMessage(this.lastMessage, this.lastImages);
811
+ this.sendMessage(this.lastMessage, this.lastImages, { hidden: this._lastWasHidden });
713
812
  }
714
813
 
715
814
  /**
@@ -717,6 +816,11 @@ export class SessionStreamManager {
717
816
  * This adds the gate directly to the message list without parsing from stream text.
718
817
  */
719
818
  injectGate(gateType: string, gateData: Record<string, unknown> = {}): void {
819
+ // Skip if the previous gate has the same type (prevents duplicate consecutive cards)
820
+ const lastGate = [...this._messages].reverse().find(m => m.type === 'gate');
821
+ if (lastGate && lastGate.gateType === gateType) {
822
+ return;
823
+ }
720
824
  const gate: ClaudeMessage = {
721
825
  type: 'gate',
722
826
  gateType,
@@ -724,7 +828,9 @@ export class SessionStreamManager {
724
828
  timestamp: Date.now(),
725
829
  };
726
830
  this._messages = [...this._messages, gate];
727
- this._narratedMode = true;
831
+ if (!this._userToggledNarratedMode) {
832
+ this._narratedMode = true;
833
+ }
728
834
  this.callbacks.onGate?.(gate);
729
835
  this.notifyStateChange();
730
836
  }
@@ -0,0 +1,7 @@
1
+ /** Design system elevation tokens — shared across all components. */
2
+ export const shadow = {
3
+ sm: '0 1px 2px rgba(0,0,0,0.04), 0 2px 6px rgba(0,0,0,0.02)',
4
+ md: '0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.03)',
5
+ lg: '0 4px 12px rgba(0,0,0,0.06), 0 12px 28px rgba(0,0,0,0.05)',
6
+ overlay: '0 8px 24px rgba(0,0,0,0.12), 0 16px 48px rgba(0,0,0,0.08)',
7
+ };
@@ -12,6 +12,7 @@ export interface TestScenario {
12
12
  title: string;
13
13
  status: 'pass' | 'fail' | 'pending';
14
14
  duration: string;
15
+ lastRun: string | null;
15
16
  error?: string;
16
17
  failedStep?: string;
17
18
  steps: string[];
@@ -200,7 +201,7 @@ export function getTestDashboardData(): TestDashboardData {
200
201
  const featureFiles = getFeatureFiles(projectRoot);
201
202
 
202
203
  // Build a map of scenario name → latest DB result
203
- let dbResults: Map<string, { status: string; duration_ms: number; error_message: string | null; failed_step: string | null }>;
204
+ let dbResults: Map<string, { status: string; duration_ms: number; error_message: string | null; failed_step: string | null; run_at: string }>;
204
205
  let lastRun: string | null = null;
205
206
  try {
206
207
  const rows = getLatestResults();
@@ -248,6 +249,7 @@ export function getTestDashboardData(): TestDashboardData {
248
249
  title: scenario.name,
249
250
  status,
250
251
  duration,
252
+ lastRun: result?.run_at || null,
251
253
  error,
252
254
  failedStep,
253
255
  steps: scenario.steps,
@@ -4,3 +4,9 @@ import { twMerge } from "tailwind-merge"
4
4
  export function cn(...inputs: ClassValue[]) {
5
5
  return twMerge(clsx(inputs))
6
6
  }
7
+
8
+ export function getWebSocketUrl(): string {
9
+ return typeof window !== 'undefined'
10
+ ? `ws://${window.location.hostname}:47808`
11
+ : 'ws://localhost:47808';
12
+ }
@@ -1,9 +1,5 @@
1
1
  const path = require('path');
2
2
 
3
- // Dashboard is at apps/dashboard, jettypod lib is at ../../lib relative to that
4
- // process.cwd() is the dashboard directory during build
5
- const jettypodLibPath = path.resolve(process.cwd(), '../../lib');
6
-
7
3
  /** @type {import('next').NextConfig} */
8
4
  const nextConfig = {
9
5
  // Externalize modules with native bindings or dynamic requires
@@ -18,22 +14,47 @@ const nextConfig = {
18
14
  // The webpack externals below properly externalize the jettypod lib chain
19
15
 
20
16
  webpack: (config, { isServer }) => {
17
+ // Split @dnd-kit into a separate chunk so it doesn't block initial page parse.
18
+ // The chunk loads in parallel and is only needed when KanbanBoard renders.
19
+ if (!isServer) {
20
+ config.optimization = config.optimization || {};
21
+ config.optimization.splitChunks = config.optimization.splitChunks || {};
22
+ config.optimization.splitChunks.cacheGroups = {
23
+ ...config.optimization.splitChunks.cacheGroups,
24
+ dndkit: {
25
+ test: /[\\/]node_modules[\\/]@dnd-kit[\\/]/,
26
+ name: 'dnd-kit',
27
+ chunks: 'all',
28
+ priority: 30,
29
+ },
30
+ };
31
+ }
32
+
21
33
  if (isServer) {
22
- // Externalize the jettypod lib using absolute path resolution
23
34
  config.externals = config.externals || [];
24
- config.externals.push(({ request, context }, callback) => {
25
- // Externalize any require that goes to jettypod lib (dynamic requires)
35
+ config.externals.push(({ request }, callback) => {
36
+ // Externalize worktree-facade with RUNTIME path resolution.
37
+ // Uses 'var' external type so the path expression evaluates at runtime,
38
+ // not at build time (which would bake in the build machine's absolute path).
39
+ // Packaged app: JETTYPOD_RESOURCES_PATH/bin/lib/<module>
40
+ // Dev mode: process.cwd()/../../lib/<module>
26
41
  if (request && request.includes('lib/worktree-facade')) {
27
- // Use absolute path to jettypod lib
28
42
  const moduleName = request.split('lib/')[1];
29
- const absolutePath = path.join(jettypodLibPath, moduleName);
30
- return callback(null, `commonjs ${absolutePath}`);
43
+ return callback(null,
44
+ `var require(process.env.JETTYPOD_RESOURCES_PATH ` +
45
+ `? require('path').join(process.env.JETTYPOD_RESOURCES_PATH, 'bin', 'lib', '${moduleName}') ` +
46
+ `: require('path').resolve(process.cwd(), '../../lib', '${moduleName}'))`
47
+ );
31
48
  }
32
- // Externalize run-migrations.js - it uses dynamic require() to load
33
- // migration files at runtime, which webpack replaces with a dead stub
49
+ // Externalize run-migrations.js with RUNTIME path resolution.
50
+ // It uses dynamic require() to load migration files, which webpack
51
+ // replaces with a dead stub if bundled.
52
+ // In both packaged and dev: process.cwd() is the dashboard dir,
53
+ // and run-migrations.js is at lib/run-migrations.js relative to it.
34
54
  if (request && request.includes('run-migrations')) {
35
- const absolutePath = path.resolve(context || __dirname, request);
36
- return callback(null, `commonjs ${absolutePath}`);
55
+ return callback(null,
56
+ `var require(require('path').join(process.cwd(), 'lib', 'run-migrations'))`
57
+ );
37
58
  }
38
59
  callback(undefined);
39
60
  });
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "dashboard",
3
- "version": "0.1.0",
3
+ "version": "4.4.120",
4
4
  "private": true,
5
5
  "main": "electron/main.js",
6
6
  "scripts": {
7
- "dev": "next dev --webpack",
7
+ "dev": "concurrently -n next,ws -c blue,green \"next dev --webpack\" \"node scripts/ws-server.js\"",
8
8
  "build": "next build --webpack",
9
9
  "start": "next start",
10
10
  "lint": "eslint",
@@ -18,7 +18,9 @@
18
18
  "electron:build:mac:universal": "npm run build && npx electron-builder --config electron-builder.config.js --mac --universal",
19
19
  "electron:build:win": "npm run build && npx electron-builder --config electron-builder.config.js --win",
20
20
  "electron:build:linux": "npm run build && npx electron-builder --config electron-builder.config.js --linux",
21
- "electron:pack": "npm run build && npx electron-builder --config electron-builder.config.js --dir"
21
+ "electron:pack": "npm run build && npx electron-builder --config electron-builder.config.js --dir",
22
+ "upload:r2": "node scripts/upload-to-r2.js",
23
+ "electron:release": "npm run electron:build:mac && npm run upload:r2"
22
24
  },
23
25
  "dependencies": {
24
26
  "@dnd-kit/core": "^6.3.1",
@@ -45,6 +47,7 @@
45
47
  "@types/node": "^20",
46
48
  "@types/react": "^19",
47
49
  "@types/react-dom": "^19",
50
+ "concurrently": "^9.2.1",
48
51
  "electron": "^32.0.0",
49
52
  "electron-builder": "^26.4.0",
50
53
  "eslint": "^9",