Files
windmill/frontend/src/lib/components/copilot/ChatModelSettings.svelte
T
56b2e106da feat: home and empty ai sessions share one session composer (#11635)
* feat: open empty ai sessions with the home build-with-ai composer

* feat: vertically center the empty session composer

* fix: drop the stray indent on the session composer's rotating placeholder

* fix: give the starter composer a stable accessible name

* feat: pick workspace, fork and permission mode from the home composer

* fix: hand off the workspace home displays, not only an explicit pick

* feat: drop the starter prompt chips from the empty session

* feat: home and empty ai sessions share one session composer

* fix: keep session draft attachments across home and session page

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

* fix: drop superseded home ai-hidden responses

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

* fix: load home session starter lazily to keep monaco out of home

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

* feat: redesign home composer and items list

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

* feat: align the composer chat icon, share the folder row, add a home preview page

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

* refactor: drop home tech debt, fix phone layout and keyboard access, remove the home preview page

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

* perf: read filter chip positions before hiding any in clampRows

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.

* fix: align the home separator, drop the dark composer band, unclip pin menu and session suggestions

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

* fix: give the home composer the input background in dark mode

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

* feat: put home items in a card, keep the row menu visible, colour kinds, add-to-context button

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

* fix: settle the session composer before the transition captures it, ease the greeting in after it lands

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

* fix: let the empty-workspace placeholder take the card's background

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

* fix: keep a remounted home composer open when its draft holds text

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

* feat: show favorites and recent edits as cards above the home tree (#11894)

* feat: show favorites and recent edits as cards above the home tree

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

* fix: keep label-filter paging on the home tree and skip draft agents in recent

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

* fix: star raw apps as apps on home cards, open filtered tree folders, keep cards under filters

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

* style: set the favorites & recent label in normal weight and sentence case

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

* feat: add a playground comparing layouts for home's favorites & recent

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

* feat: scroll home's favorites & recent cards, add section settings and counts

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

* fix: drop stale favorites loads and keep home cards restorable without the AI beta

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

---------

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

* fix: clear stale favorites on a failed load and gate card menu on role

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Guilhem Lemouel <guilhemlemouel@gmail.com>
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>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-10-21 20:02:45 +02:00

320 lines
11 KiB
Svelte

<script lang="ts">
/**
* The model button every chat puts in the bottom-right of its composer: the trigger
* names the model and its reasoning effort, and the menu holds the choices behind
* both. Driven entirely by ChatModelSettingsConfig, so the session chat and the flow
* chat render the same control from different data — see chatModelSettings.ts.
*/
import { ChevronDown, Check, Loader2 } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte'
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
import MenuItemWrapper from '$lib/components/meltComponents/MenuItemWrapper.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import ReasoningEffortSlider from './ReasoningEffortSlider.svelte'
import { getReasoningCapability, resolveEffectiveReasoning } from './reasoningRegistry'
import {
fixedReasoningReason,
reasoningControlState,
reasoningDisplay,
type ChatModelSettingsConfig,
type ChoiceSection
} from './chatModelSettings'
import type { Item } from '$lib/utils'
import type { MenubarMenuElements, createDropdownMenu } from '@melt-ui/svelte'
import { twMerge } from 'tailwind-merge'
type MeltItem = MenubarMenuElements['item']
type MeltBuilders = ReturnType<typeof createDropdownMenu>['builders']
let { config }: { config: ChatModelSettingsConfig } = $props()
const reasoning = $derived(config.reasoning)
const capability = $derived(
reasoning?.provider && reasoning.model
? getReasoningCapability(reasoning.provider, reasoning.model)
: { supported: false, levels: [] as string[], canDisable: false, known: false }
)
const controlState = $derived(reasoningControlState(reasoning, capability))
const fixedReason = $derived(fixedReasoningReason(reasoning, capability))
// Effective effort accounts for the default-on level on capable models.
const effective = $derived(
reasoning?.provider && reasoning.model
? resolveEffectiveReasoning({
provider: reasoning.provider,
model: reasoning.model,
reasoning: reasoning.value
})
: undefined
)
// The stops, the one in use and the trigger's suffix are decided together, in one tested
// place: a stop the slider shows as `off` must not read as the provider's `none` on the button.
const display = $derived(reasoningDisplay(reasoning, capability, effective))
const stops = $derived(display.stops)
const currentStop = $derived(display.currentStop)
const effortLabel = $derived(display.label)
let effortSlider: ReasoningEffortSlider | undefined = $state(undefined)
// The trigger label resizes when the effort changes (dragging the slider while the menu
// is open). With a `bottom-end` popover anchored to the trigger's right edge, that resize
// would shift the popover, so freeze the trigger to its width at open time and release it
// on close — no movement while open, natural sizing the rest of the time.
let menuOpen = $state(false)
let triggerEl: HTMLElement | undefined = $state(undefined)
let lockedWidth = $state<number | undefined>(undefined)
$effect(() => {
if (menuOpen) {
if (lockedWidth === undefined && triggerEl) {
lockedWidth = triggerEl.getBoundingClientRect().width
}
} else {
lockedWidth = undefined
}
})
// Blocks are separated, not prefixed: a rule belongs between two of them, so the first
// one rendered must not draw one above itself whichever block that turns out to be.
const BLOCK_CLASS =
'border-border-light [&:not(:first-child)]:border-t [&:not(:first-child)]:mt-1 [&:not(:first-child)]:pt-1'
const ROW_CLASS =
'w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer'
</script>
{#snippet trigger()}
<div
bind:this={triggerEl}
style={lockedWidth !== undefined ? `width: ${lockedWidth}px` : undefined}
>
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="subtle"
disabled={config.readOnly}
endIcon={config.readOnly ? undefined : { icon: ChevronDown }}
btnClasses="w-full max-w-[200px] text-secondary font-normal"
title={config.readOnly ? config.readOnlyReason : config.title}
>
<span class="flex items-center gap-1 min-w-0">
{#if config.shortLabel}
<span class="truncate hidden xl:inline">{config.label}</span>
<span class="truncate xl:hidden">{config.shortLabel}</span>
{:else}
<span class="truncate">{config.label}</span>
{/if}
{#if effortLabel}
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
{/if}
{#if config.badge}
<span
class={twMerge(
'shrink-0 rounded-full px-1.5 text-2xs',
config.badge.warn
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
: 'bg-surface-secondary text-tertiary'
)}>{config.badge.text}</span
>
{/if}
</span>
</Button>
</div>
{/snippet}
{#snippet typedField(
value: string,
placeholder: string,
onCommit: (value: string) => void,
close: () => void
)}
{#key value}
<TextInput
size="sm"
{value}
inputProps={{
placeholder,
onchange: (e) => onCommit(e.currentTarget.value.trim()),
// Capture, not bubble: Svelte delegates `keydown` to the root, which sits above the
// menu — so a bubble handler here would run only after melt's own listener had read
// the key as typeahead and moved focus. A capture key is not delegatable, so this
// becomes a real listener on the input and sees the event first.
onkeydowncapture: (e) => {
// Escape cancels: let it reach the menu with the value untouched.
if (e.key === 'Escape') return
// Tab closes the menu, unmounting this field before focus moves, so no change
// event would ever fire. Commit on the way past.
if (e.key === 'Tab') {
onCommit(e.currentTarget.value.trim())
return
}
// Enter means done: commit and close, rather than leaving the menu open around a
// field the commit is about to rebuild.
if (e.key === 'Enter') {
e.preventDefault()
onCommit(e.currentTarget.value.trim())
close()
return
}
// Everything else is typing; the menu reads loose keys as typeahead.
e.stopPropagation()
}
}}
/>
{/key}
{/snippet}
{#snippet section(sec: ChoiceSection, item: MeltItem, close: () => void)}
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">{sec.label}</div>
{#if sec.loading}
<div class="flex items-center gap-2 px-3 py-1.5 text-tertiary">
<Loader2 size={14} class="animate-spin" /> Loading...
</div>
{:else if sec.options.length === 0}
<div class="px-3 py-1.5 text-tertiary">{sec.emptyMessage ?? 'Nothing to choose from'}</div>
{:else}
<div class={twMerge('overflow-y-auto', sec.maxHeight ?? 'max-h-48')}>
{#each sec.options as option (option.key)}
<MenuItem {item} class={ROW_CLASS} onClick={() => option.onSelect()}>
<span class="truncate grow min-w-0">{option.label}</span>
{#if option.hint}
<span class="shrink-0 text-tertiary truncate max-w-[70px]">{option.hint}</span>
{/if}
{#if option.selected}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
{/each}
</div>
{/if}
{#if sec.custom && !sec.loading}
{@const custom = sec.custom}
<div class="px-3 pt-1 pb-1.5">
{@render typedField(
'',
custom.placeholder,
(value) => {
if (value) custom.onCommit(value)
},
close
)}
</div>
{/if}
{/snippet}
{#snippet rows(items: Item[], item: MeltItem, builders: MeltBuilders)}
{#each items.filter((row) => !row.hide) as row (row.displayName)}
{#if row.separatorTop}
<div class="my-1 border-t border-border-light"></div>
{/if}
{#if row.submenuItems}
<!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). -->
<DropdownSubmenuItem item={row} {builders} meltItem={item} />
{:else}
<MenuItem {item} class={ROW_CLASS} onClick={(e) => row.action?.(e)}>
{#if row.icon}
<row.icon size={14} class="shrink-0" />
{/if}
<span class="truncate grow min-w-0 text-2xs text-secondary">{row.displayName}</span>
{#if row.selected}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
{/if}
{/each}
{/snippet}
{#if config.readOnly}
{@render trigger()}
{:else}
<DropdownV2
customMenu
placement="bottom-end"
fixedHeight={false}
closeOnItemClick={false}
bind:open={menuOpen}
>
{#snippet buttonReplacement()}
{@render trigger()}
{/snippet}
{#snippet menu({ item, builders, close })}
<div
class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
>
{#if config.topItems}
<div class={BLOCK_CLASS}>
{@render rows(config.topItems(close), item, builders)}
</div>
{/if}
{#each config.sections ?? [] as sec (sec.label)}
<div class={BLOCK_CLASS}>
{@render section(sec, item, close)}
</div>
{/each}
{#if reasoning}
<div class={BLOCK_CLASS}>
{#if controlState === 'fixed'}
<ReasoningEffortSlider
stops={[]}
current=""
onSelect={() => {}}
unsupportedReason={fixedReason}
/>
{:else if controlState === 'awaiting-model'}
<ReasoningEffortSlider
stops={[]}
current=""
onSelect={() => {}}
unsupportedReason="Pick a model first"
/>
{:else if controlState === 'unknown'}
<!-- No rules for this provider, so no ladder to offer. The flow still takes a
token, so it is typed rather than picked: claiming the model cannot think
would be a guess, and offering nothing would leave it settable nowhere. -->
<div class="px-3 pt-1 pb-1.5">
<div class="text-2xs uppercase tracking-wide text-secondary mb-1">Thinking</div>
{@render typedField(reasoning.value ?? '', 'none', reasoning.onSelect, close)}
<div class="text-2xs text-tertiary mt-1">
Windmill has no thinking levels for this provider — type what it accepts.
</div>
</div>
{:else if controlState === 'ladder'}
<!-- Registered as a melt item so it joins the roving focus/highlight (and arrow
up/down navigation), and so hovering it takes the highlight off the row
above. Left/right adjust the effort; the slider's input handler also drives it. -->
<MenuItemWrapper
{item}
onKeydown={(e) => effortSlider?.adjust(e)}
class="block group"
>
<ReasoningEffortSlider
bind:this={effortSlider}
{stops}
current={currentStop}
onSelect={reasoning.onSelect}
format={(stop) => (stop === reasoning?.offToken ? 'off' : stop)}
overrideLabel={stops.includes(currentStop) ? undefined : effortLabel}
/>
</MenuItemWrapper>
{:else}
<!-- Kept in place rather than dropped: the row saying the model cannot think
is the answer to why there is no slider. -->
<ReasoningEffortSlider
stops={[]}
current=""
onSelect={() => {}}
unsupportedReason="Not supported by this model"
/>
{/if}
</div>
{/if}
{#if config.bottomItems}
<div class={BLOCK_CLASS}>
{@render rows(config.bottomItems(close), item, builders)}
</div>
{/if}
</div>
{/snippet}
</DropdownV2>
{/if}