jettypod 4.4.120 → 4.4.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/.env +2 -1
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +54 -49
  8. package/apps/dashboard/app/demo/gates/page.tsx +3 -5
  9. package/apps/dashboard/app/design-system/page.tsx +1 -1
  10. package/apps/dashboard/app/globals.css +74 -2
  11. package/apps/dashboard/app/install-claude/page.tsx +3 -5
  12. package/apps/dashboard/app/login/page.tsx +17 -20
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +60 -12
  15. package/apps/dashboard/app/signup/page.tsx +14 -17
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +12 -15
  19. package/apps/dashboard/app/work/[id]/page.tsx +90 -75
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +70 -61
  22. package/apps/dashboard/components/CardMenu.tsx +0 -1
  23. package/apps/dashboard/components/ClaudePanel.tsx +541 -283
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -4
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +1 -5
  26. package/apps/dashboard/components/CopyableId.tsx +1 -2
  27. package/apps/dashboard/components/DetailReviewActions.tsx +11 -20
  28. package/apps/dashboard/components/DragContext.tsx +132 -62
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +5 -6
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +6 -12
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +0 -1
  34. package/apps/dashboard/components/ElapsedTimer.tsx +15 -3
  35. package/apps/dashboard/components/EpicGroup.tsx +100 -70
  36. package/apps/dashboard/components/GateCard.tsx +0 -1
  37. package/apps/dashboard/components/GateChoiceCard.tsx +1 -2
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +1 -5
  39. package/apps/dashboard/components/JettyLoader.tsx +0 -1
  40. package/apps/dashboard/components/KanbanBoard.tsx +319 -173
  41. package/apps/dashboard/components/KanbanCard.tsx +341 -107
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +0 -1
  44. package/apps/dashboard/components/MainNav.tsx +24 -25
  45. package/apps/dashboard/components/MessageBlock.tsx +93 -16
  46. package/apps/dashboard/components/ModeStartCard.tsx +0 -1
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +0 -1
  48. package/apps/dashboard/components/PlaceholderCard.tsx +0 -1
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +20 -20
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +47 -26
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +308 -223
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +303 -160
  53. package/apps/dashboard/components/ReviewFooter.tsx +12 -14
  54. package/apps/dashboard/components/SessionList.tsx +0 -1
  55. package/apps/dashboard/components/SubscribeContent.tsx +40 -11
  56. package/apps/dashboard/components/TestTree.tsx +1 -2
  57. package/apps/dashboard/components/TipCard.tsx +2 -4
  58. package/apps/dashboard/components/Toast.tsx +0 -1
  59. package/apps/dashboard/components/TypeIcon.tsx +7 -8
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +5 -17
  62. package/apps/dashboard/components/WelcomeScreen.tsx +2 -6
  63. package/apps/dashboard/components/WorkItemHeader.tsx +0 -1
  64. package/apps/dashboard/components/WorkItemTree.tsx +2 -4
  65. package/apps/dashboard/components/settings/AccountSection.tsx +27 -13
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +20 -73
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +137 -26
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +0 -1
  72. package/apps/dashboard/components/ui/Button.tsx +1 -1
  73. package/apps/dashboard/components/ui/Input.tsx +1 -1
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +611 -358
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +0 -1
  77. package/apps/dashboard/contexts/UsageContext.tsx +62 -31
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  81. package/apps/dashboard/index.html +73 -0
  82. package/apps/dashboard/lib/data-bridge.ts +722 -0
  83. package/apps/dashboard/lib/db.ts +69 -1302
  84. package/apps/dashboard/lib/environment-config.ts +173 -0
  85. package/apps/dashboard/lib/environment-verification.ts +119 -0
  86. package/apps/dashboard/lib/kanban-utils.ts +226 -26
  87. package/apps/dashboard/lib/proof-run.ts +495 -0
  88. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  89. package/apps/dashboard/lib/service-recovery.ts +326 -0
  90. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  91. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  92. package/apps/dashboard/lib/session-stream-manager.ts +253 -122
  93. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  94. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  95. package/apps/dashboard/lib/tauri.ts +106 -0
  96. package/apps/dashboard/lib/utils.ts +3 -3
  97. package/apps/dashboard/next-env.d.ts +1 -1
  98. package/apps/dashboard/package.json +21 -33
  99. package/apps/dashboard/public/bug-icon.png +0 -0
  100. package/apps/dashboard/public/buoy-icon.png +0 -0
  101. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  102. package/apps/dashboard/public/pier-icon.png +0 -0
  103. package/apps/dashboard/public/star-icon.png +0 -0
  104. package/apps/dashboard/public/wrench-icon.png +0 -0
  105. package/apps/dashboard/scripts/tauri-build.js +228 -0
  106. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  107. package/apps/dashboard/src/main.tsx +12 -0
  108. package/apps/dashboard/src/router.tsx +107 -0
  109. package/apps/dashboard/src/vite-env.d.ts +1 -0
  110. package/apps/dashboard/tsconfig.json +7 -12
  111. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  112. package/apps/dashboard/vite.config.ts +33 -0
  113. package/apps/update-server/src/index.ts +167 -30
  114. package/claude-hooks/global-guardrails.js +14 -13
  115. package/crates/jettypod-cli/Cargo.toml +19 -0
  116. package/crates/jettypod-cli/src/commands.rs +1249 -0
  117. package/crates/jettypod-cli/src/main.rs +595 -0
  118. package/crates/jettypod-core/Cargo.toml +26 -0
  119. package/crates/jettypod-core/build.rs +98 -0
  120. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  121. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  122. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  123. package/crates/jettypod-core/src/auth.rs +294 -0
  124. package/crates/jettypod-core/src/config.rs +397 -0
  125. package/crates/jettypod-core/src/db/mod.rs +507 -0
  126. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  127. package/crates/jettypod-core/src/db/startup.rs +101 -0
  128. package/crates/jettypod-core/src/db/validate.rs +149 -0
  129. package/crates/jettypod-core/src/error.rs +76 -0
  130. package/crates/jettypod-core/src/git.rs +458 -0
  131. package/crates/jettypod-core/src/lib.rs +20 -0
  132. package/crates/jettypod-core/src/sessions.rs +625 -0
  133. package/crates/jettypod-core/src/skills.rs +556 -0
  134. package/crates/jettypod-core/src/work.rs +1086 -0
  135. package/crates/jettypod-core/src/worktree.rs +628 -0
  136. package/crates/jettypod-core/src/ws.rs +767 -0
  137. package/cucumber-test.cjs +6 -0
  138. package/jettypod.js +96 -4
  139. package/lib/bdd-preflight.js +96 -0
  140. package/lib/merge-lock.js +111 -253
  141. package/lib/migrations/030-rejection-round-columns.js +54 -0
  142. package/lib/migrations/031-session-isolation-index.js +17 -0
  143. package/lib/work-commands/index.js +58 -16
  144. package/lib/work-tracking/index.js +108 -8
  145. package/package.json +1 -1
  146. package/skills-templates/bug-mode/SKILL.md +43 -1
  147. package/skills-templates/chore-mode/SKILL.md +40 -1
  148. package/skills-templates/design-system-selection/SKILL.md +273 -0
  149. package/skills-templates/epic-planning/SKILL.md +14 -0
  150. package/skills-templates/feature-planning/SKILL.md +90 -1
  151. package/skills-templates/production-mode/SKILL.md +20 -0
  152. package/skills-templates/simple-improvement/SKILL.md +39 -2
  153. package/skills-templates/speed-mode/SKILL.md +10 -15
  154. package/skills-templates/stable-mode/SKILL.md +47 -0
  155. package/apps/dashboard/README.md +0 -36
  156. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -446
  157. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  158. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -280
  159. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  160. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -525
  161. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  162. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  163. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  164. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  165. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  166. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  167. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  168. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  169. package/apps/dashboard/app/api/tests/route.ts +0 -9
  170. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  171. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  172. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  173. package/apps/dashboard/app/api/usage/route.ts +0 -17
  174. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  175. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  176. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  177. package/apps/dashboard/app/api/work/[id]/route.ts +0 -35
  178. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -63
  179. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  180. package/apps/dashboard/app/layout.tsx +0 -55
  181. package/apps/dashboard/components/UpgradeBanner.tsx +0 -30
  182. package/apps/dashboard/electron/ipc-handlers.js +0 -1026
  183. package/apps/dashboard/electron/main.js +0 -2306
  184. package/apps/dashboard/electron/preload.js +0 -125
  185. package/apps/dashboard/electron/session-manager.js +0 -163
  186. package/apps/dashboard/electron-builder.config.js +0 -357
  187. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  188. package/apps/dashboard/lib/backlog-parser.ts +0 -50
  189. package/apps/dashboard/lib/claude-process-manager.ts +0 -529
  190. package/apps/dashboard/lib/db-bridge.ts +0 -283
  191. package/apps/dashboard/lib/prototypes.ts +0 -202
  192. package/apps/dashboard/lib/test-results-db.ts +0 -307
  193. package/apps/dashboard/lib/tests.ts +0 -282
  194. package/apps/dashboard/next.config.js +0 -66
  195. package/apps/dashboard/postcss.config.mjs +0 -7
  196. package/apps/dashboard/public/bug-icon.svg +0 -9
  197. package/apps/dashboard/public/buoy-icon.svg +0 -9
  198. package/apps/dashboard/public/file.svg +0 -1
  199. package/apps/dashboard/public/globe.svg +0 -1
  200. package/apps/dashboard/public/in-flight-seagull.svg +0 -9
  201. package/apps/dashboard/public/next.svg +0 -1
  202. package/apps/dashboard/public/pier-icon.svg +0 -14
  203. package/apps/dashboard/public/star-icon.svg +0 -9
  204. package/apps/dashboard/public/vercel.svg +0 -1
  205. package/apps/dashboard/public/window.svg +0 -1
  206. package/apps/dashboard/public/wrench-icon.svg +0 -9
  207. package/apps/dashboard/scripts/download-node.js +0 -104
  208. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
