jettypod 4.4.118 → 4.4.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/.env +4 -3
  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 +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  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 +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  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 +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. package/docs/bdd-guidance.md +0 -390
@@ -1,271 +1,138 @@
1
- 'use client';
2
1
 
3
- import { useEffect, useRef, useState, useCallback, DragEvent } from 'react';
4
- import { AnimatePresence, motion } from 'framer-motion';
5
- import ReactMarkdown from 'react-markdown';
6
- import remarkGfm from 'remark-gfm';
7
- import type { ClaudeMessage, StreamStatus } from '../lib/session-stream-manager';
2
+ import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react';
3
+ import { AnimatePresence, m } from 'framer-motion';
4
+ import { useVirtualizer } from '@tanstack/react-virtual';
5
+ import { listen, invoke } from '../lib/tauri';
6
+
7
+ import type { ClaudeMessage } from '../lib/session-stream-manager';
8
8
  import { ClaudePanelInput, AttachedImage } from './ClaudePanelInput';
9
9
  import { GateCard } from './GateCard';
10
- import type { SessionItem } from './SessionList';
11
- import type { Session } from '../contexts/ClaudeSessionContext';
12
-
13
- // Tool name human-friendly verb mapping for activity indicator
14
- const TOOL_VERBS: Record<string, string> = {
15
- Read: 'Reading',
16
- Grep: 'Searching for',
17
- Glob: 'Finding files matching',
18
- Bash: 'Running',
19
- Edit: 'Editing',
20
- Write: 'Writing',
21
- Task: 'Delegating',
22
- WebFetch: 'Fetching',
23
- WebSearch: 'Searching web for',
24
- };
25
-
26
- function extractFilename(path: string): string {
27
- return path.split('/').pop() || path;
28
- }
29
-
30
- function humanizeToolCall(toolName: string, param: string): string {
31
- const verb = TOOL_VERBS[toolName] || toolName;
32
- if (['Read', 'Edit', 'Write'].includes(toolName)) {
33
- return `${verb} ${extractFilename(param)}...`;
34
- }
35
- if (toolName === 'Bash') {
36
- const short = param.length > 40 ? param.slice(0, 40) : param;
37
- return `${verb} ${short}...`;
38
- }
39
- if (['Grep', 'Glob', 'WebSearch'].includes(toolName)) {
40
- const short = param.length > 30 ? param.slice(0, 30) : param;
41
- return `${verb} ${short}...`;
42
- }
43
- return `${verb} ${param}...`;
44
- }
45
-
46
- // Unescape content that may have literal \n, \t, \r from JSON stringification
47
- function unescapeContent(content: string | undefined): string {
48
- if (!content) return '';
49
- return content
50
- .replace(/\\n/g, '\n')
51
- .replace(/\\t/g, '\t')
52
- .replace(/\\r/g, '\r')
53
- .replace(/\\"/g, '"');
54
- }
55
-
56
- // Detect if error message is about Claude CLI needing an update
57
- function isVersionUpdateError(content: string | undefined): boolean {
58
- if (!content) return false;
59
- return content.includes('needs an update') ||
60
- content.includes('version') && content.includes('required');
61
- }
62
-
63
- // Collapse repeated phrases in tool output (e.g. repeated warnings, stack traces)
64
- // Finds substantial phrases (50+ chars) appearing 3+ times and shows each once with a count
65
- function deduplicateToolOutput(text: string): string {
66
- // Split on sentence/line boundaries to extract candidate phrases
67
- const phrases = text.split(/(?<=[\.\n])\s*/);
68
- const counts = new Map<string, number>();
69
-
70
- for (const phrase of phrases) {
71
- const key = phrase.trim();
72
- if (key.length >= 50) {
73
- counts.set(key, (counts.get(key) || 0) + 1);
74
- }
75
- }
76
-
77
- // Get repeated phrases, longest first to avoid partial match issues
78
- const repeated = [...counts.entries()]
79
- .filter(([, c]) => c >= 3)
80
- .sort((a, b) => b[0].length - a[0].length);
81
-
82
- if (repeated.length === 0) return text;
83
-
84
- let result = text;
85
- for (const [phrase, count] of repeated) {
86
- const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
87
- let idx = 0;
88
- result = result.replace(new RegExp(escaped, 'g'), () => {
89
- idx++;
90
- return idx === 1 ? `${phrase} [×${count}]` : '';
91
- });
92
- }
93
-
94
- // Clean up artifacts from removal
95
- result = result.replace(/\n{3,}/g, '\n');
96
- result = result.replace(/ {2,}/g, ' ');
97
-
98
- return result;
99
- }
100
-
101
- // Filter for system noise - returns true if content should be HIDDEN
102
- // Focus on truly internal/system content, NOT Claude's explanatory messages
103
- function isSystemNoise(content: string | undefined): boolean {
104
- if (!content) return true;
105
-
106
- const trimmed = content.trim();
107
-
108
- // Hide raw JSON messages (system init, tool calls, etc.)
109
- if (trimmed.startsWith('{"') || trimmed.startsWith('[{"')) {
110
- return true;
111
- }
112
-
113
- // Noise patterns - truly internal/system content that users shouldn't see
114
- const noisePatterns = [
115
- // Skill headers and metadata (internal prompt injections)
116
- 'Base directory for this skill:',
117
- '# Request Routing Skill',
118
- '# Simple Improvement Skill',
119
- '# Bug Planning Skill',
120
- '# Chore Planning Skill',
121
- '# Feature Planning Skill',
122
- '# Epic Planning Skill',
123
- '# Bug Mode Skill',
124
- '# Chore Mode Skill',
125
- '# Speed Mode Skill',
126
- '# Stable Mode Skill',
127
- '# Production Mode Skill',
128
- 'FORBIDDEN during this skill',
129
- 'ALLOWED during this skill',
130
- 'ARGUMENTS:',
131
- // System/context tags
132
- '<system-reminder>',
133
- '</system-reminder>',
134
- '<claude_context',
135
- '</claude_context>',
136
- '<jettypod_essentials>',
137
- '<communication_style>',
138
- // File content dumps (usually from Read tool)
139
- 'Contents of /',
140
- 'File: /',
141
- // Internal skill invocation phrases (Claude talking to system, not user)
142
- 'Let me invoke',
143
- 'I\'ll invoke',
144
- 'I will invoke',
145
- 'I need to invoke',
146
- 'I should invoke',
147
- 'invoke request-routing',
148
- 'invoke bug-planning',
149
- 'invoke chore-planning',
150
- 'invoke feature-planning',
151
- 'invoke epic-planning',
152
- 'invoke simple-improvement',
153
- 'invoke bug-mode',
154
- 'invoke chore-mode',
155
- 'invoke speed-mode',
156
- 'invoke stable-mode',
157
- 'invoke production-mode',
158
- 'Launching skill:',
159
- 'Invoking skill:',
160
- // Routing decision arrows (internal logging)
161
- '→ bug-planning',
162
- '→ chore-planning',
163
- '→ feature-planning',
164
- '→ epic-planning',
165
- '→ simple-improvement',
166
- '→ bug-mode',
167
- '→ chore-mode',
168
- '→ speed-mode',
169
- '→ stable-mode',
170
- // Claude CLI initialization metadata
171
- '"apiKeySource"',
172
- '"claude_code_version"',
173
- '"output_style"',
174
- '"skills":',
175
- '"agents":',
176
- '"plugins":',
177
- // Tool response metadata (from Read, Glob, Grep, etc.)
178
- '"numLines":',
179
- '"startLine":',
180
- '"totalLines":',
181
- // Gate markers (already parsed by stream manager, hide raw output)
182
- '[GATE:',
183
- '[/GATE]',
184
- ];
185
-
186
- if (noisePatterns.some(p => content.includes(p))) {
187
- return true;
188
- }
189
-
190
- // Hide if it has line number prefixes (file reads): "123→" anywhere in content
191
- // This catches file content from Read tool
192
- if (/\d+→/.test(content)) {
193
- return true;
194
- }
195
-
196
- // Hide if content ends with JSON-like tool response metadata
197
- if (/"\w+":\s*\d+\s*\}\}\}?\s*$/.test(trimmed)) {
198
- return true;
199
- }
200
-
201
- // Hide if >50% of lines start with numbers (grep/search results)
202
- const lines = content.split('\n').filter(l => l.trim());
203
- const numberedLines = lines.filter(l => /^\s*\d+[→|:]/.test(l));
204
- if (lines.length > 3 && numberedLines.length / lines.length > 0.5) {
205
- return true;
206
- }
207
-
208
- // Note: Removed length limit - long explanations are legitimate content
209
- // Note: Removed generic "Let me check/look/analyze" - these explain what Claude is doing
210
-
211
- return false;
212
- }
10
+ import { ReviewFooter } from './ReviewFooter';
11
+ import { useSessionState, useSessionActions } from '../contexts/ClaudeSessionContext';
12
+ import { getRegistry } from '../lib/stream-manager-registry';
13
+ import { useUsage } from '../contexts/UsageContext';
14
+ import { useWebSocket, type WebSocketMessage } from '../hooks/useWebSocket';
15
+ import { getWebSocketUrl } from '../lib/utils';
16
+ import { MessageBlock, MergedToolBlock, StatusIndicator, ErrorIcon, UserIcon, humanizeToolCall, unescapeContent, isSystemNoise } from './MessageBlock';
17
+ import { ElapsedTimer } from './ElapsedTimer';
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 };
213
39
 
214
40
  interface ClaudePanelProps {
215
41
  isOpen: boolean;
216
- workItemId: string;
217
- workItemTitle: string;
218
- messages: ClaudeMessage[];
219
- status: StreamStatus;
220
- error: string | null;
221
- exitCode: number | null;
222
- canRetry: boolean;
223
- queuedMessage?: { message: string; images?: unknown[] } | null;
224
42
  onClose: () => void;
225
- onRetry: () => void;
226
- onSendMessage: (message: string, images?: Array<{ type: string; dataUrl: string }>) => void;
227
- onStop?: () => void;
228
- // Multi-session support
229
- sessions?: Map<string, Session>;
230
- activeSessionId?: string | null;
231
- onSwitchSession?: (id: string) => void;
232
- // Standalone session support
233
- standaloneSessions?: SessionItem[];
234
- onNewSession?: () => void;
235
- onCloseSession?: (sessionId: string) => void;
236
- // Narrated mode support
237
- narratedMode?: boolean;
238
- onToggleNarratedMode?: () => void;
239
43
  }
240
44
 
241
45
  export function ClaudePanel({
242
46
  isOpen,
243
- workItemId,
244
- workItemTitle,
245
- messages,
246
- status,
247
- error,
248
- exitCode,
249
- canRetry,
250
- queuedMessage,
251
47
  onClose,
252
- onRetry,
253
- onSendMessage,
254
- onStop,
255
- sessions,
256
- activeSessionId,
257
- onSwitchSession,
258
- standaloneSessions = [],
259
- onNewSession,
260
- onCloseSession,
261
- narratedMode = false,
262
- onToggleNarratedMode,
263
48
  }: ClaudePanelProps) {
49
+ const {
50
+ activeSessionId,
51
+ activeSession,
52
+ sessions,
53
+ standaloneSessions: standaloneSessRaw,
54
+ messages,
55
+ status,
56
+ error,
57
+ exitCode,
58
+ canRetry,
59
+ queuedMessage,
60
+ narratedMode: narratedModeRaw,
61
+ fullReadoutMode,
62
+ rawEvents,
63
+ isTabSwitching,
64
+ } = useSessionState();
65
+ const {
66
+ switchSession: onSwitchSession,
67
+ closeSession: onCloseSession,
68
+ openSession: onOpenSession,
69
+ createNewSession: onNewSession,
70
+ sendMessage: onSendMessage,
71
+ retry: onRetry,
72
+ stop: onStop,
73
+ toggleNarratedMode: onToggleNarratedMode,
74
+ toggleFullReadout: onToggleFullReadout,
75
+ } = useSessionActions();
76
+
77
+ const workItemId = activeSessionId || 'sessions';
78
+ const workItemTitle = activeSession?.title || 'Claude Sessions';
79
+ const standaloneSessions = standaloneSessRaw || [];
80
+ const narratedMode = narratedModeRaw ?? false;
264
81
  const contentRef = useRef<HTMLDivElement>(null);
265
- const hasGates = messages.some(m => m.type === 'gate');
266
- // Force detail view when no user messages (e.g., welcome session with static content)
267
- const hasUserMessages = messages.some(m => m.type === 'user');
268
- const effectiveNarratedMode = hasUserMessages ? narratedMode : false;
82
+ const { allowed: usageAllowed, used, limit, plan, loading: usageLoading } = useUsage();
83
+ const limitReached = !usageLoading && !usageAllowed && plan === 'free';
84
+ // Force detail view when no user messages or gates (e.g., welcome session with static content).
85
+ // Gates (like rejection) count as meaningful content that warrants the narrated mode toggle.
86
+ const hasMeaningfulContent = messages.some(m => m.type === 'user' || m.type === 'gate');
87
+ const effectiveNarratedMode = hasMeaningfulContent ? narratedMode : false;
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
+
112
+ // Debounce "What's next?" to prevent flash during tab switches.
113
+ // When messages become empty (e.g., switching to a session whose content hasn't loaded yet),
114
+ // wait 300ms before showing the empty state. If content arrives in that window, no flash.
115
+ const [showEmptyState, setShowEmptyState] = useState(() => messages.length === 0 && status === 'idle');
116
+ const emptyStateTimerRef = useRef<NodeJS.Timeout>(undefined);
117
+ useEffect(() => {
118
+ if (messages.length === 0 && status === 'idle' && !isTabSwitching) {
119
+ emptyStateTimerRef.current = setTimeout(() => setShowEmptyState(true), 300);
120
+ } else {
121
+ setShowEmptyState(false);
122
+ }
123
+ return () => clearTimeout(emptyStateTimerRef.current);
124
+ }, [activeSessionId, messages.length, status, isTabSwitching]);
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]);
269
136
 
270
137
  // Track answered question gates by timestamp → selected option id
271
138
  const [answeredQuestions, setAnsweredQuestions] = useState<Map<number, string>>(new Map());
@@ -276,68 +143,243 @@ export function ClaudePanel({
276
143
  next.set(message.timestamp, optionId);
277
144
  return next;
278
145
  });
146
+
147
+ // Backlog session: "Finished" closes the tab instead of sending a message
148
+ if (activeSession?.title === 'Add to Backlog' && optionId === 'finished') {
149
+ if (activeSessionId) onCloseSession(activeSessionId);
150
+ return;
151
+ }
152
+
279
153
  onSendMessage(optionLabel);
