diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index e85cf30..e8bf83b 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import type { Message, SSEEvent, SessionResponse, TokenUsage, PermissionRequestEvent } from '@/types'; import { MessageList } from '@/components/chat/MessageList'; import { MessageInput } from '@/components/chat/MessageInput'; +import { PermissionPrompt } from '@/components/chat/PermissionPrompt'; import { usePanel } from '@/hooks/usePanel'; interface ToolUseInfo { @@ -358,9 +359,12 @@ export default function NewChatPage() { toolResults={toolResults} streamingToolOutput={streamingToolOutput} statusText={statusText} + /> + [...prev, assistantMessage]); + clearSnapshot(sessionId); + } } else { setStreamSnapshot(null); } @@ -621,19 +636,24 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal toolResults={toolResults} streamingToolOutput={streamingToolOutput} statusText={statusText} - pendingPermission={pendingPermission} - onPermissionResponse={handlePermissionResponse} - permissionResolved={permissionResolved} onForceStop={stopStreaming} hasMore={hasMore} loadingMore={loadingMore} onLoadMore={loadEarlierMessages} /> + {/* Permission prompt — rendered outside MessageList so it's always visible at bottom */} + {/* Batch image generation panels — shown above the input area */} , denyMessage?: string) => void; - permissionResolved?: 'allow' | 'deny' | null; onForceStop?: () => void; hasMore?: boolean; loadingMore?: boolean; @@ -78,9 +75,6 @@ export function MessageList({ toolResults = [], streamingToolOutput, statusText, - pendingPermission, - onPermissionResponse, - permissionResolved, onForceStop, hasMore, loadingMore, @@ -152,9 +146,6 @@ export function MessageList({ toolResults={toolResults} streamingToolOutput={streamingToolOutput} statusText={statusText} - pendingPermission={pendingPermission} - onPermissionResponse={onPermissionResponse} - permissionResolved={permissionResolved} onForceStop={onForceStop} /> )} diff --git a/src/components/chat/PermissionPrompt.tsx b/src/components/chat/PermissionPrompt.tsx new file mode 100644 index 0000000..acb8334 --- /dev/null +++ b/src/components/chat/PermissionPrompt.tsx @@ -0,0 +1,423 @@ +'use client'; + +import { useState } from 'react'; +import { useTranslation } from '@/hooks/useTranslation'; +import { + MessageResponse, +} from '@/components/ai-elements/message'; +import { + Confirmation, + ConfirmationTitle, + ConfirmationRequest, + ConfirmationAccepted, + ConfirmationRejected, + ConfirmationActions, + ConfirmationAction, +} from '@/components/ai-elements/confirmation'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import type { ToolUIPart } from 'ai'; +import type { PermissionRequestEvent } from '@/types'; + +interface ToolUseInfo { + id: string; + name: string; + input: unknown; +} + +interface PermissionPromptProps { + pendingPermission: PermissionRequestEvent | null; + permissionResolved: 'allow' | 'deny' | null; + onPermissionResponse: (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record, denyMessage?: string) => void; + toolUses?: ToolUseInfo[]; +} + +function AskUserQuestionUI({ + toolInput, + onSubmit, +}: { + toolInput: Record; + onSubmit: (decision: 'allow', updatedInput: Record) => void; +}) { + const questions = (toolInput.questions || []) as Array<{ + question: string; + options: Array<{ label: string; description?: string }>; + multiSelect: boolean; + header?: string; + }>; + + const [selections, setSelections] = useState>>({}); + const [otherTexts, setOtherTexts] = useState>({}); + const [useOther, setUseOther] = useState>({}); + + const toggleOption = (qIdx: string, label: string, multi: boolean) => { + setSelections((prev) => { + const current = new Set(prev[qIdx] || []); + if (multi) { + if (current.has(label)) { current.delete(label); } else { current.add(label); } + } else { + current.clear(); + current.add(label); + } + return { ...prev, [qIdx]: current }; + }); + 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 = {}; + 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 ( +
+ {questions.map((q, i) => { + const qIdx = String(i); + const selected = selections[qIdx] || new Set(); + return ( +
+ {q.header && ( + + {q.header} + + )} +

{q.question}

+
+ {q.options.map((opt) => { + const isSelected = selected.has(opt.label); + return ( + + ); + })} + +
+ {useOther[qIdx] && ( + 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 + /> + )} +
+ ); + })} + +
+ ); +} + +function extractPlanFilePath(toolUses: ToolUseInfo[]): string | null { + for (let i = toolUses.length - 1; i >= 0; i--) { + const tool = toolUses[i]; + const input = tool.input as Record; + if ((tool.name === 'Write' || tool.name === 'Edit') && typeof input.file_path === 'string') { + const fp = input.file_path; + if (fp.endsWith('.md') && (fp.includes('plans/') || fp.includes('plans\\'))) { + return fp; + } + } + } + return null; +} + +function ExitPlanModeUI({ + toolInput, + toolUses, + onApprove, + onDeny, + onDenyWithMessage, +}: { + toolInput: Record; + toolUses: ToolUseInfo[]; + onApprove: () => void; + onDeny: () => void; + onDenyWithMessage: (message: string) => void; +}) { + const [planOpen, setPlanOpen] = useState(false); + const [planContent, setPlanContent] = useState(null); + const [planLoading, setPlanLoading] = useState(false); + const [feedback, setFeedback] = useState(''); + const planFilePath = extractPlanFilePath(toolUses); + const allowedPrompts = (toolInput.allowedPrompts || []) as Array<{ + tool: string; + prompt: string; + }>; + + return ( +
+
+ + Plan complete — ready to execute +
+ {allowedPrompts.length > 0 && ( +
+

Requested permissions:

+
    + {allowedPrompts.map((p, i) => ( +
  • + {p.tool} + {p.prompt} +
  • + ))} +
+
+ )} +
+ + {planFilePath && ( + + )} + +
+
+ setFeedback(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && feedback.trim()) { + onDenyWithMessage(feedback.trim()); + } + }} + className="flex-1 rounded-lg border border-border bg-background px-3 py-1.5 text-xs focus:border-primary focus:outline-none" + /> + +
+ + {planOpen && planContent && ( + + + + Plan + +
+ {planContent} +
+ +
+
+ )} +
+ ); +} + +export function PermissionPrompt({ + pendingPermission, + permissionResolved, + onPermissionResponse, + toolUses = [], +}: PermissionPromptProps) { + const { t } = useTranslation(); + + // Nothing to show + if (!pendingPermission && !permissionResolved) return null; + + 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 }; + } + return { id: pendingPermission?.permissionRequestId || '' }; + }; + + const formatToolInput = (input: Record): 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); + }; + + return ( +
+ {/* ExitPlanMode */} + {pendingPermission?.toolName === 'ExitPlanMode' && !permissionResolved && ( + } + toolUses={toolUses} + onApprove={() => onPermissionResponse('allow')} + onDeny={() => onPermissionResponse('deny')} + onDenyWithMessage={(msg) => onPermissionResponse('deny', undefined, msg)} + /> + )} + {pendingPermission?.toolName === 'ExitPlanMode' && permissionResolved === 'allow' && ( +

Plan approved — executing

+ )} + {pendingPermission?.toolName === 'ExitPlanMode' && permissionResolved === 'deny' && ( +

Plan rejected

+ )} + + {/* AskUserQuestion */} + {pendingPermission?.toolName === 'AskUserQuestion' && !permissionResolved && ( + } + onSubmit={(decision, updatedInput) => onPermissionResponse(decision, updatedInput)} + /> + )} + {pendingPermission?.toolName === 'AskUserQuestion' && permissionResolved && ( +

Answer submitted

+ )} + + {/* Generic confirmation for other tools */} + {pendingPermission?.toolName !== 'AskUserQuestion' && pendingPermission?.toolName !== 'ExitPlanMode' && (pendingPermission || permissionResolved) && ( + + + {pendingPermission?.toolName} + {pendingPermission?.decisionReason && ( + + — {pendingPermission.decisionReason} + + )} + + + {pendingPermission && ( +
+ {formatToolInput(pendingPermission.toolInput)} +
+ )} + + + + onPermissionResponse('deny')} + > + Deny + + onPermissionResponse('allow')} + > + Allow Once + + {pendingPermission?.suggestions && pendingPermission.suggestions.length > 0 && ( + onPermissionResponse('allow_session')} + > + {t('streaming.allowForSession')} + + )} + + + + +

