chat: drop Ollama API key, clean up code execution UI
This commit is contained in:
parent
2622b79606
commit
a4f19171cb
2 changed files with 260 additions and 125 deletions
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue