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