Chat & sidebar UX improvements (#929)

This commit is contained in:
Haile
2026-06-29 10:27:04 +03:00
committed by Simos Mikelatos
parent 97c9b67bfc
commit 053f244d14
13 changed files with 517 additions and 203 deletions

View File

@@ -2,9 +2,7 @@ import { useMemo, useState } from 'react';
import {
Activity,
BadgeCheck,
Check,
CircleHelp,
Clipboard,
Coins,
Cpu,
Gauge,
@@ -59,19 +57,6 @@ type ModelOption = {
description?: string;
};
const formatUpdatedAt = (value?: string) => {
if (!value) {
return 'Not cached yet';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return 'Not cached yet';
}
return parsed.toLocaleString();
};
const PROVIDER_LABELS: Record<string, string> = {
claude: 'Claude',
cursor: 'Cursor',
@@ -246,7 +231,6 @@ function HelpContent({ data }: { data: HelpCommandData }) {
function ModelsContent({
data,
providerModelCatalog,
providerModelCacheCatalog,
providerModelsRefreshing,
onHardRefreshProviderModels,
currentSessionId,
@@ -254,14 +238,12 @@ function ModelsContent({
}: {
data: ModelCommandData;
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
providerModelsRefreshing: boolean;
onHardRefreshProviderModels: () => void;
currentSessionId: string | null;
onSelectProviderModel: CommandResultModalProps['onSelectProviderModel'];
}) {
const [query, setQuery] = useState('');
const [copiedModel, setCopiedModel] = useState<string | null>(null);
const [changingModel, setChangingModel] = useState<string | null>(null);
const [pendingSessionModel, setPendingSessionModel] = useState<string | null>(null);
const [selectionNotice, setSelectionNotice] = useState<string | null>(null);
@@ -269,7 +251,6 @@ function ModelsContent({
const currentModel = data?.current?.model || 'Unknown';
const providerLabel = data?.current?.providerLabel || getProviderLabel(currentProvider);
const liveDefinition = providerModelCatalog[currentProvider];
const currentCache = providerModelCacheCatalog[currentProvider] ?? data?.cache;
const availableOptions = useMemo<ModelOption[]>(() => {
if (liveDefinition?.OPTIONS && liveDefinition.OPTIONS.length > 0) {
return liveDefinition.OPTIONS;
@@ -282,7 +263,6 @@ function ModelsContent({
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
return availableModels.map((model) => ({ value: model, label: model }));
}, [data, liveDefinition]);
const defaultModel = liveDefinition?.DEFAULT || data?.defaultModel || currentModel;
const filteredOptions = useMemo(() => {
const normalized = query.trim().toLowerCase();
@@ -296,18 +276,8 @@ function ModelsContent({
});
}, [availableOptions, query]);
const activeOption = availableOptions.find((option) => option.value === currentModel);
const hasConcreteSessionId = typeof currentSessionId === 'string' && currentSessionId.trim().length > 0;
const copyModel = (model: string) => {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
void navigator.clipboard.writeText(model).catch(() => undefined);
}
setCopiedModel(model);
window.setTimeout(() => {
setCopiedModel((current) => (current === model ? null : current));
}, 1300);
};
const showSearch = availableOptions.length > 6;
const handleSelectModel = async (model: string) => {
setChangingModel(model);
@@ -330,162 +300,106 @@ function ModelsContent({
};
return (
<div className="flex h-full min-h-0 flex-col gap-2.5">
<div className="rounded-2xl border border-border/70 bg-muted/20 p-2.5">
<div className="grid gap-2.5 lg:grid-cols-[minmax(0,1.55fr)_minmax(12rem,0.7fr)_minmax(15rem,0.9fr)] lg:items-start">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="rounded-lg border border-primary/20 bg-primary/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary">
{providerLabel}
</Badge>
<Badge variant="secondary" className="rounded-lg px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-foreground">
{availableOptions.length} models
</Badge>
</div>
<div className="mt-2 rounded-xl border border-primary/15 bg-primary/[0.06] px-3 py-2">
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-primary">Active Model</p>
<p className="mt-1 break-all font-mono text-[0.98rem] font-semibold leading-5 text-foreground sm:text-[1.05rem]">
{currentModel}
</p>
{activeOption?.label && activeOption.label !== currentModel && (
<p className="mt-1 text-[11px] font-medium text-foreground/85">{activeOption.label}</p>
)}
{activeOption?.description && (
<p className="mt-0.5 line-clamp-1 text-[11px] text-muted-foreground">{activeOption.description}</p>
)}
{pendingSessionModel && pendingSessionModel !== currentModel && (
<p className="mt-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-primary">
Next response: {pendingSessionModel}
</p>
)}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Default</p>
<p className="mt-1 break-all font-mono text-[11px] font-medium text-foreground">{defaultModel}</p>
</div>
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Updated</p>
<p className="mt-1 text-[11px] font-medium text-foreground">{formatUpdatedAt(currentCache?.updatedAt)}</p>
</div>
</div>
<div className="rounded-xl border border-border/60 bg-background/55 p-2.5">
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">
Catalog Refresh
</p>
<Badge variant="secondary" className="rounded-md px-1.5 py-0 text-[9px] uppercase tracking-[0.14em]">
All providers
</Badge>
</div>
<p className="mt-1.5 text-[11px] leading-4 text-muted-foreground">
Model lists are cached for 3 days. Refresh after CLI, auth, or config changes,
or when a new model is missing.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={onHardRefreshProviderModels}
disabled={providerModelsRefreshing}
className="mt-2 h-8 w-full rounded-xl px-3"
>
<RefreshCw className={providerModelsRefreshing ? 'animate-spin' : ''} />
{providerModelsRefreshing ? 'Refreshing catalogs...' : 'Refresh from providers'}
</Button>
</div>
</div>
<div className="mt-2 border-t border-border/50 pt-1.5 text-[11px] text-muted-foreground">
{hasConcreteSessionId
? 'Selecting a model stores a session override and applies it on the next response for this session.'
: 'Selecting a model updates the default model used for new turns in this provider.'}
{selectionNotice && <span className="ml-2 text-foreground">{selectionNotice}</span>}
<div className="flex h-full min-h-0 flex-col gap-3">
{/* Compact context bar: active model + refresh, no clutter */}
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-muted/20 px-3.5 py-2.5">
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Active model · {providerLabel}
</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="break-all font-mono text-sm font-semibold text-foreground">{currentModel}</span>
{pendingSessionModel && pendingSessionModel !== currentModel && (
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-emerald-500 dark:text-emerald-400">
{pendingSessionModel} next
</span>
)}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onHardRefreshProviderModels}
disabled={providerModelsRefreshing}
title="Refresh model list from providers"
aria-label="Refresh model list from providers"
className="h-9 w-9 shrink-0 rounded-xl text-muted-foreground hover:text-foreground"
>
<RefreshCw className={`h-4 w-4 ${providerModelsRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
<div className="flex min-h-0 flex-1 flex-col rounded-3xl border border-border/70 bg-muted/15 p-3 sm:p-4">
<div className="mb-2.5 grid gap-2 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="min-w-0">
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
</div>
<Badge variant="secondary" className="h-9 justify-center rounded-xl px-3 font-mono text-xs">
{filteredOptions.length} shown
</Badge>
</div>
{showSearch && (
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
)}
{filteredOptions.length > 0 ? (
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-2 md:grid-cols-2">
{filteredOptions.map((option, index) => {
const isCurrent = option.value === currentModel;
const wasCopied = copiedModel === option.value;
const isPendingSelection = option.value === pendingSessionModel;
const isChanging = option.value === changingModel;
return (
<div
key={option.value}
className={`settings-content-enter group flex min-h-[4.5rem] items-start gap-3 rounded-2xl border p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md ${
isCurrent
? 'border-primary/45 bg-primary/10'
: isPendingSelection
? 'border-emerald-500/35 bg-emerald-500/10'
: 'border-border/70 bg-background/80 hover:border-primary/30 hover:bg-background'
}`}
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
>
<button
type="button"
onClick={() => handleSelectModel(option.value)}
disabled={Boolean(changingModel)}
className="min-w-0 flex-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Use model ${option.value}`}
>
<span className="flex items-center gap-2">
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
{isCurrent && <BadgeCheck className="h-4 w-4 shrink-0 text-primary" />}
</span>
{option.label && option.label !== option.value && (
<span className="mt-1 block text-xs text-muted-foreground">{option.label}</span>
)}
{option.description && (
<span className="mt-1 block text-xs leading-5 text-muted-foreground">{option.description}</span>
)}
{isCurrent && <span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>}
{isPendingSelection && !isCurrent && (
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-400">
Next response selection
</span>
)}
{isChanging && (
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">
Applying...
</span>
)}
</button>
<button
type="button"
onClick={() => copyModel(option.value)}
className="rounded-lg border border-border/70 bg-muted/30 p-2 text-muted-foreground transition-colors group-hover:text-primary"
aria-label={`Copy model id ${option.value}`}
>
{wasCopied ? <Check className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
</button>
</div>
);
})}
</div>
{filteredOptions.length > 0 ? (
<div className="scrollbar-thin -mr-1 min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-2 md:grid-cols-2">
{filteredOptions.map((option, index) => {
const isCurrent = option.value === currentModel;
const isPendingSelection = option.value === pendingSessionModel;
const isChanging = option.value === changingModel;
return (
<button
key={option.value}
type="button"
onClick={() => handleSelectModel(option.value)}
disabled={Boolean(changingModel)}
aria-label={`Select model ${option.value}`}
className={`settings-content-enter group flex min-h-[4rem] flex-col rounded-2xl border p-3 text-left shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-60 ${
isCurrent
? 'border-primary/45 bg-primary/10'
: isPendingSelection
? 'border-emerald-500/35 bg-emerald-500/10'
: 'border-border/70 bg-background/80 hover:border-primary/30 hover:bg-background'
}`}
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
>
<span className="flex items-center justify-between gap-2">
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
{isCurrent ? (
<BadgeCheck className="h-4 w-4 shrink-0 text-primary" />
) : isChanging ? (
<RefreshCw className="h-4 w-4 shrink-0 animate-spin text-primary" />
) : null}
</span>
{option.label && option.label !== option.value && (
<span className="mt-1 text-xs font-medium text-foreground/85">{option.label}</span>
)}
{option.description && (
<span className="mt-1 text-xs leading-5 text-muted-foreground">{option.description}</span>
)}
{isCurrent && (
<span className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>
)}
{isPendingSelection && !isCurrent && (
<span className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-500 dark:text-emerald-400">
Applies next response
</span>
)}
</button>
);
})}
</div>
</div>
) : (
<div className="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
No models match that search.
</div>
)}
{/* Single quiet line of guidance / feedback */}
<p className="shrink-0 text-[11px] leading-4 text-muted-foreground">
{selectionNotice ? (
<span className="text-foreground">{selectionNotice}</span>
) : hasConcreteSessionId ? (
'Your choice applies to this session on the next response.'
) : (
<div className="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
No models match that search.
</div>
'Your choice becomes the default model for new turns.'
)}
</div>
</p>
</div>
);
}
@@ -606,7 +520,6 @@ export default function CommandResultModal({
payload,
onClose,
providerModelCatalog,
providerModelCacheCatalog,
providerModelsRefreshing,
onHardRefreshProviderModels,
currentSessionId,
@@ -624,9 +537,9 @@ export default function CommandResultModal({
icon: CircleHelp,
},
models: {
eyebrow: 'Model inventory',
title: 'Available Models',
subtitle: 'Browse, search, and copy model IDs for the active provider.',
eyebrow: 'Model selection',
title: 'Choose a Model',
subtitle: 'Pick the model this provider should use.',
icon: Cpu,
},
cost: {
@@ -700,7 +613,6 @@ export default function CommandResultModal({
<ModelsContent
data={payload.data as ModelCommandData}
providerModelCatalog={providerModelCatalog}
providerModelCacheCatalog={providerModelCacheCatalog}
providerModelsRefreshing={providerModelsRefreshing}
onHardRefreshProviderModels={onHardRefreshProviderModels}
currentSessionId={currentSessionId}

View File

@@ -8,12 +8,48 @@ import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTranslation } from 'react-i18next';
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
import { copyTextToClipboard } from '../../../../utils/clipboard';
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
type MarkdownProps = {
children: React.ReactNode;
className?: string;
};
// Links to the wider web (or in-page anchors) keep normal browser navigation;
// everything else is treated as a workspace file reference.
const isExternalHref = (href?: string): boolean =>
!!href && (/^(https?:|mailto:|tel:|data:)/i.test(href) || href.startsWith('#'));
// Strip a trailing `:line` / `:line:col` suffix (e.g. `src/foo.ts:130`).
const stripLineSuffix = (value: string): string => value.replace(/:\d+(?::\d+)?$/, '');
// A usable file path contains a separator or a filename with an extension.
const looksLikeFilePath = (value?: string): value is string => {
if (!value) {
return false;
}
const cleaned = stripLineSuffix(value.trim());
if (!cleaned || cleaned === '#') {
return false;
}
return /[\\/]/.test(cleaned) || /\.[a-z0-9]+$/i.test(cleaned);
};
// Extract plain text from link children so a reference rendered only as link
// text (e.g. `[src/foo.ts]()` with an empty href) can still be opened.
const childrenToText = (children: React.ReactNode): string => {
if (typeof children === 'string' || typeof children === 'number') {
return String(children);
}
if (Array.isArray(children)) {
return children.map(childrenToText).join('');
}
if (React.isValidElement(children)) {
return childrenToText((children.props as { children?: React.ReactNode }).children);
}
return '';
};
type CodeBlockProps = {
node?: any;
inline?: boolean;
@@ -123,11 +159,6 @@ const markdownComponents = {
{children}
</blockquote>
),
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
<a href={href} className="text-blue-600 hover:underline dark:text-blue-400" target="_blank" rel="noopener noreferrer">
{children}
</a>
),
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
table: ({ children }: { children?: React.ReactNode }) => (
<div className="my-2 overflow-x-auto">
@@ -147,10 +178,50 @@ export function Markdown({ children, className }: MarkdownProps) {
const content = normalizeInlineCodeFences(String(children ?? ''));
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
const rehypePlugins = useMemo(() => [rehypeKatex], []);
const { openFileInEditor } = usePaletteOps();
const components = useMemo(
() => ({
...markdownComponents,
a: ({ href, children: linkChildren }: { href?: string; children?: React.ReactNode }) => {
// Prefer the href when it is a real path; otherwise fall back to the
// link text, since models often emit `[src/foo.ts]()` with an empty href.
const linkText = childrenToText(linkChildren);
const fileRef = looksLikeFilePath(href) ? href : looksLikeFilePath(linkText) ? linkText : undefined;
if (fileRef && !isExternalHref(href)) {
return (
<a
href={href || fileRef}
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
onClick={(event) => {
event.preventDefault();
openFileInEditor(stripLineSuffix(fileRef));
}}
>
{linkChildren}
</a>
);
}
return (
<a
href={href}
className="text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noopener noreferrer"
>
{linkChildren}
</a>
);
},
}),
[openFileInEditor],
);
return (
<div className={className}>
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents as any}>
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={components as any}>
{content}
</ReactMarkdown>
</div>

View File

@@ -218,8 +218,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
/>
)}
{/* Tool Result Section */}
{message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
{/* Tool Result Section — Bash renders its output inside the command row above. */}
{message.toolResult && message.toolName !== 'Bash' && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
message.toolResult.isError ? (
// Error results - red error box with content
<div