mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-10 06:28:18 +00:00
refactor: add primitives, plan mode display, and new session model selector
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import React, { memo, useMemo, useCallback } from 'react';
|
||||
|
||||
import type { Project } from '../../../types/app';
|
||||
import type { SubagentChildTool } from '../types/types';
|
||||
|
||||
import { getToolConfig } from './configs/toolConfigs';
|
||||
import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
|
||||
import { PlanDisplay } from './components/PlanDisplay';
|
||||
|
||||
type DiffLine = {
|
||||
type: string;
|
||||
@@ -119,6 +122,33 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
||||
);
|
||||
}
|
||||
|
||||
if (displayConfig.type === 'plan') {
|
||||
const title = typeof displayConfig.title === 'function'
|
||||
? displayConfig.title(parsedData)
|
||||
: displayConfig.title || 'Plan';
|
||||
|
||||
const contentProps = displayConfig.getContentProps?.(parsedData, {
|
||||
selectedProject,
|
||||
createDiff,
|
||||
onFileOpen
|
||||
}) || {};
|
||||
|
||||
const isStreaming = mode === 'input' && !toolResult;
|
||||
|
||||
return (
|
||||
<PlanDisplay
|
||||
title={title}
|
||||
content={contentProps.content || ''}
|
||||
defaultOpen={displayConfig.defaultOpen ?? autoExpandTools}
|
||||
isStreaming={isStreaming}
|
||||
showRawParameters={mode === 'input' && showRawParameters}
|
||||
rawContent={rawToolInput}
|
||||
toolName={toolName}
|
||||
toolId={toolId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayConfig.type === 'collapsible') {
|
||||
const title = typeof displayConfig.title === 'function'
|
||||
? displayConfig.title(parsedData)
|
||||
|
||||
135
src/components/chat/tools/components/PlanDisplay.tsx
Normal file
135
src/components/chat/tools/components/PlanDisplay.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import { ChevronsUpDown, FileText } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
Button,
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
Shimmer,
|
||||
} from '../../../../shared/view/ui';
|
||||
import { usePermission } from '../../../../contexts/PermissionContext';
|
||||
|
||||
import { MarkdownContent } from './ContentRenderers';
|
||||
|
||||
interface PlanDisplayProps {
|
||||
title: string;
|
||||
content: string;
|
||||
defaultOpen?: boolean;
|
||||
isStreaming?: boolean;
|
||||
showRawParameters?: boolean;
|
||||
rawContent?: string;
|
||||
toolName: string;
|
||||
toolId?: string;
|
||||
}
|
||||
|
||||
export const PlanDisplay: React.FC<PlanDisplayProps> = ({
|
||||
title,
|
||||
content,
|
||||
defaultOpen = false,
|
||||
isStreaming = false,
|
||||
showRawParameters = false,
|
||||
rawContent,
|
||||
toolName: _toolName,
|
||||
}) => {
|
||||
const permissionCtx = usePermission();
|
||||
|
||||
const pendingRequest = permissionCtx?.pendingPermissionRequests.find(
|
||||
(r) => r.toolName === 'ExitPlanMode' || r.toolName === 'exit_plan_mode'
|
||||
);
|
||||
|
||||
const handleBuild = () => {
|
||||
if (pendingRequest && permissionCtx) {
|
||||
permissionCtx.handlePermissionDecision(pendingRequest.requestId, { allow: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevise = () => {
|
||||
if (pendingRequest && permissionCtx) {
|
||||
permissionCtx.handlePermissionDecision(pendingRequest.requestId, {
|
||||
allow: false,
|
||||
message: 'User asked to revise the plan',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible defaultOpen={defaultOpen}>
|
||||
<Card className="my-1 flex flex-col shadow-none">
|
||||
{/* Header — always visible */}
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 px-4 pb-0 pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-semibold">
|
||||
{isStreaming ? <Shimmer>{title}</Shimmer> : title}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CollapsibleTrigger className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground">
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
<span className="sr-only">Toggle plan</span>
|
||||
</CollapsibleTrigger>
|
||||
</CardHeader>
|
||||
|
||||
{/* Collapsible content */}
|
||||
<CollapsibleContent>
|
||||
<CardContent className="px-4 pb-4 pt-3">
|
||||
{content ? (
|
||||
<MarkdownContent
|
||||
content={content}
|
||||
className="prose prose-sm max-w-none dark:prose-invert"
|
||||
/>
|
||||
) : isStreaming ? (
|
||||
<div className="py-2">
|
||||
<Shimmer>Generating plan...</Shimmer>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showRawParameters && rawContent && (
|
||||
<details className="group/raw relative mt-3">
|
||||
<summary className="flex cursor-pointer items-center gap-1.5 py-0.5 text-[11px] text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300">
|
||||
<svg
|
||||
className="h-2.5 w-2.5 transition-transform duration-150 group-open/raw:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
raw params
|
||||
</summary>
|
||||
<pre className="mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-gray-200/40 bg-gray-50 p-2 font-mono text-[11px] text-gray-600 dark:border-gray-700/40 dark:bg-gray-900/50 dark:text-gray-400">
|
||||
{rawContent}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
|
||||
{/* Footer — always visible when permission is pending */}
|
||||
{pendingRequest && (
|
||||
<CardFooter className="justify-end gap-2 border-t border-border/40 px-4 pb-3 pt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRevise}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Revise
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleBuild}>
|
||||
Build{' '}
|
||||
<kbd className="ml-1 rounded bg-primary-foreground/20 px-1 py-0.5 font-mono text-[10px]">
|
||||
⌘↩
|
||||
</kbd>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
export interface ToolDisplayConfig {
|
||||
input: {
|
||||
type: 'one-line' | 'collapsible' | 'hidden';
|
||||
type: 'one-line' | 'collapsible' | 'plan' | 'hidden';
|
||||
// One-line config
|
||||
icon?: string;
|
||||
label?: string;
|
||||
@@ -31,7 +31,7 @@ export interface ToolDisplayConfig {
|
||||
result?: {
|
||||
hidden?: boolean;
|
||||
hideOnSuccess?: boolean;
|
||||
type?: 'one-line' | 'collapsible' | 'special';
|
||||
type?: 'one-line' | 'collapsible' | 'plan' | 'special';
|
||||
title?: string | ((result: any) => string);
|
||||
defaultOpen?: boolean;
|
||||
// Special result handlers
|
||||
@@ -494,7 +494,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
|
||||
|
||||
exit_plan_mode: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
type: 'plan',
|
||||
title: 'Implementation plan',
|
||||
defaultOpen: true,
|
||||
contentType: 'markdown',
|
||||
@@ -503,29 +503,14 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
|
||||
})
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
contentType: 'markdown',
|
||||
getContentProps: (result) => {
|
||||
try {
|
||||
let parsed = result.content;
|
||||
if (typeof parsed === 'string') {
|
||||
parsed = JSON.parse(parsed);
|
||||
}
|
||||
return {
|
||||
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse plan content:', e);
|
||||
return { content: '' };
|
||||
}
|
||||
}
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
|
||||
// Also register as ExitPlanMode (the actual tool name used by Claude)
|
||||
ExitPlanMode: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
type: 'plan',
|
||||
title: 'Implementation plan',
|
||||
defaultOpen: true,
|
||||
contentType: 'markdown',
|
||||
@@ -534,22 +519,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
|
||||
})
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
contentType: 'markdown',
|
||||
getContentProps: (result) => {
|
||||
try {
|
||||
let parsed = result.content;
|
||||
if (typeof parsed === 'string') {
|
||||
parsed = JSON.parse(parsed);
|
||||
}
|
||||
return {
|
||||
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse plan content:', e);
|
||||
return { content: '' };
|
||||
}
|
||||
}
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||
import PermissionContext from '../../../contexts/PermissionContext';
|
||||
import { QuickSettingsPanel } from '../../quick-settings-panel';
|
||||
import type { ChatInterfaceProps, Provider } from '../types/types';
|
||||
import type { LLMProvider } from '../../../types/app';
|
||||
@@ -9,6 +11,7 @@ import { useChatSessionState } from '../hooks/useChatSessionState';
|
||||
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
|
||||
import { useChatComposerState } from '../hooks/useChatComposerState';
|
||||
import { useSessionStore } from '../../../stores/useSessionStore';
|
||||
|
||||
import ChatMessagesPane from './subcomponents/ChatMessagesPane';
|
||||
import ChatComposer from './subcomponents/ChatComposer';
|
||||
|
||||
@@ -267,6 +270,11 @@ function ChatInterface({
|
||||
};
|
||||
}, [resetStreamingState]);
|
||||
|
||||
const permissionContextValue = useMemo(() => ({
|
||||
pendingPermissionRequests,
|
||||
handlePermissionDecision,
|
||||
}), [pendingPermissionRequests, handlePermissionDecision]);
|
||||
|
||||
if (!selectedProject) {
|
||||
const selectedProviderLabel =
|
||||
provider === 'cursor'
|
||||
@@ -292,7 +300,7 @@ function ChatInterface({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PermissionContext.Provider value={permissionContextValue}>
|
||||
<div className="flex h-full flex-col">
|
||||
<ChatMessagesPane
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
@@ -410,7 +418,7 @@ function ChatInterface({
|
||||
</div>
|
||||
|
||||
<QuickSettingsPanel />
|
||||
</>
|
||||
</PermissionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { formatUsageLimitText } from '../../utils/chatFormatting';
|
||||
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
|
||||
import type { Project } from '../../../../types/app';
|
||||
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
|
||||
import { Markdown } from './Markdown';
|
||||
import MessageCopyControl from './MessageCopyControl';
|
||||
|
||||
@@ -68,7 +69,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
const shouldShowUserCopyControl = message.type === 'user' && userCopyContent.trim().length > 0;
|
||||
const shouldShowAssistantCopyControl = message.type === 'assistant' &&
|
||||
assistantCopyContent.trim().length > 0 &&
|
||||
!isCommandOrFileEditToolResponse;
|
||||
!isCommandOrFileEditToolResponse &&
|
||||
!message.isThinking;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -378,36 +380,30 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
</div>
|
||||
</div>
|
||||
) : message.isThinking ? (
|
||||
/* Thinking messages - collapsible by default */
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer items-center gap-2 font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<svg className="h-3 w-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span>{t('thinking.emoji')}</span>
|
||||
</summary>
|
||||
<div className="mt-2 border-l-2 border-gray-300 pl-4 text-sm text-gray-600 dark:border-gray-600 dark:text-gray-400">
|
||||
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
|
||||
{message.content}
|
||||
</Markdown>
|
||||
/* Thinking messages — Reasoning component (ai-elements pattern) */
|
||||
<Reasoning defaultOpen={false}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
|
||||
{message.content}
|
||||
</Markdown>
|
||||
<div className="mt-3 flex items-center text-[11px]">
|
||||
<MessageCopyControl content={String(message.content || '')} messageType="assistant" />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
) : (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{/* Thinking accordion for reasoning */}
|
||||
{/* Reasoning accordion */}
|
||||
{showThinking && message.reasoning && (
|
||||
<details className="mb-3">
|
||||
<summary className="cursor-pointer font-medium text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
{t('thinking.emoji')}
|
||||
</summary>
|
||||
<div className="mt-2 border-l-2 border-gray-300 pl-4 text-sm italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
|
||||
<Reasoning className="mb-3" defaultOpen={false}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
<div className="whitespace-pre-wrap">
|
||||
{message.reasoning}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import React from 'react';
|
||||
import { ShieldAlertIcon } from 'lucide-react';
|
||||
|
||||
import type { PendingPermissionRequest } from '../../types/types';
|
||||
import { buildClaudeToolPermissionEntry, formatToolInputForDisplay } from '../../utils/chatPermissions';
|
||||
import { getClaudeSettings } from '../../utils/chatStorage';
|
||||
import { getPermissionPanel, registerPermissionPanel } from '../../tools/configs/permissionPanelRegistry';
|
||||
import { AskUserQuestionPanel } from '../../tools/components/InteractiveRenderers';
|
||||
import {
|
||||
Confirmation,
|
||||
ConfirmationTitle,
|
||||
ConfirmationRequest,
|
||||
ConfirmationActions,
|
||||
ConfirmationAction,
|
||||
} from '../../../../shared/view/ui';
|
||||
|
||||
registerPermissionPanel('AskUserQuestion', AskUserQuestionPanel);
|
||||
|
||||
@@ -21,13 +30,18 @@ export default function PermissionRequestsBanner({
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
}: PermissionRequestsBannerProps) {
|
||||
if (!pendingPermissionRequests.length) {
|
||||
// Filter out plan tool requests — they are handled inline by PlanDisplay
|
||||
const filteredRequests = pendingPermissionRequests.filter(
|
||||
(r) => r.toolName !== 'ExitPlanMode' && r.toolName !== 'exit_plan_mode'
|
||||
);
|
||||
|
||||
if (!filteredRequests.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-3 space-y-2">
|
||||
{pendingPermissionRequests.map((request) => {
|
||||
{filteredRequests.map((request) => {
|
||||
const CustomPanel = getPermissionPanel(request.toolName);
|
||||
if (CustomPanel) {
|
||||
return (
|
||||
@@ -54,69 +68,61 @@ export default function PermissionRequestsBanner({
|
||||
: [request.requestId];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={request.requestId}
|
||||
className="rounded-lg border border-amber-200 bg-amber-50 p-3 shadow-sm dark:border-amber-800 dark:bg-amber-900/20"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-amber-900 dark:text-amber-100">Permission required</div>
|
||||
<div className="text-xs text-amber-800 dark:text-amber-200">
|
||||
Tool: <span className="font-mono">{request.toolName}</span>
|
||||
<Confirmation key={request.requestId} approval="pending">
|
||||
<ConfirmationTitle className="flex items-start gap-3">
|
||||
<ShieldAlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<ConfirmationRequest>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Permission required</span>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
Tool: <code className="rounded bg-muted px-1.5 py-0.5 text-xs">{request.toolName}</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{permissionEntry && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-300">
|
||||
Allow rule: <span className="font-mono">{permissionEntry}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{permissionEntry && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Allow rule: <code className="rounded bg-muted px-1 py-0.5 text-xs">{permissionEntry}</code>
|
||||
</div>
|
||||
)}
|
||||
{rawInput && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
|
||||
View tool input
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md border bg-muted/50 p-2 text-xs text-muted-foreground">
|
||||
{rawInput}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</ConfirmationRequest>
|
||||
</ConfirmationTitle>
|
||||
|
||||
{rawInput && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-amber-800 hover:text-amber-900 dark:text-amber-200 dark:hover:text-amber-100">
|
||||
View tool input
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md border border-amber-200/60 bg-white/80 p-2 text-xs text-amber-900 dark:border-amber-800/60 dark:bg-gray-900/60 dark:text-amber-100">
|
||||
{rawInput}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePermissionDecision(request.requestId, { allow: true })}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-amber-700"
|
||||
<ConfirmationActions>
|
||||
<ConfirmationAction
|
||||
variant="outline"
|
||||
onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
|
||||
>
|
||||
Allow once
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
Deny
|
||||
</ConfirmationAction>
|
||||
<ConfirmationAction
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (permissionEntry && !alreadyAllowed) {
|
||||
handleGrantToolPermission({ entry: permissionEntry, toolName: request.toolName });
|
||||
}
|
||||
handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry });
|
||||
}}
|
||||
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
permissionEntry
|
||||
? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30'
|
||||
: 'cursor-not-allowed border-gray-300 text-gray-400'
|
||||
}`}
|
||||
disabled={!permissionEntry}
|
||||
>
|
||||
{rememberLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-red-300 px-3 py-1.5 text-xs font-medium text-red-700 transition-colors hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30"
|
||||
</ConfirmationAction>
|
||||
<ConfirmationAction
|
||||
variant="default"
|
||||
onClick={() => handlePermissionDecision(request.requestId, { allow: true })}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
Allow once
|
||||
</ConfirmationAction>
|
||||
</ConfirmationActions>
|
||||
</Confirmation>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
|
||||
import {
|
||||
CLAUDE_MODELS,
|
||||
@@ -10,6 +11,19 @@ import {
|
||||
} from "../../../../../shared/modelConstants";
|
||||
import type { ProjectSession, LLMProvider } from "../../../../types/app";
|
||||
import { NextTaskBanner } from "../../../task-master";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
Card,
|
||||
} from "../../../../shared/view/ui";
|
||||
|
||||
type ProviderSelectionEmptyStateProps = {
|
||||
selectedSession: ProjectSession | null;
|
||||
@@ -31,48 +45,17 @@ type ProviderSelectionEmptyStateProps = {
|
||||
setInput: React.Dispatch<React.SetStateAction<string>>;
|
||||
};
|
||||
|
||||
type ProviderDef = {
|
||||
interface ProviderGroup {
|
||||
id: LLMProvider;
|
||||
name: string;
|
||||
infoKey: string;
|
||||
accent: string;
|
||||
ring: string;
|
||||
check: string;
|
||||
};
|
||||
models: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
const PROVIDERS: ProviderDef[] = [
|
||||
{
|
||||
id: "claude",
|
||||
name: "Claude Code",
|
||||
infoKey: "providerSelection.providerInfo.anthropic",
|
||||
accent: "border-primary",
|
||||
ring: "ring-primary/15",
|
||||
check: "bg-primary text-primary-foreground",
|
||||
},
|
||||
{
|
||||
id: "cursor",
|
||||
name: "Cursor",
|
||||
infoKey: "providerSelection.providerInfo.cursorEditor",
|
||||
accent: "border-violet-500 dark:border-violet-400",
|
||||
ring: "ring-violet-500/15",
|
||||
check: "bg-violet-500 text-white",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
infoKey: "providerSelection.providerInfo.openai",
|
||||
accent: "border-emerald-600 dark:border-emerald-400",
|
||||
ring: "ring-emerald-600/15",
|
||||
check: "bg-emerald-600 dark:bg-emerald-500 text-white",
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
name: "Gemini",
|
||||
infoKey: "providerSelection.providerInfo.google",
|
||||
accent: "border-blue-500 dark:border-blue-400",
|
||||
ring: "ring-blue-500/15",
|
||||
check: "bg-blue-500 text-white",
|
||||
},
|
||||
const PROVIDER_GROUPS: ProviderGroup[] = [
|
||||
{ id: "claude", name: "Anthropic", models: CLAUDE_MODELS.OPTIONS },
|
||||
{ id: "cursor", name: "Cursor", models: CURSOR_MODELS.OPTIONS },
|
||||
{ id: "codex", name: "OpenAI", models: CODEX_MODELS.OPTIONS },
|
||||
{ id: "gemini", name: "Google", models: GEMINI_MODELS.OPTIONS },
|
||||
];
|
||||
|
||||
function getModelConfig(p: LLMProvider) {
|
||||
@@ -82,7 +65,7 @@ function getModelConfig(p: LLMProvider) {
|
||||
return CURSOR_MODELS;
|
||||
}
|
||||
|
||||
function getModelValue(
|
||||
function getCurrentModel(
|
||||
p: LLMProvider,
|
||||
c: string,
|
||||
cu: string,
|
||||
@@ -95,6 +78,13 @@ function getModelValue(
|
||||
return cu;
|
||||
}
|
||||
|
||||
function getProviderDisplayName(p: LLMProvider) {
|
||||
if (p === "claude") return "Claude";
|
||||
if (p === "cursor") return "Cursor";
|
||||
if (p === "codex") return "Codex";
|
||||
return "Gemini";
|
||||
}
|
||||
|
||||
export default function ProviderSelectionEmptyState({
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
@@ -115,34 +105,12 @@ export default function ProviderSelectionEmptyState({
|
||||
setInput,
|
||||
}: ProviderSelectionEmptyStateProps) {
|
||||
const { t } = useTranslation("chat");
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const nextTaskPrompt = t("tasks.nextTaskPrompt", {
|
||||
defaultValue: "Start the next task",
|
||||
});
|
||||
|
||||
const selectProvider = (next: LLMProvider) => {
|
||||
setProvider(next);
|
||||
localStorage.setItem("selected-provider", next);
|
||||
setTimeout(() => textareaRef.current?.focus(), 100);
|
||||
};
|
||||
|
||||
const handleModelChange = (value: string) => {
|
||||
if (provider === "claude") {
|
||||
setClaudeModel(value);
|
||||
localStorage.setItem("claude-model", value);
|
||||
} else if (provider === "codex") {
|
||||
setCodexModel(value);
|
||||
localStorage.setItem("codex-model", value);
|
||||
} else if (provider === "gemini") {
|
||||
setGeminiModel(value);
|
||||
localStorage.setItem("gemini-model", value);
|
||||
} else {
|
||||
setCursorModel(value);
|
||||
localStorage.setItem("cursor-model", value);
|
||||
}
|
||||
};
|
||||
|
||||
const modelConfig = getModelConfig(provider);
|
||||
const currentModel = getModelValue(
|
||||
const currentModel = getCurrentModel(
|
||||
provider,
|
||||
claudeModel,
|
||||
cursorModel,
|
||||
@@ -150,7 +118,42 @@ export default function ProviderSelectionEmptyState({
|
||||
geminiModel,
|
||||
);
|
||||
|
||||
/* ── New session — provider picker ── */
|
||||
const currentModelLabel = useMemo(() => {
|
||||
const config = getModelConfig(provider);
|
||||
const found = config.OPTIONS.find(
|
||||
(o: { value: string; label: string }) => o.value === currentModel,
|
||||
);
|
||||
return found?.label || currentModel;
|
||||
}, [provider, currentModel]);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(providerId: LLMProvider, modelValue: string) => {
|
||||
// Set provider
|
||||
setProvider(providerId);
|
||||
localStorage.setItem("selected-provider", providerId);
|
||||
|
||||
// Set model for the correct provider
|
||||
if (providerId === "claude") {
|
||||
setClaudeModel(modelValue);
|
||||
localStorage.setItem("claude-model", modelValue);
|
||||
} else if (providerId === "codex") {
|
||||
setCodexModel(modelValue);
|
||||
localStorage.setItem("codex-model", modelValue);
|
||||
} else if (providerId === "gemini") {
|
||||
setGeminiModel(modelValue);
|
||||
localStorage.setItem("gemini-model", modelValue);
|
||||
} else {
|
||||
setCursorModel(modelValue);
|
||||
localStorage.setItem("cursor-model", modelValue);
|
||||
}
|
||||
|
||||
setDialogOpen(false);
|
||||
setTimeout(() => textareaRef.current?.focus(), 100);
|
||||
},
|
||||
[setProvider, setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, textareaRef],
|
||||
);
|
||||
|
||||
/* ── New session — provider + model picker ── */
|
||||
if (!selectedSession && !currentSessionId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-4">
|
||||
@@ -165,96 +168,100 @@ export default function ProviderSelectionEmptyState({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Provider cards — horizontal row, equal width */}
|
||||
<div className="mb-6 grid grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-2.5">
|
||||
{PROVIDERS.map((p) => {
|
||||
const active = provider === p.id;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => selectProvider(p.id)}
|
||||
className={`
|
||||
relative flex flex-col items-center gap-2.5 rounded-xl border-[1.5px] px-2
|
||||
pb-4 pt-5 transition-all duration-150
|
||||
active:scale-[0.97]
|
||||
${
|
||||
active
|
||||
? `${p.accent} ${p.ring} bg-card shadow-sm ring-2`
|
||||
: "border-border bg-card/60 hover:border-border/80 hover:bg-card"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Model selector trigger — hero card style */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Card
|
||||
className="group mx-auto max-w-sm cursor-pointer border-border/60 transition-all duration-150 hover:border-border hover:shadow-md active:scale-[0.99]"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<SessionProviderLogo
|
||||
provider={p.id}
|
||||
className={`h-9 w-9 transition-transform duration-150 ${active ? "scale-110" : ""}`}
|
||||
provider={provider}
|
||||
className="h-8 w-8 shrink-0"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<p className="text-[13px] font-semibold leading-none text-foreground">
|
||||
{p.name}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] leading-tight text-muted-foreground">
|
||||
{t(p.infoKey)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{getProviderDisplayName(provider)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="truncate text-sm text-foreground">
|
||||
{currentModelLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("providerSelection.clickToChange", {
|
||||
defaultValue: "Click to change model",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{/* Check badge */}
|
||||
{active && (
|
||||
<div
|
||||
className={`absolute -right-1 -top-1 h-[18px] w-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-y-0.5" />
|
||||
</div>
|
||||
</Card>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-md overflow-hidden p-0">
|
||||
<DialogTitle>Model Selector</DialogTitle>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("providerSelection.searchModels", { defaultValue: "Search models..." })} />
|
||||
<CommandList className="max-h-[350px]">
|
||||
<CommandEmpty>
|
||||
{t("providerSelection.noModelsFound", { defaultValue: "No models found." })}
|
||||
</CommandEmpty>
|
||||
{PROVIDER_GROUPS.map((group) => (
|
||||
<CommandGroup
|
||||
key={group.id}
|
||||
heading={
|
||||
<span className="flex items-center gap-1.5">
|
||||
<SessionProviderLogo provider={group.id} className="h-3.5 w-3.5 shrink-0" />
|
||||
{group.name}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Check className="h-2.5 w-2.5" strokeWidth={3} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{group.models.map((model) => {
|
||||
const isSelected =
|
||||
provider === group.id && currentModel === model.value;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`${group.id}-${model.value}`}
|
||||
value={`${group.name} ${model.label}`}
|
||||
onSelect={() => handleModelSelect(group.id, model.value)}
|
||||
>
|
||||
<span className="flex-1 truncate">{model.label}</span>
|
||||
{isSelected && (
|
||||
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Model picker — appears after provider is chosen */}
|
||||
<div
|
||||
className={`transition-all duration-200 ${provider ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-1 opacity-0"}`}
|
||||
>
|
||||
<div className="mb-5 flex items-center justify-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("providerSelection.selectModel")}
|
||||
</span>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={currentModel}
|
||||
onChange={(e) => handleModelChange(e.target.value)}
|
||||
tabIndex={-1}
|
||||
className="cursor-pointer appearance-none rounded-lg border border-border/60 bg-muted/50 py-1.5 pl-3 pr-7 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
>
|
||||
{modelConfig.OPTIONS.map(
|
||||
({ value, label }: { value: string; label: string }) => (
|
||||
<option key={value + label} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground/70">
|
||||
{/* Ready prompt */}
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground/70">
|
||||
{
|
||||
{
|
||||
{
|
||||
claude: t("providerSelection.readyPrompt.claude", {
|
||||
model: claudeModel,
|
||||
}),
|
||||
cursor: t("providerSelection.readyPrompt.cursor", {
|
||||
model: cursorModel,
|
||||
}),
|
||||
codex: t("providerSelection.readyPrompt.codex", {
|
||||
model: codexModel,
|
||||
}),
|
||||
gemini: t("providerSelection.readyPrompt.gemini", {
|
||||
model: geminiModel,
|
||||
}),
|
||||
}[provider]
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
claude: t("providerSelection.readyPrompt.claude", {
|
||||
model: claudeModel,
|
||||
}),
|
||||
cursor: t("providerSelection.readyPrompt.cursor", {
|
||||
model: cursorModel,
|
||||
}),
|
||||
codex: t("providerSelection.readyPrompt.codex", {
|
||||
model: codexModel,
|
||||
}),
|
||||
gemini: t("providerSelection.readyPrompt.gemini", {
|
||||
model: geminiModel,
|
||||
}),
|
||||
}[provider]
|
||||
}
|
||||
</p>
|
||||
|
||||
{/* Task banner */}
|
||||
{provider && tasksEnabled && isTaskMasterInstalled && (
|
||||
|
||||
Reference in New Issue
Block a user