Files
clawtap/src/components/FloatingReviewPanel.tsx
T
kuannnn 0fcf66fc22 feat: ClawTap v0.2.0
Interactive Prompts:
- Unified InteractivePrompt type across all 3 adapters (Claude/Codex/Gemini)
- InteractivePromptOverlay component with options, text input, countdown
- Gemini + Codex pane monitors detect tool confirmation, ask user, plan approval
- respondInteractivePrompt routing: permission → respondPermission, options → _selectOption
- Claude AskUserQuestion nested questions[0] structure parsing

Cross-AI Review:
- Client-generated reviewId, removed pendingReview state
- FloatingReviewPanel uses CSS display:none instead of unmount (keeps hooks alive)
- Child review sessions default to YOLO/bypass permission mode
- Send back to parent, send to existing/new review, tab switching, end review
- Collapsed review cards with read-only panel for ended reviews
- Full reconnect support: active + ended reviews restore correctly

AskUserQuestion Tool Card UI:
- Dedicated renderer replaces raw JSON display
- Options shown with selected (green) / unselected (gray) indicators
- Free text answers shown in quoted format with green border
- Collapsed summary: question → answer
- Shared parseAskQuestionInput utility (client + server)
- Historical tool results attached via _result on tool_use blocks

Adapter Fixes:
- Session→adapter mapping persisted in SQLite (survives server restart)
- SESSION_CREATED deferred for pendingRekey adapters (Codex/Gemini)
- session-rekeyed handler sends complete SESSION_CREATED with adapter + cwd
- Gemini: auto-accept folder trust, privacy notice, IDE nudge, YOLO * prompt
- Claude: auto-accept bypass permissions confirmation (v2.1.85+)
- Port fallback (EADDRINUSE → try +1), statusLine shell script wrapper

Other:
- Desktop Enter sends / Shift+Enter newline; Mobile Enter newline
- Strip CLAWTAP_REF marker from session list
- Active sessions tab shows adapter badge
- Rename CLAUDE_UI_PASSWORD → CLAWTAP_PASSWORD

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:46:00 +08:00

