diff --git a/studio/frontend/src/features/chat/chat-providers-dialog.tsx b/studio/frontend/src/features/chat/chat-providers-dialog.tsx
index 0127369eb4..ea99ada0e0 100644
--- a/studio/frontend/src/features/chat/chat-providers-dialog.tsx
+++ b/studio/frontend/src/features/chat/chat-providers-dialog.tsx
@@ -188,6 +188,11 @@ export function ChatProvidersSettings({
const [isReasoningModel, setIsReasoningModel] = useState(false);
const reduceMotion = useReducedMotion();
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 registryByType = useMemo(
@@ -734,7 +739,10 @@ export function ChatProvidersSettings({
async function testProvider(provider: ExternalProviderConfig) {
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)) {
await editProvider(provider);
toast.info(CUSTOM_PROVIDER_MISSING_KEY_MESSAGE);
@@ -884,42 +892,44 @@ export function ChatProvidersSettings({
-
diff --git a/studio/frontend/src/features/chat/components/openai-code-exec-section.tsx b/studio/frontend/src/features/chat/components/openai-code-exec-section.tsx
index 15cb79286c..d7aecd6c06 100644
--- a/studio/frontend/src/features/chat/components/openai-code-exec-section.tsx
+++ b/studio/frontend/src/features/chat/components/openai-code-exec-section.tsx
@@ -31,6 +31,16 @@
import { useCallback, useEffect, useMemo, useState } from "react";
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 { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
@@ -50,6 +60,11 @@ const AUTO_OPTION_VALUE = "__auto__";
const DEFAULT_TTL_MINUTES = 20;
const TTL_MIN = 1;
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 {
if (!epochSeconds) return "";
@@ -63,6 +78,21 @@ function ageLabel(epochSeconds: number | null | undefined): string {
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 {
provider: ExternalProviderConfig;
apiKey: string | null;
@@ -84,6 +114,12 @@ export function OpenAICodeExecSection({
const [createTtl, setCreateTtl] = useState(
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(null);
+ const [deleting, setDeleting] = useState(false);
const thread = useLiveQuery(
async () => (activeThreadId ? db.threads.get(activeThreadId) : undefined),
@@ -101,15 +137,27 @@ export function OpenAICodeExecSection({
[containers],
);
- // What the dropdown should display right now. We decouple this from
- // `activeContainerId` (which is whatever is in Dexie) so the user
- // immediately sees the most-recent container by name when there is
- // no thread binding yet, rather than a "Selecting most recent…"
- // placeholder while the auto-bind effect's async write propagates
- // back through useLiveQuery. The auto-bind effect still writes the
- // bind to Dexie so the chat adapter sees it on send.
+ // First running container by lastActiveAt — the auto-bind target and
+ // also what we surface visually before Dexie catches up.
+ const firstRunningContainer = useMemo(
+ () => sortedContainers.find(isContainerRunning) ?? null,
+ [sortedContainers],
+ );
+
+ // 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 =
- activeContainerId ?? sortedContainers[0]?.id ?? null;
+ (boundContainer && isContainerRunning(boundContainer)
+ ? boundContainer.id
+ : firstRunningContainer?.id) ?? null;
const refresh = useCallback(async () => {
if (!apiKey) return;
@@ -129,9 +177,28 @@ export function OpenAICodeExecSection({
}
}, [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(() => {
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]);
// 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
// on first send.
useEffect(() => {
- if (!activeThreadId || activeContainerId || containers.length === 0) {
+ if (!activeThreadId || activeContainerId || !firstRunningContainer) {
return;
}
- const sorted = [...containers].sort(
- (a, b) => (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0),
- );
- const candidate = sorted[0];
- if (!candidate) return;
+ const candidateId = firstRunningContainer.id;
void (async () => {
try {
await ensureThreadRecord({
@@ -165,13 +228,31 @@ export function OpenAICodeExecSection({
modelType: "base",
});
await db.threads.update(activeThreadId, {
- openaiCodeExecContainerId: candidate.id,
+ openaiCodeExecContainerId: candidateId,
});
} catch {
// 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;
@@ -253,15 +334,10 @@ export function OpenAICodeExecSection({
}
};
- const onDelete = async (id: string, name: string | null | undefined) => {
- if (!apiKey) return;
- if (
- !window.confirm(
- `Delete container ${name || id}? Threads using it will fall back to auto-create on their next turn.`,
- )
- ) {
- return;
- }
+ const confirmDelete = async () => {
+ if (!apiKey || !pendingDelete) return;
+ const { id, name } = pendingDelete;
+ setDeleting(true);
try {
await deleteOpenAIContainer(
{ apiKey, baseUrl: provider.baseUrl || null },
@@ -277,14 +353,19 @@ export function OpenAICodeExecSection({
),
);
toast.success(`Deleted container ${name || id}`);
+ setPendingDelete(null);
await refresh();
} catch (err) {
toast.error(
`Delete failed: ${err instanceof Error ? err.message : "Unknown"}`,
);
+ } finally {
+ setDeleting(false);
}
};
+ const displayActiveId = displayedContainerId;
+
return (
- {/* Active container picker — visually emphasized so it reads as
- the primary control vs. the static list below. Accent
- background + ring outline distinguish it from the plain
- bordered list items beneath. */}
-
+ {/* Single container list. The previously-separate "Active for
+ this thread" picker collapses into this list: clicking a row
+ binds it to the active thread, and the ACTIVE pill marks
+ which one. Avoids duplicating state across two controls. */}
+
-
- Active for this thread
+
+ Containers
- {/* When no containers exist yet, render a disabled placeholder
- 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 ? (
-
- (none yet — will be created on first send)
-
- ) : (
-
- )}
-
-
- {/* 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. */}
-