@@ -1,7 +1,8 @@
1
- 'use client';
2
1
 
3
- import { useEffect, useRef, useState, useCallback, DragEvent } from 'react';
2
+ import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react';
4
3
  import { AnimatePresence, m } from 'framer-motion';
4
+ import { useVirtualizer } from '@tanstack/react-virtual';
5
+ import { listen, invoke } from '../lib/tauri';
5
6
 
6
7
  import type { ClaudeMessage } from '../lib/session-stream-manager';
7
8
  import { ClaudePanelInput, AttachedImage } from './ClaudePanelInput';
@@ -12,9 +13,29 @@ import { getRegistry } from '../lib/stream-manager-registry';
12
13
  import { useUsage } from '../contexts/UsageContext';
13
14
  import { useWebSocket, type WebSocketMessage } from '../hooks/useWebSocket';
14
15
  import { getWebSocketUrl } from '../lib/utils';
15
- import { MessageBlock, StatusIndicator, ErrorIcon, UserIcon, humanizeToolCall, unescapeContent, isSystemNoise } from './MessageBlock';
16
+ import { MessageBlock, MergedToolBlock, StatusIndicator, ErrorIcon, UserIcon, humanizeToolCall, unescapeContent, isSystemNoise } from './MessageBlock';
16
17
  import { ElapsedTimer } from './ElapsedTimer';
17
18
  import { Button } from '@/components/ui/Button';
19
+ import { ViewModeToolbar, type ViewMode } from './ViewModeToolbar';
20
+ import { dataBridge } from '@/lib/data-bridge';
21
+
22
+ const READOUT_FILTERS = [
23
+ { id: 'init', label: 'Init', types: ['system'] },
24
+ { id: 'streaming', label: 'Streaming', types: ['content_block_start', 'content_block_delta', 'content_block_stop', 'message_start', 'message_delta', 'message_stop'] },
25
+ { id: 'messages', label: 'Messages', types: ['assistant'] },
26
+ { id: 'tools', label: 'Tools', types: ['user'] },
27
+ { id: 'completion', label: 'Completion', types: ['result', 'done'] },
28
+ { id: 'errors', label: 'Errors', types: ['error'] },
29
+ ] as const;
30
+
31
+ type ReadoutFilterId = typeof READOUT_FILTERS[number]['id'];
32
+
33
+ type DetailItem =
34
+ | { kind: 'message'; msg: ClaudeMessage; idx: number; isIntermediate: boolean; firstLine: string }
35
+ | { kind: 'merged-tool'; toolMsg: ClaudeMessage; resultMsg?: ClaudeMessage; idx: number }
36
+ | { kind: 'gate'; msg: ClaudeMessage; idx: number }
37
+ | { kind: 'elapsed'; timerKey: string }
38
+ | { kind: 'tool-indicator'; toolMsg: ClaudeMessage };
18
39
 