280
- }, [onSendMessage]);
154
+ }, [onSendMessage, activeSession?.title, activeSessionId, onCloseSession]);
155
+
156
+ const handleStartWorkItem = useCallback((id: number, title: string, type: string) => {
157
+ onOpenSession(String(id), title, type);
158
+ }, [onOpenSession]);
159
+
160
+ // Scroll ratio to restore after view mode change
161
+ const scrollRatioRef = useRef<number | null>(null);
281
162
 
282
163
  // Accordion state for detail view - tracks which intermediate messages are expanded
283
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
+ });
284
305
 
285
306
  // Reset expanded state when toggling between summary/detail views
286
307
  useEffect(() => {
287
308
  setExpandedIndices(new Set());
288
309
  }, [effectiveNarratedMode]);
289
310
 
290
- // 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).
291
313
  const [isDragging, setIsDragging] = useState(false);
292
314
  const [attachedImages, setAttachedImages] = useState<AttachedImage[]>([]);
293
- const dragCounterRef = useRef(0);
294
-
295
- const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
296
- e.preventDefault();
297
- e.stopPropagation();
298
- dragCounterRef.current++;
299
- if (e.dataTransfer.types.includes('Files')) {
300
- setIsDragging(true);
301
- }
302
- }, []);
303
315
 
304
- const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
305
- e.preventDefault();
306
- e.stopPropagation();
307
- dragCounterRef.current--;
308
- if (dragCounterRef.current === 0) {
309
- 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);
310
326
  }
311
- }, []);
327
+ const restored = activeSessionId ? imageDraftsRef.current.get(activeSessionId) ?? [] : [];
328
+ setAttachedImages(restored);
329
+ prevSessionForImagesRef.current = activeSessionId;
330
+ }, [activeSessionId]);
331
+
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>> = [];
312
338
 
313
- const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
314
- e.preventDefault();
315
- e.stopPropagation();
316
- }, []);
339
+ unlisteners.push(
340
+ listen<{ paths: string[]; position: { x: number; y: number } }>('tauri://drag-enter', () => {
341
+ if (isOpenRef.current) setIsDragging(true);
342
+ })
343
+ );
317
344
 
318
- const handleDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
319
- e.preventDefault();
320
- e.stopPropagation();
321
- setIsDragging(false);
322
- dragCounterRef.current = 0;
323
-
324
- const files = Array.from(e.dataTransfer.files);
325
- const imageFiles = files.filter(file => file.type.startsWith('image/'));
326
-
327
- imageFiles.forEach(file => {
328
- const reader = new FileReader();
329
- reader.onload = () => {
330
- const newImage: AttachedImage = {
331
- id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
332
- name: file.name,
333
- type: file.type,
334
- dataUrl: reader.result as string,
335
- size: file.size,
336
- };
337
- setAttachedImages(prev => [...prev, newImage]);
338
- };
339
- reader.readAsDataURL(file);
340
- });
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
+ };
341
383
  }, []);
342
384
 
343
385
  const handleImagesChange = useCallback((images: AttachedImage[]) => {
@@ -348,87 +390,200 @@ export function ClaudePanel({
348
390
  // Also treat 'sessions' (default state with no active session) as standalone
349
391
  const isStandalone = workItemId === 'sessions' || standaloneSessions.some(s => s.id === workItemId);
350
392
 
351
- // Auto-scroll to bottom when new messages arrive
393
+ // Track whether active work item is ready for review
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);
401
+
402
+ const fetchReadyForReview = useCallback(() => {
403
+ if (isStandalone || !activeSessionId) {
404
+ if (reviewDelayRef.current) { clearTimeout(reviewDelayRef.current); reviewDelayRef.current = null; }
405
+ setIsReadyForReview(false);
406
+ return;
407
+ }
408
+
409
+ const workId = parseInt(activeSessionId, 10);
410
+ if (isNaN(workId)) {
411
+ if (reviewDelayRef.current) { clearTimeout(reviewDelayRef.current); reviewDelayRef.current = null; }
412
+ setIsReadyForReview(false);
413
+ return;
414
+ }
415
+
416
+ dataBridge.getWorkItem(workId)
417
+ .then(data => {
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
+ }
431
+ })
432
+ .catch(() => {
433
+ if (reviewDelayRef.current) { clearTimeout(reviewDelayRef.current); reviewDelayRef.current = null; }
434
+ setIsReadyForReview(false);
435
+ });
436
+ }, [activeSessionId, isStandalone]);
437
+
438
+ // Fetch on mount / session switch; clean up delay timer
439
+ useEffect(() => {
440
+ fetchReadyForReview();
441
+ return () => {
442
+ if (reviewDelayRef.current) { clearTimeout(reviewDelayRef.current); reviewDelayRef.current = null; }
443
+ };
444
+ }, [fetchReadyForReview]);
445
+
446
+ // Re-fetch when DB changes via WebSocket so the review footer appears instantly
447
+ const handleWsMessage = useCallback((message: WebSocketMessage) => {
448
+ if (message.type === 'db_change') {
449
+ fetchReadyForReview();
450
+ }
451
+ }, [fetchReadyForReview]);
452
+
453
+ useWebSocket({ url: getWebSocketUrl(), onMessage: handleWsMessage });
454
+
455
+ const handleReviewAction = useCallback(() => {
456
+ if (activeSessionId) {
457
+ onCloseSession(activeSessionId);
458
+ }
459
+ }, [activeSessionId, onCloseSession]);
460
+
461
+ const handleRejectAction = useCallback((reason: string) => {
462
+ if (!activeSessionId) return;
463
+
464
+ // Clear review state so ReviewFooter is replaced by normal input
465
+ setIsReadyForReview(false);
466
+
467
+ // Inject rejection gate card into the chat
468
+ const registry = getRegistry();
469
+ const streamManager = registry.get(activeSessionId);
470
+ if (streamManager) {
471
+ streamManager.injectGate('rejection', { reason });
472
+ }
473
+
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);
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
352
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(() => {
353
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) {
354
526
  contentRef.current.scrollTop = contentRef.current.scrollHeight;
355
527
  }
356
528
  }, [messages]);
357
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
+
358
542
  return (
359
543
  <AnimatePresence>
360
544
  {isOpen && (
361
- <motion.div
545
+ <m.div
362
546
  initial={{ x: '100%' }}
363
547
  animate={{ x: 0 }}
364
548
  exit={{ x: '100%' }}
365
549
  transition={{ type: 'spring', damping: 25, stiffness: 200 }}
366
550
  className="fixed right-0 top-0 h-full w-[480px] bg-white border-l border-zinc-200 flex flex-col z-50"
367
551
  data-testid="claude-panel"
368
- onDragEnter={handleDragEnter}
369
- onDragLeave={handleDragLeave}
370
- onDragOver={handleDragOver}
371
- onDrop={handleDrop}
372
552
  >
373
553
  {/* Full-panel drop zone overlay */}
374
554
  {isDragging && (
375
555
  <div
376
- className="absolute inset-0 flex items-center justify-center bg-blue-50/90 z-[60] pointer-events-none"
556
+ className="absolute inset-0 flex items-center justify-center bg-[#e8f0f0]/90 z-[60] pointer-events-none"
377
557
  data-testid="panel-drop-zone-indicator"
378
558
  >
379
- <div className="text-blue-600 text-sm font-medium">
559
+ <div className="text-[#5a7d7f] text-base font-medium">
380
560
  Drop image here
381
561
  </div>
382
562
  </div>
383
563
  )}
384
564
 
385
- {/* Header */}
386
- <div className="flex items-center justify-between px-4 py-3 border-b border-zinc-200">
387
- <div className="flex items-center gap-3 min-w-0">
388
- <StatusIndicator status={status} />
389
- <div className="min-w-0">
390
- <h2 className="text-sm font-semibold text-zinc-900 truncate" data-testid="panel-title">
391
- {isStandalone ? workItemTitle : `#${workItemId} ${workItemTitle}`}
392
- </h2>
393
- <p className="text-xs text-zinc-500">
394
- {status === 'connecting' && 'Connecting...'}
395
- {status === 'creating' && 'Creating Claude session...'}
396
- {status === 'streaming' && 'Claude is working...'}
397
- {status === 'done' && 'Complete'}
398
- {status === 'error' && 'Error occurred'}
399
- {status === 'idle' && 'Ready'}
400
- </p>
565
+ {/* Usage limit banner */}
566
+ {limitReached && (
567
+ <div className="bg-amber-50 border-b border-amber-200 text-amber-800 px-5 py-3 flex items-center justify-between flex-shrink-0">
568
+ <div className="flex items-center gap-3">
569
+ <span className="text-amber-600 text-base">&#9888;</span>
570
+ <span className="text-base font-medium">
571
+ Weekly limit reached ({used}/{limit} work items). Claude is disabled until usage resets.
572
+ </span>
401
573
  </div>
402
- </div>
403
- <div className="flex items-center gap-2">
404
- {onToggleNarratedMode && (
405
- <button
406
- onClick={onToggleNarratedMode}
407
- className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
408
- effectiveNarratedMode
409
- ? 'bg-blue-100 text-blue-700 hover:bg-blue-200'
410
- : 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200'
411
- }`}
412
- aria-label={effectiveNarratedMode ? 'Show full conversation' : 'Show summary view'}
413
- data-testid="narrated-mode-toggle"
414
- >
415
- {effectiveNarratedMode ? 'Summary' : 'Details'}
416
- </button>
417
- )}
418
- <button
419
- onClick={onClose}
420
- className="p-1.5 rounded hover:bg-zinc-100 text-zinc-500 hover:text-zinc-900 transition-colors"
421
- aria-label="Slide away panel"
422
- data-testid="close-button"
574
+ <a
575
+ href="/subscribe"
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"
577
+ style={{ backgroundColor: '#e57a44', boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(229, 122, 68, 0.2)' }}
423
578
  >
424
- <SlideAwayIcon />
425
- </button>
579
+ Upgrade
580
+ </a>
426
581
  </div>
427
- </div>
582
+ )}
428
583
 
429
584
  {/* Session Tabs - shown when at least one session exists (work item or standalone) */}
430
585
  {((sessions && sessions.size >= 1) || standaloneSessions.length > 0) && (
431
- <div className="flex border-b border-zinc-200 bg-zinc-50 overflow-x-auto" data-testid="session-tabs">
586
+ <div className="grid grid-cols-3 border-b border-zinc-200 bg-zinc-50" data-testid="session-tabs">
432
587
  {/* Work item sessions (exclude standalone sessions - they render separately below) */}
433
588
  {sessions && Array.from(sessions.entries())
434
589
  .filter(([id]) => !standaloneSessions.some(s => s.id === id))
@@ -436,35 +591,33 @@ export function ClaudePanel({
436
591
  <div
437
592
  key={id}
438
593
  className={`
439
- flex-shrink-0 flex items-center gap-1 pl-3 pr-1 py-2
440
- border-r border-zinc-200 last:border-r-0
594
+ flex items-center justify-between gap-1 pl-4 pr-1.5 py-3 min-w-0
595
+ border-b border-r border-zinc-200
441
596
  cursor-pointer select-none group
442
- ${id === activeSessionId
443
- ? 'bg-white text-zinc-900'
444
- : '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'
445
604
  }
446
605
  `}
447
606
  data-testid={`session-tab-${id}`}
448
607
  onClick={() => onSwitchSession?.(id)}
449
608
  >
450
609
  <span
451
- className="flex items-center gap-1.5 text-xs font-medium truncate max-w-[120px]"
610
+ className="flex-1 flex items-center gap-2 text-sm font-medium min-w-0 overflow-hidden"
452
611
  title={session.title}
453
612
  >
454
613
  <span className="truncate">#{id} {session.title}</span>
455
- {session.status === 'creating' && (
456
- <span className="w-2 h-2 rounded-full bg-yellow-500 flex-shrink-0" />
457
- )}
458
- {session.status === 'streaming' && (
459
- <span className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />
460
- )}
461
614
  </span>
462
615
  <button
463
616
  onClick={(e) => {
464
617
  e.stopPropagation();
465
618
  onCloseSession?.(id);
466
619
  }}
467
- className="p-1 rounded hover:bg-zinc-200 text-zinc-400 hover:text-zinc-700 opacity-0 group-hover:opacity-100 transition-opacity"
620
+ className="flex-shrink-0 p-1 rounded hover:bg-zinc-200 text-zinc-400 hover:text-zinc-700 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-out"
468
621
  aria-label={`Close session ${id}`}
469
622
  data-testid={`session-close-${id}`}
470
623
  >
@@ -477,35 +630,33 @@ export function ClaudePanel({
477
630
  <div
478
631
  key={`standalone-${session.id}`}
479
632
  className={`
480
- flex-shrink-0 flex items-center gap-1 pl-3 pr-1 py-2
481
- border-r border-zinc-200 last:border-r-0
633
+ flex items-center justify-between gap-1 pl-4 pr-1.5 py-3 min-w-0
634
+ border-b border-r border-zinc-200
482
635
  cursor-pointer select-none group
483
- ${session.id === activeSessionId
484
- ? 'bg-white text-zinc-900'
485
- : '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'
486
643
  }
487
644
  `}
488
645
  data-testid={`session-tab-standalone-${session.id}`}
489
646
  onClick={() => onSwitchSession?.(session.id)}
490
647
  >
491
648
  <span
492
- className="flex items-center gap-1.5 text-xs font-medium truncate max-w-[120px]"
649
+ className="flex-1 flex items-center gap-2 text-sm font-medium min-w-0 overflow-hidden"
493
650
  title={session.title}
494
651
  >
495
652
  <span className="truncate">{session.title}</span>
496
- {sessions?.get(session.id)?.status === 'creating' && (
497
- <span className="w-2 h-2 rounded-full bg-yellow-500 flex-shrink-0" />
498
- )}
499
- {sessions?.get(session.id)?.status === 'streaming' && (
500
- <span className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />
501
- )}
502
653
  </span>
503
654
  <button
504
655
  onClick={(e) => {
505
656
  e.stopPropagation();
506
657
  onCloseSession?.(session.id);
507
658
  }}
508
- className="p-1 rounded hover:bg-zinc-200 text-zinc-400 hover:text-zinc-700 opacity-0 group-hover:opacity-100 transition-opacity"
659
+ className="flex-shrink-0 p-1 rounded hover:bg-zinc-200 text-zinc-400 hover:text-zinc-700 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-out"
509
660
  aria-label={`Close session ${session.id}`}
510
661
  data-testid={`session-close-standalone-${session.id}`}
511
662
  >
@@ -513,30 +664,52 @@ export function ClaudePanel({
513
664
  </button>
514
665
  </div>
515
666
  ))}
516
- {/* New session button */}
517
- <button
518
- onClick={() => onNewSession?.()}
519
- className="flex-shrink-0 flex items-center justify-center px-3 py-2 text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 active:bg-zinc-200"
520
- aria-label="Create new session"
521
- data-testid="new-session-button"
522
- >
523
- <PlusIcon />
524
- </button>
667
+ {/* New session button - hidden when weekly limit reached */}
668
+ {!limitReached && (
669
+ <button
670
+ onClick={() => onNewSession?.()}
671
+ className="flex items-center justify-center px-4 py-3 border-b border-r border-zinc-200 text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 active:bg-zinc-200"
672
+ aria-label="Create new session"
673
+ data-testid="new-session-button"
674
+ >
675
+ <PlusIcon />
676
+ </button>
677
+ )}
525
678
  </div>
526
679
  )}
527
680
 
681
+ {/* Header */}
682
+ <div className="flex items-center justify-between px-5 py-4 border-b border-zinc-200">
683
+ <div className="flex items-center gap-4 min-w-0">
684
+ <StatusIndicator status={status} />
685
+ <div className="min-w-0">
686
+ <h2 className="text-base font-semibold text-zinc-900 truncate" data-testid="panel-title">
687
+ {isStandalone ? workItemTitle : `#${workItemId} ${workItemTitle}`}
688
+ </h2>
689
+ <p className="text-base text-zinc-500">
690
+ {status === 'connecting' && 'Connecting...'}
691
+ {status === 'creating' && 'Creating Claude session...'}
692
+ {status === 'streaming' && 'Claude is working...'}
693
+ {status === 'done' && 'Complete'}
694
+ {status === 'error' && 'Error occurred'}
695
+ {status === 'idle' && 'Ready'}
696
+ </p>
697
+ </div>
698
+ </div>
699
+ </div>
700
+
528
701
  {/* Error banner */}
529
702
  {status === 'error' && error && (
530
- <div className="bg-red-50 border-b border-red-200 px-4 py-3" data-testid="error-banner">
531
- <div className="flex items-start gap-3">
703
+ <div className="bg-red-50 border-b border-red-200 px-5 py-4" data-testid="error-banner">
704
+ <div className="flex items-start gap-4">
532
705
  <ErrorIcon />
533
706
  <div className="flex-1 min-w-0">
534
- <p className="text-sm font-medium text-red-700" data-testid="error-message">{error}</p>
707
+ <p className="text-base font-medium text-red-700" data-testid="error-message">{error}</p>
535
708
  {exitCode !== null && (
536
- <p className="text-xs text-red-500 mt-1">Exit code: {exitCode}</p>
709
+ <p className="text-base text-red-500 mt-1">Exit code: {exitCode}</p>
537
710
  )}
538
711
  {error === 'Claude CLI not found' && (
539
- <div className="mt-2 text-xs text-red-600" data-testid="install-instructions">
712
+ <div className="mt-3 text-base text-red-600" data-testid="install-instructions">
540
713
  <p className="font-medium mb-1">To install Claude CLI:</p>
541
714
  <code className="block bg-red-100 rounded px-2 py-1 mt-1">
542
715
  npm install -g @anthropic-ai/claude-code
@@ -545,90 +718,84 @@ export function ClaudePanel({
545
718
  )}
546
719
  </div>
547
720
  {canRetry && (
548
- <button
721
+ <Button
549
722
  onClick={onRetry}
550
- className="px-3 py-1.5 text-xs font-medium bg-red-100 hover:bg-red-200 text-red-700 rounded transition-colors"
723
+ variant="destructive"
724
+ size="sm"
551
725
  data-testid="retry-button"
552
726
  >
553
727
  Retry
554
- </button>
728
+ </Button>
555
729
  )}
556
730
  </div>
557
731
  </div>
558
732
  )}
559
733
 
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
+ />
745
+ )}
746
+
560
747
  {/* Content */}
561
748
  <div
562
749
  ref={contentRef}
563
- className="flex-1 overflow-y-auto overscroll-contain p-4 space-y-3"
750
+ onScroll={handleContentScroll}
751
+ className="flex-1 overflow-y-auto overscroll-contain p-5 space-y-4"
564
752
  data-testid="panel-content"
565
753
  >
566
754
  {/* Empty state when no sessions exist */}
567
755
  {(!sessions || sessions.size === 0) && standaloneSessions.length === 0 ? (
568
756
  <div className="flex flex-col items-center justify-center h-full text-center" data-testid="no-sessions-empty-state">
569
- <p className="text-zinc-500 text-sm mb-4">No active sessions</p>
570
- <button
571
- onClick={() => onNewSession?.()}
572
- className="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-blue-600 hover:bg-blue-500 text-white rounded transition-colors"
573
- data-testid="empty-state-new-session-button"
574
- >
575
- <PlusIcon />
576
- New Session
577
- </button>
757
+ <p className="text-zinc-500 text-base mb-6">{limitReached ? 'Weekly limit reached' : 'No active sessions'}</p>
758
+ {!limitReached && (
759
+ <Button
760
+ onClick={() => onNewSession?.()}
761
+ className="flex items-center gap-2"
762
+ data-testid="empty-state-new-session-button"
763
+ >
764
+ <PlusIcon />
765
+ New Session
766
+ </Button>
767
+ )}
578
768
  </div>
579
769
  ) : (
580
770
  <>
581
771
  {effectiveNarratedMode ? (
582
772
  /* Narrated mode: show gate cards, user messages, assistant text, and a working indicator */
583
773
  <>
584
- {(() => {
585
- // Build a set of indices that are the last assistant/text response per turn
586
- // A turn boundary is defined by user messages
587
- const finalIndicesPerTurn = new Set<number>();
588
- let lastAssistantOrTextIdx = -1;
589
- for (let i = 0; i < messages.length; i++) {
590
- if (messages[i].type === 'assistant' || messages[i].type === 'text') {
591
- lastAssistantOrTextIdx = i;
592
- }
593
- if (messages[i].type === 'user' && lastAssistantOrTextIdx >= 0) {
594
- finalIndicesPerTurn.add(lastAssistantOrTextIdx);
595
- lastAssistantOrTextIdx = -1;
596
- }
597
- }
598
- // Add the final assistant/text of the last turn only if the turn is complete
599
- if (lastAssistantOrTextIdx >= 0 && status !== 'streaming') {
600
- finalIndicesPerTurn.add(lastAssistantOrTextIdx);
601
- }
602
-
603
- const narratedMessages = messages.filter((m, i) =>
604
- m.type === 'gate' || m.type === 'user' || ((m.type === 'assistant' || m.type === 'text') && finalIndicesPerTurn.has(i))
605
- );
606
- const lastGateIndex = narratedMessages.findLastIndex(m => m.type === 'gate');
607
- return narratedMessages.map((message, index) => (
608
- <div key={index}>
609
- {message.type === 'gate' ? (
610
- <GateCard
611
- message={message}
612
- isLatest={index === lastGateIndex}
613
- onAnswerQuestion={(optionId, optionLabel) => handleAnswerQuestion(message, optionId, optionLabel)}
614
- answeredQuestionId={answeredQuestions.get(message.timestamp) || null}
615
- />
616
- ) : (
617
- <MessageBlock message={message} />
618
- )}
619
- </div>
620
- ));
621
- })()}
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
+ ))}
622
789
  {status === 'creating' && (
623
- <div className="flex items-center gap-2 text-xs text-zinc-400 py-2">
624
- <span className="w-1.5 h-1.5 bg-yellow-400 rounded-full animate-pulse" />
790
+ <div className="flex items-center gap-1.5 text-sm text-zinc-400 py-3 whitespace-nowrap overflow-hidden text-ellipsis">
791
+ <span className="w-1.5 h-1.5 bg-yellow-400 rounded-full animate-pulse shrink-0" />
625
792
  Creating Claude session...
626
793
  <ElapsedTimer isStreaming={true} timerKey={`${activeSessionId ?? 'default'}-${messages.filter(m => m.type === 'user').length}`} />
627
794
  </div>
628
795
  )}
629
796
  {status === 'streaming' && (
630
- <div className="flex items-center gap-2 text-xs text-zinc-400 py-2">
631
- <span className="w-1.5 h-1.5 bg-blue-400 rounded-full animate-pulse" />
797
+ <div className="flex items-center gap-1.5 text-sm text-zinc-400 py-3 whitespace-nowrap overflow-hidden text-ellipsis">
798
+ <span className="w-1.5 h-1.5 bg-[#819D9F] rounded-full animate-pulse shrink-0" />
632
799
  <ElapsedTimer isStreaming={true} timerKey={`${activeSessionId ?? 'default'}-${messages.filter(m => m.type === 'user').length}`} />
633
800
  {(() => {
634
801
  const lastToolUse = [...messages].reverse().find(m => m.type === 'tool_use');
@@ -640,311 +807,192 @@ export function ClaudePanel({
640
807
  </div>
641
808
  )}
642
809
  </>
643
- ) : (
644
- /* Detail mode: show full conversation with collapsible intermediate responses */
645
- <>
646
- {(() => {
647
- const filteredMessages = messages.filter(m => m.type !== 'tool_use');
648
- const lastUserMessageIndex = filteredMessages.findLastIndex(m => m.type === 'user');
649
- const userMessageCount = filteredMessages.filter(m => m.type === 'user').length;
650
- const lastAssistantIndex = filteredMessages.findLastIndex(m => m.type === 'assistant' || m.type === 'text');
651
- // Only show collapse UI when there's an actual conversation (user messages exist)
652
- const hasIntermediates = userMessageCount > 0 && filteredMessages.filter(m => m.type === 'assistant' || m.type === 'text').length > 1;
653
-
654
- const allExpanded = hasIntermediates && filteredMessages.every((m, i) =>
655
- (m.type !== 'assistant' && m.type !== 'text') || i === lastAssistantIndex || expandedIndices.has(i)
656
- );
657
-
658
- return (
659
- <>
660
- {hasIntermediates && (
661
- <div className="flex justify-end mb-1">
662
- <button
663
- onClick={() => {
664
- if (allExpanded) {
665
- setExpandedIndices(new Set());
666
- } else {
667
- const all = new Set<number>();
668
- filteredMessages.forEach((m, i) => {
669
- if ((m.type === 'assistant' || m.type === 'text') && i !== lastAssistantIndex) {
670
- all.add(i);
671
- }
672
- });
673
- setExpandedIndices(all);
674
- }
675
- }}
676
- className="text-xs text-zinc-400 hover:text-zinc-600 transition-colors"
677
- data-testid="expand-collapse-all"
678
- >
679
- {allExpanded ? 'Collapse all' : 'Expand all'}
680
- </button>
681
- </div>
682
- )}
683
- {filteredMessages.map((message, index) => {
684
- const isAssistant = message.type === 'assistant' || message.type === 'text';
685
- const isFinal = isAssistant && index === lastAssistantIndex;
686
- // Don't collapse when no user messages (e.g., welcome session with static content)
687
- const isIntermediate = isAssistant && !isFinal && userMessageCount > 0;
688
- const isExpanded = expandedIndices.has(index);
689
-
690
- // Get first line for collapsed summary
691
- const firstLine = isIntermediate
692
- ? (unescapeContent(message.content).split('\n')[0] || '').slice(0, 120)
693
- : '';
694
-
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';
695
849
  return (
696
- <div key={index}>
697
- {/* Final response divider */}
698
- {isFinal && hasIntermediates && (
699
- <div className="flex items-center gap-3 my-3" data-testid="final-response-divider">
700
- <div className="flex-1 h-px bg-zinc-200" />
701
- <span className="text-xs text-zinc-400 font-medium">Final response</span>
702
- <div className="flex-1 h-px bg-zinc-200" />
703
- </div>
704
- )}
705
- {message.type === 'gate' ? (
706
- <GateCard
707
- message={message}
708
- onAnswerQuestion={(optionId, optionLabel) => handleAnswerQuestion(message, optionId, optionLabel)}
709
- answeredQuestionId={answeredQuestions.get(message.timestamp) || null}
710
- />
711
- ) : isIntermediate ? (
712
- /* Collapsible intermediate assistant response */
713
- <div
714
- className="cursor-pointer"
715
- onClick={() => {
716
- setExpandedIndices(prev => {
717
- const next = new Set(prev);
718
- if (next.has(index)) {
719
- next.delete(index);
720
- } else {
721
- next.add(index);
722
- }
723
- return next;
724
- });
725
- }}
726
- data-testid="collapsible-message"
727
- >
728
- {isExpanded ? (
729
- <AnimatePresence mode="wait">
730
- <motion.div
731
- initial={{ height: 0, opacity: 0 }}
732
- animate={{ height: 'auto', opacity: 1 }}
733
- exit={{ height: 0, opacity: 0 }}
734
- transition={{ duration: 0.2, ease: 'easeInOut' }}
735
- >
736
- <MessageBlock message={message} />
737
- </motion.div>
738
- </AnimatePresence>
739
- ) : (
740
- <div className="bg-zinc-50 rounded-lg px-3 py-2 flex items-center gap-2 hover:bg-zinc-100 transition-colors" data-testid="collapsed-summary">
741
- <svg className="w-3 h-3 text-zinc-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
742
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
743
- </svg>
744
- <span className="text-xs text-zinc-500 truncate">{firstLine.trim() ? firstLine : '(empty response)'}</span>
745
- </div>
746
- )}
747
- </div>
748
- ) : (
749
- <MessageBlock message={message} />
750
- )}
751
- {index === lastUserMessageIndex && (status === 'streaming' || status === 'creating') && (
752
- <ElapsedTimer isStreaming={true} timerKey={`${activeSessionId ?? 'default'}-${userMessageCount}`} />
753
- )}
754
- </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>
755
859
  );
756
- })}
757
- </>
758
- );
759
- })()}
760
- {/* Current tool call indicator - shown inline after last Claude message */}
761
- {(() => {
762
- const lastToolUseIndex = messages.findLastIndex(m => m.type === 'tool_use');
763
- if (lastToolUseIndex === -1) return null;
764
-
765
- const hasSubsequentMessage = messages.slice(lastToolUseIndex + 1).some(
766
- m => (m.type === 'text' || m.type === 'assistant') &&
767
- !isSystemNoise(m.content)
768
- );
769
- if (hasSubsequentMessage) return null;
770
-
771
- const toolMessage = messages[lastToolUseIndex];
772
- const firstParamValue = toolMessage.tool_input ? Object.values(toolMessage.tool_input)[0] : null;
773
- const displayValue = typeof firstParamValue === 'string'
774
- ? (firstParamValue.length > 50 ? firstParamValue.slice(0, 50) + '...' : firstParamValue)
775
- : null;
776
-
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;
777
877
  return (
778
- <div className="bg-zinc-100 rounded-lg px-3 py-2" data-testid="current-tool-call">
779
- <div className="flex items-center gap-2 text-xs">
780
- <span className="text-purple-600">{toolMessage.tool_name}</span>
781
- {displayValue && <span className="text-zinc-500 truncate">{displayValue}</span>}
782
- </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}
783
944
  </div>
784
945
  );
785
- })()}
786
- </>
946
+ })}
947
+ </div>
787
948
  )}
788
- {messages.length === 0 && status === 'idle' && (
789
- <div className="text-zinc-500 text-sm text-center py-8">
949
+ {showEmptyState && (
950
+ <div className="text-zinc-500 text-base text-center py-8">
790
951
  What's next?
791
952
  </div>
792
953
  )}
793
954
  {/* Queued message — shown below status indicator while Claude is processing */}
794
955
  {queuedMessage && (
795
- <div className="bg-blue-50 border border-blue-100 rounded-lg p-3 opacity-60" data-testid="queued-message">
796
- <div className="flex items-center gap-1.5 mb-1">
956
+ <div className="bg-[#e8f0f0] border-2 border-[#819D9F]/20 rounded-lg p-4 opacity-60" data-testid="queued-message">
957
+ <div className="flex items-center gap-2 mb-1.5">
797
958
  <UserIcon />
798
- <span className="text-xs text-zinc-400">Queued</span>
959
+ <span className="text-base text-zinc-400">Queued</span>
799
960
  </div>
800
- <div className="text-sm text-zinc-700 whitespace-pre-wrap">{queuedMessage.message}</div>
961
+ <div className="text-base text-zinc-700 whitespace-pre-wrap">{queuedMessage.message}</div>
801
962
  </div>
802
963
  )}
803
964
  </>
804
965
  )}
805
966
  </div>
806
967
 
807
- {/* Input field - visible when session is active or idle, but hidden when no tabs/sessions exist */}
968
+ {/* Footer: ReviewFooter when ready for review, otherwise normal input */}
808
969
  {(status === 'streaming' || status === 'creating' || status === 'done' || status === 'idle') && ((sessions && sessions.size > 0) || standaloneSessions.length > 0) && (
809
- <ClaudePanelInput
810
- onSendMessage={onSendMessage}
811
- onStop={onStop}
812
- isStreaming={status === 'streaming' || status === 'creating'}
813
- placeholder="Type a message..."
814
- attachedImages={attachedImages}
815
- onImagesChange={handleImagesChange}
816
- />
970
+ isReadyForReview && !isAskingQuestion && activeSessionId ? (
971
+ <ReviewFooter
972
+ workItemId={activeSessionId}
973
+ onAccepted={handleReviewAction}
974
+ onRejected={handleRejectAction}
975
+ onAskQuestion={handleAskQuestion}
976
+ />
977
+ ) : (
978
+ <ClaudePanelInput
979
+ onSendMessage={handleSendMessage}
980
+ onStop={onStop}
981
+ isStreaming={status === 'streaming' || status === 'creating'}
982
+ disabled={limitReached}
983
+ placeholder={limitReached ? 'Weekly limit reached' : 'Type a message...'}
984
+ attachedImages={attachedImages}
985
+ onImagesChange={handleImagesChange}
986
+ activeSessionId={activeSessionId}
987
+ />
988
+ )
817
989
  )}
818
- </motion.div>
990
+ </m.div>
819
991
  )}
820
992
  </AnimatePresence>
821
993
  );
822
994
  }
823
995
 
824
- function StatusIndicator({ status }: { status: StreamStatus }) {
825
- const colorClass = {
826
- idle: 'bg-zinc-500',
827
- connecting: 'bg-yellow-500 animate-pulse',
828
- creating: 'bg-yellow-500 animate-pulse',
829
- streaming: 'bg-blue-500 animate-pulse',
830
- done: 'bg-green-500',
831
- error: 'bg-red-500',
832
- }[status];
833
-
834
- return <div className={`w-2 h-2 rounded-full ${colorClass}`} />;
835
- }
836
-
837
- function MessageBlock({ message }: { message: ClaudeMessage }) {
838
- if (message.type === 'user') {
839
- return (
840
- <div className="bg-blue-50 border border-blue-200 rounded-lg p-3 ml-8" data-testid="user-message">
841
- <div className="flex items-center gap-2 mb-1">
842
- <UserIcon />
843
- <span className="text-xs font-medium text-blue-600">You</span>
844
- </div>
845
- <div className="text-sm text-blue-900 [&_p]:my-1 [&_h1]:text-lg [&_h1]:font-bold [&_h1]:my-2 [&_h2]:text-base [&_h2]:font-semibold [&_h2]:my-2 [&_h3]:font-semibold [&_h3]:my-1 [&_pre]:bg-blue-100 [&_pre]:p-2 [&_pre]:rounded [&_pre]:overflow-x-auto [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:my-2 [&_code]:text-blue-700 [&_code]:bg-blue-100 [&_code]:px-1 [&_code]:rounded [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_li]:my-0.5 [&_a]:text-blue-600 [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-blue-400 [&_blockquote]:pl-3 [&_blockquote]:italic">
846
- <ReactMarkdown remarkPlugins={[remarkGfm]}>{unescapeContent(message.content)}</ReactMarkdown>
847
- </div>
848
- </div>
849
- );
850
- }
851
-
852
- if (message.type === 'assistant' || message.type === 'text') {
853
- // Aggressive filtering: hide everything that's not genuine Claude conversation
854
- if (isSystemNoise(message.content)) {
855
- return null;
856
- }
857
-
858
- const displayContent = message.content;
859
- if (!displayContent) {
860
- return null;
861
- }
862
-
863
- return (
864
- <div className="bg-zinc-50 rounded-lg p-3" data-testid="output-block">
865
- <div className="text-zinc-700 text-sm [&_p]:my-1 [&_h1]:text-lg [&_h1]:font-bold [&_h1]:my-2 [&_h2]:text-base [&_h2]:font-semibold [&_h2]:my-2 [&_h3]:font-semibold [&_h3]:my-1 [&_pre]:bg-zinc-100 [&_pre]:p-2 [&_pre]:rounded [&_pre]:overflow-x-auto [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:my-2 [&_pre]:text-xs [&_code]:text-zinc-600 [&_code]:bg-zinc-100 [&_code]:px-1 [&_code]:rounded [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_li]:my-0.5 [&_a]:text-blue-600 [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-zinc-400 [&_blockquote]:pl-3 [&_blockquote]:italic [&_table]:text-xs [&_table]:w-full [&_th]:bg-zinc-100 [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_td]:px-2 [&_td]:py-1 [&_td]:border-t [&_td]:border-zinc-200">
866
- <ReactMarkdown remarkPlugins={[remarkGfm]}>{unescapeContent(displayContent)}</ReactMarkdown>
867
- </div>
868
- </div>
869
- );
870
- }
871
-
872
- if (message.type === 'tool_use') {
873
- // Extract first param value for preview (e.g., "Bash git status")
874
- const firstParamValue = message.tool_input ? Object.values(message.tool_input)[0] : null;
875
- const displayValue = typeof firstParamValue === 'string'
876
- ? (firstParamValue.length > 50 ? firstParamValue.slice(0, 50) + '...' : firstParamValue)
877
- : null;
878
-
879
- return (
880
- <div className="flex items-center gap-2 py-1" data-testid="tool-call">
881
- <span className="bg-purple-100 text-purple-700 px-2 py-0.5 rounded text-xs">{message.tool_name}</span>
882
- {displayValue && <span className="text-xs text-purple-500 truncate">{displayValue}</span>}
883
- </div>
884
- );
885
- }
886
-
887
- // Show tool_result messages in collapsible format (#1000103)
888
- // Filter noise content (skill prompts, file contents, etc.) - Bug #1000112
889
- if (message.type === 'tool_result') {
890
- const result = message.result || '';
891
-
892
- // Apply same noise filtering as assistant/text messages
893
- if (isSystemNoise(result)) {
894
- return null;
895
- }
896
-
897
- const deduped = deduplicateToolOutput(result);
898
- const isLong = deduped.length > 200;
899
- const preview = isLong ? deduped.slice(0, 200) + '...' : deduped;
900
-
901
- return (
902
- <details className="bg-zinc-100 rounded-lg text-xs group" data-testid="tool-result">
903
- <summary className="px-3 py-2 cursor-pointer text-zinc-500 hover:text-zinc-700 flex items-center gap-2 list-none">
904
- <svg className="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
905
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
906
- </svg>
907
- <span className="font-medium">Tool result</span>
908
- {!isLong && <span className="text-zinc-400 truncate max-w-[200px]">{preview}</span>}
909
- </summary>
910
- <div className="px-3 pb-2 pt-0">
911
- <pre className="text-zinc-600 whitespace-pre-wrap break-words overflow-x-auto max-h-[300px] overflow-y-auto">
912
- {deduped}
913
- </pre>
914
- </div>
915
- </details>
916
- );
917
- }
918
-
919
- if (message.type === 'error') {
920
- const isVersionError = isVersionUpdateError(message.content);
921
- return (
922
- <div className="bg-red-50 border border-red-200 rounded-lg p-3">
923
- <div className="flex items-center gap-2 mb-1">
924
- <ErrorIcon />
925
- <span className="text-xs font-medium text-red-600">Error</span>
926
- </div>
927
- <pre className="text-sm text-red-700 whitespace-pre-wrap font-sans">{unescapeContent(message.content)}</pre>
928
- {isVersionError && <UpdateClaudeButton />}
929
- </div>
930
- );
931
- }
932
-
933
- if (message.type === 'done') {
934
- return null;
935
- }
936
-
937
- return null;
938
- }
939
-
940
- function SlideAwayIcon() {
941
- return (
942
- <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
943
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
944
- </svg>
945
- );
946
- }
947
-
948
996
  function CloseIcon() {
949
997
  return (
950
998
  <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -960,130 +1008,3 @@ function PlusIcon() {
960
1008
  </svg>
961
1009
  );
962
1010
  }
963
-
964
- function ToolIcon() {
965
- return (
966
- <svg className="w-3.5 h-3.5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
967
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
968
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
969
- </svg>
970
- );
971
- }
972
-
973
- function ErrorIcon() {
974
- return (
975
- <svg className="w-3.5 h-3.5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
976
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
977
- </svg>
978
- );
979
- }
980
-
981
- function CheckIcon() {
982
- return (
983
- <svg className="w-3.5 h-3.5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
984
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
985
- </svg>
986
- );
987
- }
988
-
989
- function UserIcon() {
990
- return (
991
- <svg className="w-3.5 h-3.5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
992
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
993
- </svg>
994
- );
995
- }
996
-
997
- // Persist timer start timestamps outside the component so they survive remounts (e.g., tab switches)
998
- const timerStartTimes = new Map<string, number>();
999
-
1000
- function ElapsedTimer({ isStreaming, timerKey }: { isStreaming: boolean; timerKey: string }) {
1001
- const [elapsed, setElapsed] = useState(() => {
1002
- const existing = timerStartTimes.get(timerKey);
1003
- return existing ? Math.floor((Date.now() - existing) / 1000) : 0;
1004
- });
1005
-
1006
- useEffect(() => {
1007
- if (isStreaming) {
1008
- // Start or continue timing — reuse persisted start time if available
1009
- if (!timerStartTimes.has(timerKey)) {
1010
- timerStartTimes.set(timerKey, Date.now());
1011
- }
1012
- const interval = setInterval(() => {
1013
- const startTime = timerStartTimes.get(timerKey);
1014
- if (startTime != null) {
1015
- setElapsed(Math.floor((Date.now() - startTime) / 1000));
1016
- }
1017
- }, 1000);
1018
- return () => clearInterval(interval);
1019
- } else {
1020
- // Reset when not streaming
1021
- timerStartTimes.delete(timerKey);
1022
- setElapsed(0);
1023
- }
1024
- }, [isStreaming, timerKey]);
1025
-
1026
- if (!isStreaming) return null;
1027
-
1028
- const minutes = Math.floor(elapsed / 60);
1029
- const seconds = elapsed % 60;
1030
- const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
1031
-
1032
- return (
1033
- <div className="text-xs text-zinc-500 -translate-y-px" data-testid="elapsed-timer">
1034
- {display}
1035
- </div>
1036
- );
1037
- }
1038
-
1039
- function UpdateClaudeButton() {
1040
- const [isUpdating, setIsUpdating] = useState(false);
1041
- const [updateResult, setUpdateResult] = useState<{ success: boolean; error?: string } | null>(null);
1042
-
1043
- const handleUpdate = async () => {
1044
- if (!window.electronAPI?.claudeCode?.update) {
1045
- setUpdateResult({ success: false, error: 'Update is only available in the desktop app.' });
1046
- return;
1047
- }
1048
-
1049
- setIsUpdating(true);
1050
- setUpdateResult(null);
1051
-
1052
- try {
1053
- const result = await window.electronAPI.claudeCode.update();
1054
- setUpdateResult(result);
1055
- if (result.success) {
1056
- // Reload after successful update
1057
- setTimeout(() => window.location.reload(), 1500);
1058
- }
1059
- } catch (err) {
1060
- setUpdateResult({ success: false, error: String(err) });
1061
- } finally {
1062
- setIsUpdating(false);
1063
- }
1064
- };
1065
-
1066
- if (updateResult?.success) {
1067
- return (
1068
- <div className="mt-2 text-xs text-green-600" data-testid="update-success">
1069
- Update successful! Reloading...
1070
- </div>
1071
- );
1072
- }
1073
-
1074
- return (
1075
- <div className="mt-2">
1076
- <button
1077
- onClick={handleUpdate}
1078
- disabled={isUpdating}
1079
- className="px-3 py-1.5 text-xs font-medium bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white rounded transition-colors"
1080
- data-testid="update-claude-button"
1081
- >
1082
- {isUpdating ? 'Updating...' : 'Update Claude'}
1083
- </button>
1084
- {updateResult?.error && (
1085
- <p className="mt-1 text-xs text-red-500">{updateResult.error}</p>
1086
- )}
1087
- </div>
1088
- );
1089
- }