mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-22 00:03:35 +00:00
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:
co-authored by
Claude Opus 5.5
windmill-internal-app[bot]
parent
394d345e0c
commit
613adb5287
@@ -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. -->
|
||||
|
||||
Reference in New Issue
Block a user