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.
- package/.env +7 -0
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +124 -48
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +161 -10
- package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
- package/apps/dashboard/app/api/usage/route.ts +17 -0
- package/apps/dashboard/app/api/work/[id]/route.ts +35 -0
- package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
- package/apps/dashboard/app/connect-claude/page.tsx +24 -0
- package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
- package/apps/dashboard/app/demo/gates/page.tsx +42 -42
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +6 -2
- package/apps/dashboard/app/install-claude/page.tsx +9 -7
- package/apps/dashboard/app/layout.tsx +17 -5
- package/apps/dashboard/app/login/page.tsx +250 -0
- package/apps/dashboard/app/page.tsx +11 -9
- package/apps/dashboard/app/settings/page.tsx +4 -2
- package/apps/dashboard/app/signup/page.tsx +245 -0
- package/apps/dashboard/app/subscribe/page.tsx +11 -0
- package/apps/dashboard/app/welcome/page.tsx +24 -1
- package/apps/dashboard/app/work/[id]/page.tsx +34 -50
- package/apps/dashboard/components/AppShell.tsx +95 -55
- package/apps/dashboard/components/CardMenu.tsx +56 -13
- package/apps/dashboard/components/ClaudePanel.tsx +301 -582
- package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +210 -0
- package/apps/dashboard/components/CopyableId.tsx +3 -3
- package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
- package/apps/dashboard/components/DragContext.tsx +75 -65
- package/apps/dashboard/components/DraggableCard.tsx +6 -46
- package/apps/dashboard/components/DropZone.tsx +2 -2
- package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
- package/apps/dashboard/components/EditableTitle.tsx +26 -6
- package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
- package/apps/dashboard/components/EpicGroup.tsx +329 -0
- package/apps/dashboard/components/GateCard.tsx +100 -16
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
- package/apps/dashboard/components/InstallClaudeScreen.tsx +140 -51
- package/apps/dashboard/components/JettyLoader.tsx +38 -0
- package/apps/dashboard/components/KanbanBoard.tsx +147 -766
- package/apps/dashboard/components/KanbanCard.tsx +506 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
- package/apps/dashboard/components/MainNav.tsx +20 -54
- package/apps/dashboard/components/MessageBlock.tsx +391 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -15
- package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +11 -21
- package/apps/dashboard/components/ProjectSwitcher.tsx +36 -8
- package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +265 -301
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +97 -74
- package/apps/dashboard/components/ReviewFooter.tsx +141 -0
- package/apps/dashboard/components/SessionList.tsx +19 -18
- package/apps/dashboard/components/SubscribeContent.tsx +206 -0
- package/apps/dashboard/components/TestTree.tsx +15 -14
- package/apps/dashboard/components/TipCard.tsx +177 -0
- package/apps/dashboard/components/Toast.tsx +5 -5
- package/apps/dashboard/components/TypeIcon.tsx +56 -0
- package/apps/dashboard/components/UpgradeBanner.tsx +30 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
- package/apps/dashboard/components/WelcomeScreen.tsx +25 -27
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
- package/apps/dashboard/components/WorkItemTree.tsx +9 -28
- package/apps/dashboard/components/settings/AccountSection.tsx +169 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
- package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +408 -105
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
- package/apps/dashboard/contexts/UsageContext.tsx +155 -0
- package/apps/dashboard/contexts/usageHelpers.js +9 -0
- package/apps/dashboard/electron/ipc-handlers.js +281 -88
- package/apps/dashboard/electron/main.js +691 -131
- package/apps/dashboard/electron/preload.js +25 -4
- package/apps/dashboard/electron/session-manager.js +163 -0
- package/apps/dashboard/electron-builder.config.js +3 -5
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/lib/backlog-parser.ts +50 -0
- package/apps/dashboard/lib/claude-process-manager.ts +50 -11
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/db-bridge.ts +33 -0
- package/apps/dashboard/lib/db.ts +136 -20
- package/apps/dashboard/lib/kanban-utils.ts +70 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/session-state-machine.ts +3 -0
- package/apps/dashboard/lib/session-stream-manager.ts +144 -38
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/tests.ts +3 -1
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next.config.js +35 -14
- package/apps/dashboard/package.json +6 -3
- package/apps/dashboard/public/bug-icon.svg +9 -0
- package/apps/dashboard/public/buoy-icon.svg +9 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.svg +9 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.svg +14 -0
- package/apps/dashboard/public/star-icon.svg +9 -0
- package/apps/dashboard/public/wrench-icon.svg +9 -0
- package/apps/dashboard/scripts/upload-to-r2.js +89 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
- package/apps/update-server/package.json +16 -0
- package/apps/update-server/schema.sql +31 -0
- package/apps/update-server/src/index.ts +1085 -0
- package/apps/update-server/tsconfig.json +16 -0
- package/apps/update-server/wrangler.toml +35 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +54 -116
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/migrations/027-plan-at-creation-column.js +33 -0
- package/lib/migrations/028-ready-for-review-column.js +27 -0
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +13 -6
- package/lib/seed-onboarding.js +101 -69
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +129 -16
- package/lib/work-tracking/index.js +86 -46
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +39 -28
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +131 -68
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/epic-planning/SKILL.md +68 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +83 -73
- package/skills-templates/production-mode/SKILL.md +49 -49
- package/skills-templates/request-routing/SKILL.md +27 -14
- package/skills-templates/simple-improvement/SKILL.md +68 -44
- package/skills-templates/speed-mode/SKILL.md +209 -128
- package/skills-templates/stable-mode/SKILL.md +105 -94
- package/templates/bdd-guidance.md +139 -0
- package/templates/bdd-scaffolding/wait.js +18 -0
- package/templates/bdd-scaffolding/world.js +19 -0
- package/.jettypod-backup/work.db +0 -0
- package/apps/dashboard/app/access-code/page.tsx +0 -110
- package/lib/discovery-checkpoint.js +0 -123
- 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,
|
|
5
|
-
|
|
6
|
-
import
|
|
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
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
-
}, [
|
|
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
|
-
<
|
|
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-
|
|
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-
|
|
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
|
-
{/*
|
|
348
|
-
|
|
349
|
-
<div className="flex items-center
|
|
350
|
-
<
|
|
351
|
-
|
|
352
|
-
<
|
|
353
|
-
|
|
354
|
-
</
|
|
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">⚠</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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
387
|
-
</
|
|
288
|
+
Upgrade
|
|
289
|
+
</a>
|
|
388
290
|
</div>
|
|
389
|
-
|
|
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="
|
|
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
|
|
402
|
-
border-r border-zinc-200
|
|
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-
|
|
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-
|
|
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
|
|
443
|
-
border-r border-zinc-200
|
|
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-
|
|
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-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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-
|
|
493
|
-
<div className="flex items-start gap-
|
|
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-
|
|
420
|
+
<p className="text-base font-medium text-red-700" data-testid="error-message">{error}</p>
|
|
497
421
|
{exitCode !== null && (
|
|
498
|
-
<p className="text-
|
|
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-
|
|
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
|
-
<
|
|
434
|
+
<Button
|
|
511
435
|
onClick={onRetry}
|
|
512
|
-
|
|
436
|
+
variant="destructive"
|
|
437
|
+
size="sm"
|
|
513
438
|
data-testid="retry-button"
|
|
514
439
|
>
|
|
515
440
|
Retry
|
|
516
|
-
</
|
|
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-
|
|
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-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
{
|
|
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-
|
|
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-
|
|
593
|
-
<span className="w-1.5 h-1.5 bg-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
641
|
+
</m.div>
|
|
692
642
|
</AnimatePresence>
|
|
693
643
|
) : (
|
|
694
|
-
<div className="bg-zinc-50 rounded-lg px-
|
|
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-
|
|
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-
|
|
733
|
-
<div className="flex items-center gap-
|
|
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
|
-
{
|
|
743
|
-
<div className="text-zinc-500 text-
|
|
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
|
-
{/*
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
</
|
|
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
|
-
}
|