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:
kuannnn
2026-03-27 14:46:00 +08:00
parent 16f75379af
commit 0fcf66fc22
50 changed files with 2179 additions and 400 deletions
+9 -6
View File
@@ -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;
-64
View File
@@ -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>
);
}
+1 -1
View File
@@ -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
View File
@@ -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>
);
}
+23 -14
View File
@@ -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}
+165
View File
@@ -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>
);
}
-68
View File
@@ -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>
);
}
+8
View File
@@ -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">
+1 -1
View File
@@ -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(() => {});
+9
View File
@@ -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(
+51 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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) =>
+18
View File
@@ -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 };
}
+4
View File
@@ -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
View File
@@ -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;
}