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>
473 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|