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:
Guilhem Lemouel
2026-09-30 02:46:31 -07:00
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}