{t('streaming.allowed')}

+
+ + +

{t('streaming.denied')}

+
+
+ )} +
+ ); +} diff --git a/src/components/chat/StreamingMessage.tsx b/src/components/chat/StreamingMessage.tsx index 83c574f..8b6caf5 100644 --- a/src/components/chat/StreamingMessage.tsx +++ b/src/components/chat/StreamingMessage.tsx @@ -8,28 +8,11 @@ import { 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 { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog'; import { ImageGenConfirmation } from './ImageGenConfirmation'; import { BatchPlanInlinePreview } from './batch-image-gen/BatchPlanInlinePreview'; import { PENDING_KEY, buildReferenceImages } from '@/lib/image-ref-store'; -import type { ToolUIPart } from 'ai'; -import type { PermissionRequestEvent, PlannerOutput } from '@/types'; +import type { PlannerOutput } from '@/types'; interface ImageGenRequest { prompt: string; @@ -120,9 +103,6 @@ interface StreamingMessageProps { toolResults?: ToolResultInfo[]; streamingToolOutput?: string; statusText?: string; - pendingPermission?: PermissionRequestEvent | null; - onPermissionResponse?: (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record, denyMessage?: string) => void; - permissionResolved?: 'allow' | 'deny' | null; onForceStop?: () => void; } @@ -148,270 +128,6 @@ function ElapsedTimer() { ); } -function AskUserQuestionUI({ - toolInput, - onSubmit, -}: { - toolInput: Record; - onSubmit: (decision: 'allow', updatedInput: Record) => void; -}) { - const questions = (toolInput.questions || []) as Array<{ - question: string; - options: Array<{ label: string; description?: string }>; - multiSelect: boolean; - header?: string; - }>; - - const [selections, setSelections] = useState>>({}); - const [otherTexts, setOtherTexts] = useState>({}); - const [useOther, setUseOther] = useState>({}); - - 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 = {}; - 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 ( -
- {questions.map((q, i) => { - const qIdx = String(i); - const selected = selections[qIdx] || new Set(); - return ( -
- {q.header && ( - - {q.header} - - )} -

{q.question}

-
- {q.options.map((opt) => { - const isSelected = selected.has(opt.label); - return ( - - ); - })} - {/* Other option */} - -
- {useOther[qIdx] && ( - 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 - /> - )} -
- ); - })} - -
- ); -} - -function extractPlanFilePath(toolUses: ToolUseInfo[]): string | null { - // Walk backwards to find the most recent Write or Edit targeting a plans/*.md file - for (let i = toolUses.length - 1; i >= 0; i--) { - const tool = toolUses[i]; - const input = tool.input as Record; - if ((tool.name === 'Write' || tool.name === 'Edit') && typeof input.file_path === 'string') { - const fp = input.file_path; - if (fp.endsWith('.md') && (fp.includes('plans/') || fp.includes('plans\\'))) { - return fp; - } - } - } - return null; -} - -function ExitPlanModeUI({ - toolInput, - toolUses, - onApprove, - onDeny, - onDenyWithMessage, -}: { - toolInput: Record; - toolUses: ToolUseInfo[]; - onApprove: () => void; - onDeny: () => void; - onDenyWithMessage: (message: string) => void; -}) { - const [planOpen, setPlanOpen] = useState(false); - const [planContent, setPlanContent] = useState(null); - const [planLoading, setPlanLoading] = useState(false); - const [feedback, setFeedback] = useState(''); - const planFilePath = extractPlanFilePath(toolUses); - const allowedPrompts = (toolInput.allowedPrompts || []) as Array<{ - tool: string; - prompt: string; - }>; - - return ( -
-
- - Plan complete — ready to execute -
- {allowedPrompts.length > 0 && ( -
-

Requested permissions:

-
    - {allowedPrompts.map((p, i) => ( -
  • - {p.tool} - {p.prompt} -
  • - ))} -
-
- )} -
- - {planFilePath && ( - - )} - -
-
- setFeedback(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && feedback.trim()) { - onDenyWithMessage(feedback.trim()); - } - }} - className="flex-1 rounded-lg border border-border bg-background px-3 py-1.5 text-xs focus:border-primary focus:outline-none" - /> - -
- - {planOpen && planContent && ( - - - - Plan - -
- {planContent} -
- -
-
- )} -
- ); -} - function StreamingStatusBar({ statusText, onForceStop }: { statusText?: string; onForceStop?: () => void }) { const displayText = statusText || 'Thinking'; @@ -456,9 +172,6 @@ export function StreamingMessage({ toolResults = [], streamingToolOutput, statusText, - pendingPermission, - onPermissionResponse, - permissionResolved, onForceStop, }: StreamingMessageProps) { const { t } = useTranslation(); @@ -466,32 +179,6 @@ export function StreamingMessage({ (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 => { - 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) { @@ -531,79 +218,6 @@ export function StreamingMessage({ /> )} - {/* Permission approval — ExitPlanMode gets a dedicated UI */} - {pendingPermission?.toolName === 'ExitPlanMode' && !permissionResolved && ( - } - toolUses={toolUses} - onApprove={() => onPermissionResponse?.('allow')} - onDeny={() => onPermissionResponse?.('deny')} - onDenyWithMessage={(msg) => onPermissionResponse?.('deny', undefined, msg)} - /> - )} - {pendingPermission?.toolName === 'ExitPlanMode' && permissionResolved === 'allow' && ( -

Plan approved — executing

- )} - {pendingPermission?.toolName === 'ExitPlanMode' && permissionResolved === 'deny' && ( -

Plan rejected

- )} - - {/* Permission approval — generic confirmation for other tools */} - {(pendingPermission || permissionResolved) && pendingPermission?.toolName !== 'AskUserQuestion' && pendingPermission?.toolName !== 'ExitPlanMode' && ( - - - {pendingPermission?.toolName} - {pendingPermission?.decisionReason && ( - - — {pendingPermission.decisionReason} - - )} - - - {pendingPermission && ( -
- {formatToolInput(pendingPermission.toolInput)} -
- )} - - - - onPermissionResponse?.('deny')} - > - Deny - - onPermissionResponse?.('allow')} - > - Allow Once - - {pendingPermission?.suggestions && pendingPermission.suggestions.length > 0 && ( - onPermissionResponse?.('allow_session')} - > - {t('streaming.allowForSession')} - - )} - - - - -

{t('streaming.allowed')}

-
- - -

{t('streaming.denied')}

-
-
- )} - {/* Streaming text content rendered via Streamdown */} {content && (() => { // Try batch-plan first (Image Agent batch mode) @@ -612,7 +226,7 @@ export function StreamingMessage({ return ( <> {batchPlanResult.beforeText && {batchPlanResult.beforeText}} - + {batchPlanResult.afterText && {batchPlanResult.afterText}} ); @@ -659,30 +273,15 @@ export function StreamingMessage({ return stripped ? {stripped} : null; })()} - {/* Permission approval — AskUserQuestion rendered after text so it stays in view */} - {pendingPermission?.toolName === 'AskUserQuestion' && !permissionResolved && ( - } - onSubmit={(decision, updatedInput) => onPermissionResponse?.(decision, updatedInput)} - /> - )} - {pendingPermission?.toolName === 'AskUserQuestion' && permissionResolved && ( -

Answer submitted

- )} - {/* Loading indicator when no content yet */} - {isStreaming && !content && toolUses.length === 0 && !pendingPermission && ( + {isStreaming && !content && toolUses.length === 0 && (
{t('streaming.thinking')}
)} - {/* Status bar during streaming — show permission wait status when awaiting authorization */} - {isStreaming && } + {/* Status bar during streaming */} + {isStreaming && } ); diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 1d85fb2..216d46f 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -578,7 +578,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{/* Electron draggable title bar region — matches side panels' mt-5 */}
diff --git a/src/components/layout/RightPanel.tsx b/src/components/layout/RightPanel.tsx index e55cbe3..16cee86 100644 --- a/src/components/layout/RightPanel.tsx +++ b/src/components/layout/RightPanel.tsx @@ -50,7 +50,7 @@ export function RightPanel({ width }: RightPanelProps) { if (!panelOpen) { return ( -
+