perf: show the AI sessions composer before the chat code loads (#11886)

* perf: show the AI sessions composer before the chat code loads

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep the composer placeholder's focus, IME input and draft through the swap

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: drop the composer focus hand-off when the session goes inactive

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: let an explicit composer focus request override a left placeholder

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to dd0c7087f9b2453260f3d73b0d589042b4393a1d

This commit updates the EE repository reference after PR #900 was merged in windmill-ee-private.

Previous ee-repo-ref: e922599943c472a6d0f736f629ae04081f866686

New ee-repo-ref: dd0c7087f9b2453260f3d73b0d589042b4393a1d

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Guilhem
2026-10-20 19:09:41 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot]
parent 394d345e0c
commit 613adb5287
10 changed files with 367 additions and 100 deletions
@@ -10,6 +10,7 @@
import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte'
import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './script/core'
import { copilotInfo } from '$lib/aiStore'
import type { ComposerSelection } from './composerBox'
let {
hideHeader = false,
@@ -20,7 +21,8 @@
emptyHint,
inputPreface,
initialInstructions = undefined,
onDraftChange = undefined
onDraftChange = undefined,
placeholder = undefined
}: {
hideHeader?: boolean
hideModeSelector?: boolean
@@ -39,6 +41,8 @@
// Seed / observe the composer's draft text (forwarded to AIChatDisplay).
initialInstructions?: string
onDraftChange?: (text: string) => void
/** Composer placeholder for the first message. Falls back to the per-AI-mode wording. */
placeholder?: string
} = $props()
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
@@ -106,8 +110,8 @@
aiChatManager.sendRequest(options)
}
export function focusInput() {
aiChatDisplay?.focusInput()
export function focusInput(selection?: ComposerSelection) {
aiChatDisplay?.focusInput(selection)
}
const historyManager = aiChatManager.historyManager
@@ -201,4 +205,5 @@
{inputPreface}
{initialInstructions}
{onDraftChange}
{placeholder}
></AIChatDisplay>
@@ -53,6 +53,7 @@
import ToolGroupDisplay from './ToolGroupDisplay.svelte'
import { chatItemKey, groupToolRuns } from './toolGroups'
import AIChatInput from './AIChatInput.svelte'
import type { ComposerSelection } from './composerBox'
import AttachedFilesBar from './files/AttachedFilesBar.svelte'
import QueuedMessageChip from './QueuedMessageChip.svelte'
import JobsSegment from './JobsSegment.svelte'
@@ -357,8 +358,8 @@
chatHost.sendRequest({ instructions: suggestion })
}
export function focusInput() {
aiChatInput?.focusInput()
export function focusInput(selection?: ComposerSelection) {
aiChatInput?.focusInput(selection)
}
$effect(() => {
@@ -13,7 +13,12 @@
import { AIMode } from './AIChatManager.svelte'
import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import { composerBoxClass, COMPOSER_FIELD_RESET } from './composerBox'
import {
composerBoxClass,
COMPOSER_FIELD_RESET,
globalComposerSuggestion,
type ComposerSelection
} from './composerBox'
import { formatMention, hasMention, removeMentionFromText } from './mention'
import { twMerge } from 'tailwind-merge'
import { tick, untrack, type Snippet } from 'svelte'
@@ -119,44 +124,12 @@
pendingQuestionToolCallId = undefined
}: Props = $props()
// GLOBAL-mode suggestion pool. We pick one at mount-time so each new
// session lands on a different prompt; the choice stays stable for
// the lifetime of this input so the placeholder doesn't shuffle as
// the user is reading it.
const GLOBAL_PLACEHOLDER_SUGGESTIONS = [
'Write a hello-world flow',
'Create a script that lists files in an S3 bucket',
'Build a CRUD app for a customer table',
'Schedule a daily cleanup of old runs',
'Wrap an existing script into a flow with retries',
'Add an HTTP trigger to an existing script',
'Generate a report from a SQL query and email it',
'Create a Postgres resource and a script that queries it',
'Refactor a script to add error handling',
'List my workspace flows and scripts'
]
// An operator authors nothing, so nothing above is theirs to ask for; one with the
// workspace's builder right can compose flows out of deployed scripts as well.
const OPERATOR_PLACEHOLDER_SUGGESTIONS = [
'List my workspace flows and scripts',
'Show the runs that failed today and why',
'Find the script that sends a report and run it',
'Rerun the last failed run of a flow'
]
const BUILDER_PLACEHOLDER_SUGGESTIONS = [
...OPERATOR_PLACEHOLDER_SUGGESTIONS,
'Compose a flow that chains two deployed scripts'
]
// Picked at mount-time so each new session lands on a different prompt.
const permissions = useSessionPermissions(() => chatHost.operatingWorkspace)
const suggestionPick = Math.random()
const globalSuggestion = $derived.by(() => {
const pool = permissions.canOpenEditor('script')
? GLOBAL_PLACEHOLDER_SUGGESTIONS
: permissions.canOpenEditor('flow')
? BUILDER_PLACEHOLDER_SUGGESTIONS
: OPERATOR_PLACEHOLDER_SUGGESTIONS
return pool[Math.floor(suggestionPick * pool.length)]
})
const globalSuggestion = $derived(
globalComposerSuggestion(suggestionPick, permissions.canOpenEditor)
)
// Generate mode-specific placeholder
const modePlaceholder = $derived.by(() => {
@@ -636,11 +609,14 @@
draft.text = removeMentionFromText(draft.text, title)
}
export function focusInput() {
export function focusInput(selection?: ComposerSelection) {
if (isContextEnabledMode) {
contextTextareaComponent?.focus()
contextTextareaComponent?.focus(selection)
} else {
instructionsTextareaComponent?.focus()
if (selection) {
instructionsTextareaComponent?.setSelectionRange(selection.start, selection.end)
}
}
}
@@ -0,0 +1,93 @@
<script lang="ts">
import { ArrowUp } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { Button } from '$lib/components/common'
import autosize from '$lib/autosize'
import { CHAT_INPUT_PADDING } from './aiChatManagerContext'
import { composerBoxClass, COMPOSER_FIELD_RESET, type ComposerSelection } from './composerBox'
/**
* The chat composer's box, drawn while the chat code loads. It must import nothing of
* AIChat's: it exists so the page can show and type into the composer before that code
* arrives. The host passes `value` and the last selection to the real composer when it mounts.
*/
let {
value = $bindable(),
placeholder,
disabled = false,
autofocus = false,
onSelectionChange,
onCompositionChange
}: {
value: string
placeholder?: string
disabled?: boolean
autofocus?: boolean
/** The caret while the field has focus, undefined once the user moves to another element. */
onSelectionChange?: (selection: ComposerSelection | undefined) => void
/** Whether an IME composition is in progress: replacing the field then would lose it. */
onCompositionChange?: (composing: boolean) => void
} = $props()
function trackSelection(e: Event & { currentTarget: HTMLTextAreaElement }) {
onSelectionChange?.({
start: e.currentTarget.selectionStart,
end: e.currentTarget.selectionEnd
})
}
</script>
<div class="relative mt-1">
<div class={composerBoxClass(disabled)}>
<div class="relative w-full">
<!-- Text metrics match ContextTextarea's `.textarea-input`, so the swap moves nothing. -->
<textarea
{@attach (node) => {
if (autofocus) node.focus()
}}
bind:value
use:autosize={{ maxHeight: '40vh' }}
rows={1}
{placeholder}
{disabled}
class={twMerge(
'w-full resize-none overflow-clip whitespace-pre-wrap break-words text-xs leading-[1.72] min-h-[1.9rem]',
COMPOSER_FIELD_RESET,
CHAT_INPUT_PADDING
)}
onfocus={trackSelection}
oninput={trackSelection}
onselect={trackSelection}
onkeyup={trackSelection}
onpointerup={trackSelection}
onblur={(e) => {
// Unmounting the focused field blurs it, while still connected; that is the hand-off,
// not the user leaving. The field is detached by the time a microtask runs. A window
// blur leaves the field the active element, so it is not the user leaving either.
const field = e.currentTarget
queueMicrotask(() => {
if (field.isConnected && document.activeElement !== field) {
onSelectionChange?.(undefined)
}
})
}}
oncompositionstart={() => onCompositionChange?.(true)}
oncompositionend={() => onCompositionChange?.(false)}
onkeydown={(e) => {
// Enter sends in the real composer; here it must not add a newline to the draft.
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) e.preventDefault()
}}
></textarea>
<div class="absolute bottom-1 right-1">
<Button
variant="subtle"
unifiedSize="md"
iconOnly
title="Send"
startIcon={{ icon: ArrowUp }}
disabled
/>
</div>
</div>
</div>
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { composerBoxClass, COMPOSER_FIELD_RESET } from './composerBox'
import { composerBoxClass, COMPOSER_FIELD_RESET, type ComposerSelection } from './composerBox'
import autosize from '$lib/autosize'
import { tick, type Snippet } from 'svelte'
import type { ContextElement } from './context'
@@ -761,8 +761,11 @@
prevMentionedTitles = cur
})
export function focus() {
export function focus(selection?: ComposerSelection) {
textarea?.focus()
if (selection) {
textarea?.setSelectionRange(selection.start, selection.end)
}
}
// Wipe after dispatching a send: pre-zero `prevMentionedTitles` so the effect
@@ -6,7 +6,6 @@
import SessionStatusPopover from '$lib/components/sessions/SessionStatusPopover.svelte'
import { zIndexes } from '$lib/zIndexes'
import JobStatusIcon from '$lib/components/runs/JobStatusIcon.svelte'
import FlowStatusWaitingForEvents from '$lib/components/FlowStatusWaitingForEvents.svelte'
import { ChevronUp, Hourglass, ThumbsUp, TimerOff } from 'lucide-svelte'
import { slide } from 'svelte/transition'
import { JobService, type Job } from '$lib/gen'
@@ -244,6 +243,11 @@
let approvalOpen = $state(false)
let approvalLabel = $state('')
let approvalJob = $state<Job | undefined>(undefined)
// Loaded with the job, not with this segment: the approval form reaches the code editor, and
// the sessions page renders this segment on load.
let FlowStatusWaitingForEvents = $state.raw<
typeof import('$lib/components/FlowStatusWaitingForEvents.svelte').default | undefined
>(undefined)
async function openApproval(job: ChatJob) {
approvalLabel = job.label
@@ -252,12 +256,16 @@
try {
// The stored snapshot is trimmed; the approval form needs the full job
// (flow_status + raw_flow), so fetch it fresh.
const full = await JobService.getJob({
workspace: job.workspace,
id: job.jobId,
noLogs: true,
noCode: true
})
const [full, form] = await Promise.all([
JobService.getJob({
workspace: job.workspace,
id: job.jobId,
noLogs: true,
noCode: true
}),
import('$lib/components/FlowStatusWaitingForEvents.svelte')
])
FlowStatusWaitingForEvents = form.default
if (deriveChatJobStatus(full) === 'suspended') {
approvalJob = full
} else {
@@ -383,7 +391,7 @@
bind:open={approvalOpen}
minZIndex={zIndexes.monacoEditorSuggestions + 1}
>
{#if approvalJob}
{#if approvalJob && FlowStatusWaitingForEvents}
<FlowStatusWaitingForEvents
job={approvalJob}
workspaceId={approvalJob.workspace_id}
@@ -25,3 +25,47 @@ export function composerBoxClass(disabled: boolean = false): string {
/** Applied to the field inside the box; without it the field draws a second border. */
export const COMPOSER_FIELD_RESET =
'!border-transparent !bg-transparent !shadow-none focus:!border-transparent focus:!ring-0 disabled:cursor-not-allowed disabled:placeholder:text-disabled'
/** A caret or selected range in the composer's text. */
export type ComposerSelection = { start: number; end: number }
const GLOBAL_PLACEHOLDER_SUGGESTIONS = [
'Write a hello-world flow',
'Create a script that lists files in an S3 bucket',
'Build a CRUD app for a customer table',
'Schedule a daily cleanup of old runs',
'Wrap an existing script into a flow with retries',
'Add an HTTP trigger to an existing script',
'Generate a report from a SQL query and email it',
'Create a Postgres resource and a script that queries it',
'Refactor a script to add error handling',
'List my workspace flows and scripts'
]
// An operator authors nothing, so nothing above is theirs to ask for; one with the
// workspace's builder right can compose flows out of deployed scripts as well.
const OPERATOR_PLACEHOLDER_SUGGESTIONS = [
'List my workspace flows and scripts',
'Show the runs that failed today and why',
'Find the script that sends a report and run it',
'Rerun the last failed run of a flow'
]
const BUILDER_PLACEHOLDER_SUGGESTIONS = [
...OPERATOR_PLACEHOLDER_SUGGESTIONS,
'Compose a flow that chains two deployed scripts'
]
/**
* The global-mode composer's placeholder: one suggestion the user may ask for. `pick` is a
* number in [0, 1) the caller draws once, so the text does not shuffle while it is read.
*/
export function globalComposerSuggestion(
pick: number,
canOpenEditor: (kind: string) => boolean
): string {
const pool = canOpenEditor('script')
? GLOBAL_PLACEHOLDER_SUGGESTIONS
: canOpenEditor('flow')
? BUILDER_PLACEHOLDER_SUGGESTIONS
: OPERATOR_PLACEHOLDER_SUGGESTIONS
return pool[Math.floor(pick * pool.length)]
}
@@ -1,7 +1,23 @@
<script module lang="ts">
import { preloadComponent } from '$lib/utils/preloadComponent.svelte'
// AIChat's code is most of this page's: loaded beside it rather than as part of it, so the
// page and its ComposerPlaceholder show without waiting on it.
const aiChatComponent = preloadComponent(
() => import('$lib/components/copilot/chat/AIChat.svelte')
)
</script>
<script lang="ts">
import { onDestroy, setContext, untrack } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import AIChat from '$lib/components/copilot/chat/AIChat.svelte'
import type AIChat from '$lib/components/copilot/chat/AIChat.svelte'
import ComposerPlaceholder from '$lib/components/copilot/chat/ComposerPlaceholder.svelte'
import {
globalComposerSuggestion,
type ComposerSelection
} from '$lib/components/copilot/chat/composerBox'
import { useSessionPermissions } from './sessionPermissions.svelte'
import EditableInput from '$lib/components/common/EditableInput.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { Button } from '$lib/components/common'
@@ -13,7 +29,7 @@
import { WorkspaceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import Toggle from '$lib/components/Toggle.svelte'
import { copilotInfo } from '$lib/aiStore'
import { copilotInfo, copilotWorkspace } from '$lib/aiStore'
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
import {
Archive,
@@ -54,11 +70,14 @@
let {
sessionId,
headerInPage = false
headerInPage = false,
covered = false
}: {
sessionId: string
/** True for the session the page is showing: its header rides the page header bar. */
headerInPage?: boolean
/** True while the page holds this wrapper inert under an overlay, where nothing takes focus. */
covered?: boolean
} = $props()
// Parent keys by sessionId; this wrapper only mounts when the session exists.
@@ -85,6 +104,26 @@
// mount-time empty draft would otherwise erase the prompt from the record.
const armedAtInit = sessionState.currentSessionId === sessionId && peekSessionAutoSend(sessionId)
// The composer's text before AIChat mounts, typed into the placeholder, and then the real
// composer's initial draft.
let draftText = $state(armedAtInit ? '' : (restoredDraftPrompt ?? ''))
// What the first focus after the swap does: put the caret where the placeholder had it, or,
// when null, leave focus where the user moved it while the chat loaded. Undefined when the
// placeholder never had focus.
let handoff: ComposerSelection | null | undefined = undefined
// The swap waits for an IME composition to end: the new field would not carry it over.
let placeholderComposing = $state(false)
// The placeholder's text counts as unsent input, like the real composer's draft, so the page
// does not evict this session's chat (and the text with it) while it waits for the chat code.
const PLACEHOLDER_DRAFT_KEY = 'composer-placeholder'
// Drawn once, and shared by the placeholder and the real composer so the text survives the swap.
const suggestionPick = Math.random()
const permissions = useSessionPermissions(() => runtime?.manager.operatingWorkspace)
const composerPlaceholder = $derived(
globalComposerSuggestion(suggestionPick, permissions.canOpenEditor)
)
// A hand-off whose click already stated the intent asks for its prompt to be
// sent, not parked. Reactive rather than mount-time: `createSession` reuses an
// untouched blank session and the sessions page keys wrappers by id, so a
@@ -258,17 +297,43 @@
// warm-session switch via the picker, and the moment copilot finishes
// loading.
let aiChat: AIChat | undefined = $state(undefined)
let seenFocusNonce = untrack(() => composerFocusRequest.nonce)
$effect(() => {
// Focus the composer when this session becomes active, or on an explicit
// focus request — the latter covers `+` reusing the untouched draft you're
// already viewing, where currentSessionId doesn't change so activation alone
// wouldn't re-run this.
void composerFocusRequest.nonce
if (sessionState.currentSessionId !== sessionId) return
const focusNonce = composerFocusRequest.nonce
if (focusNonce !== seenFocusNonce) {
// An explicit request overrides the user having left the placeholder before it.
seenFocusNonce = focusNonce
handoff = undefined
}
if (sessionState.currentSessionId !== sessionId) {
// The user left for another session: coming back is an activation, which focuses.
handoff = undefined
return
}
if (!aiChat) return
if (!$copilotInfo.enabled) return
const chat = aiChat
setTimeout(() => chat.focusInput(), 0)
const selection = handoff
handoff = undefined
if (selection === null) return
setTimeout(() => chat.focusInput(selection), 0)
})
// The real composer is disabled until the copilot config of the workspace it acts on is in,
// so the active session keeps the placeholder, which takes typing, until then. Latched: a
// later config reload must not swap a mounted chat, and its transcript, back out.
let chatShown = false
const showChat = $derived.by(() => {
chatShown ||=
!!aiChatComponent.current &&
!placeholderComposing &&
(sessionState.currentSessionId !== sessionId ||
$copilotWorkspace === (actingWorkspaceId ?? $workspaceStore))
return chatShown
})
// True when the session committed to a workspace that's no longer in
@@ -464,22 +529,58 @@
<Splitpanes horizontal={false} class="flex-1 min-h-0 splitter-hidden">
<Pane minSize={25} class="flex flex-col min-h-0 pb-2">
<div class="flex-1 min-h-0 w-full flex flex-col {hasFirstUserMessage ? '' : 'pt-8'}">
<AIChat
bind:this={aiChat}
hideHeader
hideModeSelector
wideLayout
initialInstructions={armedAtInit ? undefined : restoredDraftPrompt}
onDraftChange={(text) => setSessionDraftPrompt(sessionId, text)}
forceDisabled={isUnavailable || !!session.archived}
forceDisabledMessage={isUnavailable
? 'This session is linked to a workspace that no longer exists. Move it or discard it from the banner above to keep working.'
: session.archived
? 'This session is archived. Unarchive it from the banner above to keep working.'
: ''}
emptyHint={sessionEmptyHint}
{inputPreface}
/>
{#if showChat && aiChatComponent.current}
<aiChatComponent.current
bind:this={aiChat}
hideHeader
hideModeSelector
wideLayout
initialInstructions={draftText}
onDraftChange={(text) => setSessionDraftPrompt(sessionId, text)}
placeholder={composerPlaceholder}
forceDisabled={isUnavailable || !!session.archived}
forceDisabledMessage={isUnavailable
? 'This session is linked to a workspace that no longer exists. Move it or discard it from the banner above to keep working.'
: session.archived
? 'This session is archived. Unarchive it from the banner above to keep working.'
: ''}
emptyHint={sessionEmptyHint}
{inputPreface}
/>
{:else}
<!-- AIChatDisplay's layout, so the composer stays put when the chat replaces it:
its transcript area, its column, and its footer row's height. -->
<div
class="flex flex-col h-full"
{@attach () => () =>
runtime.manager.setComposerHasDraft(PLACEHOLDER_DRAFT_KEY, false)}
>
{#if runtime.manager.displayMessages.length > 0}
<div class="flex-1 min-h-0"></div>
{/if}
<div class="w-full max-w-3xl mx-auto px-7 pb-2">
{@render inputPreface()}
<ComposerPlaceholder
bind:value={
() => draftText,
(text) => {
draftText = text
setSessionDraftPrompt(sessionId, text)
runtime.manager.setComposerHasDraft(PLACEHOLDER_DRAFT_KEY, text.trim() !== '')
}
}
placeholder={runtime.manager.displayMessages.length > 0
? 'Ask followup'
: composerPlaceholder}
disabled={isUnavailable || !!session.archived}
autofocus={sessionState.currentSessionId === sessionId && !covered}
onSelectionChange={(selection) => (handoff = selection ?? null)}
onCompositionChange={(composing) => (placeholderComposing = composing)}
/>
<div class="mt-1 h-5"></div>
</div>
</div>
{/if}
</div>
</Pane>
</Splitpanes>
@@ -0,0 +1,19 @@
/**
* Starts loading a component's chunk when the calling module is evaluated and exposes it once
* loaded. Call it from a `<script module>` so a mount after the load renders the component in its
* first frame: an `{#await import()}` shows its pending branch for a tick even on a cached chunk.
* A failed load is left unhandled on purpose: the root layout reports chunk-load failures.
*/
export function preloadComponent<T>(load: () => Promise<{ default: T }>): {
readonly current: T | undefined
} {
let current = $state.raw<T | undefined>(undefined)
load().then((m) => {
current = m.default
})
return {
get current() {
return current
}
}
}
@@ -1,3 +1,13 @@
<script module lang="ts">
import { preloadComponent } from '$lib/utils/preloadComponent.svelte'
// The preview tabs render whole items, through most of AIChat's code (Monaco among it):
// loaded beside the page, not as part of it, so the page shows without waiting on them.
const previewTabHostComponent = preloadComponent(
() => import('$lib/components/sessions/PreviewTabHost.svelte')
)
</script>
<script lang="ts">
import { onDestroy, onMount, tick, untrack } from 'svelte'
import { SvelteSet } from 'svelte/reactivity'
@@ -28,7 +38,7 @@
import { resource } from 'runed'
import { WorkspaceService } from '$lib/gen'
import SessionWrapper from '$lib/components/sessions/SessionWrapper.svelte'
import PreviewTabHost from '$lib/components/sessions/PreviewTabHost.svelte'
import type PreviewTabHost from '$lib/components/sessions/PreviewTabHost.svelte'
import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte'
import {
createSession,
@@ -1119,7 +1129,11 @@
: 'z-0 opacity-0 pointer-events-none'}"
aria-hidden={s.id !== activeSession?.id}
>
<SessionWrapper sessionId={s.id} headerInPage={s.id === activeSession?.id} />
<SessionWrapper
sessionId={s.id}
headerInPage={s.id === activeSession?.id}
covered={aiHiddenVerdict !== false}
/>
</div>
{/each}
</div>
@@ -1195,32 +1209,35 @@
content-gated by the shared mount MRU. Each host renders a
live editor (script/flow/raw_app target) or an iframe fallback. -->
<div class="relative flex-1 min-h-0">
{#each warmSessions as s (s.id)}
{@const rt = getRuntime(s.id)}
{@const tabs = rt?.previewTabs}
{#each tabs?.tabs ?? [] as tab (tab.id)}
<!-- tabHosts is an imperative ref-bag (only tabHosts[key]?.reload() in
{#if previewTabHostComponent.current}
{@const TabHost = previewTabHostComponent.current}
{#each warmSessions as s (s.id)}
{@const rt = getRuntime(s.id)}
{@const tabs = rt?.previewTabs}
{#each tabs?.tabs ?? [] as tab (tab.id)}
<!-- tabHosts is an imperative ref-bag (only tabHosts[key]?.reload() in
reloadTabs); it is intentionally a plain object so component
instances aren't proxied. Nothing reads it reactively, so the
non-reactive binding is fine. -->
<!-- svelte-ignore binding_property_non_reactive -->
<PreviewTabHost
bind:this={tabHosts[tabKey(s.id, tab.id)]}
{tab}
session={s}
runtime={rt}
active={s.id === activeSession?.id && tab.id === tabs?.activeId}
collapsed={(tabs?.collapsed ?? false) && !fullscreen}
mounted={mountedTabKeys.has(tabKey(s.id, tab.id))}
label={tabLabelFor(tab, s.workspace_id ?? '')}
darkMode={isDarkMode.val}
{fullscreen}
onNavigate={navigateEditorTo}
onSeeDetails={seeDetailsInPreview}
onLoad={(frame) => tabs && onTabLoad(tabs, tab, frame)}
/>
<!-- svelte-ignore binding_property_non_reactive -->
<TabHost
bind:this={tabHosts[tabKey(s.id, tab.id)]}
{tab}
session={s}
runtime={rt}
active={s.id === activeSession?.id && tab.id === tabs?.activeId}
collapsed={(tabs?.collapsed ?? false) && !fullscreen}
mounted={mountedTabKeys.has(tabKey(s.id, tab.id))}
label={tabLabelFor(tab, s.workspace_id ?? '')}
darkMode={isDarkMode.val}
{fullscreen}
onNavigate={navigateEditorTo}
onSeeDetails={seeDetailsInPreview}
onLoad={(frame) => tabs && onTabLoad(tabs, tab, frame)}
/>
{/each}
{/each}
{/each}
{/if}
{#if (owner?.tabs.length ?? 0) === 0}
<!-- New session with nothing to preview: an empty state with a
picker to open one, instead of defaulting to the home page. -->