studio/chat: inherit container across threads + styled active picker
New threads on the same OpenAI provider now default to the most recently used container instead of "Auto-create per thread" — both in the chat-adapter (so a send works even if the side panel was never opened) and in the side panel itself (auto-binds the active thread when the dropdown loads on a thread that has no container). Picker is visually emphasized with an accent panel and the currently-active row in the list below is highlighted with the same accent so the two views stay in sync.
This commit is contained in:
parent
78a9456d9b
commit
c263059887
2 changed files with 128 additions and 47 deletions
|
|
@ -1002,12 +1002,47 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
} catch {
|
||||
openaiCodeExecContainerId = null;
|
||||
}
|
||||
// Cross-thread inheritance: when the active thread has
|
||||
// no container yet, default to the one most recently
|
||||
// used on *any* other thread (provider-scoped).
|
||||
// Matches what the Code Execution settings section
|
||||
// shows in the picker, and keeps the user from getting
|
||||
// a fresh container on every new thread. The picker
|
||||
// can still be set to "Auto-create per thread"
|
||||
// explicitly to opt into a fresh container — but
|
||||
// that's done via the dropdown, not silently.
|
||||
if (
|
||||
!openaiCodeExecContainerId &&
|
||||
externalProvider.providerType === "openai"
|
||||
) {
|
||||
try {
|
||||
const others = await db.threads
|
||||
.orderBy("createdAt")
|
||||
.reverse()
|
||||
.toArray();
|
||||
for (const t of others) {
|
||||
if (t.id === resolvedThreadId) continue;
|
||||
if (t.openaiCodeExecContainerId) {
|
||||
openaiCodeExecContainerId = t.openaiCodeExecContainerId;
|
||||
void db.threads
|
||||
.update(resolvedThreadId, {
|
||||
openaiCodeExecContainerId,
|
||||
})
|
||||
.catch(() => {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fall through to lazy-create below */
|
||||
}
|
||||
}
|
||||
// Lazy pre-create when the user has configured a non-
|
||||
// default TTL: we POST /v1/containers ourselves with
|
||||
// that TTL so the auto-create-per-thread path actually
|
||||
// honors the user's preference. The default 20-min
|
||||
// container_auto path stays as-is (no extra round trip)
|
||||
// when TTL is unset or matches OpenAI's default.
|
||||
// default TTL and there's no inherited container: we
|
||||
// POST /v1/containers ourselves with that TTL so the
|
||||
// auto-create-per-thread path actually honors the
|
||||
// user's preference. The default 20-min container_auto
|
||||
// path stays as-is (no extra round trip) when TTL is
|
||||
// unset or matches OpenAI's default.
|
||||
if (
|
||||
!openaiCodeExecContainerId &&
|
||||
externalProvider.providerType === "openai"
|
||||
|
|
|
|||
|
|
@ -113,6 +113,30 @@ export function OpenAICodeExecSection({
|
|||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Auto-bind the active thread to the most-recently-active container
|
||||
// when the thread has none set. Mirrors the chat-adapter's cross-
|
||||
// thread inheritance so the picker shows the same default the
|
||||
// backend would use, and so the user doesn't have to manually
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
if (!activeThreadId || activeContainerId || containers.length === 0) {
|
||||
return;
|
||||
}
|
||||
const sorted = [...containers].sort(
|
||||
(a, b) => (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0),
|
||||
);
|
||||
const candidate = sorted[0];
|
||||
if (!candidate) return;
|
||||
void db.threads
|
||||
.update(activeThreadId, {
|
||||
openaiCodeExecContainerId: candidate.id,
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [activeThreadId, activeContainerId, containers]);
|
||||
|
||||
const ttlValue = provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES;
|
||||
|
||||
const onTtlChange = (raw: string) => {
|
||||
|
|
@ -217,11 +241,14 @@ export function OpenAICodeExecSection({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Active container picker */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* 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. */}
|
||||
<div className="flex flex-col gap-1.5 rounded-md border border-primary/30 bg-primary/5 p-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
|
||||
Active container (this thread)
|
||||
<span className="text-[13px] font-semibold leading-[1.25] tracking-nav text-primary">
|
||||
Active for this thread
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -240,7 +267,7 @@ export function OpenAICodeExecSection({
|
|||
value={activeContainerId ?? AUTO_OPTION_VALUE}
|
||||
onChange={(e) => onPick(e.target.value)}
|
||||
disabled={!activeThreadId}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm"
|
||||
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"
|
||||
>
|
||||
<option value={AUTO_OPTION_VALUE}>Auto-create per thread</option>
|
||||
{containers.map((c) => (
|
||||
|
|
@ -252,43 +279,62 @@ export function OpenAICodeExecSection({
|
|||
</select>
|
||||
</div>
|
||||
|
||||
{/* Container list with delete actions */}
|
||||
{isLoading && containers.length === 0 ? (
|
||||
<Skeleton className="h-16 w-full" />
|
||||
) : containers.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1 max-h-44 overflow-auto">
|
||||
{containers.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className="flex items-center justify-between gap-2 rounded-md border border-border/60 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium">
|
||||
{c.name ?? "(unnamed)"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{c.id} · TTL{" "}
|
||||
{c.expiresAfterMinutes ?? DEFAULT_TTL_MINUTES}m
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-destructive"
|
||||
onClick={() => void onDelete(c.id, c.name)}
|
||||
aria-label={`Delete container ${c.name ?? c.id}`}
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No saved containers yet. Use auto-create or create a named
|
||||
one below.
|
||||
</p>
|
||||
)}
|
||||
{/* 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" />
|
||||
) : containers.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1 max-h-44 overflow-auto">
|
||||
{containers.map((c) => {
|
||||
const isActive = c.id === activeContainerId;
|
||||
return (
|
||||
<li
|
||||
key={c.id}
|
||||
className={`flex items-center justify-between gap-2 rounded-md border px-2 py-1.5 text-xs ${
|
||||
isActive
|
||||
? "border-primary/30 bg-primary/5"
|
||||
: "border-border/60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium">
|
||||
{c.name ?? "(unnamed)"}
|
||||
{isActive ? (
|
||||
<span className="ml-1.5 text-[10px] font-normal uppercase tracking-wider text-primary">
|
||||
· active
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{c.id} · TTL{" "}
|
||||
{c.expiresAfterMinutes ?? DEFAULT_TTL_MINUTES}m
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-destructive"
|
||||
onClick={() => void onDelete(c.id, c.name)}
|
||||
aria-label={`Delete container ${c.name ?? c.id}`}
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No saved containers yet. Use auto-create or create a named
|
||||
one below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create new */}
|
||||
{createOpen ? (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue