mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
feat: the home composer flies to the menu that restores it
Removing the composer puts it away rather than throws it out, and the way back is one item in the band's menu — easy to miss on a page the composer has just vacated. A mark flies from the control that removed it to that menu, and the menu answers with one ring as the mark dissolves, so where it went is witnessed rather than explained. The cross and the menu item now read "Remove session chat", one name for one thing whichever a reader reaches for. Reduced motion skips both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
93e96dde8e
commit
3ac98f2620
@@ -20,7 +20,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { ArrowUp, Globe2, KeyRound, Pin, PinOff, PlugZap, Settings } from 'lucide-svelte'
|
||||
import { ArrowUp, Globe2, KeyRound, PlugZap, Settings, WandSparkles, X } from 'lucide-svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { Badge } from '../common'
|
||||
import CloseButton from '../common/CloseButton.svelte'
|
||||
@@ -67,6 +67,52 @@
|
||||
storeLocalSetting(COLLAPSED_SETTING, next ? 'true' : undefined)
|
||||
}
|
||||
|
||||
// Removing puts the composer away rather than throwing it out, and the way back is one item in
|
||||
// the band's menu — easy to miss on a page the composer has just vacated. A mark flies from the
|
||||
// control that removed it to that menu, so where it went is witnessed rather than explained.
|
||||
const FLIGHT_MS = 450
|
||||
// The mark's last stretch, where it fades into the menu.
|
||||
const FLIGHT_FADE_MS = 160
|
||||
// Two refs for the two controls that remove it — the cross on a working composer and the
|
||||
// button on the overlay that replaces it without a provider. Only one is ever mounted, and one
|
||||
// ref for both would depend on which block Svelte tears down first.
|
||||
let crossEl: HTMLElement | undefined = $state(undefined)
|
||||
let overlayRemoveEl: HTMLElement | undefined = $state(undefined)
|
||||
let flight = $state<{ from: DOMRect; to: DOMRect } | undefined>(undefined)
|
||||
let flightArrived = $state(false)
|
||||
let flightTimer: ReturnType<typeof setTimeout> | undefined
|
||||
// The menu answers the arriving mark with one ring, so the eye that followed the flight has
|
||||
// something to land on. It blooms as the mark dissolves, not after it: the two read as one
|
||||
// gesture.
|
||||
const PULSE_MS = 500
|
||||
let pulsing = $state(false)
|
||||
let pulseOut = $state(false)
|
||||
let pulseStartTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let pulseEndTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function removeComposer() {
|
||||
const from = (crossEl ?? overlayRemoveEl)?.getBoundingClientRect()
|
||||
const to = document.querySelector('[data-home-ai-menu]')?.getBoundingClientRect()
|
||||
setCollapsed(true)
|
||||
if (reducedMotion.val || !from || !to) return
|
||||
clearTimeout(flightTimer)
|
||||
clearTimeout(pulseStartTimer)
|
||||
clearTimeout(pulseEndTimer)
|
||||
flightArrived = false
|
||||
pulsing = false
|
||||
pulseOut = false
|
||||
flight = { from, to }
|
||||
// A frame at the origin before the transition starts, or the element mounts already there.
|
||||
requestAnimationFrame(() => (flightArrived = true))
|
||||
flightTimer = setTimeout(() => (flight = undefined), FLIGHT_MS)
|
||||
pulseStartTimer = setTimeout(() => {
|
||||
pulsing = true
|
||||
// Same reason as the mark's: one frame at rest, or it mounts already expanded.
|
||||
requestAnimationFrame(() => (pulseOut = true))
|
||||
pulseEndTimer = setTimeout(() => (pulsing = false), PULSE_MS)
|
||||
}, FLIGHT_MS - FLIGHT_FADE_MS)
|
||||
}
|
||||
|
||||
// In global-AI mode the layout's chat panel is disabled and never loads the copilot
|
||||
// config, so the home chat loads it for the current workspace itself.
|
||||
$effect(() => {
|
||||
@@ -176,8 +222,8 @@
|
||||
{#if !disabled}
|
||||
<!-- The one dismiss control while the composer is usable; the overlay below carries its
|
||||
own once it takes over, so the two never show at the same time. -->
|
||||
<div class="absolute right-0 top-0 z-20">
|
||||
<CloseButton small noBg title="Hide Build with AI" onClick={() => setCollapsed(true)} />
|
||||
<div class="absolute right-0 top-0 z-20" bind:this={crossEl}>
|
||||
<CloseButton small noBg title="Remove session chat" onClick={removeComposer} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center justify-center gap-2 mb-4">
|
||||
@@ -264,9 +310,9 @@
|
||||
{freeTierExhausted ? 'Add your own API key' : 'Configure AI'}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button unifiedSize="sm" variant="default" onClick={() => setCollapsed(true)}>
|
||||
Hide
|
||||
</Button>
|
||||
<div bind:this={overlayRemoveEl}>
|
||||
<Button unifiedSize="sm" variant="default" onClick={removeComposer}>Remove</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -301,42 +347,78 @@
|
||||
<PageHeaderContent actions={homeMenu} actionsOrder={100} />
|
||||
|
||||
{#snippet homeMenu()}
|
||||
<!-- The band's own menu: the connect helper, the hub, and whether the composer is pinned to
|
||||
this page — all of them preferences or side trips, none of them the page's work. -->
|
||||
<DropdownV2
|
||||
placement="bottom-end"
|
||||
size="sm"
|
||||
items={[
|
||||
...(showComposer
|
||||
? [
|
||||
collapsed
|
||||
? {
|
||||
displayName: 'Pin session chat to the page',
|
||||
icon: Pin,
|
||||
action: () => setCollapsed(false)
|
||||
}
|
||||
: {
|
||||
displayName: 'Unpin session chat',
|
||||
icon: PinOff,
|
||||
action: () => setCollapsed(true)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'CLI / MCP',
|
||||
icon: PlugZap,
|
||||
action: () => homeConnectDrawer?.openDrawer?.()
|
||||
},
|
||||
...(!$userStore?.operator && HOME_SHOW_HUB
|
||||
? [
|
||||
{
|
||||
displayName: 'Hub',
|
||||
icon: Globe2,
|
||||
href: $hubBaseUrlStore,
|
||||
hrefTarget: '_blank' as const
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
/>
|
||||
<!-- The band's own menu: the connect helper, the hub, and whether the composer is on this page
|
||||
— all of them preferences or side trips, none of them the page's work. -->
|
||||
<!-- data-home-ai-menu: where the mark flies to when the composer is removed. -->
|
||||
<span data-home-ai-menu class="relative flex">
|
||||
{#if pulsing}
|
||||
<!-- One ring out of the menu as the mark lands. Not `animate-ping`: that curve spends its
|
||||
opacity in the first three quarters, so the ring is already invisible by the time it
|
||||
has grown clear of the button. -->
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -inset-1 rounded-lg border-2 border-ai/60"
|
||||
style:transition="transform {PULSE_MS}ms ease-out, opacity {PULSE_MS}ms ease-out"
|
||||
style:transform="scale({pulseOut ? 1.7 : 1})"
|
||||
style:opacity={pulseOut ? 0 : 1}
|
||||
></span>
|
||||
{/if}
|
||||
<DropdownV2
|
||||
placement="bottom-end"
|
||||
size="sm"
|
||||
items={[
|
||||
...(showComposer
|
||||
? [
|
||||
collapsed
|
||||
? {
|
||||
displayName: 'Restore session chat',
|
||||
icon: WandSparkles,
|
||||
action: () => setCollapsed(false)
|
||||
}
|
||||
: {
|
||||
// The same words as the cross on the composer itself: one thing, one name,
|
||||
// whichever of the two a reader reaches for.
|
||||
displayName: 'Remove session chat',
|
||||
icon: X,
|
||||
action: () => setCollapsed(true)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'CLI / MCP',
|
||||
icon: PlugZap,
|
||||
action: () => homeConnectDrawer?.openDrawer?.()
|
||||
},
|
||||
...(!$userStore?.operator && HOME_SHOW_HUB
|
||||
? [
|
||||
{
|
||||
displayName: 'Hub',
|
||||
icon: Globe2,
|
||||
href: $hubBaseUrlStore,
|
||||
hrefTarget: '_blank' as const
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#if flight}
|
||||
<!-- The mark on its way to the menu. Transform and opacity only, so the trip costs no layout,
|
||||
and `aria-hidden` because it says nothing a reader of the menu item does not already read. -->
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fixed z-[6000] pointer-events-none flex items-center justify-center h-6 w-6 rounded-full border bg-surface shadow-sm"
|
||||
style:left="{flight.from.left}px"
|
||||
style:top="{flight.from.top}px"
|
||||
style:transition="transform {FLIGHT_MS}ms cubic-bezier(0.4, 0, 0.2, 1), opacity {FLIGHT_FADE_MS}ms
|
||||
ease-in {FLIGHT_MS - FLIGHT_FADE_MS}ms"
|
||||
style:opacity={flightArrived ? 0 : 1}
|
||||
style:transform="translate({flightArrived ? flight.to.left - flight.from.left : 0}px, {flightArrived
|
||||
? flight.to.top - flight.from.top
|
||||
: 0}px) scale({flightArrived ? 0.5 : 1})"
|
||||
>
|
||||
<WandSparkles size={12} class="text-hint" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user