255 lines
9.1 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from 'react';
import { useChat } from '../hooks/useChat';
import { ChatBody } from './ChatBody';
import { InteractivePromptOverlay } from './InteractivePromptOverlay';
import { getBrand } from '@/lib/adapter-brands';
import { extractTextFromBlocks } from '@/lib/content-utils';
import { api } from '@/lib/api';
// ===== Types =====
export interface ReviewEntry {
reviewId: string;
childSessionId: string;
childAdapter: string;
reviewTitle?: string;
prompt?: string;
permissionMode?: string;
}
interface ReviewPanelProps {
reviews: ReviewEntry[];
onEnd: (reviewId: string) => void;
onMinimize: () => void;
cwd?: string;
onSessionCreated?: (reviewId: string, childSessionId: string) => void;
readOnly?: boolean;
}
export interface ReviewPanelHandle {
sendToReview: (reviewId: string, text: string) => void;
}
// ===== ReviewTab (one per review, keeps useChat hook alive) =====
const ReviewTab = React.memo(function ReviewTab({ review, cwd, onSessionCreated, isActive, readOnly, sendRef }: {
review: ReviewEntry;
cwd?: string;
onSessionCreated?: (reviewId: string, sid: string) => void;
isActive: boolean;
readOnly?: boolean;
sendRef?: React.MutableRefObject<Map<string, (text: string) => void>>;
}) {
const {
messages, streaming, pendingResponse, liveStatus, toolStatuses,
sendMessage, abort, sessionId: chatSessionId,
interactivePrompt, respondPrompt,
} = useChat(
review.childSessionId || undefined,
cwd,
review.childAdapter,
review.prompt,
review.permissionMode,
);
// Notify parent when child session is created
const sessionCreatedRef = useRef(false);
useEffect(() => {
if (chatSessionId && !review.childSessionId && onSessionCreated && !sessionCreatedRef.current) {
sessionCreatedRef.current = true;
onSessionCreated(review.reviewId, chatSessionId);
}
}, [chatSessionId, review.childSessionId, onSessionCreated, review.reviewId]);
// Register sendMessage in parent's ref map for sendToReview
useEffect(() => {
if (sendRef && review.reviewId) {
sendRef.current.set(review.reviewId, sendMessage);
return () => { sendRef.current.delete(review.reviewId); };
}
}, [sendRef, review.reviewId, sendMessage]);
const brand = getBrand(review.childAdapter);
// Send-back handler: extract text from message and send to parent via API
const handleSendBack = useCallback(async (messageId: string) => {
const msg = messages.find(m => m.id === messageId);
if (!msg) return;
const text = extractTextFromBlocks(msg.content);
if (!review.reviewId) {
console.warn('Send back unavailable: review not yet registered');
return;
}
try {
await api.sendBackToParent(review.reviewId, text);
} catch (err: any) {
console.error('Send back failed:', err.message || err);
}
}, [messages, review.reviewId]);
return (
<div style={{ display: isActive ? 'flex' : 'none', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<ChatBody
messages={messages}
streaming={streaming}
pendingResponse={pendingResponse}
liveStatus={liveStatus}
toolStatuses={toolStatuses || new Map()}
onSend={sendMessage}
onStop={abort}
disabled={false}
interrupted={false}
onSendBack={readOnly ? undefined : handleSendBack}
hideInput={readOnly}
inputPlaceholder={`Reply to ${brand.displayName} review...`}
className="flex-1"
/>
{interactivePrompt && (
<InteractivePromptOverlay
prompt={interactivePrompt}
onRespond={respondPrompt}
/>
)}
</div>
);
});
// ===== Main Panel =====
export const FloatingReviewPanel = forwardRef<ReviewPanelHandle, ReviewPanelProps>(
function FloatingReviewPanel({ reviews, onEnd, onMinimize, cwd, onSessionCreated, readOnly }, ref) {
const [activeTabIndex, setActiveTabIndex] = useState(Math.max(0, reviews.length - 1));
// Keep activeTabIndex in bounds
useEffect(() => {
if (activeTabIndex >= reviews.length) {
setActiveTabIndex(Math.max(0, reviews.length - 1));
}
}, [reviews.length, activeTabIndex]);
// Auto-focus newest tab when a review is added
const prevCountRef = useRef(reviews.length);
useEffect(() => {
if (reviews.length > prevCountRef.current) {
setActiveTabIndex(reviews.length - 1);
}
prevCountRef.current = reviews.length;
}, [reviews.length]);
// Ref map: reviewId → sendMessage function (populated by each ReviewTab)
const sendRefs = useRef<Map<string, (text: string) => void>>(new Map());
useImperativeHandle(ref, () => ({
sendToReview(reviewId: string, text: string) {
const send = sendRefs.current.get(reviewId);
if (send) send(text);
const idx = reviews.findIndex(r => r.reviewId === reviewId);
if (idx >= 0) setActiveTabIndex(idx);
},
}), [reviews]);
const activeReview = reviews[activeTabIndex] || reviews[0];
if (!activeReview) return null;
const brand = getBrand(activeReview.childAdapter);
return (
<div
className="absolute bottom-0 left-0 right-0 z-10 flex flex-col rounded-t-xl review-panel-compact"
style={{ height: '55%', backgroundColor: '#0f0f11', borderTop: `2px solid ${brand.color}40` }}
>
{/* Handle bar */}
<div className="cursor-pointer mx-auto my-2" onClick={onMinimize}>
<div className="w-8 h-0.5 rounded-sm bg-border" />
</div>
{/* Tab bar (multiple reviews) or single-review header */}
{reviews.length > 1 ? (
<div className="flex items-center border-b border-border">
<div className="flex gap-0.5 px-3 flex-1 overflow-x-auto">
{reviews.map((r, i) => {
const b = getBrand(r.childAdapter);
const tabActive = i === activeTabIndex;
return (
<div
key={r.reviewId}
className="flex items-center gap-0.5 text-xs whitespace-nowrap"
style={{
color: tabActive ? b.color : '#71717a',
borderBottom: tabActive ? `2px solid ${b.color}` : '2px solid transparent',
}}
>
<button
onClick={() => setActiveTabIndex(i)}
className="flex items-center gap-1 px-2 py-2 transition-colors"
>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: b.color }} />
{b.displayName}
</button>
{!readOnly && (
<button
className="px-1 py-2 text-[10px] hover:text-red-400 transition-colors"
onClick={() => onEnd(r.reviewId)}
aria-label={`End ${b.displayName} review`}
>{'\u2715'}</button>
)}
</div>
);
})}
</div>
{!readOnly && (
<button
onClick={onMinimize}
className="text-xs text-text-dim/50 hover:text-text-dim px-3 py-2"
>{'\u25BC'}</button>
)}
</div>
) : (
<div className="flex items-center gap-2 px-4 pb-2">
<span
className="text-xs font-semibold px-2 py-0.5 rounded"
style={{
backgroundColor: readOnly ? '#88888820' : `${brand.color}20`,
color: readOnly ? '#888' : brand.color,
}}
>
{brand.displayName}
</span>
<span className="text-xs text-text-dim flex-1 truncate">
{activeReview.reviewTitle || 'Review Session'}
{readOnly && <span className="ml-1 text-text-dim/50">(ended)</span>}
</span>
<button
onClick={onMinimize}
className="text-xs text-text-dim/50 hover:text-text-dim px-1 py-0.5 rounded hover:bg-white/5 transition-colors"
title="Minimize"
>{'\u25BC'}</button>
<button
onClick={() => onEnd(activeReview.reviewId)}
className={readOnly
? 'text-xs text-text-dim/60 hover:text-text-dim px-2 py-1 rounded hover:bg-white/5 transition-colors'
: 'text-xs text-red-400 hover:text-red-300 px-2 py-1 rounded hover:bg-red-400/10 transition-colors'
}
>
{readOnly ? '\u2715' : 'End'}
</button>
</div>
)}
{/* Tabs — ALL rendered to keep hooks alive, only active one visible via CSS */}
{reviews.map((r, i) => (
<ReviewTab
key={r.reviewId}
review={r}
cwd={cwd}
onSessionCreated={!r.childSessionId ? onSessionCreated : undefined}
isActive={i === activeTabIndex}
readOnly={readOnly}
sendRef={sendRefs}
/>
))}
</div>
);
}
);