19
40
  interface ClaudePanelProps {
20
41
  isOpen: boolean;
@@ -37,6 +58,8 @@ export function ClaudePanel({
37
58
  canRetry,
38
59
  queuedMessage,
39
60
  narratedMode: narratedModeRaw,
61
+ fullReadoutMode,
62
+ rawEvents,
40
63
  isTabSwitching,
41
64
  } = useSessionState();
42
65
  const {
@@ -48,6 +71,7 @@ export function ClaudePanel({
48
71
  retry: onRetry,
49
72
  stop: onStop,
50
73
  toggleNarratedMode: onToggleNarratedMode,
74
+ toggleFullReadout: onToggleFullReadout,
51
75
  } = useSessionActions();
52
76
 
53
77
  const workItemId = activeSessionId || 'sessions';
@@ -62,11 +86,34 @@ export function ClaudePanel({
62
86
  const hasMeaningfulContent = messages.some(m => m.type === 'user' || m.type === 'gate');
63
87
  const effectiveNarratedMode = hasMeaningfulContent ? narratedMode : false;
64
88
 
89
+ // Memoize narrated message computation — avoids recomputing on every render
90
+ const { narratedMessages, lastGateIndex } = useMemo(() => {
91
+ if (!effectiveNarratedMode) return { narratedMessages: [], lastGateIndex: -1 };
92
+ const finalIndicesPerTurn = new Set<number>();
93
+ let lastAssistantOrTextIdx = -1;
94
+ for (let i = 0; i < messages.length; i++) {
95
+ if (messages[i].type === 'assistant' || messages[i].type === 'text') {
96
+ lastAssistantOrTextIdx = i;
97
+ }
98
+ if (messages[i].type === 'user' && lastAssistantOrTextIdx >= 0) {
99
+ finalIndicesPerTurn.add(lastAssistantOrTextIdx);
100
+ lastAssistantOrTextIdx = -1;
101
+ }
102
+ }
103
+ if (lastAssistantOrTextIdx >= 0 && status !== 'streaming') {
104
+ finalIndicesPerTurn.add(lastAssistantOrTextIdx);
105
+ }
106
+ const filtered = messages.filter((m, i) =>
107
+ m.type === 'gate' || m.type === 'user' || ((m.type === 'assistant' || m.type === 'text') && finalIndicesPerTurn.has(i))
108
+ );
109
+ return { narratedMessages: filtered, lastGateIndex: filtered.findLastIndex(m => m.type === 'gate') };
110
+ }, [messages, status, effectiveNarratedMode]);
111
+
65
112
  // Debounce "What's next?" to prevent flash during tab switches.
66
113
  // When messages become empty (e.g., switching to a session whose content hasn't loaded yet),
67
114
  // wait 300ms before showing the empty state. If content arrives in that window, no flash.
68
115
  const [showEmptyState, setShowEmptyState] = useState(() => messages.length === 0 && status === 'idle');
69
- const emptyStateTimerRef = useRef<NodeJS.Timeout>();
116
+ const emptyStateTimerRef = useRef<NodeJS.Timeout>(undefined);
70
117
  useEffect(() => {
71
118
  if (messages.length === 0 && status === 'idle' && !isTabSwitching) {
72
119
  emptyStateTimerRef.current = setTimeout(() => setShowEmptyState(true), 300);
@@ -76,6 +123,17 @@ export function ClaudePanel({
76
123
  return () => clearTimeout(emptyStateTimerRef.current);
77
124
  }, [activeSessionId, messages.length, status, isTabSwitching]);
78
125
 
126
+ // Auto-create a session only when the panel transitions from closed to open with no sessions
127
+ const hasNoSessions = (!sessions || sessions.size === 0) && standaloneSessions.length === 0;
128
+ const prevIsOpenRef = useRef(isOpen);
129
+ useEffect(() => {
130
+ const wasOpen = prevIsOpenRef.current;
131
+ prevIsOpenRef.current = isOpen;
132
+ if (isOpen && !wasOpen && hasNoSessions && !limitReached) {
133
+ onNewSession?.();
134
+ }
135
+ }, [isOpen, hasNoSessions, limitReached, onNewSession]);
136
+
79
137
  // Track answered question gates by timestamp → selected option id
80
138
  const [answeredQuestions, setAnsweredQuestions] = useState<Map<number, string>>(new Map());
81
139
 
@@ -99,65 +157,229 @@ export function ClaudePanel({
99
157
  onOpenSession(String(id), title, type);
100
158
  }, [onOpenSession]);
101
159
 
160
+ // Scroll ratio to restore after view mode change
161
+ const scrollRatioRef = useRef<number | null>(null);
162
+
102
163
  // Accordion state for detail view - tracks which intermediate messages are expanded
103
164
  const [expandedIndices, setExpandedIndices] = useState<Set<number>>(new Set());
165
+ const [activeFilters, setActiveFilters] = useState<Set<ReadoutFilterId>>(() => new Set(READOUT_FILTERS.map(f => f.id)));
166
+ const toggleFilter = useCallback((id: ReadoutFilterId) => {
167
+ setActiveFilters(prev => {
168
+ const next = new Set(prev);
169
+ if (next.has(id)) next.delete(id);
170
+ else next.add(id);
171
+ return next;
172
+ });
173
+ }, []);
174
+
175
+ // Derive viewMode from existing state
176
+ const viewMode: ViewMode = effectiveNarratedMode ? 'summary' : fullReadoutMode ? 'raw' : 'detail';
177
+
178
+ // Compute detail-view intermediates at component level for toolbar
179
+ // Include tool_use so we can pair them with tool_result as merged blocks
180
+ const detailFilteredMessages = messages;
181
+ const detailUserMessageCount = detailFilteredMessages.filter(m => m.type === 'user').length;
182
+ const detailLastAssistantIndex = detailFilteredMessages.findLastIndex(m => m.type === 'assistant' || m.type === 'text');
183
+ const detailHasIntermediates = detailUserMessageCount > 0 && detailFilteredMessages.filter(m => m.type === 'assistant' || m.type === 'text').length > 1;
184
+ const detailAllExpanded = detailHasIntermediates && detailFilteredMessages.every((m, i) =>
185
+ (m.type !== 'assistant' && m.type !== 'text') || i === detailLastAssistantIndex || expandedIndices.has(i)
186
+ );
187
+
188
+ const handleViewModeChange = useCallback((mode: ViewMode) => {
189
+ // Save scroll ratio before switching so we can restore position
190
+ if (contentRef.current) {
191
+ const { scrollTop, scrollHeight, clientHeight } = contentRef.current;
192
+ const scrollableHeight = scrollHeight - clientHeight;
193
+ scrollRatioRef.current = scrollableHeight > 0 ? scrollTop / scrollableHeight : 0;
194
+ }
195
+ // Reset expand state when leaving Detail mode
196
+ if (viewMode === 'detail' && mode !== 'detail') {
197
+ setExpandedIndices(new Set());
198
+ }
199
+ if (mode === 'summary' && !effectiveNarratedMode) {
200
+ onToggleNarratedMode?.();
201
+ if (fullReadoutMode) onToggleFullReadout?.();
202
+ } else if (mode === 'detail') {
203
+ if (effectiveNarratedMode) onToggleNarratedMode?.();
204
+ if (fullReadoutMode) onToggleFullReadout?.();
205
+ } else if (mode === 'raw') {
206
+ if (effectiveNarratedMode) onToggleNarratedMode?.();
207
+ if (!fullReadoutMode) onToggleFullReadout?.();
208
+ }
209
+ }, [viewMode, effectiveNarratedMode, fullReadoutMode, onToggleNarratedMode, onToggleFullReadout]);
210
+
211
+ // Restore scroll ratio after view mode change renders new content
212
+ useLayoutEffect(() => {
213
+ if (scrollRatioRef.current !== null && contentRef.current) {
214
+ const ratio = scrollRatioRef.current;
215
+ scrollRatioRef.current = null;
216
+ // Use rAF to wait for the browser to layout the new content
217
+ requestAnimationFrame(() => {
218
+ if (contentRef.current) {
219
+ const { scrollHeight, clientHeight } = contentRef.current;
220
+ contentRef.current.scrollTop = ratio * (scrollHeight - clientHeight);
221
+ }
222
+ });
223
+ }
224
+ }, [effectiveNarratedMode, fullReadoutMode]);
225
+
226
+ const handleToggleExpandAll = useCallback(() => {
227
+ if (detailAllExpanded) {
228
+ setExpandedIndices(new Set());
229
+ } else {
230
+ const all = new Set<number>();
231
+ detailFilteredMessages.forEach((m, i) => {
232
+ if ((m.type === 'assistant' || m.type === 'text') && i !== detailLastAssistantIndex) {
233
+ all.add(i);
234
+ }
235
+ });
236
+ setExpandedIndices(all);
237
+ }
238
+ }, [detailAllExpanded, detailFilteredMessages, detailLastAssistantIndex]);
239
+
240
+ // Pre-compute renderable items for detail mode virtualization
241
+ const detailItems = useMemo<DetailItem[]>(() => {
242
+ if (effectiveNarratedMode || fullReadoutMode) return [];
243
+
244
+ const items: DetailItem[] = [];
245
+ const fm = detailFilteredMessages;
246
+ const lastUserIdx = fm.findLastIndex(m => m.type === 'user');
247
+ const lastAssistIdx = detailLastAssistantIndex;
248
+ const userCount = detailUserMessageCount;
249
+
250
+ const pairedResultIndices = new Set<number>();
251
+ for (let i = 0; i < fm.length; i++) {
252
+ if (fm[i].type === 'tool_use') {
253
+ for (let j = i + 1; j < fm.length; j++) {
254
+ if (fm[j].type === 'tool_result') { pairedResultIndices.add(j); break; }
255
+ if (fm[j].type !== 'tool_use') break;
256
+ }
257
+ }
258
+ }
259
+
260
+ for (let i = 0; i < fm.length; i++) {
261
+ const msg = fm[i];
262
+ if (pairedResultIndices.has(i)) continue;
263
+
264
+ if (msg.type === 'tool_use') {
265
+ let resultMsg: ClaudeMessage | undefined;
266
+ for (let j = i + 1; j < fm.length; j++) {
267
+ if (fm[j].type === 'tool_result') { resultMsg = fm[j]; break; }
268
+ if (fm[j].type !== 'tool_use') break;
269
+ }
270
+ items.push({ kind: 'merged-tool', toolMsg: msg, resultMsg, idx: i });
271
+ } else if (msg.type === 'gate') {
272
+ items.push({ kind: 'gate', msg, idx: i });
273
+ } else {
274
+ const isAssistant = msg.type === 'assistant' || msg.type === 'text';
275
+ const isFinal = isAssistant && i === lastAssistIdx;
276
+ const isIntermediate = isAssistant && !isFinal && userCount > 0;
277
+ const firstLine = isIntermediate ? (unescapeContent(msg.content).split('\n')[0] || '').slice(0, 120) : '';
278
+ items.push({ kind: 'message', msg, idx: i, isIntermediate, firstLine });
279
+ }
280
+
281
+ if (i === lastUserIdx && (status === 'streaming' || status === 'creating')) {
282
+ items.push({ kind: 'elapsed', timerKey: `${activeSessionId ?? 'default'}-${userCount}` });
283
+ }
284
+ }
285
+
286
+ const lastToolIdx = fm.findLastIndex(m => m.type === 'tool_use');
287
+ if (lastToolIdx !== -1) {
288
+ const hasSubsequent = fm.slice(lastToolIdx + 1).some(
289
+ m => (m.type === 'text' || m.type === 'assistant') && !isSystemNoise(m.content)
290
+ );
291
+ if (!hasSubsequent) {
292
+ items.push({ kind: 'tool-indicator', toolMsg: fm[lastToolIdx] });
293
+ }
294
+ }
295
+
296
+ return items;
297
+ }, [detailFilteredMessages, detailLastAssistantIndex, detailUserMessageCount, status, activeSessionId, effectiveNarratedMode, fullReadoutMode]);
298
+
299
+ const detailVirtualizer = useVirtualizer({
300
+ count: detailItems.length,
301
+ getScrollElement: () => contentRef.current,
302
+ estimateSize: () => 80,
303
+ overscan: 5,
304
+ });
104
305
 
105
306
  // Reset expanded state when toggling between summary/detail views
106
307
  useEffect(() => {
107
308
  setExpandedIndices(new Set());
108
309
  }, [effectiveNarratedMode]);
109
310
 
110
- // Drag-and-drop state lifted to panel level so the entire panel is a drop target
311
+ // Drag-and-drop state lifted to panel level so the entire panel is a drop target.
312
+ // Uses Tauri native drag-drop events (HTML5 dataTransfer.files is empty in WKWebView).
111
313
  const [isDragging, setIsDragging] = useState(false);
112
314
  const [attachedImages, setAttachedImages] = useState<AttachedImage[]>([]);
113
- const dragCounterRef = useRef(0);
114
-
115
- const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
116
- e.preventDefault();
117
- e.stopPropagation();
118
- dragCounterRef.current++;
119
- if (e.dataTransfer.types.includes('Files')) {
120
- setIsDragging(true);
121
- }
122
- }, []);
123
315
 
124
- const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
125
- e.preventDefault();
126
- e.stopPropagation();
127
- dragCounterRef.current--;
128
- if (dragCounterRef.current === 0) {
129
- setIsDragging(false);
316
+ // Per-session image draft map: save/restore attached images when switching tabs
317
+ const imageDraftsRef = useRef(new Map<string, AttachedImage[]>());
318
+ const prevSessionForImagesRef = useRef(activeSessionId);
319
+ const attachedImagesRef = useRef(attachedImages);
320
+ attachedImagesRef.current = attachedImages;
321
+
322
+ useEffect(() => {
323
+ const prevId = prevSessionForImagesRef.current;
324
+ if (prevId && prevId !== activeSessionId) {
325
+ imageDraftsRef.current.set(prevId, attachedImagesRef.current);
130
326
  }
131
- }, []);
327
+ const restored = activeSessionId ? imageDraftsRef.current.get(activeSessionId) ?? [] : [];
328
+ setAttachedImages(restored);
329
+ prevSessionForImagesRef.current = activeSessionId;
330
+ }, [activeSessionId]);
132
331
 
133
- const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
134
- e.preventDefault();
135
- e.stopPropagation();
136
- }, []);
332
+ // Tauri native drag-drop listener replaces HTML5 drag handlers that don't
333
+ // work in WKWebView (dataTransfer.files is always empty on macOS).
334
+ const isOpenRef = useRef(isOpen);
335
+ isOpenRef.current = isOpen;
336
+ useEffect(() => {
337
+ const unlisteners: Array<Promise<() => void>> = [];
137
338
 
138
- const handleDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
139
- e.preventDefault();
140
- e.stopPropagation();
141
- setIsDragging(false);
142
- dragCounterRef.current = 0;
143
-
144
- const files = Array.from(e.dataTransfer.files);
145
- const imageFiles = files.filter(file => file.type.startsWith('image/'));
146
-
147
- imageFiles.forEach(file => {
148
- const reader = new FileReader();
149
- reader.onload = () => {
150
- const newImage: AttachedImage = {
151
- id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
152
- name: file.name,
153
- type: file.type,
154
- dataUrl: reader.result as string,
155
- size: file.size,
156
- };
157
- setAttachedImages(prev => [...prev, newImage]);
158
- };
159
- reader.readAsDataURL(file);
160
- });
339
+ unlisteners.push(
340
+ listen<{ paths: string[]; position: { x: number; y: number } }>('tauri://drag-enter', () => {
341
+ if (isOpenRef.current) setIsDragging(true);
342
+ })
343
+ );
344
+
345
+ unlisteners.push(
346
+ listen('tauri://drag-leave', () => {
347
+ setIsDragging(false);
348
+ })
349
+ );
350
+
351
+ unlisteners.push(
352
+ listen<{ paths: string[]; position: { x: number; y: number } }>('tauri://drag-drop', async (event) => {
353
+ setIsDragging(false);
354
+ if (!isOpenRef.current) return;
355
+
356
+ try {
357
+ const images = await invoke<Array<{
358
+ name: string;
359
+ type: string;
360
+ dataUrl: string;
361
+ size: number;
362
+ }>>('read_image_files', { paths: event.payload.paths });
363
+
364
+ if (images.length > 0) {
365
+ const newImages: AttachedImage[] = images.map(img => ({
366
+ id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
367
+ name: img.name,
368
+ type: img.type,
369
+ dataUrl: img.dataUrl,
370
+ size: img.size,
371
+ }));
372
+ setAttachedImages(prev => [...prev, ...newImages]);
373
+ }
374
+ } catch (err) {
375
+ console.error('[ClaudePanel] Failed to read dropped images:', err);
376
+ }
377
+ })
378
+ );
379
+
380
+ return () => {
381
+ unlisteners.forEach(p => p.then(unlisten => unlisten()));
382
+ };
161
383
  }, []);
162
384
 
163
385
  const handleImagesChange = useCallback((images: AttachedImage[]) => {
@@ -170,31 +392,55 @@ export function ClaudePanel({
170
392
 
171
393
  // Track whether active work item is ready for review
172
394
  const [isReadyForReview, setIsReadyForReview] = useState(false);
395
+ const reviewDelayRef = useRef<ReturnType<typeof setTimeout> | null>(null);
396
+
397
+ // Track whether user clicked "Ask a question" to temporarily hide the review footer
398
+ const [isAskingQuestion, setIsAskingQuestion] = useState(false);
399
+ // Track whether user has sent a message during the ask-question flow
400
+ const questionSentRef = useRef(false);
173
401
 
174
402
  const fetchReadyForReview = useCallback(() => {
175
403
  if (isStandalone || !activeSessionId) {
404
+ if (reviewDelayRef.current) { clearTimeout(reviewDelayRef.current); reviewDelayRef.current = null; }
176
405
  setIsReadyForReview(false);
177
406
  return;
178
407
  }
179
408
 
180
409
  const workId = parseInt(activeSessionId, 10);
181
410
  if (isNaN(workId)) {
411
+ if (reviewDelayRef.current) { clearTimeout(reviewDelayRef.current); reviewDelayRef.current = null; }
182
412
  setIsReadyForReview(false);
183
413
  return;
184
414
  }
185
415
 
186
- fetch(`/api/work/${workId}`, { cache: 'no-store' })
187
- .then(r => r.ok ? r.json() : null)
416
+ dataBridge.getWorkItem(workId)
188
417
  .then(data => {
189
- if (data) setIsReadyForReview(!!data.ready_for_review);
190
- else setIsReadyForReview(false);
418
+ const ready = !!(data && data.ready_for_review);
419
+ if (ready) {
420
+ // Delay showing the review footer by 5 seconds
421
+ if (!reviewDelayRef.current) {
422
+ reviewDelayRef.current = setTimeout(() => {
423
+ reviewDelayRef.current = null;
424
+ setIsReadyForReview(true);
425
+ }, 5000);
426
+ }
427
+ } else {
428
+ if (reviewDelayRef.current) { clearTimeout(reviewDelayRef.current); reviewDelayRef.current = null; }
429
+ setIsReadyForReview(false);
430
+ }
191
431
  })
192
- .catch(() => setIsReadyForReview(false));
432
+ .catch(() => {
433
+ if (reviewDelayRef.current) { clearTimeout(reviewDelayRef.current); reviewDelayRef.current = null; }
434
+ setIsReadyForReview(false);
435
+ });
193
436
  }, [activeSessionId, isStandalone]);
194
437
 
195
- // Fetch on mount / session switch
438
+ // Fetch on mount / session switch; clean up delay timer
196
439
  useEffect(() => {
197
440
  fetchReadyForReview();
441
+ return () => {
442
+ if (reviewDelayRef.current) { clearTimeout(reviewDelayRef.current); reviewDelayRef.current = null; }
443
+ };
198
444
  }, [fetchReadyForReview]);
199
445
 
200
446
  // Re-fetch when DB changes via WebSocket so the review footer appears instantly
@@ -224,15 +470,75 @@ export function ClaudePanel({
224
470
  if (streamManager) {
225
471
  streamManager.injectGate('rejection', { reason });
226
472
  }
227
- }, [activeSessionId]);
228
473
 
229
- // Auto-scroll to bottom when new messages arrive
474
+ // Send rejection reason to Claude so it can act on the feedback
475
+ onSendMessage(reason);
476
+ }, [activeSessionId, onSendMessage]);
477
+
478
+ const handleAskQuestion = useCallback(() => {
479
+ setIsReadyForReview(false);
480
+ setIsAskingQuestion(true);
481
+ questionSentRef.current = false;
482
+ }, []);
483
+
484
+ // Wrap onSendMessage to track when user sends a message during ask-question flow
485
+ const handleSendMessage = useCallback((...args: Parameters<typeof onSendMessage>) => {
486
+ if (isAskingQuestion) {
487
+ questionSentRef.current = true;
488
+ }
489
+ return onSendMessage(...args);
490
+ }, [onSendMessage, isAskingQuestion]);
491
+
492
+ // Restore review footer after Claude finishes responding to the user's question.
493
+ // Uses questionSentRef to avoid premature restoration from status flicker
494
+ // (e.g., streaming→idle→streaming during tool use gaps).
495
+ const prevStatusRef = useRef(status);
230
496
  useEffect(() => {
497
+ const wasStreaming = prevStatusRef.current === 'streaming' || prevStatusRef.current === 'creating';
498
+ const isNowIdle = status === 'idle' || status === 'done' || status === 'error';
499
+ if (isAskingQuestion && questionSentRef.current && wasStreaming && isNowIdle) {
500
+ setIsReadyForReview(true);
501
+ setIsAskingQuestion(false);
502
+ questionSentRef.current = false;
503
+ }
504
+ prevStatusRef.current = status;
505
+ }, [status, isAskingQuestion]);
506
+
507
+ // Reset ask-question state when switching sessions
508
+ useEffect(() => {
509
+ setIsAskingQuestion(false);
510
+ questionSentRef.current = false;
511
+ }, [activeSessionId]);
512
+
513
+ // Smart auto-scroll: only scroll if user is near the bottom.
514
+ // Force scroll when Claude finishes (status → idle/done) so the final response is visible.
515
+ const isNearBottomRef = useRef(true);
516
+
517
+ const handleContentScroll = useCallback(() => {
231
518
  if (contentRef.current) {
519
+ const { scrollTop, scrollHeight, clientHeight } = contentRef.current;
520
+ isNearBottomRef.current = scrollHeight - scrollTop - clientHeight < 100;
521
+ }
522
+ }, []);
523
+
524
+ useEffect(() => {
525
+ if (contentRef.current && isNearBottomRef.current) {
232
526
  contentRef.current.scrollTop = contentRef.current.scrollHeight;
233
527
  }
234
528
  }, [messages]);
235
529
 
530
+ // Force scroll to bottom when Claude finishes working
531
+ const prevStatusForScrollRef = useRef(status);
532
+ useEffect(() => {
533
+ const wasWorking = prevStatusForScrollRef.current === 'streaming' || prevStatusForScrollRef.current === 'creating';
534
+ const isNowDone = status === 'idle' || status === 'done';
535
+ if (wasWorking && isNowDone && contentRef.current) {
536
+ contentRef.current.scrollTop = contentRef.current.scrollHeight;
537
+ isNearBottomRef.current = true;
538
+ }
539
+ prevStatusForScrollRef.current = status;
540
+ }, [status]);
541
+
236
542
  return (
237
543
  <AnimatePresence>
238
544
  {isOpen && (
@@ -243,22 +549,7 @@ export function ClaudePanel({
243
549
  transition={{ type: 'spring', damping: 25, stiffness: 200 }}
244
550
  className="fixed right-0 top-0 h-full w-[480px] bg-white border-l border-zinc-200 flex flex-col z-50"
245
551
  data-testid="claude-panel"
246
- onDragEnter={handleDragEnter}
247
- onDragLeave={handleDragLeave}
248
- onDragOver={handleDragOver}
249
- onDrop={handleDrop}
250
552
  >
251
- {/* Hide Claudes tab - sticks off left edge */}
252
- <button
253
- onClick={onClose}
254
- className="absolute left-0 rounded-b-xl px-2 py-3 text-sm font-medium text-white hover:brightness-105 active:scale-[0.98] transition-[color,background-color,border-color,box-shadow,transform,opacity] duration-200 ease-out cursor-pointer"
255
- style={{ writingMode: 'vertical-lr', transform: 'translateX(-100%) translateY(-50%) rotate(180deg)', top: '33.33%', backgroundColor: '#819D9F', boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(129, 157, 159, 0.2)' }}
256
- aria-label="Hide Claudes panel"
257
- data-testid="hide-claudes-tab"
258
- >
259
- Hide Claudes
260
- </button>
261
-
262
553
  {/* Full-panel drop zone overlay */}
263
554
  {isDragging && (
264
555
  <div
@@ -282,7 +573,7 @@ export function ClaudePanel({
282
573
  </div>
283
574
  <a
284
575
  href="/subscribe"
285
- className="inline-flex items-center justify-center px-3.5 py-1.5 text-base font-medium text-white rounded-xl hover:brightness-105 active:scale-[0.98] transition-[color,background-color,border-color,box-shadow,transform,opacity] duration-200 ease-out whitespace-nowrap"
576
+ className="inline-flex items-center justify-center px-3.5 py-1.5 text-base font-medium text-white rounded-xl hover:brightness-105 active:scale-[0.98] transition-[color,background-color,border-color,opacity] duration-200 ease-out whitespace-nowrap"
286
577
  style={{ backgroundColor: '#e57a44', boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(229, 122, 68, 0.2)' }}
287
578
  >
288
579
  Upgrade
@@ -303,9 +594,13 @@ export function ClaudePanel({
303
594
  flex items-center justify-between gap-1 pl-4 pr-1.5 py-3 min-w-0
304
595
  border-b border-r border-zinc-200
305
596
  cursor-pointer select-none group
306
- ${id === activeSessionId
307
- ? 'bg-white text-zinc-900'
308
- : 'text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 active:bg-zinc-200'
597
+ ${session.status === 'streaming'
598
+ ? 'bg-[#819D9F]/10 text-zinc-900'
599
+ : session.status === 'connecting' || session.status === 'creating'
600
+ ? 'bg-yellow-50 text-zinc-900'
601
+ : id === activeSessionId
602
+ ? 'bg-white text-zinc-900'
603
+ : 'text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 active:bg-zinc-200'
309
604
  }
310
605
  `}
311
606
  data-testid={`session-tab-${id}`}
@@ -316,12 +611,6 @@ export function ClaudePanel({
316
611
  title={session.title}
317
612
  >
318
613
  <span className="truncate">#{id} {session.title}</span>
319
- {(session.status === 'connecting' || session.status === 'creating') && (
320
- <span className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse flex-shrink-0" />
321
- )}
322
- {session.status === 'streaming' && (
323
- <span className="w-2 h-2 rounded-full bg-[#819D9F] animate-pulse flex-shrink-0" />
324
- )}
325
614
  </span>
326
615
  <button
327
616
  onClick={(e) => {
@@ -344,9 +633,13 @@ export function ClaudePanel({
344
633
  flex items-center justify-between gap-1 pl-4 pr-1.5 py-3 min-w-0
345
634
  border-b border-r border-zinc-200
346
635
  cursor-pointer select-none group
347
- ${session.id === activeSessionId
348
- ? 'bg-white text-zinc-900'
349
- : 'text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 active:bg-zinc-200'
636
+ ${sessions?.get(session.id)?.status === 'streaming'
637
+ ? 'bg-[#819D9F]/10 text-zinc-900'
638
+ : sessions?.get(session.id)?.status === 'connecting' || sessions?.get(session.id)?.status === 'creating'
639
+ ? 'bg-yellow-50 text-zinc-900'
640
+ : session.id === activeSessionId
641
+ ? 'bg-white text-zinc-900'
642
+ : 'text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 active:bg-zinc-200'
350
643
  }
351
644
  `}
352
645
  data-testid={`session-tab-standalone-${session.id}`}
@@ -357,12 +650,6 @@ export function ClaudePanel({
357
650
  title={session.title}
358
651
  >
359
652
  <span className="truncate">{session.title}</span>
360
- {(sessions?.get(session.id)?.status === 'connecting' || sessions?.get(session.id)?.status === 'creating') && (
361
- <span className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse flex-shrink-0" />
362
- )}
363
- {sessions?.get(session.id)?.status === 'streaming' && (
364
- <span className="w-2 h-2 rounded-full bg-[#819D9F] animate-pulse flex-shrink-0" />
365
- )}
366
653
  </span>
367
654
  <button
368
655
  onClick={(e) => {
@@ -444,23 +731,23 @@ export function ClaudePanel({
444
731
  </div>
445
732
  )}
446
733
 
447
- {/* Sticky details toggle header */}
448
- {onToggleNarratedMode && hasMeaningfulContent && ((sessions && sessions.size > 0) || standaloneSessions.length > 0) && (
449
- <div className="flex justify-end px-5 py-2 border-b border-zinc-100 flex-shrink-0">
450
- <button
451
- onClick={onToggleNarratedMode}
452
- className="text-xs text-zinc-400 hover:text-zinc-600 cursor-pointer transition-colors duration-200 ease-out"
453
- aria-label={effectiveNarratedMode ? 'Show full conversation' : 'Show summary view'}
454
- data-testid="narrated-mode-toggle"
455
- >
456
- {effectiveNarratedMode ? 'Show details' : 'Hide details'}
457
- </button>
458
- </div>
734
+ {/* View mode toolbar */}
735
+ {hasMeaningfulContent && ((sessions && sessions.size > 0) || standaloneSessions.length > 0) && (
736
+ <ViewModeToolbar
737
+ viewMode={viewMode}
738
+ onViewModeChange={handleViewModeChange}
739
+ hasIntermediates={detailHasIntermediates}
740
+ allExpanded={detailAllExpanded}
741
+ onToggleExpandAll={handleToggleExpandAll}
742
+ activeFilters={activeFilters}
743
+ onToggleFilter={toggleFilter as (id: any) => void}
744
+ />
459
745
  )}
460
746
 
461
747
  {/* Content */}
462
748
  <div
463
749
  ref={contentRef}
750
+ onScroll={handleContentScroll}
464
751
  className="flex-1 overflow-y-auto overscroll-contain p-5 space-y-4"
465
752
  data-testid="panel-content"
466
753
  >
@@ -484,45 +771,21 @@ export function ClaudePanel({
484
771
  {effectiveNarratedMode ? (
485
772
  /* Narrated mode: show gate cards, user messages, assistant text, and a working indicator */
486
773
  <>
487
- {(() => {
488
- // Build a set of indices that are the last assistant/text response per turn
489
- // A turn boundary is defined by user messages
490
- const finalIndicesPerTurn = new Set<number>();
491
- let lastAssistantOrTextIdx = -1;
492
- for (let i = 0; i < messages.length; i++) {
493
- if (messages[i].type === 'assistant' || messages[i].type === 'text') {
494
- lastAssistantOrTextIdx = i;
495
- }
496
- if (messages[i].type === 'user' && lastAssistantOrTextIdx >= 0) {
497
- finalIndicesPerTurn.add(lastAssistantOrTextIdx);
498
- lastAssistantOrTextIdx = -1;
499
- }
500
- }
501
- // Add the final assistant/text of the last turn only if the turn is complete
502
- if (lastAssistantOrTextIdx >= 0 && status !== 'streaming') {
503
- finalIndicesPerTurn.add(lastAssistantOrTextIdx);
504
- }
505
-
506
- const narratedMessages = messages.filter((m, i) =>
507
- m.type === 'gate' || m.type === 'user' || ((m.type === 'assistant' || m.type === 'text') && finalIndicesPerTurn.has(i))
508
- );
509
- const lastGateIndex = narratedMessages.findLastIndex(m => m.type === 'gate');
510
- return narratedMessages.map((message, index) => (
511
- <div key={index}>
512
- {message.type === 'gate' ? (
513
- <GateCard
514
- message={message}
515
- isLatest={index === lastGateIndex}
516
- onAnswerQuestion={(optionId, optionLabel) => handleAnswerQuestion(message, optionId, optionLabel)}
517
- answeredQuestionId={answeredQuestions.get(message.timestamp) || null}
518
- onStartWorkItem={handleStartWorkItem}
519
- />
520
- ) : (
521
- <MessageBlock message={message} />
522
- )}
523
- </div>
524
- ));
525
- })()}
774
+ {narratedMessages.map((message, index) => (
775
+ <div key={index}>
776
+ {message.type === 'gate' ? (
777
+ <GateCard
778
+ message={message}
779
+ isLatest={index === lastGateIndex}
780
+ onAnswerQuestion={(optionId, optionLabel) => handleAnswerQuestion(message, optionId, optionLabel)}
781
+ answeredQuestionId={answeredQuestions.get(message.timestamp) || null}
782
+ onStartWorkItem={handleStartWorkItem}
783
+ />
784
+ ) : (
785
+ <MessageBlock message={message} />
786
+ )}
787
+ </div>
788
+ ))}
526
789
  {status === 'creating' && (
527
790
  <div className="flex items-center gap-1.5 text-sm text-zinc-400 py-3 whitespace-nowrap overflow-hidden text-ellipsis">
528
791
  <span className="w-1.5 h-1.5 bg-yellow-400 rounded-full animate-pulse shrink-0" />
@@ -544,150 +807,144 @@ export function ClaudePanel({
544
807
  </div>
545
808
  )}
546
809
  </>
547
- ) : (
548
- /* Detail mode: show full conversation with collapsible intermediate responses */
549
- <>
550
- {(() => {
551
- const filteredMessages = messages.filter(m => m.type !== 'tool_use');
552
- const lastUserMessageIndex = filteredMessages.findLastIndex(m => m.type === 'user');
553
- const userMessageCount = filteredMessages.filter(m => m.type === 'user').length;
554
- const lastAssistantIndex = filteredMessages.findLastIndex(m => m.type === 'assistant' || m.type === 'text');
555
- // Only show collapse UI when there's an actual conversation (user messages exist)
556
- const hasIntermediates = userMessageCount > 0 && filteredMessages.filter(m => m.type === 'assistant' || m.type === 'text').length > 1;
557
-
558
- const allExpanded = hasIntermediates && filteredMessages.every((m, i) =>
559
- (m.type !== 'assistant' && m.type !== 'text') || i === lastAssistantIndex || expandedIndices.has(i)
560
- );
561
-
562
- return (
563
- <>
564
- {hasIntermediates && (
565
- <div className="flex justify-end mb-1.5">
566
- <button
567
- onClick={() => {
568
- if (allExpanded) {
569
- setExpandedIndices(new Set());
570
- } else {
571
- const all = new Set<number>();
572
- filteredMessages.forEach((m, i) => {
573
- if ((m.type === 'assistant' || m.type === 'text') && i !== lastAssistantIndex) {
574
- all.add(i);
575
- }
576
- });
577
- setExpandedIndices(all);
578
- }
579
- }}
580
- className="text-base text-zinc-400 hover:text-zinc-600 transition-colors duration-200 ease-out"
581
- data-testid="expand-collapse-all"
582
- >
583
- {allExpanded ? 'Collapse all' : 'Expand all'}
584
- </button>
585
- </div>
586
- )}
587
- {filteredMessages.map((message, index) => {
588
- const isAssistant = message.type === 'assistant' || message.type === 'text';
589
- const isFinal = isAssistant && index === lastAssistantIndex;
590
- // Don't collapse when no user messages (e.g., welcome session with static content)
591
- const isIntermediate = isAssistant && !isFinal && userMessageCount > 0;
592
- const isExpanded = expandedIndices.has(index);
593
-
594
- // Get first line for collapsed summary
595
- const firstLine = isIntermediate
596
- ? (unescapeContent(message.content).split('\n')[0] || '').slice(0, 120)
597
- : '';
598
-
810
+ ) : fullReadoutMode ? (
811
+ /* Full readout mode: raw stream-json events with filter chips */
812
+ (() => {
813
+ const allowedTypes = new Set(
814
+ READOUT_FILTERS
815
+ .filter(f => activeFilters.has(f.id))
816
+ .flatMap(f => [...f.types])
817
+ );
818
+ const filteredEvents = rawEvents.filter(event => {
819
+ const evt = event as Record<string, unknown>;
820
+ return (allowedTypes as Set<string>).has((evt.type as string) || 'unknown');
821
+ });
822
+ const typeColors: Record<string, string> = {
823
+ system: 'text-blue-600 bg-blue-50',
824
+ assistant: 'text-emerald-600 bg-emerald-50',
825
+ user: 'text-cyan-600 bg-cyan-50',
826
+ result: 'text-amber-600 bg-amber-50',
827
+ error: 'text-red-600 bg-red-50',
828
+ content_block_delta: 'text-zinc-500 bg-zinc-50',
829
+ content_block_start: 'text-zinc-400 bg-zinc-50',
830
+ content_block_stop: 'text-zinc-400 bg-zinc-50',
831
+ message_start: 'text-zinc-400 bg-zinc-50',
832
+ message_stop: 'text-zinc-400 bg-zinc-50',
833
+ message_delta: 'text-zinc-400 bg-zinc-50',
834
+ done: 'text-amber-600 bg-amber-50',
835
+ };
836
+ return (
837
+ <>
838
+ {filteredEvents.length === 0 ? (
839
+ <div className="text-zinc-400 text-sm text-center py-8">
840
+ {rawEvents.length === 0
841
+ ? 'No raw events captured yet. Send a message to start capturing.'
842
+ : 'No events match the selected filters.'}
843
+ </div>
844
+ ) : (
845
+ filteredEvents.map((event, index) => {
846
+ const evt = event as Record<string, unknown>;
847
+ const eventType = (evt.type as string) || 'unknown';
848
+ const colorClass = typeColors[eventType] || 'text-zinc-500 bg-zinc-50';
599
849
  return (
600
- <div key={index}>
601
- {/* Final response divider */}
602
- {isFinal && hasIntermediates && (
603
- <div className="flex items-center gap-4 my-4" data-testid="final-response-divider">
604
- <div className="flex-1 h-px bg-zinc-200" />
605
- <span className="text-base text-zinc-400 font-medium">Final response</span>
606
- <div className="flex-1 h-px bg-zinc-200" />
607
- </div>
608
- )}
609
- {message.type === 'gate' ? (
610
- <GateCard
611
- message={message}
612
- onAnswerQuestion={(optionId, optionLabel) => handleAnswerQuestion(message, optionId, optionLabel)}
613
- answeredQuestionId={answeredQuestions.get(message.timestamp) || null}
614
- />
615
- ) : isIntermediate ? (
616
- /* Collapsible intermediate assistant response */
617
- <div
618
- className="cursor-pointer"
619
- onClick={() => {
620
- setExpandedIndices(prev => {
621
- const next = new Set(prev);
622
- if (next.has(index)) {
623
- next.delete(index);
624
- } else {
625
- next.add(index);
626
- }
627
- return next;
628
- });
629
- }}
630
- data-testid="collapsible-message"
631
- >
632
- {isExpanded ? (
633
- <AnimatePresence mode="wait">
634
- <m.div
635
- initial={{ height: 0, opacity: 0 }}
636
- animate={{ height: 'auto', opacity: 1 }}
637
- exit={{ height: 0, opacity: 0 }}
638
- transition={{ duration: 0.2, ease: 'easeInOut' }}
639
- >
640
- <MessageBlock message={message} />
641
- </m.div>
642
- </AnimatePresence>
643
- ) : (
644
- <div className="bg-zinc-50 rounded-lg px-4 py-3 flex items-center gap-3 hover:bg-zinc-100 transition-colors duration-200 ease-out" data-testid="collapsed-summary">
645
- <svg className="w-3 h-3 text-zinc-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
646
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
647
- </svg>
648
- <span className="text-base text-zinc-500 truncate">{firstLine.trim() ? firstLine : '(empty response)'}</span>
649
- </div>
650
- )}
651
- </div>
652
- ) : (
653
- <MessageBlock message={message} />
654
- )}
655
- {index === lastUserMessageIndex && (status === 'streaming' || status === 'creating') && (
656
- <ElapsedTimer isStreaming={true} timerKey={`${activeSessionId ?? 'default'}-${userMessageCount}`} />
657
- )}
658
- </div>
850
+ <details key={index} className="group">
851
+ <summary className={`flex items-center gap-2 px-3 py-1.5 rounded cursor-pointer text-xs font-mono ${colorClass}`}>
852
+ <span className="font-semibold">{eventType}</span>
853
+ <span className="text-zinc-400">#{index}</span>
854
+ </summary>
855
+ <pre className="mt-1 px-3 py-2 bg-zinc-50 rounded text-xs font-mono text-zinc-700 overflow-x-auto whitespace-pre-wrap break-all max-h-64 overflow-y-auto">
856
+ {JSON.stringify(event, null, 2)}
857
+ </pre>
858
+ </details>
659
859
  );
660
- })}
661
- </>
662
- );
663
- })()}
664
- {/* Current tool call indicator - shown inline after last Claude message */}
665
- {(() => {
666
- const lastToolUseIndex = messages.findLastIndex(m => m.type === 'tool_use');
667
- if (lastToolUseIndex === -1) return null;
668
-
669
- const hasSubsequentMessage = messages.slice(lastToolUseIndex + 1).some(
670
- m => (m.type === 'text' || m.type === 'assistant') &&
671
- !isSystemNoise(m.content)
672
- );
673
- if (hasSubsequentMessage) return null;
674
-
675
- const toolMessage = messages[lastToolUseIndex];
676
- const firstParamValue = toolMessage.tool_input ? Object.values(toolMessage.tool_input)[0] : null;
677
- const displayValue = typeof firstParamValue === 'string'
678
- ? (firstParamValue.length > 50 ? firstParamValue.slice(0, 50) + '...' : firstParamValue)
679
- : null;
680
-
860
+ })
861
+ )}
862
+ </>
863
+ );
864
+ })()
865
+ ) : (
866
+ /* Detail mode: virtualized message list */
867
+ <div
868
+ style={{
869
+ height: detailVirtualizer.getTotalSize(),
870
+ width: '100%',
871
+ position: 'relative',
872
+ }}
873
+ >
874
+ {detailVirtualizer.getVirtualItems().map(virtualRow => {
875
+ const item = detailItems[virtualRow.index];
876
+ if (!item) return null;
681
877
  return (
682
- <div className="bg-zinc-100 rounded-lg px-4 py-3" data-testid="current-tool-call">
683
- <div className="flex items-center gap-3 text-base">
684
- <span className="text-purple-600">{toolMessage.tool_name}</span>
685
- {displayValue && <span className="text-zinc-500 truncate">{displayValue}</span>}
686
- </div>
878
+ <div
879
+ key={virtualRow.key}
880
+ data-index={virtualRow.index}
881
+ ref={detailVirtualizer.measureElement}
882
+ style={{
883
+ position: 'absolute',
884
+ top: 0,
885
+ left: 0,
886
+ width: '100%',
887
+ transform: `translateY(${virtualRow.start}px)`,
888
+ paddingBottom: 16,
889
+ }}
890
+ >
891
+ {item.kind === 'gate' ? (
892
+ <GateCard
893
+ message={item.msg}
894
+ onAnswerQuestion={(optionId, optionLabel) => handleAnswerQuestion(item.msg, optionId, optionLabel)}
895
+ answeredQuestionId={answeredQuestions.get(item.msg.timestamp) || null}
896
+ />
897
+ ) : item.kind === 'merged-tool' ? (
898
+ <MergedToolBlock toolMessage={item.toolMsg} resultMessage={item.resultMsg} />
899
+ ) : item.kind === 'elapsed' ? (
900
+ <ElapsedTimer isStreaming={true} timerKey={item.timerKey} />
901
+ ) : item.kind === 'tool-indicator' ? (
902
+ (() => {
903
+ const firstParamValue = item.toolMsg.tool_input ? Object.values(item.toolMsg.tool_input)[0] : null;
904
+ const displayValue = typeof firstParamValue === 'string'
905
+ ? (firstParamValue.length > 50 ? firstParamValue.slice(0, 50) + '...' : firstParamValue)
906
+ : null;
907
+ return (
908
+ <div className="rounded-xl text-sm" style={{ backgroundColor: '#E8EEEF' }} data-testid="current-tool-call">
909
+ <div className="flex items-center gap-2.5 px-3.5 py-2.5">
910
+ <span className="w-3 h-3 rounded-full animate-spin flex-shrink-0" style={{ border: '2px solid #4A6365', borderTopColor: 'transparent' }} data-testid="tool-spinner" />
911
+ <span className="font-semibold text-sm" style={{ color: '#4A6365' }}>{item.toolMsg.tool_name}</span>
912
+ {displayValue && <span className="text-sm truncate" style={{ color: '#6B8E90' }}>({displayValue})</span>}
913
+ </div>
914
+ </div>
915
+ );
916
+ })()
917
+ ) : item.kind === 'message' && item.isIntermediate ? (
918
+ <div
919
+ className="cursor-pointer"
920
+ onClick={() => {
921
+ setExpandedIndices(prev => {
922
+ const next = new Set(prev);
923
+ if (next.has(item.idx)) next.delete(item.idx);
924
+ else next.add(item.idx);
925
+ return next;
926
+ });
927
+ }}
928
+ data-testid="collapsible-message"
929
+ >
930
+ {expandedIndices.has(item.idx) ? (
931
+ <MessageBlock message={item.msg} />
932
+ ) : (
933
+ <div className="bg-zinc-50 rounded-lg px-4 py-3 flex items-center gap-3 hover:bg-zinc-100 transition-colors duration-200 ease-out" data-testid="collapsed-summary">
934
+ <svg className="w-3 h-3 text-zinc-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
935
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
936
+ </svg>
937
+ <span className="text-base text-zinc-500 truncate">{item.firstLine.trim() ? item.firstLine : '(empty response)'}</span>
938
+ </div>
939
+ )}
940
+ </div>
941
+ ) : item.kind === 'message' ? (
942
+ <MessageBlock message={item.msg} />
943
+ ) : null}
687
944
  </div>
688
945
  );
689
- })()}
690
- </>
946
+ })}
947
+ </div>
691
948
  )}
692
949
  {showEmptyState && (
693
950
  <div className="text-zinc-500 text-base text-center py-8">
@@ -710,15 +967,16 @@ export function ClaudePanel({
710
967
 
711
968
  {/* Footer: ReviewFooter when ready for review, otherwise normal input */}
712
969
  {(status === 'streaming' || status === 'creating' || status === 'done' || status === 'idle') && ((sessions && sessions.size > 0) || standaloneSessions.length > 0) && (
713
- isReadyForReview && activeSessionId ? (
970
+ isReadyForReview && !isAskingQuestion && activeSessionId ? (
714
971
  <ReviewFooter
715
972
  workItemId={activeSessionId}
716
973
  onAccepted={handleReviewAction}
717
974
  onRejected={handleRejectAction}
975
+ onAskQuestion={handleAskQuestion}
718
976
  />
719
977
  ) : (
720
978
  <ClaudePanelInput
721
- onSendMessage={onSendMessage}
979
+ onSendMessage={handleSendMessage}
722
980
  onStop={onStop}
723
981
  isStreaming={status === 'streaming' || status === 'creating'}
724
982
  disabled={limitReached}