From 7763e60fb32e34742058c055c57664a503a34d1d Mon Sep 17 00:00:00 2001 From: simosmik Date: Mon, 20 Apr 2026 12:47:55 +0000 Subject: [PATCH 1/8] refactor: add primitives, plan mode display, and new session model selector --- src/components/chat/tools/ToolRenderer.tsx | 30 ++ .../chat/tools/components/PlanDisplay.tsx | 135 ++++++++ .../chat/tools/configs/toolConfigs.ts | 42 +-- src/components/chat/view/ChatInterface.tsx | 14 +- .../view/subcomponents/MessageComponent.tsx | 44 ++- .../PermissionRequestsBanner.tsx | 110 +++--- .../ProviderSelectionEmptyState.tsx | 305 +++++++++-------- src/contexts/PermissionContext.tsx | 19 ++ src/shared/view/ui/Alert.tsx | 64 ++++ src/shared/view/ui/Badge.tsx | 1 + src/shared/view/ui/Button.tsx | 3 +- src/shared/view/ui/Card.tsx | 78 +++++ src/shared/view/ui/Collapsible.tsx | 103 ++++++ src/shared/view/ui/Command.tsx | 320 ++++++++++++++++++ src/shared/view/ui/Confirmation.tsx | 139 ++++++++ src/shared/view/ui/DarkModeToggle.tsx | 1 + src/shared/view/ui/Dialog.tsx | 217 ++++++++++++ src/shared/view/ui/Input.tsx | 1 + src/shared/view/ui/LanguageSelector.tsx | 1 + src/shared/view/ui/PillBar.tsx | 1 + src/shared/view/ui/Reasoning.tsx | 198 +++++++++++ src/shared/view/ui/ScrollArea.tsx | 1 + src/shared/view/ui/Shimmer.tsx | 26 ++ src/shared/view/ui/Tooltip.tsx | 1 + src/shared/view/ui/index.ts | 8 + tailwind.config.js | 19 ++ 26 files changed, 1616 insertions(+), 265 deletions(-) create mode 100644 src/components/chat/tools/components/PlanDisplay.tsx create mode 100644 src/contexts/PermissionContext.tsx create mode 100644 src/shared/view/ui/Alert.tsx create mode 100644 src/shared/view/ui/Card.tsx create mode 100644 src/shared/view/ui/Collapsible.tsx create mode 100644 src/shared/view/ui/Command.tsx create mode 100644 src/shared/view/ui/Confirmation.tsx create mode 100644 src/shared/view/ui/Dialog.tsx create mode 100644 src/shared/view/ui/Reasoning.tsx create mode 100644 src/shared/view/ui/Shimmer.tsx diff --git a/src/components/chat/tools/ToolRenderer.tsx b/src/components/chat/tools/ToolRenderer.tsx index 26524c51..7023a1ac 100644 --- a/src/components/chat/tools/ToolRenderer.tsx +++ b/src/components/chat/tools/ToolRenderer.tsx @@ -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 = 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 ( + + ); + } + if (displayConfig.type === 'collapsible') { const title = typeof displayConfig.title === 'function' ? displayConfig.title(parsedData) diff --git a/src/components/chat/tools/components/PlanDisplay.tsx b/src/components/chat/tools/components/PlanDisplay.tsx new file mode 100644 index 00000000..22477555 --- /dev/null +++ b/src/components/chat/tools/components/PlanDisplay.tsx @@ -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 = ({ + 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 ( + + + {/* Header — always visible */} + +
+ + + {isStreaming ? {title} : title} + +
+ + + Toggle plan + +
+ + {/* Collapsible content */} + + + {content ? ( + + ) : isStreaming ? ( +
+ Generating plan... +
+ ) : null} + + {showRawParameters && rawContent && ( +
+ + + + + raw params + +
+                  {rawContent}
+                
+
+ )} +
+
+ + {/* Footer — always visible when permission is pending */} + {pendingRequest && ( + + + + + )} +
+
+ ); +}; diff --git a/src/components/chat/tools/configs/toolConfigs.ts b/src/components/chat/tools/configs/toolConfigs.ts index 40dd3dbc..fbb3f2d4 100644 --- a/src/components/chat/tools/configs/toolConfigs.ts +++ b/src/components/chat/tools/configs/toolConfigs.ts @@ -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 = { exit_plan_mode: { input: { - type: 'collapsible', + type: 'plan', title: 'Implementation plan', defaultOpen: true, contentType: 'markdown', @@ -503,29 +503,14 @@ export const TOOL_CONFIGS: Record = { }) }, 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 = { }) }, 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 } }, diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 44709502..191b6294 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -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 ( - <> +
- + ); } diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index 75a6f69c..1b678a57 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -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
) : message.isThinking ? ( - /* Thinking messages - collapsible by default */ -
-
- - - - - {t('thinking.emoji')} - -
- - {message.content} - + /* Thinking messages — Reasoning component (ai-elements pattern) */ + + + + + {message.content} + +
+
-
-
+ + ) : (
- {/* Thinking accordion for reasoning */} + {/* Reasoning accordion */} {showThinking && message.reasoning && ( -
- - {t('thinking.emoji')} - -
+ + +
{message.reasoning}
-
-
+ + )} {(() => { diff --git a/src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx b/src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx index 7171edd5..dc1e6f84 100644 --- a/src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx +++ b/src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx @@ -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 (
- {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 ( -
-
-
-
Permission required
-
- Tool: {request.toolName} + + + + +
+ Permission required + + Tool: {request.toolName} +
-
- {permissionEntry && ( -
- Allow rule: {permissionEntry} -
- )} -
+ {permissionEntry && ( +
+ Allow rule: {permissionEntry} +
+ )} + {rawInput && ( +
+ + View tool input + +
+                      {rawInput}
+                    
+
+ )} + + - {rawInput && ( -
- - View tool input - -
-                  {rawInput}
-                
-
- )} - -
- - - -
-
+ Allow once + + + ); })}
diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx index 9eaf690e..f328f72e 100644 --- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx +++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx @@ -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>; }; -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 (
@@ -165,96 +168,100 @@ export default function ProviderSelectionEmptyState({

- {/* Provider cards — horizontal row, equal width */} -
- {PROVIDERS.map((p) => { - const active = provider === p.id; - return ( - - ); - })} -
+ {group.models.map((model) => { + const isSelected = + provider === group.id && currentModel === model.value; + return ( + handleModelSelect(group.id, model.value)} + > + {model.label} + {isSelected && ( + + )} + + ); + })} + + ))} + + + + - {/* Model picker — appears after provider is chosen */} -
-
- - {t("providerSelection.selectModel")} - -
- - -
-
- -

+ {/* Ready prompt */} +

+ { { - { - 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] - } -

-
+ 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] + } +

{/* Task banner */} {provider && tasksEnabled && isTaskMasterInstalled && ( diff --git a/src/contexts/PermissionContext.tsx b/src/contexts/PermissionContext.tsx new file mode 100644 index 00000000..344b573a --- /dev/null +++ b/src/contexts/PermissionContext.tsx @@ -0,0 +1,19 @@ +import { createContext, useContext } from 'react'; + +import type { PendingPermissionRequest } from '../components/chat/types/types'; + +export interface PermissionContextValue { + pendingPermissionRequests: PendingPermissionRequest[]; + handlePermissionDecision: ( + requestIds: string | string[], + decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown }, + ) => void; +} + +const PermissionContext = createContext(null); + +export function usePermission(): PermissionContextValue | null { + return useContext(PermissionContext); +} + +export default PermissionContext; diff --git a/src/shared/view/ui/Alert.tsx b/src/shared/view/ui/Alert.tsx new file mode 100644 index 00000000..79cfe1b3 --- /dev/null +++ b/src/shared/view/ui/Alert.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '../../../lib/utils'; + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', + { + variants: { + variant: { + default: 'bg-card text-card-foreground', + destructive: + 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +type AlertProps = React.HTMLAttributes & VariantProps; + +const Alert = React.forwardRef( + ({ className, variant, ...props }, ref) => ( +
+ ) +); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertTitle, AlertDescription, alertVariants }; diff --git a/src/shared/view/ui/Badge.tsx b/src/shared/view/ui/Badge.tsx index 9eb1264a..837c96fa 100644 --- a/src/shared/view/ui/Badge.tsx +++ b/src/shared/view/ui/Badge.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; + import { cn } from '../../../lib/utils'; const badgeVariants = cva( diff --git a/src/shared/view/ui/Button.tsx b/src/shared/view/ui/Button.tsx index 2acc93a9..b3aaf1c7 100644 --- a/src/shared/view/ui/Button.tsx +++ b/src/shared/view/ui/Button.tsx @@ -1,10 +1,11 @@ import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; + import { cn } from '../../../lib/utils'; // Keep visual variants centralized so all button usages stay consistent. const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium touch-manipulation transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + 'inline-flex touch-manipulation items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', { variants: { variant: { diff --git a/src/shared/view/ui/Card.tsx b/src/shared/view/ui/Card.tsx new file mode 100644 index 00000000..882973e1 --- /dev/null +++ b/src/shared/view/ui/Card.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; + +import { cn } from '../../../lib/utils'; + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ) +); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ) +); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ) +); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardFooter.displayName = 'CardFooter'; + +/** + * Use inside a CardHeader with `className="flex flex-row items-start justify-between"`. + * Positions an action (button/icon) at the trailing edge of the header. + */ +const CardAction = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardAction.displayName = 'CardAction'; + +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction }; diff --git a/src/shared/view/ui/Collapsible.tsx b/src/shared/view/ui/Collapsible.tsx new file mode 100644 index 00000000..241bb4f6 --- /dev/null +++ b/src/shared/view/ui/Collapsible.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; + +import { cn } from '../../../lib/utils'; + +interface CollapsibleContextValue { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const CollapsibleContext = React.createContext(null); + +function useCollapsible() { + const ctx = React.useContext(CollapsibleContext); + if (!ctx) throw new Error('Collapsible components must be used within '); + return ctx; +} + +interface CollapsibleProps extends React.HTMLAttributes { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +const Collapsible = React.forwardRef( + ({ defaultOpen = false, open: controlledOpen, onOpenChange: controlledOnOpenChange, className, children, ...props }, ref) => { + const [internalOpen, setInternalOpen] = React.useState(defaultOpen); + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + const onOpenChange = React.useCallback( + (next: boolean) => { + if (!isControlled) setInternalOpen(next); + controlledOnOpenChange?.(next); + }, + [isControlled, controlledOnOpenChange] + ); + + const value = React.useMemo(() => ({ open, onOpenChange }), [open, onOpenChange]); + + return ( + +
+ {children} +
+
+ ); + } +); +Collapsible.displayName = 'Collapsible'; + +const CollapsibleTrigger = React.forwardRef>( + ({ onClick, children, className, ...props }, ref) => { + const { open, onOpenChange } = useCollapsible(); + + const handleClick = React.useCallback( + (e: React.MouseEvent) => { + onOpenChange(!open); + onClick?.(e); + }, + [open, onOpenChange, onClick] + ); + + return ( + + ); + } +); +CollapsibleTrigger.displayName = 'CollapsibleTrigger'; + +const CollapsibleContent = React.forwardRef>( + ({ className, children, ...props }, ref) => { + const { open } = useCollapsible(); + + return ( +
+
+ {children} +
+
+ ); + } +); +CollapsibleContent.displayName = 'CollapsibleContent'; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent, useCollapsible }; diff --git a/src/shared/view/ui/Command.tsx b/src/shared/view/ui/Command.tsx new file mode 100644 index 00000000..5bdccdbb --- /dev/null +++ b/src/shared/view/ui/Command.tsx @@ -0,0 +1,320 @@ +import * as React from 'react'; +import { Search } from 'lucide-react'; + +import { cn } from '../../../lib/utils'; + +/* + * Lightweight command palette — inspired by cmdk but no external deps. + * + * Architecture: + * - Command owns the search string and a flat list of registered item values. + * - Items register via context on mount and deregister on unmount. + * - Filtering, active index, and keyboard nav happen centrally in Command. + * - Items read their "is visible" / "is active" state from context. + */ + +interface ItemEntry { + id: string; + value: string; // searchable text (lowercase) + onSelect: () => void; + element: HTMLElement | null; +} + +interface CommandContextValue { + search: string; + setSearch: (value: string) => void; + /** Set of visible item IDs after filtering (derived state, not a ref). */ + visibleIds: Set; + activeId: string | null; + setActiveId: (id: string | null) => void; + register: (entry: ItemEntry) => void; + unregister: (id: string) => void; + updateEntry: (id: string, patch: Partial>) => void; +} + +const CommandContext = React.createContext(null); + +function useCommand() { + const ctx = React.useContext(CommandContext); + if (!ctx) throw new Error('Command components must be used within '); + return ctx; +} + +/* ─── Command (root) ─────────────────────────────────────────────── */ + +type CommandProps = React.HTMLAttributes; + +const Command = React.forwardRef( + ({ className, children, ...props }, ref) => { + const [search, setSearch] = React.useState(''); + const entriesRef = React.useRef>(new Map()); + // Bump this counter whenever the entry set changes so derived state recalculates + const [revision, setRevision] = React.useState(0); + + const register = React.useCallback((entry: ItemEntry) => { + entriesRef.current.set(entry.id, entry); + setRevision(r => r + 1); + }, []); + + const unregister = React.useCallback((id: string) => { + entriesRef.current.delete(id); + setRevision(r => r + 1); + }, []); + + const updateEntry = React.useCallback((id: string, patch: Partial>) => { + const existing = entriesRef.current.get(id); + if (existing) { + Object.assign(existing, patch); + } + }, []); + + // Derive visible IDs from search + entries + const visibleIds = React.useMemo(() => { + const lowerSearch = search.toLowerCase(); + const ids = new Set(); + for (const [id, entry] of entriesRef.current) { + if (!lowerSearch || entry.value.includes(lowerSearch)) { + ids.add(id); + } + } + return ids; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [search, revision]); + + // Ordered list of visible entries (preserves DOM order via insertion order) + const visibleEntries = React.useMemo(() => { + const result: ItemEntry[] = []; + for (const [, entry] of entriesRef.current) { + if (visibleIds.has(entry.id)) result.push(entry); + } + return result; + }, [visibleIds]); + + // Active item tracking + const [activeId, setActiveId] = React.useState(null); + + // Reset active to first visible item when search or visible set changes + React.useEffect(() => { + setActiveId(visibleEntries.length > 0 ? visibleEntries[0].id : null); + }, [visibleEntries]); + + const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') { + e.preventDefault(); + } else { + return; + } + + const entries = visibleEntries; + if (entries.length === 0) return; + + if (e.key === 'Enter') { + const active = entries.find(entry => entry.id === activeId); + active?.onSelect(); + return; + } + + const currentIndex = entries.findIndex(entry => entry.id === activeId); + let nextIndex: number; + if (e.key === 'ArrowDown') { + nextIndex = currentIndex < entries.length - 1 ? currentIndex + 1 : 0; + } else { + nextIndex = currentIndex > 0 ? currentIndex - 1 : entries.length - 1; + } + const nextId = entries[nextIndex].id; + setActiveId(nextId); + + // Scroll the active item into view + const nextEntry = entries[nextIndex]; + nextEntry.element?.scrollIntoView({ block: 'nearest' }); + }, [visibleEntries, activeId]); + + const value = React.useMemo( + () => ({ search, setSearch, visibleIds, activeId, setActiveId, register, unregister, updateEntry }), + [search, visibleIds, activeId, register, unregister, updateEntry] + ); + + return ( + +
+ {children} +
+
+ ); + } +); +Command.displayName = 'Command'; + +/* ─── CommandInput ───────────────────────────────────────────────── */ + +type CommandInputProps = Omit, 'onChange' | 'value' | 'type'>; + +const CommandInput = React.forwardRef( + ({ className, placeholder = 'Search...', ...props }, ref) => { + const { search, setSearch } = useCommand(); + + return ( +
+ + setSearch(e.target.value)} + placeholder={placeholder} + className={cn( + 'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none', + 'placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', + className + )} + {...props} + /> +
+ ); + } +); +CommandInput.displayName = 'CommandInput'; + +/* ─── CommandList ────────────────────────────────────────────────── */ + +const CommandList = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CommandList.displayName = 'CommandList'; + +/* ─── CommandEmpty ───────────────────────────────────────────────── */ + +const CommandEmpty = React.forwardRef>( + ({ className, ...props }, ref) => { + const { search, visibleIds } = useCommand(); + + // Only show when there's a search term and zero matches + if (!search || visibleIds.size > 0) return null; + + return ( +
+ ); + } +); +CommandEmpty.displayName = 'CommandEmpty'; + +/* ─── CommandGroup ───────────────────────────────────────────────── */ + +interface CommandGroupProps extends React.HTMLAttributes { + heading?: React.ReactNode; +} + +const CommandGroup = React.forwardRef( + ({ className, heading, children, ...props }, ref) => ( +
+ {heading && ( +
+ {heading} +
+ )} + {children} +
+ ) +); +CommandGroup.displayName = 'CommandGroup'; + +/* ─── CommandItem ────────────────────────────────────────────────── */ + +interface CommandItemProps extends React.HTMLAttributes { + value?: string; + onSelect?: () => void; + disabled?: boolean; +} + +const CommandItem = React.forwardRef( + ({ className, value, onSelect, disabled, children, ...props }, ref) => { + const { visibleIds, activeId, setActiveId, register, unregister, updateEntry } = useCommand(); + const stableId = React.useId(); + const elementRef = React.useRef(null); + const searchableText = value || (typeof children === 'string' ? children : ''); + + // Register on mount, unregister on unmount + React.useEffect(() => { + register({ + id: stableId, + value: searchableText.toLowerCase(), + onSelect: onSelect || (() => {}), + element: elementRef.current, + }); + return () => unregister(stableId); + // Only re-register when the identity changes, not onSelect + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stableId, searchableText, register, unregister]); + + // Keep onSelect up-to-date without re-registering + React.useEffect(() => { + updateEntry(stableId, { onSelect: onSelect || (() => {}) }); + }, [stableId, onSelect, updateEntry]); + + // Keep element ref up-to-date + const setRef = React.useCallback((node: HTMLDivElement | null) => { + elementRef.current = node; + updateEntry(stableId, { element: node }); + if (typeof ref === 'function') ref(node); + else if (ref) ref.current = node; + }, [stableId, updateEntry, ref]); + + // Hidden by filter + if (!visibleIds.has(stableId)) return null; + + const isActive = activeId === stableId; + + return ( +
{ if (!disabled && activeId !== stableId) setActiveId(stableId); }} + onClick={() => !disabled && onSelect?.()} + {...props} + > + {children} +
+ ); + } +); +CommandItem.displayName = 'CommandItem'; + +/* ─── CommandSeparator ───────────────────────────────────────────── */ + +const CommandSeparator = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CommandSeparator.displayName = 'CommandSeparator'; + +export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator }; diff --git a/src/shared/view/ui/Confirmation.tsx b/src/shared/view/ui/Confirmation.tsx new file mode 100644 index 00000000..445adc3d --- /dev/null +++ b/src/shared/view/ui/Confirmation.tsx @@ -0,0 +1,139 @@ +import * as React from 'react'; + +import { cn } from '../../../lib/utils'; +import { Alert } from './Alert'; +import { Button } from './Button'; + +/* ─── Context ────────────────────────────────────────────────────── */ + +type ApprovalState = 'pending' | 'approved' | 'rejected' | undefined; + +interface ConfirmationContextValue { + approval: ApprovalState; +} + +const ConfirmationContext = React.createContext(null); + +const useConfirmation = () => { + const context = React.useContext(ConfirmationContext); + if (!context) { + throw new Error('Confirmation components must be used within Confirmation'); + } + return context; +}; + +/* ─── Confirmation (root) ────────────────────────────────────────── */ + +export interface ConfirmationProps extends React.HTMLAttributes { + approval?: ApprovalState; +} + +export const Confirmation: React.FC = ({ + className, + approval = 'pending', + children, + ...props +}) => { + const contextValue = React.useMemo(() => ({ approval }), [approval]); + + return ( + + + {children} + + + ); +}; +Confirmation.displayName = 'Confirmation'; + +/* ─── ConfirmationTitle ──────────────────────────────────────────── */ + +export type ConfirmationTitleProps = React.HTMLAttributes; + +export const ConfirmationTitle: React.FC = ({ + className, + ...props +}) => ( +
+); +ConfirmationTitle.displayName = 'ConfirmationTitle'; + +/* ─── ConfirmationRequest — visible only when pending ────────────── */ + +export interface ConfirmationRequestProps { + children?: React.ReactNode; +} + +export const ConfirmationRequest: React.FC = ({ children }) => { + const { approval } = useConfirmation(); + if (approval !== 'pending') return null; + return <>{children}; +}; +ConfirmationRequest.displayName = 'ConfirmationRequest'; + +/* ─── ConfirmationAccepted — visible only when approved ──────────── */ + +export interface ConfirmationAcceptedProps { + children?: React.ReactNode; +} + +export const ConfirmationAccepted: React.FC = ({ children }) => { + const { approval } = useConfirmation(); + if (approval !== 'approved') return null; + return <>{children}; +}; +ConfirmationAccepted.displayName = 'ConfirmationAccepted'; + +/* ─── ConfirmationRejected — visible only when rejected ──────────── */ + +export interface ConfirmationRejectedProps { + children?: React.ReactNode; +} + +export const ConfirmationRejected: React.FC = ({ children }) => { + const { approval } = useConfirmation(); + if (approval !== 'rejected') return null; + return <>{children}; +}; +ConfirmationRejected.displayName = 'ConfirmationRejected'; + +/* ─── ConfirmationActions — visible only when pending ────────────── */ + +export type ConfirmationActionsProps = React.HTMLAttributes; + +export const ConfirmationActions: React.FC = ({ + className, + ...props +}) => { + const { approval } = useConfirmation(); + if (approval !== 'pending') return null; + + return ( +
+ ); +}; +ConfirmationActions.displayName = 'ConfirmationActions'; + +/* ─── ConfirmationAction — styled button ─────────────────────────── */ + +export type ConfirmationActionProps = React.ButtonHTMLAttributes & { + variant?: 'default' | 'outline' | 'ghost' | 'destructive'; +}; + +export const ConfirmationAction: React.FC = ({ + variant = 'default', + ...props +}) => ( + + ); + } +); +DialogTrigger.displayName = 'DialogTrigger'; + +interface DialogContentProps extends React.HTMLAttributes { + onEscapeKeyDown?: () => void; + onPointerDownOutside?: () => void; +} + +const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +const DialogContent = React.forwardRef( + ({ className, children, onEscapeKeyDown, onPointerDownOutside, ...props }, ref) => { + const { open, onOpenChange, triggerRef } = useDialog(); + const contentRef = React.useRef(null); + const previousFocusRef = React.useRef(null); + + // Save the element that had focus before opening, restore on close + React.useEffect(() => { + if (open) { + previousFocusRef.current = document.activeElement as HTMLElement; + } else if (previousFocusRef.current) { + // Prefer the trigger, fall back to whatever was focused before + const restoreTarget = triggerRef.current || previousFocusRef.current; + restoreTarget?.focus(); + previousFocusRef.current = null; + } + }, [open, triggerRef]); + + React.useEffect(() => { + if (!open) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onEscapeKeyDown?.(); + onOpenChange(false); + return; + } + + // Focus trap: Tab / Shift+Tab cycle within the dialog + if (e.key === 'Tab' && contentRef.current) { + const focusable = Array.from( + contentRef.current.querySelectorAll(FOCUSABLE_SELECTOR) + ); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown, true); + + // Prevent body scroll + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + return () => { + document.removeEventListener('keydown', handleKeyDown, true); + document.body.style.overflow = prev; + }; + }, [open, onOpenChange, onEscapeKeyDown]); + + // Auto-focus first focusable element on open + React.useEffect(() => { + if (open && contentRef.current) { + // Small delay to let the portal render + requestAnimationFrame(() => { + const first = contentRef.current?.querySelector(FOCUSABLE_SELECTOR); + first?.focus(); + }); + } + }, [open]); + + if (!open) return null; + + return createPortal( +
+ {/* Overlay */} +
{ + onPointerDownOutside?.(); + onOpenChange(false); + }} + aria-hidden + /> + {/* Content */} +
{ + contentRef.current = node; + if (typeof ref === 'function') ref(node); + else if (ref) (ref as React.MutableRefObject).current = node; + }} + role="dialog" + aria-modal="true" + className={cn( + 'fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2', + 'rounded-xl border bg-popover text-popover-foreground shadow-lg', + 'animate-dialog-content-show', + className + )} + {...props} + > + {children} +
+
, + document.body + ); + } +); +DialogContent.displayName = 'DialogContent'; + +const DialogTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ) +); +DialogTitle.displayName = 'DialogTitle'; + +export { Dialog, DialogTrigger, DialogContent, DialogTitle, useDialog }; diff --git a/src/shared/view/ui/Input.tsx b/src/shared/view/ui/Input.tsx index 1b625034..a5566790 100644 --- a/src/shared/view/ui/Input.tsx +++ b/src/shared/view/ui/Input.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; + import { cn } from '../../../lib/utils'; type InputProps = React.InputHTMLAttributes; diff --git a/src/shared/view/ui/LanguageSelector.tsx b/src/shared/view/ui/LanguageSelector.tsx index 3c0bce9e..2d792a3e 100644 --- a/src/shared/view/ui/LanguageSelector.tsx +++ b/src/shared/view/ui/LanguageSelector.tsx @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'; import { Languages } from 'lucide-react'; + import { languages } from '../../../i18n/languages'; type LanguageSelectorProps = { diff --git a/src/shared/view/ui/PillBar.tsx b/src/shared/view/ui/PillBar.tsx index beffc2a3..81e83813 100644 --- a/src/shared/view/ui/PillBar.tsx +++ b/src/shared/view/ui/PillBar.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; + import { cn } from '../../../lib/utils'; /* ── Container ─────────────────────────────────────────────────── */ diff --git a/src/shared/view/ui/Reasoning.tsx b/src/shared/view/ui/Reasoning.tsx new file mode 100644 index 00000000..130b7969 --- /dev/null +++ b/src/shared/view/ui/Reasoning.tsx @@ -0,0 +1,198 @@ +"use client"; + +import * as React from 'react'; +import { BrainIcon, ChevronDownIcon } from 'lucide-react'; + +import { cn } from '../../../lib/utils'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './Collapsible'; +import { Shimmer } from './Shimmer'; + +/* ─── Context ────────────────────────────────────────────────────── */ + +interface ReasoningContextValue { + isStreaming: boolean; + isOpen: boolean; + setIsOpen: (open: boolean) => void; + duration: number | undefined; +} + +const ReasoningContext = React.createContext(null); + +export const useReasoning = () => { + const context = React.useContext(ReasoningContext); + if (!context) { + throw new Error('Reasoning components must be used within Reasoning'); + } + return context; +}; + +/* ─── Reasoning (root) ───────────────────────────────────────────── */ + +const AUTO_CLOSE_DELAY = 1000; +const MS_IN_S = 1000; + +export interface ReasoningProps extends React.HTMLAttributes { + isStreaming?: boolean; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + duration?: number; +} + +export const Reasoning = React.memo( + ({ + className, + isStreaming = false, + open: controlledOpen, + defaultOpen, + onOpenChange, + duration: durationProp, + children, + ...props + }) => { + const resolvedDefaultOpen = defaultOpen ?? isStreaming; + const isExplicitlyClosed = defaultOpen === false; + + // Controllable open state + const [internalOpen, setInternalOpen] = React.useState(resolvedDefaultOpen); + const isControlled = controlledOpen !== undefined; + const isOpen = isControlled ? controlledOpen : internalOpen; + const setIsOpen = React.useCallback( + (next: boolean) => { + if (!isControlled) setInternalOpen(next); + onOpenChange?.(next); + }, + [isControlled, onOpenChange] + ); + + // Duration tracking + const [duration, setDuration] = React.useState(durationProp); + const hasEverStreamedRef = React.useRef(isStreaming); + const [hasAutoClosed, setHasAutoClosed] = React.useState(false); + const startTimeRef = React.useRef(null); + + // Sync external duration prop + React.useEffect(() => { + if (durationProp !== undefined) setDuration(durationProp); + }, [durationProp]); + + // Track streaming start/end for duration + React.useEffect(() => { + if (isStreaming) { + hasEverStreamedRef.current = true; + if (startTimeRef.current === null) { + startTimeRef.current = Date.now(); + } + } else if (startTimeRef.current !== null) { + setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S)); + startTimeRef.current = null; + } + }, [isStreaming]); + + // Auto-open when streaming starts + React.useEffect(() => { + if (isStreaming && !isOpen && !isExplicitlyClosed) { + setIsOpen(true); + } + }, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]); + + // Auto-close after streaming ends + React.useEffect(() => { + if (hasEverStreamedRef.current && !isStreaming && isOpen && !hasAutoClosed) { + const timer = setTimeout(() => { + setIsOpen(false); + setHasAutoClosed(true); + }, AUTO_CLOSE_DELAY); + return () => clearTimeout(timer); + } + }, [isStreaming, isOpen, setIsOpen, hasAutoClosed]); + + const contextValue = React.useMemo( + () => ({ duration, isOpen, isStreaming, setIsOpen }), + [duration, isOpen, isStreaming, setIsOpen] + ); + + return ( + + + {children} + + + ); + } +); +Reasoning.displayName = 'Reasoning'; + +/* ─── ReasoningTrigger ───────────────────────────────────────────── */ + +export interface ReasoningTriggerProps extends React.ButtonHTMLAttributes { + getThinkingMessage?: (isStreaming: boolean, duration?: number) => React.ReactNode; +} + +const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number): React.ReactNode => { + if (isStreaming || duration === 0) { + return Thinking...; + } + if (duration === undefined) { + return

Thought for a few seconds

; + } + return

Thought for {duration} seconds

; +}; + +export const ReasoningTrigger = React.memo( + ({ + className, + children, + getThinkingMessage = defaultGetThinkingMessage, + ...props + }) => { + const { isStreaming, isOpen, duration } = useReasoning(); + + return ( + + {children ?? ( + <> + + {getThinkingMessage(isStreaming, duration)} + + + )} + + ); + } +); +ReasoningTrigger.displayName = 'ReasoningTrigger'; + +/* ─── ReasoningContent ───────────────────────────────────────────── */ + +export interface ReasoningContentProps extends React.HTMLAttributes { + children: React.ReactNode; +} + +export const ReasoningContent = React.memo( + ({ className, children, ...props }) => ( + + {children} + + ) +); +ReasoningContent.displayName = 'ReasoningContent'; diff --git a/src/shared/view/ui/ScrollArea.tsx b/src/shared/view/ui/ScrollArea.tsx index 4100fe22..f0fd347a 100644 --- a/src/shared/view/ui/ScrollArea.tsx +++ b/src/shared/view/ui/ScrollArea.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; + import { cn } from '../../../lib/utils'; type ScrollAreaProps = React.HTMLAttributes; diff --git a/src/shared/view/ui/Shimmer.tsx b/src/shared/view/ui/Shimmer.tsx new file mode 100644 index 00000000..d3a5b6ae --- /dev/null +++ b/src/shared/view/ui/Shimmer.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; + +import { cn } from '../../../lib/utils'; + +interface ShimmerProps { + children: string; + className?: string; + as?: React.ElementType; +} + +const Shimmer = React.memo(({ children, className, as: Component = 'span' }) => { + return ( + + {children} + + ); +}); +Shimmer.displayName = 'Shimmer'; + +export { Shimmer }; diff --git a/src/shared/view/ui/Tooltip.tsx b/src/shared/view/ui/Tooltip.tsx index 2167d69f..69380e2d 100644 --- a/src/shared/view/ui/Tooltip.tsx +++ b/src/shared/view/ui/Tooltip.tsx @@ -1,5 +1,6 @@ import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; + import { cn } from '../../../lib/utils'; type TooltipPosition = 'top' | 'bottom' | 'left' | 'right'; diff --git a/src/shared/view/ui/index.ts b/src/shared/view/ui/index.ts index a68250f4..f4cc407b 100644 --- a/src/shared/view/ui/index.ts +++ b/src/shared/view/ui/index.ts @@ -1,7 +1,15 @@ +export { Alert, AlertTitle, AlertDescription, alertVariants } from './Alert'; export { Badge, badgeVariants } from './Badge'; export { Button, buttonVariants } from './Button'; +export { Confirmation, ConfirmationTitle, ConfirmationRequest, ConfirmationAccepted, ConfirmationRejected, ConfirmationActions, ConfirmationAction } from './Confirmation'; +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction } from './Card'; +export { Collapsible, CollapsibleTrigger, CollapsibleContent } from './Collapsible'; +export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator } from './Command'; export { default as DarkModeToggle } from './DarkModeToggle'; +export { Dialog, DialogTrigger, DialogContent, DialogTitle } from './Dialog'; export { Input } from './Input'; export { ScrollArea } from './ScrollArea'; +export { Reasoning, ReasoningTrigger, ReasoningContent, useReasoning } from './Reasoning'; +export { Shimmer } from './Shimmer'; export { default as Tooltip } from './Tooltip'; export { PillBar, Pill } from './PillBar'; diff --git a/tailwind.config.js b/tailwind.config.js index 98923262..0b2517d0 100755 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -58,6 +58,25 @@ export default { 'safe-area-inset-bottom': 'env(safe-area-inset-bottom)', 'mobile-nav': 'var(--mobile-nav-total)', }, + keyframes: { + shimmer: { + '0%': { backgroundPosition: '200% 0' }, + '100%': { backgroundPosition: '-200% 0' }, + }, + 'dialog-overlay-show': { + from: { opacity: '0' }, + to: { opacity: '1' }, + }, + 'dialog-content-show': { + from: { opacity: '0', transform: 'translate(-50%, -48%) scale(0.96)' }, + to: { opacity: '1', transform: 'translate(-50%, -50%) scale(1)' }, + }, + }, + animation: { + shimmer: 'shimmer 2s linear infinite', + 'dialog-overlay-show': 'dialog-overlay-show 150ms ease-out', + 'dialog-content-show': 'dialog-content-show 150ms ease-out', + }, }, }, plugins: [require('@tailwindcss/typography')], From 5758bee8a038ed50073dba882108617959dda82c Mon Sep 17 00:00:00 2001 From: simosmik Date: Mon, 20 Apr 2026 14:47:49 +0000 Subject: [PATCH 2/8] refactor: chat composer new design --- .../chat/hooks/useChatComposerState.ts | 4 +- .../chat/view/subcomponents/ChatComposer.tsx | 274 +++++++++++------- .../view/subcomponents/ChatInputControls.tsx | 137 --------- src/shared/view/ui/PromptInput.tsx | 219 ++++++++++++++ src/shared/view/ui/index.ts | 1 + 5 files changed, 392 insertions(+), 243 deletions(-) delete mode 100644 src/components/chat/view/subcomponents/ChatInputControls.tsx create mode 100644 src/shared/view/ui/PromptInput.tsx diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 15f4b63f..3b167215 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -737,7 +737,7 @@ export function useChatComposerState({ } // Re-run when input changes so restored drafts get the same autosize behavior as typed text. textareaRef.current.style.height = 'auto'; - textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; + textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`; const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); const expanded = textareaRef.current.scrollHeight > lineHeight * 2; setIsTextareaExpanded(expanded); @@ -824,7 +824,7 @@ export function useChatComposerState({ (event: FormEvent) => { const target = event.currentTarget; target.style.height = 'auto'; - target.style.height = `${target.scrollHeight}px`; + target.style.height = `${Math.max(22, target.scrollHeight)}px`; setCursorPosition(target.selectionStart); syncInputOverlayScroll(target); diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index 69e8fc3c..1be8273f 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -11,12 +11,24 @@ import type { SetStateAction, TouchEvent, } from 'react'; +import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react'; import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types'; import CommandMenu from './CommandMenu'; import ClaudeStatus from './ClaudeStatus'; import ImageAttachment from './ImageAttachment'; import PermissionRequestsBanner from './PermissionRequestsBanner'; -import ChatInputControls from './ChatInputControls'; +import ThinkingModeSelector from './ThinkingModeSelector'; +import TokenUsagePie from './TokenUsagePie'; +import { + PromptInput, + PromptInputHeader, + PromptInputBody, + PromptInputTextarea, + PromptInputFooter, + PromptInputTools, + PromptInputButton, + PromptInputSubmit, +} from '../../../../shared/view/ui'; interface MentionableFile { name: string; @@ -171,73 +183,37 @@ export default function ChatComposer({ return (
{!hasPendingPermissions && ( -
- + )} + + {pendingPermissionRequests.length > 0 && ( +
+
)} -
- - - {!hasQuestionPanel && } -
- - {!hasQuestionPanel &&
) => void} className="relative mx-auto max-w-4xl"> - {isDragActive && ( -
-
- - - -

Drop images here

-
+ {!hasQuestionPanel &&
+ {isUserScrolledUp && hasMessages && ( +
+
)} - - {attachedImages.length > 0 && ( -
-
- {attachedImages.map((file, index) => ( - onRemoveImage(index)} - uploadProgress={uploadingImages.get(file.name)} - error={imageErrors.get(file.name)} - /> - ))} -
-
- )} - {showFileDropdown && filteredFiles.length > 0 && (
{filteredFiles.map((file, index) => ( @@ -275,21 +251,56 @@ export default function ChatComposer({ frequentCommands={frequentCommands} /> -
) => void} + status={isLoading ? 'streaming' : 'ready'} + className={isTextareaExpanded ? 'chat-input-expanded' : ''} {...getRootProps()} - className={`relative overflow-hidden rounded-2xl border border-border/50 bg-card/80 shadow-sm backdrop-blur-sm transition-all duration-200 focus-within:border-primary/30 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/15 ${ - isTextareaExpanded ? 'chat-input-expanded' : '' - }`} > - -