fix: resolve 7 bugs — idle output, permission scroll, file selector, drag area, input reset, markdown tables, panel collapse

- ChatView: consume unconsumed stream snapshots on mount so output
  appears after idle without needing a conversation switch
- PermissionPrompt: extract permission UI from StreamingMessage into a
  fixed-position component at the bottom of ChatView, preventing
  permission popups from scrolling off-screen during long responses
- StreamingMessage/MessageList: remove ~409 lines of permission UI code
  now handled by PermissionPrompt
- AssistantWorkspaceSection: fix macOS Electron file selector by using
  the correct IPC API (window.electronAPI.dialog.openFolder)
- AppShell: increase title bar drag region from h-5 to h-10 (#152)
- ChatView: add key={sessionId} on MessageInput to reset input state
  when switching conversations (#176)
- UpdateDialog: add remark-gfm plugin and table component mappings for
  proper GFM markdown rendering in release notes (#117)
- RightPanel: add border and top margin to collapsed panel toggle for
  visibility (#116)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-07 14:24:19 +08:00
1 parent a53c60531f
commit 5eedb5bacc
9 files changed
+479 -429

No files matched your search

+5 -1
View File
@@ -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}
/>
<PermissionPrompt
pendingPermission={pendingPermission}
onPermissionResponse={handlePermissionResponse}
permissionResolved={permissionResolved}
onPermissionResponse={handlePermissionResponse}
toolUses={toolUses}
/>
<MessageInput
onSend={sendFirstMessage}
+23 -3
View File
@@ -7,6 +7,7 @@ import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput';
import { usePanel } from '@/hooks/usePanel';
import { useTranslation } from '@/hooks/useTranslation';
import { PermissionPrompt } from './PermissionPrompt';
import { BatchExecutionDashboard, BatchContextSync } from './batch-image-gen';
import { setLastGeneratedImages, transferPendingToMessage } from '@/lib/image-ref-store';
import {
@@ -117,6 +118,20 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
if (existing.pendingPermission && !existing.permissionResolved) {
setPendingApprovalSessionId(sessionId);
}
// If stream completed while this ChatView was unmounted, consume finalMessageContent now
if (existing.phase !== 'active' && existing.finalMessageContent) {
const assistantMessage: Message = {
id: 'temp-assistant-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: existing.finalMessageContent,
created_at: new Date().toISOString(),
token_usage: existing.tokenUsage ? JSON.stringify(existing.tokenUsage) : null,
};
transferPendingToMessage(assistantMessage.id);
setMessages((prev) => [...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 */}
<PermissionPrompt
pendingPermission={pendingPermission}
permissionResolved={permissionResolved}
onPermissionResponse={handlePermissionResponse}
toolUses={toolUses}
/>
{/* Batch image generation panels — shown above the input area */}
<BatchExecutionDashboard />
<BatchContextSync />
<MessageInput
key={sessionId}
onSend={sendMessage}
onCommand={handleCommand}
onStop={stopStreaming}
+1 -10
View File
@@ -3,7 +3,7 @@
import { useRef, useEffect } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { useStickToBottomContext } from 'use-stick-to-bottom';
import type { Message, PermissionRequestEvent } from '@/types';
import type { Message } from '@/types';
import {
Conversation,
ConversationContent,
@@ -61,9 +61,6 @@ interface MessageListProps {
toolResults?: ToolResultInfo[];
streamingToolOutput?: string;
statusText?: string;
pendingPermission?: PermissionRequestEvent | null;
onPermissionResponse?: (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record<string, unknown>, 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}
/>
)}
+423
View File
@@ -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<string, unknown>, denyMessage?: string) => void;
toolUses?: ToolUseInfo[];
}
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) {
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<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 h-8 px-3 text-sm font-medium 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>
);
})}
<button
onClick={() => toggleOther(qIdx, q.multiSelect)}
className={`rounded-lg border h-8 px-3 text-sm font-medium 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 h-8 px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40"
>
Submit
</button>
</div>
);
}
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<string, unknown>;
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<string, unknown>;
toolUses: ToolUseInfo[];
onApprove: () => void;
onDeny: () => void;
onDenyWithMessage: (message: string) => void;
}) {
const [planOpen, setPlanOpen] = useState(false);
const [planContent, setPlanContent] = useState<string | null>(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 (
<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>
{planFilePath && (
<button
onClick={async () => {
setPlanLoading(true);
try {
const res = await fetch(`/api/files/preview?path=${encodeURIComponent(planFilePath)}&maxLines=1000`);
if (res.ok) {
const data = await res.json();
setPlanContent(data.preview?.content || 'Failed to load plan');
} else {
setPlanContent('Failed to load plan file');
}
} catch {
setPlanContent('Failed to load plan file');
}
setPlanLoading(false);
setPlanOpen(true);
}}
disabled={planLoading}
className="rounded-lg border border-primary/30 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/10 disabled:opacity-50"
>
{planLoading ? 'Loading...' : 'View Plan'}
</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 className="flex gap-2">
<input
type="text"
placeholder="Provide feedback on the plan..."
value={feedback}
onChange={(e) => 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"
/>
<button
onClick={() => {
if (feedback.trim()) onDenyWithMessage(feedback.trim());
}}
disabled={!feedback.trim()}
className="rounded-lg border border-border px-3 py-1.5 text-xs transition-colors hover:bg-muted disabled:opacity-40"
>
Do this instead
</button>
</div>
{planOpen && planContent && (
<Dialog open={planOpen} onOpenChange={setPlanOpen}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Plan</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto flex-1 min-h-0">
<MessageResponse>{planContent}</MessageResponse>
</div>
<DialogFooter showCloseButton />
</DialogContent>
</Dialog>
)}
</div>
);
}
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, 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);
};
return (
<div className="mx-auto w-full max-w-3xl border-t border-border bg-background px-4 py-3">
{/* ExitPlanMode */}
{pendingPermission?.toolName === 'ExitPlanMode' && !permissionResolved && (
<ExitPlanModeUI
toolInput={pendingPermission.toolInput as Record<string, unknown>}
toolUses={toolUses}
onApprove={() => onPermissionResponse('allow')}
onDeny={() => onPermissionResponse('deny')}
onDenyWithMessage={(msg) => onPermissionResponse('deny', undefined, msg)}
/>
)}
{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>
)}
{/* AskUserQuestion */}
{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>
)}
{/* Generic confirmation for other tools */}
{pendingPermission?.toolName !== 'AskUserQuestion' && pendingPermission?.toolName !== 'ExitPlanMode' && (pendingPermission || permissionResolved) && (
<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>
)}
</div>
);
}
+5 -406
View File
@@ -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<string, unknown>, denyMessage?: string) => void;
permissionResolved?: 'allow' | 'deny' | null;
onForceStop?: () => void;
}
@@ -148,270 +128,6 @@ function ElapsedTimer() {
);
}
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 h-8 px-3 text-sm font-medium 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 h-8 px-3 text-sm font-medium 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 h-8 px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40"
>
Submit
</button>
</div>
);
}
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<string, unknown>;
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<string, unknown>;
toolUses: ToolUseInfo[];
onApprove: () => void;
onDeny: () => void;
onDenyWithMessage: (message: string) => void;
}) {
const [planOpen, setPlanOpen] = useState(false);
const [planContent, setPlanContent] = useState<string | null>(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 (
<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>
{planFilePath && (
<button
onClick={async () => {
setPlanLoading(true);
try {
const res = await fetch(`/api/files/preview?path=${encodeURIComponent(planFilePath)}&maxLines=1000`);
if (res.ok) {
const data = await res.json();
setPlanContent(data.preview?.content || 'Failed to load plan');
} else {
setPlanContent('Failed to load plan file');
}
} catch {
setPlanContent('Failed to load plan file');
}
setPlanLoading(false);
setPlanOpen(true);
}}
disabled={planLoading}
className="rounded-lg border border-primary/30 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/10 disabled:opacity-50"
>
{planLoading ? 'Loading...' : 'View Plan'}
</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 className="flex gap-2">
<input
type="text"
placeholder="Provide feedback on the plan..."
value={feedback}
onChange={(e) => 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"
/>
<button
onClick={() => {
if (feedback.trim()) onDenyWithMessage(feedback.trim());
}}
disabled={!feedback.trim()}
className="rounded-lg border border-border px-3 py-1.5 text-xs transition-colors hover:bg-muted disabled:opacity-40"
>
Do this instead
</button>
</div>
{planOpen && planContent && (
<Dialog open={planOpen} onOpenChange={setPlanOpen}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Plan</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto flex-1 min-h-0">
<MessageResponse>{planContent}</MessageResponse>
</div>
<DialogFooter showCloseButton />
</DialogContent>
</Dialog>
)}
</div>
);
}
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, 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) {
@@ -531,79 +218,6 @@ export function StreamingMessage({
/>
)}
{/* Permission approval — ExitPlanMode gets a dedicated UI */}
{pendingPermission?.toolName === 'ExitPlanMode' && !permissionResolved && (
<ExitPlanModeUI
toolInput={pendingPermission.toolInput as Record<string, unknown>}
toolUses={toolUses}
onApprove={() => onPermissionResponse?.('allow')}
onDeny={() => onPermissionResponse?.('deny')}
onDenyWithMessage={(msg) => onPermissionResponse?.('deny', undefined, msg)}
/>
)}
{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 && (() => {
// Try batch-plan first (Image Agent batch mode)
@@ -612,7 +226,7 @@ export function StreamingMessage({
return (
<>
{batchPlanResult.beforeText && <MessageResponse>{batchPlanResult.beforeText}</MessageResponse>}
<BatchPlanInlinePreview plan={batchPlanResult.plan} messageId={`streaming-${Date.now()}`} />
<BatchPlanInlinePreview plan={batchPlanResult.plan} messageId="streaming-preview" />
{batchPlanResult.afterText && <MessageResponse>{batchPlanResult.afterText}</MessageResponse>}
</>
);
@@ -659,30 +273,15 @@ export function StreamingMessage({
return stripped ? <MessageResponse>{stripped}</MessageResponse> : null;
})()}
{/* Permission approval — AskUserQuestion rendered after text so it stays in view */}
{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>
)}
{/* Loading indicator when no content yet */}
{isStreaming && !content && toolUses.length === 0 && !pendingPermission && (
{isStreaming && !content && toolUses.length === 0 && (
<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} />}
{/* Status bar during streaming */}
{isStreaming && <StreamingStatusBar statusText={statusText || getRunningCommandSummary()} onForceStop={onForceStop} />}
</MessageContent>
</AIMessage>
);
+1 -1
View File
@@ -578,7 +578,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Electron draggable title bar region — matches side panels' mt-5 */}
<div
className="h-5 w-full shrink-0"
className="h-10 w-full shrink-0"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
/>
<UpdateBanner />
+1 -1
View File
@@ -50,7 +50,7 @@ export function RightPanel({ width }: RightPanelProps) {
if (!panelOpen) {
return (
<div className="flex flex-col items-center gap-2 bg-background p-2">
<div className="flex flex-col items-center gap-2 bg-background p-2 mt-5 border-l border-border/40">
<Tooltip>
<TooltipTrigger asChild>
<Button
+14
View File
@@ -1,6 +1,7 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -42,6 +43,7 @@ export function UpdateDialog() {
{updateInfo.releaseNotes && (
<div className="max-h-60 overflow-auto rounded-md border border-border/50 bg-muted/30 p-3 text-sm">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => <h3 className="mb-1 text-sm font-semibold">{children}</h3>,
h2: ({ children }) => <h3 className="mb-1 text-sm font-semibold">{children}</h3>,
@@ -58,6 +60,18 @@ export function UpdateDialog() {
code: ({ children }) => (
<code className="rounded bg-muted px-1 py-0.5 text-xs">{children}</code>
),
table: ({ children }) => (
<table className="mb-2 w-full border-collapse text-xs">{children}</table>
),
thead: ({ children }) => (
<thead className="border-b border-border">{children}</thead>
),
th: ({ children }) => (
<th className="px-2 py-1 text-left font-medium">{children}</th>
),
td: ({ children }) => (
<td className="px-2 py-1 border-t border-border/30">{children}</td>
),
}}
>
{updateInfo.releaseNotes}
@@ -320,12 +320,11 @@ export function AssistantWorkspaceSection() {
const handleSelectFolder = useCallback(async () => {
try {
const w = window as unknown as { electronAPI?: { selectFolder?: () => Promise<string | null> } };
if (w.electronAPI?.selectFolder) {
const selected = await w.electronAPI.selectFolder();
if (selected) {
setPathInput(selected);
validatePath(selected);
if (window.electronAPI?.dialog?.openFolder) {
const result = await window.electronAPI.dialog.openFolder({ title: t('assistant.selectFolder') });
if (!result.canceled && result.filePaths[0]) {
setPathInput(result.filePaths[0]);
validatePath(result.filePaths[0]);
}
} else {
const input = prompt("Enter workspace directory path:");
@@ -337,7 +336,7 @@ export function AssistantWorkspaceSection() {
} catch (e) {
console.error("Failed to select folder:", e);
}
}, [validatePath]);
}, [validatePath, t]);
const handleRefreshDocs = useCallback(async () => {
setRefreshingDocs(true);