From bc28a5d80e3825a534376ea7706f0da0161d9a8f Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Sat, 16 May 2026 18:14:01 +0100 Subject: [PATCH] Studio: code execution config visual polish (#5471) * style: polish code execution * studio/chat: optimistic insert for created OpenAI containers Container creation now prepends the row immediately with a "Creating" pill instead of waiting on /v1/containers, which is eventually consistent and can lag the create response by several seconds. A 5s follow-up refresh reconciles with the server view. --------- Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Co-authored-by: Roland Tannous --- .../src/features/chat/chat-settings-sheet.tsx | 2 +- .../components/openai-code-exec-section.tsx | 234 ++++++++++-------- 2 files changed, 137 insertions(+), 99 deletions(-) diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index 6de7d6a330..394601f07b 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -243,7 +243,7 @@ function loadSavedActivePreset(): string { } } -function InfoHint({ children }: { children: ReactNode }) { +export function InfoHint({ children }: { children: ReactNode }) { return ( 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 d76b4a282f..21c3bb1ed0 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 @@ -29,7 +29,7 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { AlertDialog, @@ -43,7 +43,6 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Skeleton } from "@/components/ui/skeleton"; import { TrashIcon, RefreshCwIcon, PlusIcon } from "lucide-react"; import { createOpenAIContainer, @@ -55,6 +54,7 @@ import { db } from "../db"; import type { ExternalProviderConfig } from "../external-providers"; import { useLiveQuery } from "../db"; import { ensureThreadRecord } from "../runtime-provider"; +import { InfoHint } from "../chat-settings-sheet"; const AUTO_OPTION_VALUE = "__auto__"; const DEFAULT_TTL_MINUTES = 20; @@ -111,9 +111,6 @@ export function OpenAICodeExecSection({ const [creating, setCreating] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [createName, setCreateName] = useState(""); - const [createTtl, setCreateTtl] = useState( - provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES, - ); // Ids that have been deleted in this session. Once tombstoned, an id // stays hidden from the picker for the lifetime of the page — OpenAI's // /containers list can keep returning a freshly-deleted id for an @@ -121,6 +118,22 @@ export function OpenAICodeExecSection({ // creates more confusion than it solves. Refreshing the page resets // the tombstone naturally. const [tombstones, setTombstones] = useState>(() => new Set()); + // Ids optimistically inserted after a successful create but not yet + // confirmed by a /v1/containers list response. OpenAI's list endpoint + // is eventually consistent — a freshly-created container can be absent + // for several seconds. We render the row immediately with a "Creating" + // pill, then drop it from this set once a refresh sees the id. + const [pendingIds, setPendingIds] = useState>(() => new Set()); + // Ref mirror so `refresh()` can read the current pending set without + // re-binding when it changes (the callback is in a useEffect dep). + const pendingIdsRef = useRef>(pendingIds); + useEffect(() => { + pendingIdsRef.current = pendingIds; + }, [pendingIds]); + // One-shot follow-up refresh scheduled after a create, to catch the + // common case where the server list lags the create response by a few + // seconds. Tracked so we can clear it on unmount. + const pendingRetryRef = useRef(null); // 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. @@ -182,7 +195,24 @@ export function OpenAICodeExecSection({ apiKey, baseUrl: provider.baseUrl || null, }); - setContainers(list); + const serverIds = new Set(list.map((c) => c.id)); + setContainers((prev) => { + // Preserve optimistic inserts the server hasn't acknowledged + // yet so they don't disappear on the reconciling refresh. + const orphans = prev.filter( + (c) => !serverIds.has(c.id) && pendingIdsRef.current.has(c.id), + ); + return orphans.length > 0 ? [...orphans, ...list] : list; + }); + setPendingIds((prev) => { + if (prev.size === 0) return prev; + const next = new Set(prev); + let changed = false; + for (const id of serverIds) { + if (next.delete(id)) changed = true; + } + return changed ? next : prev; + }); } catch (err) { toast.error( `Failed to list containers: ${err instanceof Error ? err.message : "Unknown"}`, @@ -213,6 +243,10 @@ export function OpenAICodeExecSection({ return () => { window.clearInterval(interval); document.removeEventListener("visibilitychange", onVisibility); + if (pendingRetryRef.current != null) { + window.clearTimeout(pendingRetryRef.current); + pendingRetryRef.current = null; + } }; }, [refresh]); @@ -300,15 +334,43 @@ export function OpenAICodeExecSection({ toast.error("Container name is required"); return; } + // TTL inherits from the section-level "Idle timeout" control — + // there is no per-container override on the form. Read it at + // submit time so a last-second change to the TTL row applies. + const ttlMinutes = + provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES; setCreating(true); try { const created = await createOpenAIContainer( { apiKey, baseUrl: provider.baseUrl || null }, - { name, ttlMinutes: createTtl }, + { name, ttlMinutes }, ); toast.success(`Created container ${name}`); setCreateName(""); setCreateOpen(false); + // Optimistic insert + "Creating" pill. OpenAI's /v1/containers + // list endpoint is eventually consistent and can omit the new + // container for several seconds — without this, the row only + // shows up on the next 30s poll or a manual refresh. + setContainers((prev) => + prev.some((c) => c.id === created.id) ? prev : [created, ...prev], + ); + setPendingIds((prev) => { + if (prev.has(created.id)) return prev; + const next = new Set(prev); + next.add(created.id); + return next; + }); + // Follow-up refresh ~5s later to reconcile the optimistic row + // with the server's view once /v1/containers catches up. One + // shot; the regular poll covers any longer tail. + if (pendingRetryRef.current != null) { + window.clearTimeout(pendingRetryRef.current); + } + pendingRetryRef.current = window.setTimeout(() => { + pendingRetryRef.current = null; + void refresh(); + }, 5000); // Auto-bind the just-created container to the active thread. // ensureThreadRecord first so the bind lands even when the user // creates a container before sending the first message — without @@ -389,12 +451,18 @@ export function OpenAICodeExecSection({
{/* TTL */}
- +
+ + + Minutes a newly-created container stays alive between calls. + OpenAI caps this at 20. + +
- {/* 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. */} + {/* Single container list. Clicking a row binds it to the active + thread and the ACTIVE pill marks which one — no separate + picker needed. */}
@@ -428,45 +495,20 @@ export function OpenAICodeExecSection({ />
- {/* 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) + // Quiet placeholder with the same muted border as row cards + // so an empty section doesn't masquerade as an active control. + // The first container is minted by the chat-adapter on first + // send (lazy-create) and appears here after the next refresh. +
+ None yet - one 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. */} -
- - All containers - - {isLoading && visibleContainers.length === 0 ? ( - - ) : sortedContainers.length > 0 ? (
    {sortedContainers.map((c) => { const running = isContainerRunning(c); const isActive = running && c.id === displayActiveId; + const isPending = pendingIds.has(c.id); const ttlMinutes = c.expiresAfterMinutes ?? DEFAULT_TTL_MINUTES; const canActivate = activeThreadId != null && !isActive && running; @@ -510,7 +552,11 @@ export function OpenAICodeExecSection({ {c.name ?? "(unnamed)"} - {isActive ? ( + {isPending ? ( + + Creating + + ) : isActive ? ( Active @@ -548,69 +594,61 @@ export function OpenAICodeExecSection({ ); })}
- ) : ( -

- None yet — one will be created on first send. -

)}
- {/* Create new */} + {/* Create new — inline single-row edit that visually echoes a + container card. TTL is inherited from the section's top + "Idle timeout" control (no per-container override), which + keeps the form light and avoids a duplicated input. */} {createOpen ? ( -
+
setCreateName(e.target.value)} - className="h-8 text-sm" - /> -
- { - const n = parseInt(e.target.value, 10); - if (!Number.isNaN(n)) - setCreateTtl(Math.min(Math.max(n, TTL_MIN), TTL_MAX)); - }} - className="h-8 w-24 text-sm" - aria-label="Idle timeout in minutes" - /> - min idle -
- - -
+ } + }} + className="h-7 min-w-0 flex-1 border-0 bg-transparent px-1.5 text-xs shadow-none focus-visible:ring-0" + /> + +
) : (