jettypod 4.4.116 → 4.4.120

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