Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/extension/entrypoints/sidepanel/agent-chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,7 @@ export const AgentChatPanel = ({

<footer className="border-t border-border bg-surface-raised px-4 py-2">
<AgentFooterControls
auth={auth}
contextDonut={contextDonut}
inspectableTabs={inspectableTabs}
isLoadingTabs={isLoadingTabs}
Expand All @@ -906,6 +907,7 @@ export const AgentChatPanel = ({
onThinkingEffortChange={nextThinkingEffort => {
updateActiveConversationSettings({ thinkingEffort: nextThinkingEffort });
}}
organizationId={organizationId}
selectedTabId={selectedTabId}
tabDebuggerError={tabDebuggerError}
thinkingEffort={thinkingEffort}
Expand Down
29 changes: 13 additions & 16 deletions apps/extension/entrypoints/sidepanel/agent-footer-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { useState } from 'react';
import type { JSX, ReactNode } from 'react';
import { Shield, TriangleAlert } from 'lucide-react';
import { getFooterControlDisplay } from '@/src/shared/agent-chat-placeholder';
import type { StoredAuth } from '@/src/shared/auth';
import { thinkingEffortLabel } from '@/src/shared/kilo-api-client';
import type { KiloGatewayModelOption } from '@/src/shared/kilo-api-client';
import type { InspectableTab } from '@/src/shared/tab-debugger';
import { ModelPicker } from './model-picker';

const modeOptions = [
{ label: 'Safe', value: 'safe' },
Expand Down Expand Up @@ -129,6 +131,7 @@ const ModeControl = ({
};

export const AgentFooterControls = ({
auth,
contextDonut,
inspectableTabs,
isLoadingTabs,
Expand All @@ -145,11 +148,13 @@ export const AgentFooterControls = ({
onRetryModels,
onSelectedTabChange,
onThinkingEffortChange,
organizationId,
selectedTabId,
tabDebuggerError,
thinkingEffort,
thinkingOptions,
}: {
auth: StoredAuth;
contextDonut?: ReactNode;
inspectableTabs: InspectableTab[];
isLoadingTabs: boolean;
Expand All @@ -166,6 +171,7 @@ export const AgentFooterControls = ({
onRetryModels: () => Promise<void>;
onSelectedTabChange: (tabId: number) => void;
onThinkingEffortChange: (thinkingEffort: string) => void;
organizationId: string | undefined;
selectedTabId: number | undefined;
tabDebuggerError: string | undefined;
thinkingEffort: string;
Expand Down Expand Up @@ -217,23 +223,14 @@ export const AgentFooterControls = ({
mode={mode}
onChange={onModeChange}
/>
<CompactSelectControl
ariaLabel="Model"
className="flex-1 pl-2 pr-6"
<ModelPicker
auth={auth}
disabled={isConversationControlDisabled || isModelSelectDisabled}
onChange={onModelChange}
value={model}
>
{modelOptions.length === 0 ? (
<option value="">Loading models...</option>
) : (
modelOptions.map(option => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))
)}
</CompactSelectControl>
model={model}
modelOptions={modelOptions}
onModelChange={onModelChange}
organizationId={organizationId}
/>
<CompactSelectControl
ariaLabel="Thinking effort"
className="w-24 pl-2 pr-6"
Expand Down
97 changes: 97 additions & 0 deletions apps/extension/entrypoints/sidepanel/model-picker-row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { BookOpenCheck, Check, Star } from 'lucide-react';
import type { JSX } from 'react';
import type { KiloGatewayModelOption } from '@/src/shared/kilo-api-client';

const rowButtonClass =
'flex min-w-0 flex-1 items-start gap-2 rounded-md px-2 py-2 text-left outline-none transition hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-brand-primary-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-background';

const starButtonClass =
'flex size-8 shrink-0 items-center justify-center rounded-md text-foreground-muted outline-none transition hover:bg-surface-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-brand-primary-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-background disabled:cursor-not-allowed disabled:opacity-50';

const chipClass =
'rounded-full border border-border bg-surface-overlay px-1.5 py-0.5 type-label text-foreground-muted';

export const ModelPickerModelRow = ({
isFavorite,
isSelected,
model,
onSelect,
onToggleFavorite,
showStar,
starDisabled,
}: {
readonly isFavorite: boolean;
readonly isSelected: boolean;
readonly model: KiloGatewayModelOption;
readonly onSelect: (modelId: string) => void;
readonly onToggleFavorite: (model: KiloGatewayModelOption) => void;
readonly showStar: boolean;
readonly starDisabled: boolean;
}): JSX.Element => {
const showFree = model.isFree === true && model.hasUserByokAvailable !== true;
const showByok = model.hasUserByokAvailable === true;
const showDataCollected = model.mayTrainOnYourPrompts === true;

return (
<div className="flex items-start gap-1 px-1 py-0.5" data-model-row={model.id}>
<button
aria-current={isSelected ? 'true' : undefined}
aria-label={model.name}
className={
isSelected ? `${rowButtonClass} bg-surface-selected text-foreground` : rowButtonClass
}
data-model-id={model.id}
onClick={() => {
onSelect(model.id);
}}
type="button"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">{model.name}</span>
<span className="mt-0.5 block truncate font-mono text-xs text-foreground-muted">
{model.id}
</span>
{showFree || showByok || showDataCollected ? (
<span className="mt-1 flex flex-wrap items-center gap-1">
{showFree ? <span className={chipClass}>Free</span> : null}
{showByok ? <span className={chipClass}>BYOK</span> : null}
{showDataCollected ? (
<BookOpenCheck
aria-label="Data collected"
className="size-3.5 text-status-yellow-500"
role="img"
/>
) : null}
</span>
) : null}
</span>
{isSelected ? (
<Check aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-brand-primary" />
) : null}
</button>
{showStar ? (
<button
aria-label={
isFavorite ? `Remove ${model.name} from favorites` : `Add ${model.name} to favorites`
}
aria-pressed={isFavorite}
className={starButtonClass}
disabled={starDisabled}
onClick={() => {
onToggleFavorite(model);
}}
type="button"
>
<Star
aria-hidden="true"
className={
isFavorite
? 'size-4 fill-brand-primary text-brand-primary'
: 'size-4 text-foreground-muted'
}
/>
</button>
) : null}
</div>
);
};
211 changes: 211 additions & 0 deletions apps/extension/entrypoints/sidepanel/model-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { ChevronsUpDown, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { JSX } from 'react';
import type { StoredAuth } from '@/src/shared/auth';
import type { KiloGatewayModelOption } from '@/src/shared/kilo-api-client';
import { buildExtensionModelPickerRows } from '@/src/shared/model-picker-rows';
import { ModelPickerModelRow } from './model-picker-row';
import { useModelPreferences } from './use-model-preferences';

const triggerClassName =
'flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md border border-border-strong bg-input-bg px-2 type-label text-foreground outline-none transition focus-visible:ring-2 focus-visible:ring-brand-primary-ring ring-offset-2 ring-offset-surface-background disabled:cursor-not-allowed disabled:text-foreground-subtle';

const secondaryButtonClassName =
'h-8 shrink-0 rounded-md border border-border bg-surface-overlay px-2 type-label text-foreground-on-secondary outline-none transition hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-brand-primary-ring ring-offset-2 ring-offset-surface-background';

export const ModelPicker = ({
auth,
disabled,
model,
modelOptions,
onModelChange,
organizationId,
}: {
readonly auth: StoredAuth;
readonly disabled: boolean;
readonly model: string;
readonly modelOptions: readonly KiloGatewayModelOption[];
readonly onModelChange: (modelId: string) => void;
readonly organizationId: string | undefined;
}): JSX.Element => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const searchInputRef = useRef<HTMLInputElement | null>(null);
const selectedRowRef = useRef<HTMLDivElement | null>(null);
const { favorites, refetch, status, toggleError, toggleFavorite } = useModelPreferences({
auth,
organizationId,
});

const selectedOption = modelOptions.find(option => option.id === model);
const triggerLabel =
modelOptions.length === 0 ? 'Loading models...' : (selectedOption?.name ?? model);

const rows = useMemo(
() =>
buildExtensionModelPickerRows({
favoriteIds: favorites,
models: modelOptions,
search,
}),
[favorites, modelOptions, search]
);

useEffect(() => {
if (!isOpen) {
return;
}

searchInputRef.current?.focus();
selectedRowRef.current?.scrollIntoView({ block: 'nearest' });
}, [isOpen]);

const closeOverlay = (): void => {
setIsOpen(false);
setSearch('');
};

const handleSelect = (modelId: string): void => {
onModelChange(modelId);
closeOverlay();
};

return (
<div className="relative min-w-0 flex-1">
<button
aria-label="Model"
className={triggerClassName}
data-model-id={modelOptions.length === 0 || model === '' ? undefined : model}
disabled={disabled}
onClick={() => {
if (disabled) {
return;
}

setSearch('');
setIsOpen(true);
}}
type="button"
>
<span className="min-w-0 flex-1 truncate text-left">{triggerLabel}</span>
<ChevronsUpDown aria-hidden="true" className="size-3.5 shrink-0 text-foreground-muted" />
</button>

{isOpen ? (
<div
aria-label="Select model"
aria-modal="true"
className="agent-conversation-scrollbar fixed inset-0 z-30 flex flex-col overflow-y-auto bg-surface-background"
role="dialog"
>
<div className="sticky top-0 z-10 flex h-14 shrink-0 items-center justify-between border-b border-border bg-surface-raised px-4">
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">Select model</p>
<p className="type-label text-foreground-muted">Search and favorites</p>
</div>
<button
aria-label="Close model picker"
className="flex size-8 items-center justify-center rounded-md border border-border bg-surface-overlay text-foreground-on-secondary transition hover:bg-surface-hover outline-none focus-visible:ring-2 focus-visible:ring-brand-primary-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-background"
onClick={closeOverlay}
type="button"
>
<X aria-hidden="true" className="size-4" />
</button>
</div>

<div className="border-b border-border px-3 py-3">
<input
aria-label="Search models"
className="h-9 w-full rounded-md border border-border-strong bg-input-bg px-3 type-label text-foreground outline-none transition placeholder:text-foreground-subtle focus-visible:ring-2 focus-visible:ring-brand-primary-ring ring-offset-2 ring-offset-surface-background"
onChange={event => {
setSearch(event.currentTarget.value);
}}
placeholder="Search models..."
ref={searchInputRef}
type="search"
value={search}
/>
</div>

{status === 'retryable' ? (
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2">
<p className="type-label text-status-red-400">Couldn&apos;t load favorites.</p>
<button
className={secondaryButtonClassName}
onClick={() => {
void refetch();
}}
type="button"
>
Retry
</button>
</div>
) : null}

{status === 'terminal' ? (
<p className="border-b border-border px-4 py-2 type-label text-foreground-muted">
Favorites aren&apos;t available here.
</p>
) : null}

{toggleError ? (
<p className="border-b border-border px-4 py-2 type-label text-status-red-400">
Couldn&apos;t update favorites.
</p>
) : null}

<div className="px-2 py-2">
{rows.length === 0 && search.trim().length > 0 ? (
<div className="grid gap-3 px-2 py-8 text-center">
<p className="type-body text-foreground-muted">
No models match &quot;{search}&quot;.
</p>
<button
className={`${secondaryButtonClassName} mx-auto`}
onClick={() => {
setSearch('');
}}
type="button"
>
Clear search
</button>
</div>
) : (
// Ponytail: no list virtualization — gateway catalog is a few hundred models.
<>
{rows.map(row => {
if (row.type === 'header') {
return (
<p
className="type-eyebrow px-3 pb-1 pt-3 text-foreground-muted"
key={row.key}
>
{row.title}
</p>
);
}

const isSelected = row.model.id === model;

return (
<div key={row.key} ref={isSelected ? selectedRowRef : undefined}>
<ModelPickerModelRow
isFavorite={row.isFavorite}
isSelected={isSelected}
model={row.model}
onSelect={handleSelect}
onToggleFavorite={toggleFavorite}
showStar={status !== 'terminal'}
starDisabled={status === 'loading'}
/>
</div>
);
})}
</>
)}
</div>
</div>
) : null}
</div>
);
};
Loading