mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-23 16:03:33 +00:00
* 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>
320 lines
11 KiB
Svelte
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}
|