Files
CodePilot/src/components/chat/StreamingMessage.tsx
T
7418andClaude Opus 4.6 edd55a9b0f fix: improve session resume resilience, permission timeout, and stream idle handling
1. Resume pre-check and degradation (claude-client.ts):
   - Check working_directory exists on disk before attempting resume;
     if missing, skip resume, clear sdk_session_id, notify frontend
   - On resume peek failure, clear sdk_session_id to prevent repeated
     retries on the same broken session
   - Change iterator wrap condition from sdkSessionId to shouldResume
     so skipped resumes don't enter the peek path

2. Per-request independent permission timeout (permission-registry.ts):
   - Replace lazy cleanup (cleanupExpired on next register) with a
     dedicated setTimeout per permission request
   - Auto-deny after 5 minutes with clearTimeout on resolve/abort
   - Extract denyAndRemove helper for abort and timeout paths

3. Permission wait visibility (StreamingMessage.tsx):
   - Always show StreamingStatusBar during streaming (remove
     !pendingPermission guard)
   - Display "Waiting for authorization: <tool>" with elapsed timer
     when a permission request is pending, instead of hiding the bar

4. Stream idle timeout (ChatView.tsx):
   - Add 330s idle timeout (slightly above 5-min permission timeout
     to avoid races) using setInterval that checks lastEventTime
   - Every SSE callback calls markActive() to reset the timer
   - On idle timeout, abort the stream and show a readable error
   - Clean up interval in both catch and finally blocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:39:09 +08:00

473 lines
17 KiB
TypeScript

'use client';
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import {
Message as AIMessage,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message';
import { ToolActionsGroup } from '@/components/ai-elements/tool-actions-group';
import {
Confirmation,
ConfirmationTitle,
ConfirmationRequest,
ConfirmationAccepted,
ConfirmationRejected,
ConfirmationActions,
ConfirmationAction,
} from '@/components/ai-elements/confirmation';
import { Shimmer } from '@/components/ai-elements/shimmer';
import type { ToolUIPart } from 'ai';
import type { PermissionRequestEvent } from '@/types';
interface ToolUseInfo {
id: string;
name: string;
input: unknown;
}
interface ToolResultInfo {
tool_use_id: string;
content: string;
is_error?: boolean;
}
interface StreamingMessageProps {
content: string;
isStreaming: boolean;
toolUses?: ToolUseInfo[];
toolResults?: ToolResultInfo[];
streamingToolOutput?: string;
statusText?: string;
pendingPermission?: PermissionRequestEvent | null;
onPermissionResponse?: (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record<string, unknown>) => void;
permissionResolved?: 'allow' | 'deny' | null;
onForceStop?: () => void;
}
function ElapsedTimer() {
const [elapsed, setElapsed] = useState(0);
const startRef = useRef(0);
useEffect(() => {
startRef.current = Date.now();
const interval = setInterval(() => {
setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
}, 1000);
return () => clearInterval(interval);
}, []);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
return (
<span className="tabular-nums">
{mins > 0 ? `${mins}m ${secs}s` : `${secs}s`}
</span>
);
}
function AskUserQuestionUI({
toolInput,
onSubmit,
}: {
toolInput: Record<string, unknown>;
onSubmit: (decision: 'allow', updatedInput: Record<string, unknown>) => void;
}) {
const questions = (toolInput.questions || []) as Array<{
question: string;
options: Array<{ label: string; description?: string }>;
multiSelect: boolean;
header?: string;
}>;
const [selections, setSelections] = useState<Record<string, Set<string>>>({});
const [otherTexts, setOtherTexts] = useState<Record<string, string>>({});
const [useOther, setUseOther] = useState<Record<string, boolean>>({});
const toggleOption = (qIdx: string, label: string, multi: boolean) => {
setSelections((prev) => {
const current = new Set(prev[qIdx] || []);
if (multi) {
current.has(label) ? current.delete(label) : current.add(label);
} else {
current.clear();
current.add(label);
}
return { ...prev, [qIdx]: current };
});
// Deselect "Other" when picking a regular option
setUseOther((prev) => ({ ...prev, [qIdx]: false }));
};
const toggleOther = (qIdx: string, multi: boolean) => {
if (!multi) {
setSelections((prev) => ({ ...prev, [qIdx]: new Set() }));
}
setUseOther((prev) => ({ ...prev, [qIdx]: !prev[qIdx] }));
};
const handleSubmit = () => {
const answers: Record<string, string> = {};
questions.forEach((q, i) => {
const qIdx = String(i);
const selected = Array.from(selections[qIdx] || []);
if (useOther[qIdx] && otherTexts[qIdx]?.trim()) {
selected.push(otherTexts[qIdx].trim());
}
answers[q.question] = selected.join(', ');
});
onSubmit('allow', { questions: toolInput.questions, answers });
};
const hasAnswer = questions.some((_, i) => {
const qIdx = String(i);
return (selections[qIdx]?.size || 0) > 0 || (useOther[qIdx] && otherTexts[qIdx]?.trim());
});
return (
<div className="space-y-4 py-2">
{questions.map((q, i) => {
const qIdx = String(i);
const selected = selections[qIdx] || new Set<string>();
return (
<div key={qIdx} className="space-y-2">
{q.header && (
<span className="inline-block rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{q.header}
</span>
)}
<p className="text-sm font-medium">{q.question}</p>
<div className="flex flex-wrap gap-2">
{q.options.map((opt) => {
const isSelected = selected.has(opt.label);
return (
<button
key={opt.label}
onClick={() => toggleOption(qIdx, opt.label, q.multiSelect)}
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
isSelected
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-background text-foreground hover:bg-muted'
}`}
title={opt.description}
>
{q.multiSelect && (
<span className="mr-1.5">{isSelected ? '☑' : '☐'}</span>
)}
{opt.label}
</button>
);
})}
{/* Other option */}
<button
onClick={() => toggleOther(qIdx, q.multiSelect)}
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
useOther[qIdx]
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-background text-foreground hover:bg-muted'
}`}
>
Other
</button>
</div>
{useOther[qIdx] && (
<input
type="text"
placeholder="Type your answer..."
value={otherTexts[qIdx] || ''}
onChange={(e) => setOtherTexts((prev) => ({ ...prev, [qIdx]: e.target.value }))}
className="w-full rounded-md border border-border bg-background px-3 py-1.5 text-xs focus:border-primary focus:outline-none"
autoFocus
/>
)}
</div>
);
})}
<button
onClick={handleSubmit}
disabled={!hasAnswer}
className="rounded-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40"
>
Submit
</button>
</div>
);
}
function ExitPlanModeUI({
toolInput,
onApprove,
onDeny,
}: {
toolInput: Record<string, unknown>;
onApprove: () => void;
onDeny: () => void;
}) {
const allowedPrompts = (toolInput.allowedPrompts || []) as Array<{
tool: string;
prompt: string;
}>;
return (
<div className="space-y-3 rounded-lg border border-primary/30 bg-primary/5 p-4">
<div className="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<span className="text-sm font-medium">Plan complete — ready to execute</span>
</div>
{allowedPrompts.length > 0 && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Requested permissions:</p>
<ul className="space-y-0.5">
{allowedPrompts.map((p, i) => (
<li key={i} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">{p.tool}</span>
<span>{p.prompt}</span>
</li>
))}
</ul>
</div>
)}
<div className="flex gap-2">
<button
onClick={onDeny}
className="rounded-lg border border-border px-3 py-1.5 text-xs transition-colors hover:bg-muted"
>
Reject
</button>
<button
onClick={onApprove}
className="rounded-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Approve & Execute
</button>
</div>
</div>
);
}
function StreamingStatusBar({ statusText, onForceStop }: { statusText?: string; onForceStop?: () => void }) {
const displayText = statusText || 'Thinking';
// Parse elapsed seconds from statusText like "Running bash... (45s)"
const elapsedMatch = statusText?.match(/\((\d+)s\)/);
const toolElapsed = elapsedMatch ? parseInt(elapsedMatch[1], 10) : 0;
const isWarning = toolElapsed >= 60;
const isCritical = toolElapsed >= 90;
return (
<div className="flex items-center gap-3 py-2 px-1 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className={isCritical ? 'text-red-500' : isWarning ? 'text-yellow-500' : undefined}>
<Shimmer duration={1.5}>{displayText}</Shimmer>
</span>
{isWarning && !isCritical && (
<span className="text-yellow-500 text-[10px]">Running longer than usual</span>
)}
{isCritical && (
<span className="text-red-500 text-[10px]">Tool may be stuck</span>
)}
</div>
<span className="text-muted-foreground/50">|</span>
<ElapsedTimer />
{isCritical && onForceStop && (
<button
type="button"
onClick={onForceStop}
className="ml-auto rounded-md border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[10px] font-medium text-red-500 transition-colors hover:bg-red-500/20"
>
Force stop
</button>
)}
</div>
);
}
export function StreamingMessage({
content,
isStreaming,
toolUses = [],
toolResults = [],
streamingToolOutput,
statusText,
pendingPermission,
onPermissionResponse,
permissionResolved,
onForceStop,
}: StreamingMessageProps) {
const { t } = useTranslation();
const runningTools = toolUses.filter(
(tool) => !toolResults.some((r) => r.tool_use_id === tool.id)
);
// Determine confirmation state for the AI Elements component
const getConfirmationState = (): ToolUIPart['state'] => {
if (permissionResolved) return 'approval-responded';
if (pendingPermission) return 'approval-requested';
return 'input-available';
};
const getApproval = () => {
if (!pendingPermission && !permissionResolved) return undefined;
if (permissionResolved === 'allow') {
return { id: pendingPermission?.permissionRequestId || '', approved: true as const };
}
if (permissionResolved === 'deny') {
return { id: pendingPermission?.permissionRequestId || '', approved: false as const };
}
// Pending - no decision yet
return { id: pendingPermission?.permissionRequestId || '' };
};
const formatToolInput = (input: Record<string, unknown>): string => {
if (input.command) return String(input.command);
if (input.file_path) return String(input.file_path);
if (input.path) return String(input.path);
return JSON.stringify(input, null, 2);
};
// Extract a human-readable summary of the running command
const getRunningCommandSummary = (): string | undefined => {
if (runningTools.length === 0) {
// All tools completed but still streaming — AI is generating text
if (toolUses.length > 0) return 'Generating response...';
return undefined;
}
const tool = runningTools[runningTools.length - 1];
const input = tool.input as Record<string, unknown>;
if (tool.name === 'Bash' && input.command) {
const cmd = String(input.command);
return cmd.length > 80 ? cmd.slice(0, 80) + '...' : cmd;
}
if (input.file_path) return `${tool.name}: ${String(input.file_path)}`;
if (input.path) return `${tool.name}: ${String(input.path)}`;
return `Running ${tool.name}...`;
};
return (
<AIMessage from="assistant">
<MessageContent>
{/* Tool calls — compact collapsible group */}
{toolUses.length > 0 && (
<ToolActionsGroup
tools={toolUses.map((tool) => {
const result = toolResults.find((r) => r.tool_use_id === tool.id);
return {
id: tool.id,
name: tool.name,
input: tool.input,
result: result?.content,
isError: result?.is_error,
};
})}
isStreaming={isStreaming}
streamingToolOutput={streamingToolOutput}
/>
)}
{/* Permission approval — AskUserQuestion gets a dedicated UI */}
{pendingPermission?.toolName === 'AskUserQuestion' && !permissionResolved && (
<AskUserQuestionUI
toolInput={pendingPermission.toolInput as Record<string, unknown>}
onSubmit={(decision, updatedInput) => onPermissionResponse?.(decision, updatedInput)}
/>
)}
{pendingPermission?.toolName === 'AskUserQuestion' && permissionResolved && (
<p className="py-1 text-xs text-green-600 dark:text-green-400">Answer submitted</p>
)}
{/* Permission approval — ExitPlanMode gets a dedicated UI */}
{pendingPermission?.toolName === 'ExitPlanMode' && !permissionResolved && (
<ExitPlanModeUI
toolInput={pendingPermission.toolInput as Record<string, unknown>}
onApprove={() => onPermissionResponse?.('allow')}
onDeny={() => onPermissionResponse?.('deny')}
/>
)}
{pendingPermission?.toolName === 'ExitPlanMode' && permissionResolved === 'allow' && (
<p className="py-1 text-xs text-green-600 dark:text-green-400">Plan approved — executing</p>
)}
{pendingPermission?.toolName === 'ExitPlanMode' && permissionResolved === 'deny' && (
<p className="py-1 text-xs text-red-600 dark:text-red-400">Plan rejected</p>
)}
{/* Permission approval — generic confirmation for other tools */}
{(pendingPermission || permissionResolved) && pendingPermission?.toolName !== 'AskUserQuestion' && pendingPermission?.toolName !== 'ExitPlanMode' && (
<Confirmation
approval={getApproval()}
state={getConfirmationState()}
>
<ConfirmationTitle>
<span className="font-medium">{pendingPermission?.toolName}</span>
{pendingPermission?.decisionReason && (
<span className="text-muted-foreground ml-2">
— {pendingPermission.decisionReason}
</span>
)}
</ConfirmationTitle>
{pendingPermission && (
<div className="mt-1 rounded bg-muted/50 px-3 py-2 font-mono text-xs">
{formatToolInput(pendingPermission.toolInput)}
</div>
)}
<ConfirmationRequest>
<ConfirmationActions>
<ConfirmationAction
variant="outline"
onClick={() => onPermissionResponse?.('deny')}
>
Deny
</ConfirmationAction>
<ConfirmationAction
variant="outline"
onClick={() => onPermissionResponse?.('allow')}
>
Allow Once
</ConfirmationAction>
{pendingPermission?.suggestions && pendingPermission.suggestions.length > 0 && (
<ConfirmationAction
variant="default"
onClick={() => onPermissionResponse?.('allow_session')}
>
{t('streaming.allowForSession')}
</ConfirmationAction>
)}
</ConfirmationActions>
</ConfirmationRequest>
<ConfirmationAccepted>
<p className="text-xs text-green-600 dark:text-green-400">{t('streaming.allowed')}</p>
</ConfirmationAccepted>
<ConfirmationRejected>
<p className="text-xs text-red-600 dark:text-red-400">{t('streaming.denied')}</p>
</ConfirmationRejected>
</Confirmation>
)}
{/* Streaming text content rendered via Streamdown */}
{content && (
<MessageResponse>{content}</MessageResponse>
)}
{/* Loading indicator when no content yet */}
{isStreaming && !content && toolUses.length === 0 && !pendingPermission && (
<div className="py-2">
<Shimmer>{t('streaming.thinking')}</Shimmer>
</div>
)}
{/* Status bar during streaming — show permission wait status when awaiting authorization */}
{isStreaming && <StreamingStatusBar statusText={
pendingPermission && !permissionResolved
? `Waiting for authorization: ${pendingPermission.toolName}`
: statusText || getRunningCommandSummary()
} onForceStop={onForceStop} />}
</MessageContent>
</AIMessage>
);
}