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:
1 parent
a53c60531f
commit
5eedb5bacc
9 files changed
+479
-429
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user