chat: drop Ollama API key, clean up code execution UI

This commit is contained in:
imagineer99 2026-05-15 22:08:02 +01:00
commit a4f19171cb
2 changed files with 260 additions and 125 deletions

View file

@ -188,6 +188,11 @@ export function ChatProvidersSettings({
const [isReasoningModel, setIsReasoningModel] = useState(false); const [isReasoningModel, setIsReasoningModel] = useState(false);
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const isCustomProvider = isCustomProviderType(providerType); const isCustomProvider = isCustomProviderType(providerType);
// Ollama runs locally and does not require an API key. Hide the input
// entirely rather than just marking it optional so users aren't prompted
// for a credential the provider never uses.
const isOllamaProvider = providerType === "ollama";
const showApiKeyField = !isOllamaProvider;
const showReasoningToggle = supportsProviderReasoningToggle(providerType); const showReasoningToggle = supportsProviderReasoningToggle(providerType);
const registryByType = useMemo( const registryByType = useMemo(
@ -734,7 +739,10 @@ export function ChatProvidersSettings({
async function testProvider(provider: ExternalProviderConfig) { async function testProvider(provider: ExternalProviderConfig) {
const savedKey = getExternalProviderApiKey(provider.id).trim(); const savedKey = getExternalProviderApiKey(provider.id).trim();
if (!savedKey) { // Ollama runs locally and never requires a key — fall through to the
// real connection check instead of prompting for credentials the form
// no longer exposes.
if (!savedKey && provider.providerType !== "ollama") {
if (isCustomProviderType(provider.providerType)) { if (isCustomProviderType(provider.providerType)) {
await editProvider(provider); await editProvider(provider);
toast.info(CUSTOM_PROVIDER_MISSING_KEY_MESSAGE); toast.info(CUSTOM_PROVIDER_MISSING_KEY_MESSAGE);
@ -884,42 +892,44 @@ export function ChatProvidersSettings({
</Select> </Select>
</div> </div>
<div className="grid grid-cols-[minmax(150px,0.8fr)_minmax(260px,1.2fr)] items-center gap-4 px-4 py-3 max-sm:grid-cols-1"> {showApiKeyField ? (
<div className="flex min-w-0 flex-col gap-0.5"> <div className="grid grid-cols-[minmax(150px,0.8fr)_minmax(260px,1.2fr)] items-center gap-4 px-4 py-3 max-sm:grid-cols-1">
<Label <div className="flex min-w-0 flex-col gap-0.5">
htmlFor="provider-api-key" <Label
className="text-sm font-medium" htmlFor="provider-api-key"
> className="text-sm font-medium"
API key {isCustomProvider ? "(optional)" : ""} >
</Label> API key {isCustomProvider ? "(optional)" : ""}
<p className="text-xs leading-snug text-muted-foreground"> </Label>
Stored locally. <p className="text-xs leading-snug text-muted-foreground">
</p> Stored locally.
</p>
</div>
<div className="relative min-w-0">
<Input
id="provider-api-key"
type={showApiKey ? "text" : "password"}
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder="Enter API key"
className="h-9 pr-9 text-sm"
/>
<button
type="button"
onClick={() => setShowApiKey((visible) => !visible)}
className="absolute top-1/2 right-1.5 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
aria-pressed={showApiKey}
>
{showApiKey ? (
<Eye className="size-3.5" />
) : (
<EyeOff className="size-3.5" />
)}
</button>
</div>
</div> </div>
<div className="relative min-w-0"> ) : null}
<Input
id="provider-api-key"
type={showApiKey ? "text" : "password"}
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder="Enter API key"
className="h-9 pr-9 text-sm"
/>
<button
type="button"
onClick={() => setShowApiKey((visible) => !visible)}
className="absolute top-1/2 right-1.5 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
aria-pressed={showApiKey}
>
{showApiKey ? (
<Eye className="size-3.5" />
) : (
<EyeOff className="size-3.5" />
)}
</button>
</div>
</div>
{isCustomProvider ? ( {isCustomProvider ? (
<div className="grid grid-cols-[minmax(150px,0.8fr)_minmax(260px,1.2fr)] items-center gap-4 px-4 py-3 max-sm:grid-cols-1"> <div className="grid grid-cols-[minmax(150px,0.8fr)_minmax(260px,1.2fr)] items-center gap-4 px-4 py-3 max-sm:grid-cols-1">

View file

@ -31,6 +31,16 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@ -50,6 +60,11 @@ const AUTO_OPTION_VALUE = "__auto__";
const DEFAULT_TTL_MINUTES = 20; const DEFAULT_TTL_MINUTES = 20;
const TTL_MIN = 1; const TTL_MIN = 1;
const TTL_MAX = 10080; // one week — matches backend bound const TTL_MAX = 10080; // one week — matches backend bound
// Cadence for re-fetching the container list while the section is
// mounted. OpenAI's container TTL flips at minute granularity, so 30s
// is fast enough that an expired container loses its ACTIVE pill within
// half a minute without hammering /v1/containers.
const REFRESH_POLL_MS = 30_000;
function ageLabel(epochSeconds: number | null | undefined): string { function ageLabel(epochSeconds: number | null | undefined): string {
if (!epochSeconds) return ""; if (!epochSeconds) return "";
@ -63,6 +78,21 @@ function ageLabel(epochSeconds: number | null | undefined): string {
return `${ageDay}d ago`; return `${ageDay}d ago`;
} }
function shortContainerId(id: string): string {
// Mid-truncate keeps the "cntr_" prefix readable and still surfaces the
// tail digits users sometimes copy off OpenAI's dashboard.
if (id.length <= 18) return id;
return `${id.slice(0, 12)}${id.slice(-4)}`;
}
function isContainerRunning(c: OpenAIContainerSummary): boolean {
// OpenAI's containers API reports `status: "running"` while idle TTL is
// valid and `status: "expired"` once the idle window has passed. Treat
// a missing status as running so we don't false-positive on any older
// payloads that didn't include the field.
return c.status == null || c.status === "running";
}
interface OpenAICodeExecSectionProps { interface OpenAICodeExecSectionProps {
provider: ExternalProviderConfig; provider: ExternalProviderConfig;
apiKey: string | null; apiKey: string | null;
@ -84,6 +114,12 @@ export function OpenAICodeExecSection({
const [createTtl, setCreateTtl] = useState<number>( const [createTtl, setCreateTtl] = useState<number>(
provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES, provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES,
); );
// Target row for the destructive confirmation dialog. Held in state
// (rather than blocking with window.confirm) so the dialog sits inside
// the settings sheet instead of a native browser alert.
const [pendingDelete, setPendingDelete] =
useState<OpenAIContainerSummary | null>(null);
const [deleting, setDeleting] = useState(false);
const thread = useLiveQuery( const thread = useLiveQuery(
async () => (activeThreadId ? db.threads.get(activeThreadId) : undefined), async () => (activeThreadId ? db.threads.get(activeThreadId) : undefined),
@ -101,15 +137,27 @@ export function OpenAICodeExecSection({
[containers], [containers],
); );
// What the dropdown should display right now. We decouple this from // First running container by lastActiveAt — the auto-bind target and
// `activeContainerId` (which is whatever is in Dexie) so the user // also what we surface visually before Dexie catches up.
// immediately sees the most-recent container by name when there is const firstRunningContainer = useMemo(
// no thread binding yet, rather than a "Selecting most recent…" () => sortedContainers.find(isContainerRunning) ?? null,
// placeholder while the auto-bind effect's async write propagates [sortedContainers],
// back through useLiveQuery. The auto-bind effect still writes the );
// bind to Dexie so the chat adapter sees it on send.
// What the picker should treat as "active" right now. We decouple
// this from `activeContainerId` (Dexie state) so the user immediately
// sees the most-recent running container while the auto-bind effect's
// async write propagates. If the Dexie-bound container has since
// expired, fall back to the first running candidate — the stale-bind
// sweeper below will clear Dexie shortly after.
const boundContainer = useMemo(
() => sortedContainers.find((c) => c.id === activeContainerId) ?? null,
[sortedContainers, activeContainerId],
);
const displayedContainerId = const displayedContainerId =
activeContainerId ?? sortedContainers[0]?.id ?? null; (boundContainer && isContainerRunning(boundContainer)
? boundContainer.id
: firstRunningContainer?.id) ?? null;
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!apiKey) return; if (!apiKey) return;
@ -129,9 +177,28 @@ export function OpenAICodeExecSection({
} }
}, [apiKey, provider.baseUrl]); }, [apiKey, provider.baseUrl]);
// Fetch once when the section mounts (or provider changes). // Fetch once when the section mounts (or provider changes), then
// poll on a low cadence so an expired container's ACTIVE pill clears
// without the user clicking the refresh button. Also re-fetch when
// the tab regains visibility — covers the common case of leaving the
// sheet open across a long idle period.
useEffect(() => { useEffect(() => {
void refresh(); void refresh();
const interval = window.setInterval(() => {
if (document.visibilityState === "visible") {
void refresh();
}
}, REFRESH_POLL_MS);
const onVisibility = () => {
if (document.visibilityState === "visible") {
void refresh();
}
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
window.clearInterval(interval);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [refresh]); }, [refresh]);
// Auto-bind the active thread to the most-recently-active container // Auto-bind the active thread to the most-recently-active container
@ -150,14 +217,10 @@ export function OpenAICodeExecSection({
// the chat-adapter's lazy-create path will mint the first container // the chat-adapter's lazy-create path will mint the first container
// on first send. // on first send.
useEffect(() => { useEffect(() => {
if (!activeThreadId || activeContainerId || containers.length === 0) { if (!activeThreadId || activeContainerId || !firstRunningContainer) {
return; return;
} }
const sorted = [...containers].sort( const candidateId = firstRunningContainer.id;
(a, b) => (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0),
);
const candidate = sorted[0];
if (!candidate) return;
void (async () => { void (async () => {
try { try {
await ensureThreadRecord({ await ensureThreadRecord({
@ -165,13 +228,31 @@ export function OpenAICodeExecSection({
modelType: "base", modelType: "base",
}); });
await db.threads.update(activeThreadId, { await db.threads.update(activeThreadId, {
openaiCodeExecContainerId: candidate.id, openaiCodeExecContainerId: candidateId,
}); });
} catch { } catch {
// Best-effort; the chat-adapter will inherit/create on send. // Best-effort; the chat-adapter will inherit/create on send.
} }
})(); })();
}, [activeThreadId, activeContainerId, containers]); }, [activeThreadId, activeContainerId, firstRunningContainer]);
// Stale-bind sweeper: when the active thread is pinned to a container
// that the latest list no longer reports as running (expired by idle
// TTL, or deleted out-of-band), drop the binding so the auto-bind
// effect above can pick a healthy candidate and the chat adapter's
// lazy-create path can mint a fresh container on the next send. Only
// fires when `containers` has actually been fetched at least once to
// avoid clearing on a transient empty list during initial load.
useEffect(() => {
if (!activeThreadId || !activeContainerId) return;
if (containers.length === 0) return;
if (boundContainer && isContainerRunning(boundContainer)) return;
void db.threads
.update(activeThreadId, { openaiCodeExecContainerId: null })
.catch(() => {
/* best-effort cleanup */
});
}, [activeThreadId, activeContainerId, boundContainer, containers.length]);
const ttlValue = provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES; const ttlValue = provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES;
@ -253,15 +334,10 @@ export function OpenAICodeExecSection({
} }
}; };
const onDelete = async (id: string, name: string | null | undefined) => { const confirmDelete = async () => {
if (!apiKey) return; if (!apiKey || !pendingDelete) return;
if ( const { id, name } = pendingDelete;
!window.confirm( setDeleting(true);
`Delete container ${name || id}? Threads using it will fall back to auto-create on their next turn.`,
)
) {
return;
}
try { try {
await deleteOpenAIContainer( await deleteOpenAIContainer(
{ apiKey, baseUrl: provider.baseUrl || null }, { apiKey, baseUrl: provider.baseUrl || null },
@ -277,14 +353,19 @@ export function OpenAICodeExecSection({
), ),
); );
toast.success(`Deleted container ${name || id}`); toast.success(`Deleted container ${name || id}`);
setPendingDelete(null);
await refresh(); await refresh();
} catch (err) { } catch (err) {
toast.error( toast.error(
`Delete failed: ${err instanceof Error ? err.message : "Unknown"}`, `Delete failed: ${err instanceof Error ? err.message : "Unknown"}`,
); );
} finally {
setDeleting(false);
} }
}; };
const displayActiveId = displayedContainerId;
return ( return (
<div className="flex flex-col gap-3 pt-1"> <div className="flex flex-col gap-3 pt-1">
{/* TTL */} {/* TTL */}
@ -293,7 +374,7 @@ export function OpenAICodeExecSection({
htmlFor="openai-container-ttl" htmlFor="openai-container-ttl"
className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg" className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg"
> >
New-container idle timeout (min) Idle timeout (min)
</label> </label>
<Input <Input
id="openai-container-ttl" id="openai-container-ttl"
@ -302,23 +383,23 @@ export function OpenAICodeExecSection({
max={TTL_MAX} max={TTL_MAX}
value={ttlValue} value={ttlValue}
onChange={(e) => onTtlChange(e.target.value)} onChange={(e) => onTtlChange(e.target.value)}
className="h-8 w-24 text-sm" className="h-8 w-14 px-2 text-center text-sm tabular-nums"
/> />
</div> </div>
{/* Active container picker visually emphasized so it reads as {/* Single container list. The previously-separate "Active for
the primary control vs. the static list below. Accent this thread" picker collapses into this list: clicking a row
background + ring outline distinguish it from the plain binds it to the active thread, and the ACTIVE pill marks
bordered list items beneath. */} which one. Avoids duplicating state across two controls. */}
<div className="flex flex-col gap-1.5 rounded-md border border-primary/30 bg-primary/5 p-2.5"> <div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-[13px] font-semibold leading-[1.25] tracking-nav text-primary"> <span className="text-[11px] uppercase tracking-wider text-muted-foreground">
Active for this thread Containers
</span> </span>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-7 px-2" className="-mr-1 h-6 w-6 p-0 text-muted-foreground"
onClick={() => void refresh()} onClick={() => void refresh()}
disabled={isLoading || !apiKey} disabled={isLoading || !apiKey}
aria-label="Refresh container list" aria-label="Refresh container list"
@ -328,72 +409,80 @@ export function OpenAICodeExecSection({
/> />
</Button> </Button>
</div> </div>
{/* When no containers exist yet, render a disabled placeholder {isLoading && sortedContainers.length === 0 ? (
instead of the picker. The first one is created by the
chat-adapter on first send (lazy-create) and will appear
here after the next refresh. */}
{sortedContainers.length === 0 ? (
<div className="h-9 w-full rounded-md border border-primary/40 bg-background px-2 flex items-center text-sm text-muted-foreground">
(none yet will be created on first send)
</div>
) : (
<select
value={displayedContainerId ?? sortedContainers[0].id}
onChange={(e) => onPick(e.target.value)}
disabled={!activeThreadId}
className="h-9 w-full rounded-md border border-primary/40 bg-background px-2 text-sm font-medium shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
>
{sortedContainers.map((c) => (
<option key={c.id} value={c.id}>
{c.name ?? "(unnamed)"} · {c.id.slice(0, 14)}
{c.lastActiveAt ? ` · active ${ageLabel(c.lastActiveAt)}` : ""}
</option>
))}
</select>
)}
</div>
{/* Container list with delete actions labeled and visually
quieter so it's clearly the "all containers, manage them"
area rather than the active selector above. */}
<div className="flex flex-col gap-1.5">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
All containers
</span>
{isLoading && containers.length === 0 ? (
<Skeleton className="h-16 w-full" /> <Skeleton className="h-16 w-full" />
) : containers.length > 0 ? ( ) : sortedContainers.length > 0 ? (
<ul className="flex flex-col gap-1 max-h-44 overflow-auto"> <ul className="flex max-h-52 flex-col gap-1 overflow-auto">
{containers.map((c) => { {sortedContainers.map((c) => {
const isActive = c.id === activeContainerId; const running = isContainerRunning(c);
const isActive = running && c.id === displayActiveId;
const ttlMinutes = c.expiresAfterMinutes ?? DEFAULT_TTL_MINUTES;
const canActivate =
activeThreadId != null && !isActive && running;
const statusLabel = !running
? (c.status ?? "expired")
: null;
return ( return (
<li <li
key={c.id} key={c.id}
className={`flex items-center justify-between gap-2 rounded-md border px-2 py-1.5 text-xs ${ className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-xs transition-colors ${
isActive isActive
? "border-primary/30 bg-primary/5" ? "border-primary/30 bg-primary/5"
: "border-border/60" : "border-border/60 hover:bg-muted/40"
} ${canActivate ? "cursor-pointer" : ""} ${
running ? "" : "opacity-60"
}`} }`}
onClick={() => {
if (canActivate) void onPick(c.id);
}}
role={canActivate ? "button" : undefined}
aria-pressed={isActive}
title={
canActivate
? "Use this container for the active thread"
: !running
? `Container is ${statusLabel}`
: undefined
}
> >
<div className="flex min-w-0 flex-col"> {/* min-w-0 + truncate keeps long OpenAI container ids
<span className="truncate font-medium"> from spilling under the trash button on narrow
{c.name ?? "(unnamed)"} settings sheets. */}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate font-medium">
{c.name ?? "(unnamed)"}
</span>
{isActive ? ( {isActive ? (
<span className="ml-1.5 text-[10px] font-normal uppercase tracking-wider text-primary"> <span className="shrink-0 rounded-sm bg-primary/15 px-1 py-px text-[9px] font-medium uppercase tracking-wider text-primary">
· active Active
</span>
) : statusLabel ? (
<span className="shrink-0 rounded-sm bg-muted px-1 py-px text-[9px] font-medium uppercase tracking-wider text-muted-foreground">
{statusLabel}
</span> </span>
) : null} ) : null}
</span> </div>
<span className="text-muted-foreground"> <div
{c.id} · TTL{" "} className="flex min-w-0 items-center gap-1.5 text-muted-foreground"
{c.expiresAfterMinutes ?? DEFAULT_TTL_MINUTES}m title={c.id}
</span> >
<span className="min-w-0 truncate font-mono text-[11px]">
{shortContainerId(c.id)}
</span>
<span className="shrink-0 text-[10px] uppercase tracking-wider">
· {ttlMinutes}m
</span>
</div>
</div> </div>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-6 w-6 p-0 text-destructive" className="h-6 w-6 shrink-0 p-0 text-muted-foreground hover:text-destructive"
onClick={() => void onDelete(c.id, c.name)} onClick={(e) => {
e.stopPropagation();
setPendingDelete(c);
}}
aria-label={`Delete container ${c.name ?? c.id}`} aria-label={`Delete container ${c.name ?? c.id}`}
> >
<TrashIcon className="size-3.5" /> <TrashIcon className="size-3.5" />
@ -404,8 +493,7 @@ export function OpenAICodeExecSection({
</ul> </ul>
) : ( ) : (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
No saved containers yet. Use auto-create or create a named None yet one will be created on first send.
one below.
</p> </p>
)} )}
</div> </div>
@ -472,6 +560,43 @@ export function OpenAICodeExecSection({
New container New container
</Button> </Button>
)} )}
<AlertDialog
open={pendingDelete !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen && deleting) return;
if (!nextOpen) setPendingDelete(null);
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>
Delete{" "}
<span className="font-mono">
{pendingDelete?.name ?? "container"}
</span>
?
</AlertDialogTitle>
<AlertDialogDescription>
Threads using this container will fall back to auto-create on
their next turn. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={(e) => {
e.preventDefault();
void confirmDelete();
}}
>
{deleting ? "Deleting…" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }