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