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>
This commit is contained in:
+9
-6
@@ -38,11 +38,13 @@ function persistView(view: View) {
|
||||
|
||||
function navigateTo(view: View) {
|
||||
persistView(view);
|
||||
const url = view.name === 'chat' && view.sessionId
|
||||
? `/?view=chat&session=${view.sessionId}`
|
||||
: view.name === 'settings'
|
||||
? '/?view=settings'
|
||||
: '/';
|
||||
let url = '/';
|
||||
if (view.name === 'chat' && view.sessionId) {
|
||||
url = `/?view=chat&session=${view.sessionId}`;
|
||||
if (view.adapter) url += `&adapter=${view.adapter}`;
|
||||
} else if (view.name === 'settings') {
|
||||
url = '/?view=settings';
|
||||
}
|
||||
window.history.pushState({ view }, '', url);
|
||||
}
|
||||
|
||||
@@ -208,7 +210,8 @@ export function App() {
|
||||
const action = params.get('action');
|
||||
if (sessionId) {
|
||||
urlParamsHandled.current = true;
|
||||
openChat(sessionId);
|
||||
const adapter = params.get('adapter');
|
||||
openChat(sessionId, undefined, adapter || undefined);
|
||||
window.history.replaceState({}, '', '/');
|
||||
} else if (action === 'newchat') {
|
||||
urlParamsHandled.current = true;
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Send } from 'lucide-react';
|
||||
|
||||
export function AskQuestion({ toolUseId, input, onRespond }: {
|
||||
toolUseId: string; input: any; onRespond: (toolUseId: string, response: string) => void;
|
||||
}) {
|
||||
const [customText, setCustomText] = useState('');
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
const [answered, setAnswered] = useState(false);
|
||||
// SDK AskUserQuestion uses questions[0].question/options structure
|
||||
const firstQ = input?.questions?.[0];
|
||||
const question = firstQ?.question || input?.question || input?.text || 'Choose an option';
|
||||
const options: Array<{ value: string; label: string; description?: string }> = firstQ?.options || input?.options || input?.choices || [];
|
||||
|
||||
function select(value: string) { if (answered) return; setAnswered(true); onRespond(toolUseId, value); }
|
||||
function submitCustom() { if (!customText.trim() || answered) return; setAnswered(true); onRespond(toolUseId, customText.trim()); }
|
||||
|
||||
if (answered) return (
|
||||
<div className="mb-3">
|
||||
<p className="text-text-dim text-sm italic">Question answered</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-medium font-mono text-text mb-3">{question}</p>
|
||||
<div className="space-y-2">
|
||||
{options.map((opt, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant="outline"
|
||||
onClick={() => select(opt.value || opt.label)}
|
||||
className="w-full justify-start text-left h-auto py-3 px-4"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{opt.label || opt.value}</div>
|
||||
{opt.description && <div className="text-text-dim text-xs mt-0.5">{opt.description}</div>}
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
{!showCustom ? (
|
||||
<Button variant="ghost" onClick={() => setShowCustom(true)} className="w-full text-sm">
|
||||
Other...
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={customText}
|
||||
onChange={(e) => setCustomText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submitCustom()}
|
||||
placeholder="Type your answer..."
|
||||
className="flex-1 bg-bg border border-border rounded-md px-3 py-2 text-text text-sm focus:outline-none focus:border-accent"
|
||||
autoFocus
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={submitCustom} disabled={!customText.trim()}>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -146,7 +146,7 @@ export function ChatBody({
|
||||
: isLastAssistant && interrupted ? 'interrupted'
|
||||
: 'success';
|
||||
elements.push(
|
||||
<ToolCallCard key={tool.id} toolName={tool.name} input={tool.input} status={status?.status || fallbackStatus} result={status?.result} />,
|
||||
<ToolCallCard key={tool.id} toolName={tool.name} input={tool.input} status={status?.status || fallbackStatus} result={status?.result || tool._result} />,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+70
-91
@@ -1,9 +1,8 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo, Fragment, type RefObject } from 'react';
|
||||
import { useChat } from '../hooks/useChat';
|
||||
import { PLAN_OPTION } from '../lib/ws-types';
|
||||
import { PermissionOverlay } from './PermissionOverlay';
|
||||
import { InteractivePromptOverlay } from './InteractivePromptOverlay';
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { AskQuestion } from './AskQuestion';
|
||||
import { PlanMode } from './PlanMode';
|
||||
import { ChatBody } from './ChatBody';
|
||||
import { FloatingReviewPanel, type ReviewPanelHandle } from './FloatingReviewPanel';
|
||||
@@ -11,11 +10,11 @@ import { ReviewActionMenu } from './ReviewActionMenu';
|
||||
import { SendToExistingSheet } from './SendToExistingSheet';
|
||||
import { CollapsedReviewCard } from './CollapsedReviewCard';
|
||||
import { BlockMarker } from './BlockMarker';
|
||||
import { BottomSheet } from './BottomSheet';
|
||||
import { api } from '../lib/api';
|
||||
import { getBrand } from '../lib/adapter-brands';
|
||||
import { extractTextFromBlocks } from '../lib/content-utils';
|
||||
import { patchAdapterPrefs } from '../lib/adapter-prefs';
|
||||
import { LoadingAnimation } from './ui/LoadingAnimation';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { ChevronLeft, Copy, Check, X } from 'lucide-react';
|
||||
@@ -140,11 +139,11 @@ export function ChatView({
|
||||
|
||||
const {
|
||||
messages, toolStatuses, streaming, pendingResponse, wsStatus, sessionId, liveStatus,
|
||||
interrupted, sessionStatus, adapterConfig, selectedAdapter, permissionRequest, model, permissionMode,
|
||||
interrupted, sessionStatus, adapterConfig, selectedAdapter, interactivePrompt, model, permissionMode,
|
||||
queuedMessage, clearQueuedMessage,
|
||||
activeReviews, setActiveReviews, activeReviewPanel, setActiveReviewPanel,
|
||||
historyReview, setHistoryReview,
|
||||
sendMessage, respondPermission, respondAsk, respondPlan, abort,
|
||||
sendMessage, respondPrompt, respondPlan, abort,
|
||||
updateModel, updatePermissionMode,
|
||||
} = useChat(initialSessionId, cwd, adapter, initialPrompt);
|
||||
|
||||
@@ -189,22 +188,13 @@ export function ChatView({
|
||||
|
||||
// Shared cleanup for ending/closing an active review
|
||||
const closeReview = useCallback(async (reviewId?: string) => {
|
||||
// Empty reviewId means the pending tab's close button — just cancel it
|
||||
if (reviewId === '') {
|
||||
setPendingReview(null);
|
||||
return;
|
||||
}
|
||||
const targetId = reviewId || activeReviews[0]?.reviewId;
|
||||
if (!targetId) return;
|
||||
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
const endAnchorMessageId = lastMsg?.id || undefined;
|
||||
|
||||
try { await api.endReview(targetId, endAnchorMessageId); } catch {}
|
||||
|
||||
setActiveReviews(prev => prev.filter(r => r.reviewId !== targetId));
|
||||
setHistoryReview(null);
|
||||
setPendingReview(null);
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
const endAnchorMessageId = lastMsg?.id || undefined;
|
||||
try { await api.endReview(targetId, endAnchorMessageId); } catch {}
|
||||
}, [activeReviews, messages]);
|
||||
|
||||
// Close history panel only (does not affect active review)
|
||||
@@ -227,6 +217,7 @@ export function ChatView({
|
||||
// New reviews added — batch into a single setReviews call
|
||||
const newReviews = activeReviews.filter(r => !prevIds.has(r.reviewId));
|
||||
if (newReviews.length > 0) {
|
||||
console.log(`[reviewSync] ${newReviews.length} new review(s):`, newReviews.map(r => `${r.reviewId.slice(0,8)} anchor=${r.anchorMessageId}`));
|
||||
setReviews(prev => {
|
||||
const existingIds = new Set(prev.map(r => r.id));
|
||||
const toAdd = newReviews
|
||||
@@ -303,23 +294,29 @@ export function ChatView({
|
||||
|
||||
const [saveToast, setSaveToast] = useState<{ instruction: string; label: string } | null>(null);
|
||||
|
||||
// Pending review: waiting for child session to be created (not yet in activeReviews)
|
||||
const [pendingReview, setPendingReview] = useState<{
|
||||
childAdapter: string;
|
||||
anchorMessageId: string;
|
||||
reviewTitle: string;
|
||||
prompt: string;
|
||||
} | null>(null);
|
||||
|
||||
const openReview = useCallback((adapter: string, model: string, prompt: string, title: string) => {
|
||||
const anchorId = reviewMenuMessageId;
|
||||
setReviewMenuMessageId(null);
|
||||
if (!anchorId) return;
|
||||
const reviewId = crypto.randomUUID();
|
||||
console.log(`[openReview] creating review=${reviewId.slice(0,8)} adapter=${adapter} anchor=${anchorId} prompt=${prompt?.substring(0, 30)}`);
|
||||
patchAdapterPrefs(adapter, { model });
|
||||
setHistoryReview(null);
|
||||
setPendingReview({ childAdapter: adapter, anchorMessageId: anchorId, reviewTitle: title, prompt });
|
||||
setActiveReviews(prev => {
|
||||
console.log(`[openReview] activeReviews before: ${prev.length} entries, ids: ${prev.map(r => r.reviewId.slice(0,8)).join(',')}`);
|
||||
return [...prev, {
|
||||
reviewId,
|
||||
childSessionId: '',
|
||||
childCliSessionId: '',
|
||||
childAdapter: adapter,
|
||||
anchorMessageId: anchorId,
|
||||
reviewTitle: title,
|
||||
prompt,
|
||||
permissionMode: adapter === 'gemini' ? 'yolo' : 'bypassPermissions',
|
||||
}];
|
||||
});
|
||||
setActiveReviewPanel('expanded');
|
||||
}, [reviewMenuMessageId, cwd]);
|
||||
}, [reviewMenuMessageId]);
|
||||
|
||||
const handleDirectSend = useCallback((adapter: string, model: string) => {
|
||||
const anchorMsg = messages.find(m => m.id === reviewMenuMessageId);
|
||||
@@ -437,7 +434,7 @@ export function ChatView({
|
||||
permissionMode={permissionMode}
|
||||
sessionStatus={sessionStatus}
|
||||
adapterConfig={adapterConfig}
|
||||
selectedAdapter={selectedAdapter}
|
||||
selectedAdapter={selectedAdapter!}
|
||||
streaming={streaming}
|
||||
onModelChange={updateModel}
|
||||
onPermissionModeChange={updatePermissionMode}
|
||||
@@ -447,40 +444,38 @@ export function ChatView({
|
||||
|
||||
const isHistoryPanel = !!historyReview;
|
||||
|
||||
// Use ref so onSessionCreatedCallback always reads the latest pendingReview
|
||||
// (prevents stale closure if a second review is opened while the first is still pending)
|
||||
const pendingReviewRef = useRef(pendingReview);
|
||||
pendingReviewRef.current = pendingReview;
|
||||
const activeReviewsRef = useRef(activeReviews);
|
||||
activeReviewsRef.current = activeReviews;
|
||||
|
||||
const onSessionCreatedCallback = useCallback(async (childSid: string) => {
|
||||
const pending = pendingReviewRef.current;
|
||||
if (!sessionId || !pending) return;
|
||||
const onSessionCreatedCallback = useCallback(async (reviewId: string, childSid: string) => {
|
||||
console.log(`[onSessionCreated] reviewId=${reviewId.slice(0,8)} childSid=${childSid.slice(0,8)} parentSid=${sessionId?.slice(0,8)}`);
|
||||
if (!sessionId) return;
|
||||
const review = activeReviewsRef.current.find(r => r.reviewId === reviewId);
|
||||
if (!review) { console.log(`[onSessionCreated] review not found in activeReviews`); return; }
|
||||
try {
|
||||
const result = await api.registerReview(
|
||||
sessionId,
|
||||
childSid,
|
||||
pending.childAdapter,
|
||||
pending.anchorMessageId,
|
||||
pending.prompt,
|
||||
pending.reviewTitle,
|
||||
);
|
||||
setActiveReviews(prev => {
|
||||
if (prev.some(r => r.reviewId === result.reviewId)) return prev;
|
||||
return [...prev, {
|
||||
reviewId: result.reviewId,
|
||||
childSessionId: childSid,
|
||||
childCliSessionId: childSid,
|
||||
childAdapter: pending.childAdapter,
|
||||
anchorMessageId: pending.anchorMessageId,
|
||||
reviewTitle: pending.reviewTitle,
|
||||
}];
|
||||
console.log(`[onSessionCreated] calling api.registerReview...`);
|
||||
await api.registerReview({
|
||||
reviewId,
|
||||
parentCliSessionId: sessionId,
|
||||
childSessionId: childSid,
|
||||
targetAdapter: review.childAdapter,
|
||||
anchorMessageId: review.anchorMessageId || '',
|
||||
prompt: review.prompt || '',
|
||||
title: review.reviewTitle || '',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to register review:', err);
|
||||
}
|
||||
setPendingReview(null);
|
||||
}, [sessionId]);
|
||||
|
||||
if (!selectedAdapter) {
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-bg items-center justify-center">
|
||||
<LoadingAnimation size="md" label="Connecting..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-bg relative overflow-hidden">
|
||||
{/* Header — auto-hides when scrolling up to view history */}
|
||||
@@ -516,31 +511,25 @@ export function ChatView({
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
{/* Floating review panel — active reviews (tabbed) + pending review */}
|
||||
{activeReviewPanel === 'expanded' && (activeReviews.length > 0 || pendingReview) && (
|
||||
<FloatingReviewPanel
|
||||
ref={reviewPanelRef}
|
||||
reviews={[
|
||||
...activeReviews.map(r => ({
|
||||
{/* Floating review panel — CSS-hidden when minimized to keep hooks alive */}
|
||||
{activeReviews.length > 0 && (
|
||||
<div style={{ display: activeReviewPanel === 'expanded' ? 'contents' : 'none' }}>
|
||||
<FloatingReviewPanel
|
||||
ref={reviewPanelRef}
|
||||
reviews={activeReviews.map(r => ({
|
||||
reviewId: r.reviewId,
|
||||
childSessionId: r.childSessionId,
|
||||
childAdapter: r.childAdapter,
|
||||
reviewTitle: r.reviewTitle,
|
||||
})),
|
||||
// Pending review: no reviewId yet, triggers session creation in ReviewTab
|
||||
...(pendingReview ? [{
|
||||
reviewId: '',
|
||||
childSessionId: '',
|
||||
childAdapter: pendingReview.childAdapter,
|
||||
reviewTitle: pendingReview.reviewTitle,
|
||||
}] : []),
|
||||
]}
|
||||
onEnd={(reviewId) => closeReview(reviewId)}
|
||||
onMinimize={() => setActiveReviewPanel('minimized')}
|
||||
initialPrompt={pendingReview?.prompt || undefined}
|
||||
cwd={cwd}
|
||||
onSessionCreated={onSessionCreatedCallback}
|
||||
/>
|
||||
prompt: r.prompt,
|
||||
permissionMode: r.permissionMode,
|
||||
}))}
|
||||
onEnd={(reviewId) => closeReview(reviewId)}
|
||||
onMinimize={() => setActiveReviewPanel('minimized')}
|
||||
cwd={cwd}
|
||||
onSessionCreated={onSessionCreatedCallback}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating review panel — read-only history view */}
|
||||
@@ -590,23 +579,13 @@ export function ChatView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permission / Ask overlays */}
|
||||
{permissionRequest && permissionRequest.toolName === 'AskUserQuestion' ? (
|
||||
<BottomSheet visible zIndex="z-40" backdropClassName="bg-black/60" className="p-6" showHandle={false}>
|
||||
<AskQuestion
|
||||
toolUseId={permissionRequest.requestId}
|
||||
input={permissionRequest.input}
|
||||
onRespond={(requestId: string, response: string) => respondAsk(requestId, response)}
|
||||
/>
|
||||
</BottomSheet>
|
||||
) : permissionRequest ? (
|
||||
<PermissionOverlay
|
||||
request={permissionRequest}
|
||||
onAllow={() => respondPermission(permissionRequest.requestId, 'allow')}
|
||||
onAllowAll={() => respondPermission(permissionRequest.requestId, 'allow_session')}
|
||||
onDeny={(msg?: string) => respondPermission(permissionRequest.requestId, 'deny', msg)}
|
||||
{/* Interactive prompt overlay (permissions, questions, plan approval, etc.) */}
|
||||
{interactivePrompt && (
|
||||
<InteractivePromptOverlay
|
||||
prompt={interactivePrompt}
|
||||
onRespond={respondPrompt}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -12,15 +13,16 @@ export interface ReviewEntry {
|
||||
childSessionId: string;
|
||||
childAdapter: string;
|
||||
reviewTitle?: string;
|
||||
prompt?: string;
|
||||
permissionMode?: string;
|
||||
}
|
||||
|
||||
interface ReviewPanelProps {
|
||||
reviews: ReviewEntry[];
|
||||
onEnd: (reviewId: string) => void;
|
||||
onMinimize: () => void;
|
||||
initialPrompt?: string;
|
||||
cwd?: string;
|
||||
onSessionCreated?: (childSessionId: string) => void;
|
||||
onSessionCreated?: (reviewId: string, childSessionId: string) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
@@ -30,23 +32,24 @@ export interface ReviewPanelHandle {
|
||||
|
||||
// ===== ReviewTab (one per review, keeps useChat hook alive) =====
|
||||
|
||||
const ReviewTab = React.memo(function ReviewTab({ review, cwd, initialPrompt, onSessionCreated, isActive, readOnly, sendRef }: {
|
||||
const ReviewTab = React.memo(function ReviewTab({ review, cwd, onSessionCreated, isActive, readOnly, sendRef }: {
|
||||
review: ReviewEntry;
|
||||
cwd?: string;
|
||||
initialPrompt?: string;
|
||||
onSessionCreated?: (sid: string) => void;
|
||||
onSessionCreated?: (reviewId: string, sid: string) => void;
|
||||
isActive: boolean;
|
||||
readOnly?: boolean;
|
||||
sendRef?: React.MutableRefObject<Map<string, (text: string) => void>>;
|
||||
}) {
|
||||
const {
|
||||
messages, streaming, liveStatus, toolStatuses,
|
||||
messages, streaming, pendingResponse, liveStatus, toolStatuses,
|
||||
sendMessage, abort, sessionId: chatSessionId,
|
||||
interactivePrompt, respondPrompt,
|
||||
} = useChat(
|
||||
review.childSessionId || undefined,
|
||||
cwd,
|
||||
review.childAdapter,
|
||||
initialPrompt,
|
||||
review.prompt,
|
||||
review.permissionMode,
|
||||
);
|
||||
|
||||
// Notify parent when child session is created
|
||||
@@ -54,9 +57,9 @@ const ReviewTab = React.memo(function ReviewTab({ review, cwd, initialPrompt, on
|
||||
useEffect(() => {
|
||||
if (chatSessionId && !review.childSessionId && onSessionCreated && !sessionCreatedRef.current) {
|
||||
sessionCreatedRef.current = true;
|
||||
onSessionCreated(chatSessionId);
|
||||
onSessionCreated(review.reviewId, chatSessionId);
|
||||
}
|
||||
}, [chatSessionId, review.childSessionId, onSessionCreated]);
|
||||
}, [chatSessionId, review.childSessionId, onSessionCreated, review.reviewId]);
|
||||
|
||||
// Register sendMessage in parent's ref map for sendToReview
|
||||
useEffect(() => {
|
||||
@@ -89,6 +92,7 @@ const ReviewTab = React.memo(function ReviewTab({ review, cwd, initialPrompt, on
|
||||
<ChatBody
|
||||
messages={messages}
|
||||
streaming={streaming}
|
||||
pendingResponse={pendingResponse}
|
||||
liveStatus={liveStatus}
|
||||
toolStatuses={toolStatuses || new Map()}
|
||||
onSend={sendMessage}
|
||||
@@ -100,6 +104,12 @@ const ReviewTab = React.memo(function ReviewTab({ review, cwd, initialPrompt, on
|
||||
inputPlaceholder={`Reply to ${brand.displayName} review...`}
|
||||
className="flex-1"
|
||||
/>
|
||||
{interactivePrompt && (
|
||||
<InteractivePromptOverlay
|
||||
prompt={interactivePrompt}
|
||||
onRespond={respondPrompt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -107,7 +117,7 @@ const ReviewTab = React.memo(function ReviewTab({ review, cwd, initialPrompt, on
|
||||
// ===== Main Panel =====
|
||||
|
||||
export const FloatingReviewPanel = forwardRef<ReviewPanelHandle, ReviewPanelProps>(
|
||||
function FloatingReviewPanel({ reviews, onEnd, onMinimize, initialPrompt, cwd, onSessionCreated, readOnly }, ref) {
|
||||
function FloatingReviewPanel({ reviews, onEnd, onMinimize, cwd, onSessionCreated, readOnly }, ref) {
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(Math.max(0, reviews.length - 1));
|
||||
|
||||
// Keep activeTabIndex in bounds
|
||||
@@ -162,7 +172,7 @@ export const FloatingReviewPanel = forwardRef<ReviewPanelHandle, ReviewPanelProp
|
||||
const tabActive = i === activeTabIndex;
|
||||
return (
|
||||
<div
|
||||
key={r.reviewId || `tab-${i}`}
|
||||
key={r.reviewId}
|
||||
className="flex items-center gap-0.5 text-xs whitespace-nowrap"
|
||||
style={{
|
||||
color: tabActive ? b.color : '#71717a',
|
||||
@@ -229,11 +239,10 @@ export const FloatingReviewPanel = forwardRef<ReviewPanelHandle, ReviewPanelProp
|
||||
{/* Tabs — ALL rendered to keep hooks alive, only active one visible via CSS */}
|
||||
{reviews.map((r, i) => (
|
||||
<ReviewTab
|
||||
key={r.reviewId || `pending-${i}`}
|
||||
key={r.reviewId}
|
||||
review={r}
|
||||
cwd={cwd}
|
||||
initialPrompt={i === reviews.length - 1 ? initialPrompt : undefined}
|
||||
onSessionCreated={i === reviews.length - 1 ? onSessionCreated : undefined}
|
||||
onSessionCreated={!r.childSessionId ? onSessionCreated : undefined}
|
||||
isActive={i === activeTabIndex}
|
||||
readOnly={readOnly}
|
||||
sendRef={sendRefs}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { BottomSheet } from './BottomSheet';
|
||||
import { Send } from 'lucide-react';
|
||||
|
||||
export interface InteractivePromptData {
|
||||
requestId: string;
|
||||
promptType: string; // 'permission' | 'question' | 'plan' | 'loop-detected'
|
||||
title: string;
|
||||
description: string;
|
||||
toolName?: string;
|
||||
toolInput?: any;
|
||||
options?: { value: string; label: string }[];
|
||||
textInput?: { placeholder?: string };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
prompt: InteractivePromptData;
|
||||
onRespond: (requestId: string, selectedOption?: string, textValue?: string) => void;
|
||||
}
|
||||
|
||||
const BADGE_COLORS: Record<string, string> = {
|
||||
permission: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||
question: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
plan: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
'loop-detected': 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
};
|
||||
|
||||
function formatToolInput(toolName: string, input: any): string {
|
||||
if (!input) return '';
|
||||
if (toolName === 'Bash' && input.command) return input.command;
|
||||
if (input.file_path) return input.file_path;
|
||||
if (input.pattern) return input.pattern;
|
||||
if (input.path) return input.path;
|
||||
if (input.command) return input.command;
|
||||
const str = JSON.stringify(input, null, 2);
|
||||
return str.length > 300 ? str.slice(0, 300) + '...' : str;
|
||||
}
|
||||
|
||||
export function InteractivePromptOverlay({ prompt, onRespond }: Props) {
|
||||
const [textValue, setTextValue] = useState('');
|
||||
const [countdown, setCountdown] = useState(120);
|
||||
const onRespondRef = useRef(onRespond);
|
||||
onRespondRef.current = onRespond;
|
||||
const requestIdRef = useRef(prompt.requestId);
|
||||
requestIdRef.current = prompt.requestId;
|
||||
|
||||
// 120s countdown for permission type
|
||||
useEffect(() => {
|
||||
if (prompt.promptType !== 'permission') return;
|
||||
setCountdown(120);
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((c) => {
|
||||
if (c <= 1) {
|
||||
onRespondRef.current(requestIdRef.current, 'deny');
|
||||
return 0;
|
||||
}
|
||||
return c - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [prompt.requestId, prompt.promptType]);
|
||||
|
||||
// Reset text when prompt changes
|
||||
useEffect(() => {
|
||||
setTextValue('');
|
||||
}, [prompt.requestId]);
|
||||
|
||||
const handleOptionClick = useCallback((value: string) => {
|
||||
onRespond(prompt.requestId, value);
|
||||
}, [onRespond, prompt.requestId]);
|
||||
|
||||
const handleTextSubmit = useCallback(() => {
|
||||
if (!textValue.trim()) return;
|
||||
onRespond(prompt.requestId, undefined, textValue.trim());
|
||||
}, [onRespond, prompt.requestId, textValue]);
|
||||
|
||||
const badgeClass = BADGE_COLORS[prompt.promptType] || BADGE_COLORS.question;
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
visible
|
||||
onClose={() => onRespond(prompt.requestId, 'deny')}
|
||||
zIndex="z-40"
|
||||
backdropClassName="backdrop-blur-sm"
|
||||
className="p-5"
|
||||
showHandle={false}
|
||||
>
|
||||
{/* Title bar */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded border ${badgeClass}`}>
|
||||
{prompt.title || prompt.promptType}
|
||||
</span>
|
||||
{prompt.promptType === 'permission' && (
|
||||
<span className="text-xs text-text-dim font-mono">{countdown}s</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{prompt.description && (
|
||||
<p className="text-sm text-text mb-3 whitespace-pre-wrap">{prompt.description}</p>
|
||||
)}
|
||||
|
||||
{/* Tool info card */}
|
||||
{prompt.toolName && (
|
||||
<div className="bg-bg rounded-md p-3 mb-4 max-h-40 overflow-y-auto">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="mono">{prompt.toolName}</Badge>
|
||||
</div>
|
||||
{prompt.toolInput && (
|
||||
<pre className="font-mono text-xs text-text whitespace-pre-wrap break-all">
|
||||
{formatToolInput(prompt.toolName, prompt.toolInput)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options — vertical button list */}
|
||||
{prompt.options && prompt.options.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mb-3">
|
||||
{prompt.options.map((opt, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant={i === 0 ? 'default' : i === prompt.options!.length - 1 ? 'ghost' : 'secondary'}
|
||||
onClick={() => handleOptionClick(opt.value)}
|
||||
className="w-full"
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text input */}
|
||||
{prompt.textInput && (
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={textValue}
|
||||
onChange={(e) => setTextValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleTextSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder={prompt.textInput.placeholder || 'Type your response...'}
|
||||
className="flex-1 bg-bg border border-border rounded-md px-3 py-2 text-text text-sm focus:outline-none focus:border-accent resize-none min-h-[60px]"
|
||||
autoFocus={!prompt.options}
|
||||
rows={2}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleTextSubmit}
|
||||
disabled={!textValue.trim()}
|
||||
className="self-end"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { PermissionRequest } from '../hooks/useChat';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { BottomSheet } from './BottomSheet';
|
||||
|
||||
function formatInput(toolName: string, input: any): string {
|
||||
if (toolName === 'Bash' && input?.command) return input.command;
|
||||
if (input?.file_path) return input.file_path;
|
||||
if (input?.pattern) return input.pattern;
|
||||
if (input?.path) return input.path;
|
||||
if (input?.command) return input.command;
|
||||
return JSON.stringify(input, null, 2).slice(0, 300);
|
||||
}
|
||||
|
||||
export function PermissionOverlay({ request, onAllow, onAllowAll, onDeny }: {
|
||||
request: PermissionRequest; onAllow: () => void; onAllowAll: () => void; onDeny: (message?: string) => void;
|
||||
}) {
|
||||
const [countdown, setCountdown] = useState(120);
|
||||
const onDenyRef = useRef(onDeny);
|
||||
onDenyRef.current = onDeny;
|
||||
|
||||
useEffect(() => {
|
||||
setCountdown(120);
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((c) => {
|
||||
if (c <= 1) { onDenyRef.current('Permission timed out'); return 0; }
|
||||
return c - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [request.requestId]);
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
visible
|
||||
onClose={() => onDeny('Dismissed')}
|
||||
zIndex="z-40"
|
||||
backdropClassName="backdrop-blur-sm"
|
||||
className="p-5"
|
||||
showHandle={false}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Badge variant="mono">{request.toolName}</Badge>
|
||||
<span className="text-xs text-text-dim font-mono">{countdown}s</span>
|
||||
</div>
|
||||
{request.decisionReason && (
|
||||
<p className="text-text-dim text-sm mb-3">{request.decisionReason}</p>
|
||||
)}
|
||||
<div className="bg-bg rounded-md p-3 mb-4 max-h-40 overflow-y-auto">
|
||||
<pre className="font-mono text-xs text-text whitespace-pre-wrap break-all">
|
||||
{formatInput(request.toolName, request.input)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button variant="default" onClick={onAllow} className="w-full">
|
||||
Allow
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onAllowAll} className="w-full">
|
||||
Allow all for this session
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => onDeny()} className="w-full">
|
||||
Deny
|
||||
</Button>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
@@ -337,6 +337,14 @@ export function SessionsView({
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className="text-sm text-text truncate flex-1 mr-3">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-success mr-1.5 shrink-0" />
|
||||
{session.adapter && (
|
||||
<span
|
||||
className="text-[10px] font-semibold px-1.5 rounded shrink-0 mr-1"
|
||||
style={{ color: getBrand(session.adapter).color, backgroundColor: `${getBrand(session.adapter).color}20` }}
|
||||
>
|
||||
{getBrand(session.adapter).displayName}
|
||||
</span>
|
||||
)}
|
||||
{session.firstPrompt || session.sessionId}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
|
||||
@@ -21,7 +21,7 @@ export function SettingsView({ onBack }: { onBack: () => void }) {
|
||||
|
||||
useEffect(() => {
|
||||
api.adapters().then(setAdapters).catch(() => {});
|
||||
fetch('/api/health')
|
||||
fetch('/health')
|
||||
.then(r => r.json())
|
||||
.then((data: { version: string }) => setVersion(data.version))
|
||||
.catch(() => {});
|
||||
|
||||
@@ -260,6 +260,15 @@ export function ShimmerInput({ onSend, onStop, disabled, streaming, interrupted,
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => { setText(e.target.value); handleInput(); }}
|
||||
onKeyDown={(e) => {
|
||||
// Desktop: Enter sends, Shift+Enter newline
|
||||
// Mobile: Enter always newline (send via button)
|
||||
if (e.key === 'Enter' && !('ontouchstart' in window) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
enterKeyHint="enter"
|
||||
placeholder={isRecording && interimText ? interimText : imageFile ? "Add a message (optional)..." : interrupted ? "What should Claude do instead?" : placeholderProp || "Send a message..."}
|
||||
rows={1}
|
||||
className={cn(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Loader2, Check, X, Ban, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { parseAskQuestionInput } from '@/lib/ask-question-utils';
|
||||
import { DiffViewer } from './DiffViewer';
|
||||
|
||||
const STATUS_CONFIG: Record<string, { icon: React.ReactNode }> = {
|
||||
@@ -11,7 +12,19 @@ const STATUS_CONFIG: Record<string, { icon: React.ReactNode }> = {
|
||||
interrupted: { icon: <Ban className="size-4 text-text-dim" /> },
|
||||
};
|
||||
|
||||
function toolSummary(toolName: string, input: any): string {
|
||||
function matchesAskOption(answer: string | null, o: { label: string; value?: string }): boolean {
|
||||
return !!answer && (o.label === answer || o.value === answer || answer.includes(o.label));
|
||||
}
|
||||
|
||||
function toolSummary(toolName: string, input: any, result?: any): string {
|
||||
if (toolName === 'AskUserQuestion') {
|
||||
const { question, options } = parseAskQuestionInput(input);
|
||||
const rawAnswer = getResultText(result);
|
||||
const matchedOpt = options?.find(o => matchesAskOption(rawAnswer, o));
|
||||
const answer = matchedOpt?.label || (rawAnswer && rawAnswer.length > 60 ? null : rawAnswer);
|
||||
const q = question.length > 40 ? question.slice(0, 40) + '...' : question;
|
||||
return answer ? `${q} → ${answer.length > 30 ? answer.slice(0, 30) + '...' : answer}` : q;
|
||||
}
|
||||
if (toolName === 'Bash' && input?.command) {
|
||||
return input.command.length > 60 ? input.command.slice(0, 60) + '...' : input.command;
|
||||
}
|
||||
@@ -57,9 +70,13 @@ export function ToolCallCard({ toolName, input, status, result }: {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showFullDiff, setShowFullDiff] = useState(false);
|
||||
const { icon } = STATUS_CONFIG[status];
|
||||
const summary = toolSummary(toolName, input);
|
||||
const summary = toolSummary(toolName, input, result);
|
||||
const isDiff = hasDiff(toolName, input);
|
||||
const isNewFile = hasNewFile(toolName);
|
||||
const askData = useMemo(
|
||||
() => toolName === 'AskUserQuestion' ? parseAskQuestionInput(input) : null,
|
||||
[toolName, input],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -154,6 +171,37 @@ export function ToolCallCard({ toolName, input, status, result }: {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
if (askData) {
|
||||
const { question, options } = askData;
|
||||
const answer = resultStr;
|
||||
const isOptionAnswer = options?.some(o => matchesAskOption(answer, o));
|
||||
return (
|
||||
<div className="max-h-48 overflow-y-auto font-sans">
|
||||
<div className="flex items-start gap-1.5 mb-1">
|
||||
<span className="text-purple-400">{'\u2753'}</span>
|
||||
<span className="text-text font-semibold text-[13px]">{question}</span>
|
||||
</div>
|
||||
{options && (
|
||||
<div className="flex flex-col gap-1 mt-2 ml-5">
|
||||
{options.map((o, i) => {
|
||||
const selected = matchesAskOption(answer, o) || (!!answer && String(i) === answer);
|
||||
return (
|
||||
<div key={i} className={cn('flex items-center gap-1.5', selected ? 'opacity-100' : 'opacity-40')}>
|
||||
<span className={cn('text-[10px]', selected ? 'text-green-500' : 'text-zinc-500')}>{selected ? '\u25CF' : '\u25CB'}</span>
|
||||
<span className={cn('text-[12px]', selected ? 'text-green-500' : 'text-zinc-500')}>{o.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{answer && !isOptionAnswer && (
|
||||
<div className="mt-2 ml-5 border-l-2 border-green-500 pl-2.5">
|
||||
<span className="text-[12px] text-green-500">{'\u300C'}{answer}{'\u300D'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="text-text-dim mb-1">Input:</div>
|
||||
|
||||
+117
-35
@@ -5,6 +5,7 @@ import { WS } from '../lib/ws-types';
|
||||
import { api } from '../lib/api';
|
||||
import { patchAdapterPrefs, loadAdapterPrefs } from '../lib/adapter-prefs';
|
||||
import { stripMarker } from '@/lib/content-utils';
|
||||
import { parseAskQuestionInput } from '@/lib/ask-question-utils';
|
||||
|
||||
export type ChatMessage = {
|
||||
id?: string;
|
||||
@@ -19,6 +20,17 @@ export type PermissionRequest = {
|
||||
decisionReason?: string;
|
||||
};
|
||||
|
||||
export type InteractivePrompt = {
|
||||
requestId: string;
|
||||
promptType: string;
|
||||
title: string;
|
||||
description: string;
|
||||
toolName?: string;
|
||||
toolInput?: any;
|
||||
options?: { value: string; label: string }[];
|
||||
textInput?: { placeholder?: string };
|
||||
};
|
||||
|
||||
export type ToolStatus = {
|
||||
toolUseId: string;
|
||||
toolName: string;
|
||||
@@ -91,9 +103,11 @@ export interface ReviewInfo {
|
||||
childAdapter: string;
|
||||
anchorMessageId?: string;
|
||||
reviewTitle?: string;
|
||||
prompt?: string;
|
||||
permissionMode?: string;
|
||||
}
|
||||
|
||||
export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?: string, initialPrompt?: string) {
|
||||
export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?: string, initialPrompt?: string, initialPermissionMode?: string) {
|
||||
// --- State ---
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [streamingText, setStreamingText] = useState<string>('');
|
||||
@@ -108,23 +122,23 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
const [pendingResponse, setPendingResponse] = useState(false);
|
||||
const [wsStatus, setWsStatus] = useState<WsStatus>('disconnected');
|
||||
const [sessionId, setSessionId] = useState<string | null>(initialSessionId || null);
|
||||
const [permissionRequest, setPermissionRequest] = useState<PermissionRequest | null>(null);
|
||||
const [interactivePrompt, setInteractivePrompt] = useState<InteractivePrompt | null>(null);
|
||||
// True when the most recent turn was interrupted — used for input placeholder
|
||||
const [interrupted, setInterrupted] = useState(false);
|
||||
// Resolve adapter + prefs once, share across state initializers
|
||||
const resolvedAdapter = initialAdapter || localStorage.getItem(STORAGE.ADAPTER) || 'claude';
|
||||
const initialPrefs = loadAdapterPrefs(resolvedAdapter);
|
||||
// If adapter is known (from prop or URL), use immediately. Otherwise null → wait for server.
|
||||
const knownAdapter = initialAdapter || null;
|
||||
const initialPrefs = knownAdapter ? loadAdapterPrefs(knownAdapter) : null;
|
||||
|
||||
const [model, setModel] = useState<string>(initialPrefs.model || '');
|
||||
const [permissionMode, setPermissionMode] = useState<string>(initialPrefs.permissionMode || 'default');
|
||||
const [effort, setEffort] = useState<string>(initialPrefs.effort || 'high');
|
||||
const [model, setModel] = useState<string>(initialPrefs?.model || '');
|
||||
const [permissionMode, setPermissionMode] = useState<string>(initialPermissionMode || initialPrefs?.permissionMode || 'default');
|
||||
const [effort, setEffort] = useState<string>(initialPrefs?.effort || 'high');
|
||||
const [sessionStatus, setSessionStatus] = useState<{
|
||||
contextPercent: number | null;
|
||||
model: string | null;
|
||||
cost: number | null;
|
||||
} | null>(null);
|
||||
const [queuedMessage, setQueuedMessage] = useState<string | null>(null);
|
||||
const [selectedAdapter, setSelectedAdapter] = useState<string>(resolvedAdapter);
|
||||
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(knownAdapter);
|
||||
const [adapterConfig, setAdapterConfig] = useState<{
|
||||
models: { value: string; label: string; contextWindow: number }[];
|
||||
permissionModes: { value: string; label: string }[];
|
||||
@@ -146,7 +160,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
const wsRef = useRef<WsClient | null>(null);
|
||||
const actualSendRef = useRef<(text: string) => void>(() => {});
|
||||
const clientIdRef = useRef<string | null>(null);
|
||||
const selectedAdapterRef = useRef<string>(selectedAdapter);
|
||||
const selectedAdapterRef = useRef<string | null>(selectedAdapter);
|
||||
selectedAdapterRef.current = selectedAdapter;
|
||||
|
||||
streamingRef.current = streaming;
|
||||
@@ -168,7 +182,20 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
switch (msg.type) {
|
||||
case WS.SESSION_CREATED:
|
||||
setSessionId(msg.sessionId);
|
||||
if (msg.permissionMode) setPermissionMode(msg.permissionMode);
|
||||
if (msg.adapter) {
|
||||
setSelectedAdapter(msg.adapter);
|
||||
const prefs = loadAdapterPrefs(msg.adapter);
|
||||
if (!knownAdapter) {
|
||||
// First time learning adapter — initialize prefs
|
||||
setModel(prefs.model || '');
|
||||
setPermissionMode(msg.permissionMode || prefs.permissionMode || 'default');
|
||||
setEffort(prefs.effort || 'high');
|
||||
} else {
|
||||
if (msg.permissionMode) setPermissionMode(msg.permissionMode);
|
||||
}
|
||||
} else {
|
||||
if (msg.permissionMode) setPermissionMode(msg.permissionMode);
|
||||
}
|
||||
break;
|
||||
|
||||
case WS.CLIENT_ID:
|
||||
@@ -221,11 +248,11 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
return next;
|
||||
});
|
||||
// AskUserQuestion completed — dismiss overlay on all clients
|
||||
// Guard: only dismiss if the current overlay IS an AskUserQuestion
|
||||
// (a new PermissionRequest may have arrived between answer and TOOL_DONE)
|
||||
// Guard: only dismiss if the current overlay IS a question type
|
||||
// (a new prompt may have arrived between answer and TOOL_DONE)
|
||||
if (msg.toolName === 'AskUserQuestion') {
|
||||
setPermissionRequest((prev) =>
|
||||
prev?.toolName === 'AskUserQuestion' ? null : prev
|
||||
setInteractivePrompt((prev) =>
|
||||
prev?.promptType === 'question' ? null : prev
|
||||
);
|
||||
}
|
||||
break;
|
||||
@@ -285,7 +312,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
setPendingResponse(false);
|
||||
setStreamingText('');
|
||||
setThinkingStatus(null);
|
||||
setPermissionRequest(null);
|
||||
setInteractivePrompt(null);
|
||||
// Mark remaining running tools: if user interrupted → 'interrupted', otherwise → 'success'
|
||||
setToolStatuses(markToolsAs(interruptedRef.current ? 'interrupted' : 'success'));
|
||||
streamingRef.current = false;
|
||||
@@ -293,8 +320,11 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
break;
|
||||
|
||||
case WS.REVIEW_STARTED:
|
||||
console.log(`[WS.REVIEW_STARTED] reviewId=${msg.reviewId?.slice(0,8)} childSid=${msg.childSessionId?.slice(0,8)} adapter=${msg.childAdapter}`);
|
||||
setActiveReviews(prev => {
|
||||
if (prev.some(r => r.reviewId === msg.reviewId)) return prev;
|
||||
const isDup = prev.some(r => r.reviewId === msg.reviewId);
|
||||
console.log(`[WS.REVIEW_STARTED] dedup check: ${isDup ? 'DUPLICATE, skipping' : 'NEW, adding'}`);
|
||||
if (isDup) return prev;
|
||||
return [...prev, {
|
||||
reviewId: msg.reviewId,
|
||||
childSessionId: msg.childSessionId,
|
||||
@@ -311,18 +341,59 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
setActiveReviews(prev => prev.filter(r => r.reviewId !== msg.reviewId));
|
||||
break;
|
||||
|
||||
// Hook: permission request
|
||||
case WS.PERMISSION_REQUEST:
|
||||
setPermissionRequest({
|
||||
// Unified interactive prompt (from Gemini/Codex pane monitors or session-manager conversion)
|
||||
case WS.INTERACTIVE_PROMPT:
|
||||
setInteractivePrompt({
|
||||
requestId: msg.requestId,
|
||||
promptType: msg.promptType,
|
||||
title: msg.title || '',
|
||||
description: msg.description || '',
|
||||
toolName: msg.toolName,
|
||||
input: msg.input,
|
||||
toolInput: msg.toolInput,
|
||||
options: msg.options,
|
||||
textInput: msg.textInput,
|
||||
});
|
||||
break;
|
||||
|
||||
// Another client answered the permission request — dismiss overlay
|
||||
// Another client answered the prompt — dismiss overlay
|
||||
case WS.PROMPT_DISMISSED:
|
||||
setInteractivePrompt(prev =>
|
||||
prev?.requestId === msg.requestId ? null : prev
|
||||
);
|
||||
break;
|
||||
|
||||
// Legacy hook: permission request — convert to InteractivePrompt
|
||||
case WS.PERMISSION_REQUEST: {
|
||||
const isAsk = msg.toolName === 'AskUserQuestion';
|
||||
if (isAsk) {
|
||||
const parsed = parseAskQuestionInput(msg.input);
|
||||
setInteractivePrompt({
|
||||
requestId: msg.requestId,
|
||||
promptType: 'question',
|
||||
title: parsed.header || 'Question',
|
||||
description: parsed.question,
|
||||
toolName: msg.toolName,
|
||||
toolInput: msg.input,
|
||||
options: parsed.options,
|
||||
textInput: parsed.options ? undefined : { placeholder: 'Enter your response...' },
|
||||
});
|
||||
} else {
|
||||
setInteractivePrompt({
|
||||
requestId: msg.requestId,
|
||||
promptType: 'permission',
|
||||
title: 'Permission Request',
|
||||
description: `${msg.toolName} wants to execute`,
|
||||
toolName: msg.toolName,
|
||||
toolInput: msg.input,
|
||||
options: [{ value: 'allow', label: 'Allow' }, { value: 'allow_session', label: 'Allow All' }, { value: 'deny', label: 'Deny' }],
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Legacy: another client answered the permission request — dismiss overlay
|
||||
case WS.PERMISSION_DISMISSED:
|
||||
setPermissionRequest((prev) =>
|
||||
setInteractivePrompt(prev =>
|
||||
prev?.requestId === msg.requestId ? null : prev
|
||||
);
|
||||
break;
|
||||
@@ -358,9 +429,9 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
|
||||
case WS.MODE_UPDATED:
|
||||
setPermissionMode(msg.mode);
|
||||
patchAdapterPrefs(selectedAdapterRef.current, { permissionMode: msg.mode });
|
||||
if (selectedAdapterRef.current) patchAdapterPrefs(selectedAdapterRef.current, { permissionMode: msg.mode });
|
||||
if (msg.mode === 'bypassPermissions' || msg.mode === 'plan') {
|
||||
setPermissionRequest(null);
|
||||
setInteractivePrompt(null);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -403,7 +474,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
const client = new WsClient(token, handleWsMessage, setWsStatus);
|
||||
wsRef.current = client;
|
||||
if (initialSessionId) {
|
||||
client.setActiveSession(initialSessionId, selectedAdapter);
|
||||
client.setActiveSession(initialSessionId, selectedAdapter ?? undefined);
|
||||
}
|
||||
client.connect();
|
||||
return () => {
|
||||
@@ -425,17 +496,18 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
// Keep WsClient's activeAdapter in sync so reconnect sends correct adapter hint
|
||||
useEffect(() => {
|
||||
if (wsRef.current && sessionId) {
|
||||
wsRef.current.setActiveSession(sessionId, selectedAdapter);
|
||||
wsRef.current.setActiveSession(sessionId, selectedAdapter ?? undefined);
|
||||
}
|
||||
}, [sessionId, selectedAdapter]);
|
||||
|
||||
// --- Fetch adapter config (models, permission modes) ---
|
||||
useEffect(() => {
|
||||
api.adapterConfig(selectedAdapter).then(setAdapterConfig).catch(console.error);
|
||||
if (selectedAdapter) api.adapterConfig(selectedAdapter).then(setAdapterConfig).catch(console.error);
|
||||
}, [selectedAdapter]);
|
||||
|
||||
// --- Send Message ---
|
||||
const actualSend = useCallback((text: string) => {
|
||||
if (!selectedAdapter) return;
|
||||
if (!text.trim() || !wsRef.current) return;
|
||||
streamingRef.current = true;
|
||||
setMessages(prev => [
|
||||
@@ -485,7 +557,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
behavior,
|
||||
message,
|
||||
});
|
||||
setPermissionRequest(null);
|
||||
setInteractivePrompt(null);
|
||||
}, []);
|
||||
|
||||
const respondAsk = useCallback((requestId: string, response: string) => {
|
||||
@@ -494,7 +566,17 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
requestId,
|
||||
response,
|
||||
});
|
||||
setPermissionRequest(null);
|
||||
setInteractivePrompt(null);
|
||||
}, []);
|
||||
|
||||
const respondPrompt = useCallback((requestId: string, selectedOption?: string, textValue?: string) => {
|
||||
wsRef.current?.send({
|
||||
type: WS.PROMPT_RESPONSE,
|
||||
requestId,
|
||||
selectedOption,
|
||||
textValue,
|
||||
});
|
||||
setInteractivePrompt(null);
|
||||
}, []);
|
||||
|
||||
// --- Plan Response ---
|
||||
@@ -519,7 +601,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
setPendingResponse(false);
|
||||
setStreamingText('');
|
||||
setThinkingStatus(null);
|
||||
setPermissionRequest(null);
|
||||
setInteractivePrompt(null);
|
||||
setInterrupted(true); // Immediately mark as interrupted for tool card fallback
|
||||
setToolStatuses(markToolsAs('interrupted'));
|
||||
}, [sessionId]);
|
||||
@@ -527,7 +609,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
// --- Settings ---
|
||||
const updateModel = useCallback((m: string) => {
|
||||
setModel(m);
|
||||
patchAdapterPrefs(selectedAdapter, { model: m });
|
||||
if (selectedAdapter) patchAdapterPrefs(selectedAdapter, { model: m });
|
||||
if (sessionId) {
|
||||
wsRef.current?.send({ type: WS.SET_MODEL, sessionId, model: m });
|
||||
}
|
||||
@@ -544,7 +626,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
|
||||
const updatePermissionMode = useCallback((m: string) => {
|
||||
setPermissionMode(m);
|
||||
patchAdapterPrefs(selectedAdapter, { permissionMode: m });
|
||||
if (selectedAdapter) patchAdapterPrefs(selectedAdapter, { permissionMode: m });
|
||||
if (sessionId) {
|
||||
wsRef.current?.send({ type: WS.SET_PERMISSION_MODE, sessionId, mode: m });
|
||||
}
|
||||
@@ -559,11 +641,11 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
|
||||
return {
|
||||
messages, toolStatuses, streaming, pendingResponse, wsStatus, sessionId, liveStatus,
|
||||
interrupted, sessionStatus, adapterConfig, selectedAdapter,
|
||||
permissionRequest, model, permissionMode, effort,
|
||||
interactivePrompt, model, permissionMode, effort,
|
||||
queuedMessage, clearQueuedMessage,
|
||||
activeReviews, setActiveReviews, activeReviewPanel, setActiveReviewPanel,
|
||||
historyReview, setHistoryReview,
|
||||
sendMessage, respondPermission, respondAsk, respondPlan, abort,
|
||||
sendMessage, respondPermission, respondAsk, respondPrompt, respondPlan, abort,
|
||||
updateModel, updatePermissionMode, updateAdapter,
|
||||
};
|
||||
}
|
||||
|
||||
+2
-2
@@ -124,10 +124,10 @@ export const api = {
|
||||
pushPending: () =>
|
||||
request<Record<string, number>>('/api/push/pending'),
|
||||
|
||||
registerReview: (parentCliSessionId: string, childSessionId: string, targetAdapter: string, anchorMessageId: string, prompt: string, title: string) =>
|
||||
registerReview: (params: { reviewId: string; parentCliSessionId: string; childSessionId: string; targetAdapter: string; anchorMessageId: string; prompt: string; title: string }) =>
|
||||
request<{ reviewId: string }>('/api/reviews/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ parentCliSessionId, childSessionId, targetAdapter, anchorMessageId, prompt, title }),
|
||||
body: JSON.stringify(params),
|
||||
}),
|
||||
|
||||
endReview: (reviewId: string, endAnchorMessageId?: string) =>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Parse Claude's AskUserQuestion nested input structure into a flat format */
|
||||
export function parseAskQuestionInput(input: any): {
|
||||
question: string;
|
||||
header?: string;
|
||||
options?: { label: string; value: string }[];
|
||||
} {
|
||||
const q = input?.questions?.[0] || input || {};
|
||||
const question = q.question || q.text || input?.question || input?.text || '';
|
||||
const header = q.header;
|
||||
const rawOpts = q.options || input?.options;
|
||||
const options = Array.isArray(rawOpts) && rawOpts.length > 0
|
||||
? rawOpts.map((o: any, i: number) => ({
|
||||
value: typeof o === 'string' ? String(i) : (o.value ?? String(i)),
|
||||
label: typeof o === 'string' ? o : (o.label || o.text || `Option ${i + 1}`),
|
||||
}))
|
||||
: undefined;
|
||||
return { question, header, options };
|
||||
}
|
||||
@@ -30,6 +30,10 @@ export const WS = {
|
||||
CLIENT_ID: 'client-id',
|
||||
PENDING_NOTIFICATIONS: 'pending-notifications',
|
||||
ERROR: 'error',
|
||||
// Interactive Prompts (unified permission/question/plan overlay)
|
||||
INTERACTIVE_PROMPT: 'interactive-prompt',
|
||||
PROMPT_RESPONSE: 'prompt-response',
|
||||
PROMPT_DISMISSED: 'prompt-dismissed',
|
||||
// Cross-AI Review
|
||||
REVIEW_STARTED: 'review-started',
|
||||
REVIEW_ENDED: 'review-ended',
|
||||
|
||||
+2
-1
@@ -43,6 +43,7 @@ export class WsClient {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === WS.SESSION_CREATED) {
|
||||
this.activeSessionId = msg.sessionId;
|
||||
if (msg.adapter) this.activeAdapter = msg.adapter;
|
||||
}
|
||||
this.onMessage(msg);
|
||||
} catch {}
|
||||
@@ -68,7 +69,7 @@ export class WsClient {
|
||||
}
|
||||
}
|
||||
|
||||
setActiveSession(sessionId: string | null, adapter?: string) {
|
||||
setActiveSession(sessionId: string | null, adapter?: string | null) {
|
||||
this.activeSessionId = sessionId;
|
||||
this.activeAdapter = adapter || null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user