studio/chat: drop "Auto-create per thread" option, default to latest

The dropdown previously offered "Auto-create per thread" as an
explicit value (null in storage), with the chat-adapter then
inheriting from the most recent container at send-time. That made
the picker display disagree with what the backend would actually do:
the picker said "auto", but the backend was reusing an existing
container.

Behaviour after this commit, when code execution is enabled on an
OpenAI cloud provider:
- Containers list non-empty: dropdown defaults to the container with
  the latest lastActiveAt, eagerly bound via ensureThreadRecord +
  db.threads.update so the bind survives even when the thread row
  has not been materialized by the chat adapter yet. User can pick
  any other container in the list.
- Containers list empty: render a disabled placeholder "(none yet —
  will be created on first send)". The chat-adapter's lazy-create
  path (chat-adapter.ts:1040-1082) mints the first container on
  first send and writes it back to the thread; the next refresh
  surfaces it in the picker.

Expiration mid-operation is unchanged: the existing
container_invalidated _toolEvent clears the thread's stored id and
the next turn re-creates.
This commit is contained in:
Roland Tannous 2026-05-15 23:20:31 +04:00
commit d0cbeb99b6

View file

@ -115,19 +115,20 @@ export function OpenAICodeExecSection({
}, [refresh]); }, [refresh]);
// Auto-bind the active thread to the most-recently-active container // Auto-bind the active thread to the most-recently-active container
// when the thread has none set. Mirrors the chat-adapter's cross- // whenever the thread has none set and at least one container exists
// thread inheritance so the picker shows the same default the // on the user's OpenAI account. Sorting by `lastActiveAt` matches
// backend would use, and so the user doesn't have to manually // what feels "most recent" from the user's perspective.
// re-pick on every new thread. Sorting by `lastActiveAt` lines up
// with what feels "most recent" from the user's perspective. The
// user can still pick "Auto-create per thread" explicitly to start
// fresh.
// //
// Dexie's `update()` returns 0 affected rows when the thread record // We eagerly materialize the thread row via `ensureThreadRecord` so
// is not yet persisted (empty-state composer, no message sent). We // the bind actually lands in Dexie before the user has sent a first
// silently ignore that here because auto-bind is best-effort; the // message. This does NOT create anything at OpenAI — only a local
// user-initiated `onPick` below surfaces the same condition as a // ThreadRecord — so it does not bypass the user's expectation that
// toast so the user understands why the picker didn't stick. // a fresh OpenAI container is not created until first send.
//
// If `containers` is empty (no OpenAI containers exist yet), this
// effect short-circuits: the picker renders an empty-state hint and
// the chat-adapter's lazy-create path will mint the first container
// on first send.
useEffect(() => { useEffect(() => {
if (!activeThreadId || activeContainerId || containers.length === 0) { if (!activeThreadId || activeContainerId || containers.length === 0) {
return; return;
@ -137,11 +138,19 @@ export function OpenAICodeExecSection({
); );
const candidate = sorted[0]; const candidate = sorted[0];
if (!candidate) return; if (!candidate) return;
void db.threads void (async () => {
.update(activeThreadId, { try {
openaiCodeExecContainerId: candidate.id, await ensureThreadRecord({
}) threadId: activeThreadId,
.catch(() => {}); modelType: "base",
});
await db.threads.update(activeThreadId, {
openaiCodeExecContainerId: candidate.id,
});
} catch {
// Best-effort; the chat-adapter will inherit/create on send.
}
})();
}, [activeThreadId, activeContainerId, containers]); }, [activeThreadId, activeContainerId, containers]);
const ttlValue = provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES; const ttlValue = provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES;
@ -154,24 +163,21 @@ export function OpenAICodeExecSection({
}; };
const onPick = async (value: string) => { const onPick = async (value: string) => {
if (!activeThreadId) return; if (!activeThreadId || !value) return;
const next = value === AUTO_OPTION_VALUE ? null : value; // value is always a container id now — the "Auto-create per thread"
// The thread row is normally materialized on first send by the // option has been removed in favour of always defaulting to the
// chat adapter. When the user picks a container before sending the // most-recently-active container. The chat-adapter still handles
// first message, the row does not exist yet and Dexie's `update` // the no-containers-exist case (lazy-create on first send).
// returns 0 rows — the selection would silently no-op and the //
// picker would snap back to "Auto-create per thread". Eagerly // ensureThreadRecord materializes the thread row eagerly (modelType
// create the row so the user can pin a container up front. // "base" — settings sheet is single-thread-mode only) so the update
// modelType "base" is correct here: the settings sheet that hosts // actually lands when the user hasn't sent a message yet.
// this section is only rendered in single-thread mode, where the
// chat-page passes modelType="base" into ChatRuntimeProvider.
try { try {
await ensureThreadRecord({ threadId: activeThreadId, modelType: "base" }); await ensureThreadRecord({ threadId: activeThreadId, modelType: "base" });
const affected = await db.threads.update(activeThreadId, { const affected = await db.threads.update(activeThreadId, {
openaiCodeExecContainerId: next, openaiCodeExecContainerId: value,
}); });
if (affected === 0) { if (affected === 0) {
// Defensive: ensureThreadRecord should have written the row.
toast.error("Could not update thread."); toast.error("Could not update thread.");
} }
} catch (err) { } catch (err) {
@ -290,20 +296,38 @@ export function OpenAICodeExecSection({
/> />
</Button> </Button>
</div> </div>
<select {/* When no containers exist yet, render a disabled placeholder
value={activeContainerId ?? AUTO_OPTION_VALUE} instead of the picker. The first one is created by the
onChange={(e) => onPick(e.target.value)} chat-adapter on first send (lazy-create) and will appear
disabled={!activeThreadId} here after the next refresh. */}
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" {containers.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">
<option value={AUTO_OPTION_VALUE}>Auto-create per thread</option> (none yet will be created on first send)
{containers.map((c) => ( </div>
<option key={c.id} value={c.id}> ) : (
{c.name ?? "(unnamed)"} · {c.id.slice(0, 14)} <select
{c.lastActiveAt ? ` · active ${ageLabel(c.lastActiveAt)}` : ""} value={activeContainerId ?? ""}
</option> onChange={(e) => onPick(e.target.value)}
))} disabled={!activeThreadId}
</select> 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"
>
{/* Empty placeholder option only renders while the
auto-bind effect is in flight (activeContainerId still
null). React requires a matching <option> for the
controlled value, so we render a hidden one. */}
{activeContainerId === null ? (
<option value="" disabled hidden>
Selecting most recent
</option>
) : null}
{containers.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> </div>
{/* Container list with delete actions labeled and visually {/* Container list with delete actions labeled and visually