- {/* Thinking accordion for reasoning */}
+ {/* Reasoning accordion */}
{showThinking && message.reasoning && (
-
+
+
)}
{(() => {
diff --git a/src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx b/src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx
index 7171edd5..0641f186 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,62 @@ 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}
)}
-
- 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"
+
+ handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
>
- Allow once
-
-
+ {
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}
-
- 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"
+
+ handlePermissionDecision(request.requestId, { allow: true })}
>
- Deny
-
-
-
+ Allow once
+
+
+
);
})}
diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
index 7c4f21c2..75e603d1 100644
--- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
+++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
@@ -1,6 +1,7 @@
-import React, { useEffect, useMemo } from "react";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Check, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
+
import { useServerPlatform } from "../../../../hooks/useServerPlatform";
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
import {
@@ -11,6 +12,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;
@@ -41,6 +55,12 @@ type ProviderDef = {
check: string;
};
+type ProviderGroup = {
+ id: LLMProvider;
+ name: string;
+ models: { value: string; label: string }[];
+};
+
const PROVIDERS: ProviderDef[] = [
{
id: "claude",
@@ -76,6 +96,13 @@ const PROVIDERS: ProviderDef[] = [
},
];
+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) {
if (p === "claude") return CLAUDE_MODELS;
if (p === "codex") return CODEX_MODELS;
@@ -83,7 +110,7 @@ function getModelConfig(p: LLMProvider) {
return CURSOR_MODELS;
}
-function getModelValue(
+function getCurrentModel(
p: LLMProvider,
c: string,
cu: string,
@@ -96,6 +123,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,
@@ -117,12 +151,15 @@ export default function ProviderSelectionEmptyState({
}: ProviderSelectionEmptyStateProps) {
const { t } = useTranslation("chat");
const { isWindowsServer } = useServerPlatform();
+ const [dialogOpen, setDialogOpen] = useState(false);
const visibleProviders = useMemo(
- () =>
- isWindowsServer
- ? PROVIDERS.filter((p) => p.id !== "cursor")
- : PROVIDERS,
+ () => (isWindowsServer ? PROVIDERS.filter((p) => p.id !== "cursor") : PROVIDERS),
+ [isWindowsServer],
+ );
+
+ const visibleProviderGroups = useMemo(
+ () => (isWindowsServer ? PROVIDER_GROUPS.filter((p) => p.id !== "cursor") : PROVIDER_GROUPS),
[isWindowsServer],
);
@@ -137,30 +174,7 @@ export default function ProviderSelectionEmptyState({
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,
@@ -168,12 +182,57 @@ 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 selectProvider = useCallback(
+ (next: LLMProvider) => {
+ setProvider(next);
+ localStorage.setItem("selected-provider", next);
+ setTimeout(() => textareaRef.current?.focus(), 100);
+ },
+ [setProvider, textareaRef],
+ );
+
+ const setModelForProvider = useCallback(
+ (providerId: LLMProvider, modelValue: string) => {
+ 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);
+ }
+ },
+ [setClaudeModel, setCursorModel, setCodexModel, setGeminiModel],
+ );
+
+ const handleModelSelect = useCallback(
+ (providerId: LLMProvider, modelValue: string) => {
+ setProvider(providerId);
+ localStorage.setItem("selected-provider", providerId);
+ setModelForProvider(providerId, modelValue);
+ setDialogOpen(false);
+ setTimeout(() => textareaRef.current?.focus(), 100);
+ },
+ [setProvider, setModelForProvider, textareaRef],
+ );
+
if (!selectedSession && !currentSessionId) {
return (
- {/* Heading */}
{t("providerSelection.title")}
@@ -183,7 +242,6 @@ export default function ProviderSelectionEmptyState({
- {/* Provider cards — horizontal row, equal width */}
= 4 ? "sm:grid-cols-4" : "sm:grid-cols-3"}`}
>
@@ -216,7 +274,6 @@ export default function ProviderSelectionEmptyState({
{t(p.infoKey)}
- {/* Check badge */}
{active && (
- {/* Model picker — appears after provider is chosen */}
-
-
-
- {t("providerSelection.selectModel")}
-
-
- 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 }) => (
-
- {label}
-
- ),
- )}
-
-
-
-
+
+
+
+
+
+
+
+
+ {getProviderDisplayName(provider)}
+
+ /
+
+ {currentModelLabel}
+
+
+
+ {t("providerSelection.clickToChange", {
+ defaultValue: "Click to change model",
+ })}
+
+
+
+
+
+
-
+
+ Model Selector
+
+
+
+
+ {t("providerSelection.noModelsFound", {
+ defaultValue: "No models found.",
+ })}
+
+ {visibleProviderGroups.map((group) => (
+
+
+ {group.name}
+
+ }
+ >
+ {group.models.map((model) => {
+ const isSelected = provider === group.id && currentModel === model.value;
+ return (
+ handleModelSelect(group.id, model.value)}
+ >
+ {model.label}
+ {isSelected && (
+
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+
+
+
+
+ {
{
- {
- 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 (
+
+ {children}
+
+ );
+ }
+);
+CollapsibleTrigger.displayName = 'CollapsibleTrigger';
+
+const CollapsibleContent = React.forwardRef>(
+ ({ className, children, ...props }, ref) => {
+ const { open } = useCollapsible();
+
+ return (
+
+ );
+ }
+);
+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
+}) => (
+
+);
+ConfirmationAction.displayName = 'ConfirmationAction';
+
+export { useConfirmation };
diff --git a/src/shared/view/ui/DarkModeToggle.tsx b/src/shared/view/ui/DarkModeToggle.tsx
index be078a11..aeeeb31d 100644
--- a/src/shared/view/ui/DarkModeToggle.tsx
+++ b/src/shared/view/ui/DarkModeToggle.tsx
@@ -1,4 +1,5 @@
import { Moon, Sun } from 'lucide-react';
+
import { useTheme } from '../../../contexts/ThemeContext';
import { cn } from '../../../lib/utils';
diff --git a/src/shared/view/ui/Dialog.tsx b/src/shared/view/ui/Dialog.tsx
new file mode 100644
index 00000000..a3fb3740
--- /dev/null
+++ b/src/shared/view/ui/Dialog.tsx
@@ -0,0 +1,217 @@
+import * as React from 'react';
+import { createPortal } from 'react-dom';
+
+import { cn } from '../../../lib/utils';
+
+interface DialogContextValue {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ triggerRef: React.MutableRefObject;
+}
+
+const DialogContext = React.createContext(null);
+
+function useDialog() {
+ const ctx = React.useContext(DialogContext);
+ if (!ctx) throw new Error('Dialog components must be used within ');
+ return ctx;
+}
+
+interface DialogProps {
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ defaultOpen?: boolean;
+ children: React.ReactNode;
+}
+
+const Dialog: React.FC = ({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, children }) => {
+ const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
+ const triggerRef = React.useRef(null) as React.MutableRefObject;
+ 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, triggerRef }), [open, onOpenChange]);
+
+ return {children} ;
+};
+
+const DialogTrigger = React.forwardRef & { asChild?: boolean }>(
+ ({ onClick, children, asChild, ...props }, ref) => {
+ const { onOpenChange, triggerRef } = useDialog();
+
+ const handleClick = React.useCallback(
+ (e: React.MouseEvent) => {
+ onOpenChange(true);
+ onClick?.(e);
+ },
+ [onOpenChange, onClick]
+ );
+
+ // asChild: clone child element and compose onClick + capture ref
+ if (asChild && React.isValidElement(children)) {
+ const child = children as React.ReactElement;
+ return React.cloneElement(child, {
+ onClick: (e: React.MouseEvent) => {
+ onOpenChange(true);
+ child.props.onClick?.(e);
+ },
+ ref: (node: HTMLElement | null) => {
+ triggerRef.current = node;
+ // Forward the outer ref
+ if (typeof ref === 'function') ref(node as any);
+ else if (ref) (ref as React.MutableRefObject).current = node;
+ },
+ });
+ }
+
+ return (
+ {
+ triggerRef.current = node;
+ if (typeof ref === 'function') ref(node);
+ else if (ref) ref.current = node;
+ }}
+ type="button"
+ onClick={handleClick}
+ {...props}
+ >
+ {children}
+
+ );
+ }
+);
+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/PromptInput.tsx b/src/shared/view/ui/PromptInput.tsx
new file mode 100644
index 00000000..c4d23890
--- /dev/null
+++ b/src/shared/view/ui/PromptInput.tsx
@@ -0,0 +1,219 @@
+"use client";
+
+import * as React from 'react';
+import { SendHorizonalIcon, SquareIcon } from 'lucide-react';
+
+import { cn } from '../../../lib/utils';
+import { Button } from './Button';
+import Tooltip from './Tooltip';
+
+/* ─── Context ────────────────────────────────────────────────────── */
+
+type PromptInputStatus = 'ready' | 'submitted' | 'streaming' | 'error';
+
+interface PromptInputContextValue {
+ status: PromptInputStatus;
+}
+
+const PromptInputContext = React.createContext(null);
+
+const usePromptInput = () => {
+ const context = React.useContext(PromptInputContext);
+ if (!context) {
+ throw new Error('PromptInput components must be used within PromptInput');
+ }
+ return context;
+};
+
+/* ─── PromptInput (root form) ────────────────────────────────────── */
+
+export interface PromptInputProps extends React.FormHTMLAttributes {
+ status?: PromptInputStatus;
+}
+
+export const PromptInput = React.forwardRef(
+ ({ className, status = 'ready', children, ...props }, ref) => {
+ const contextValue = React.useMemo(() => ({ status }), [status]);
+
+ return (
+
+
+
+ );
+ }
+);
+PromptInput.displayName = 'PromptInput';
+
+/* ─── PromptInputHeader ──────────────────────────────────────────── */
+
+export const PromptInputHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+PromptInputHeader.displayName = 'PromptInputHeader';
+
+/* ─── PromptInputBody ────────────────────────────────────────────── */
+
+export const PromptInputBody = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+PromptInputBody.displayName = 'PromptInputBody';
+
+/* ─── PromptInputTextarea ────────────────────────────────────────── */
+
+export const PromptInputTextarea = React.forwardRef<
+ HTMLTextAreaElement,
+ React.TextareaHTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+PromptInputTextarea.displayName = 'PromptInputTextarea';
+
+/* ─── PromptInputFooter ──────────────────────────────────────────── */
+
+export const PromptInputFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+PromptInputFooter.displayName = 'PromptInputFooter';
+
+/* ─── PromptInputTools ───────────────────────────────────────────── */
+
+export const PromptInputTools = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+PromptInputTools.displayName = 'PromptInputTools';
+
+/* ─── PromptInputButton ──────────────────────────────────────────── */
+
+export interface PromptInputButtonTooltip {
+ content: React.ReactNode;
+ shortcut?: string;
+ side?: 'top' | 'bottom' | 'left' | 'right';
+}
+
+export interface PromptInputButtonProps extends React.ButtonHTMLAttributes {
+ tooltip?: PromptInputButtonTooltip;
+}
+
+export const PromptInputButton = React.forwardRef(
+ ({ className, tooltip, children, ...props }, ref) => {
+ const button = (
+
+ {children}
+
+ );
+
+ if (tooltip) {
+ return (
+
+ {tooltip.content}
+ {tooltip.shortcut}
+
+ ) : (
+ tooltip.content
+ )
+ }
+ position={tooltip.side ?? 'top'}
+ >
+ {button}
+
+ );
+ }
+
+ return button;
+ }
+);
+PromptInputButton.displayName = 'PromptInputButton';
+
+/* ─── PromptInputSubmit ──────────────────────────────────────────── */
+
+export interface PromptInputSubmitProps extends React.ButtonHTMLAttributes {
+ status?: PromptInputStatus;
+}
+
+export const PromptInputSubmit = React.forwardRef(
+ ({ className, status: statusProp, children, ...props }, ref) => {
+ const context = React.useContext(PromptInputContext);
+ const status = statusProp ?? context?.status ?? 'ready';
+ const isActive = status === 'submitted' || status === 'streaming';
+
+ return (
+
+ {children ?? (isActive ? (
+
+ ) : (
+
+ ))}
+
+ );
+ }
+);
+PromptInputSubmit.displayName = 'PromptInputSubmit';
+
+export { usePromptInput };
diff --git a/src/shared/view/ui/Queue.tsx b/src/shared/view/ui/Queue.tsx
new file mode 100644
index 00000000..9f8a3941
--- /dev/null
+++ b/src/shared/view/ui/Queue.tsx
@@ -0,0 +1,122 @@
+import * as React from 'react';
+import { cn } from '../../../lib/utils';
+
+/* ─── Types ──────────────────────────────────────────────────────── */
+
+export type QueueItemStatus = 'completed' | 'in_progress' | 'pending';
+
+/* ─── Context ────────────────────────────────────────────────────── */
+
+interface QueueItemContextValue {
+ status: QueueItemStatus;
+}
+
+const QueueItemContext = React.createContext(null);
+
+function useQueueItem() {
+ const ctx = React.useContext(QueueItemContext);
+ if (!ctx) throw new Error('QueueItem sub-components must be used within ');
+ return ctx;
+}
+
+/* ─── Queue ──────────────────────────────────────────────────────── */
+
+export const Queue = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+Queue.displayName = 'Queue';
+
+/* ─── QueueItem ──────────────────────────────────────────────────── */
+
+export interface QueueItemProps extends React.HTMLAttributes {
+ status?: QueueItemStatus;
+}
+
+export const QueueItem = React.forwardRef(
+ ({ status = 'pending', className, children, ...props }, ref) => {
+ const value = React.useMemo(() => ({ status }), [status]);
+
+ return (
+
+
+ {children}
+
+
+ );
+ },
+);
+QueueItem.displayName = 'QueueItem';
+
+/* ─── QueueItemIndicator ─────────────────────────────────────────── */
+
+export const QueueItemIndicator = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ const { status } = useQueueItem();
+
+ return (
+
+ {status === 'completed' && (
+
+
+
+ )}
+ {status === 'in_progress' && (
+
+ )}
+ {status === 'pending' && (
+
+
+
+ )}
+
+ );
+ },
+);
+QueueItemIndicator.displayName = 'QueueItemIndicator';
+
+/* ─── QueueItemContent ───────────────────────────────────────────── */
+
+export const QueueItemContent = React.forwardRef>(
+ ({ className, children, ...props }, ref) => {
+ const { status } = useQueueItem();
+
+ return (
+
+ {children}
+
+ );
+ },
+);
+QueueItemContent.displayName = 'QueueItemContent';
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..c75e952f 100644
--- a/src/shared/view/ui/index.ts
+++ b/src/shared/view/ui/index.ts
@@ -1,7 +1,18 @@
+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 { PromptInput, PromptInputHeader, PromptInputBody, PromptInputTextarea, PromptInputFooter, PromptInputTools, PromptInputButton, PromptInputSubmit } from './PromptInput';
export { PillBar, Pill } from './PillBar';
+export { Queue, QueueItem, QueueItemIndicator, QueueItemContent } from './Queue';
+export type { QueueItemStatus } from './Queue';
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')],