mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-28 08:02:43 +00:00
remove the home and AI sessions composer view transition (#12020)
* fix: remove the home and AI sessions composer view transition Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: drop view transition rationale from composer comments Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7b1dce5af0
commit
a5a4caf354
@@ -426,46 +426,3 @@ input[type='time']::-webkit-calendar-picker-indicator {
|
||||
.svelte-flow__edges {
|
||||
z-index: -10;
|
||||
}
|
||||
|
||||
/* The composer's glide, defined once: the Home ↔ AI Sessions view transition below and the
|
||||
in-page move on a session's first message (the spacer under AIChatDisplay's composer) both
|
||||
read it. */
|
||||
:root {
|
||||
--composer-glide-duration: 380ms;
|
||||
--composer-glide-easing: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Home ↔ AI Sessions (homeSessionsTransition.svelte.ts). The composer glides from where it
|
||||
was to where it lands; everything else, Home's list and the session's greeting included, is
|
||||
the page cross-fading, kept short. The pseudo-elements hang off :root itself, so no space
|
||||
before `::view-transition-*`: with one, the selector looks for a descendant and never
|
||||
matches. */
|
||||
:root[data-home-sessions-transition]::view-transition-group(composer) {
|
||||
animation-duration: var(--composer-glide-duration);
|
||||
animation-timing-function: var(--composer-glide-easing);
|
||||
}
|
||||
:root[data-home-sessions-transition]::view-transition-old(root),
|
||||
:root[data-home-sessions-transition]::view-transition-new(root) {
|
||||
animation-duration: 150ms;
|
||||
}
|
||||
/* The session's greeting sits above where the composer lands, so it waits for the composer:
|
||||
shown while the composer is still gliding, it reads as text placed in mid-air. It starts as
|
||||
the composer settles and rises into place on the same easing, so the two read as one motion.
|
||||
Leaving, it goes with the page. */
|
||||
:root[data-home-sessions-transition='enter']::view-transition-new(greeting) {
|
||||
animation: greeting-in 260ms var(--composer-glide-easing)
|
||||
calc(var(--composer-glide-duration) * 0.6) both;
|
||||
}
|
||||
:root[data-home-sessions-transition]::view-transition-old(greeting) {
|
||||
animation-duration: 150ms;
|
||||
}
|
||||
@keyframes greeting-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
filter: blur(2px);
|
||||
}
|
||||
}
|
||||
:root[data-home-sessions-transition='fade'] * {
|
||||
view-transition-name: none !important;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
wideLayout = false,
|
||||
emptyHint,
|
||||
starter = undefined,
|
||||
viewTransitionNames = false,
|
||||
onSent = undefined,
|
||||
inputPreface,
|
||||
actingOn,
|
||||
@@ -45,7 +44,6 @@
|
||||
wideLayout?: boolean
|
||||
emptyHint?: import('svelte').Snippet
|
||||
starter?: import('./starter/starterPrompts.svelte').ChatStarter
|
||||
viewTransitionNames?: boolean
|
||||
onSent?: () => void
|
||||
inputPreface?: import('svelte').Snippet
|
||||
/** Forwarded to AIChatDisplay: where the session acts, first among the controls. */
|
||||
@@ -222,7 +220,6 @@
|
||||
{wideLayout}
|
||||
{emptyHint}
|
||||
{starter}
|
||||
{viewTransitionNames}
|
||||
{onSent}
|
||||
{inputPreface}
|
||||
{actingOn}
|
||||
|
||||
@@ -118,7 +118,6 @@
|
||||
wideLayout = false,
|
||||
emptyHint,
|
||||
starter = undefined,
|
||||
viewTransitionNames = false,
|
||||
onSent = undefined,
|
||||
inputPreface,
|
||||
actingOn,
|
||||
@@ -160,11 +159,6 @@
|
||||
* both mount it, so the two are one entry point rather than look-alikes. Replaces
|
||||
* `emptyHint`. */
|
||||
starter?: ChatStarter
|
||||
/** Names the composer for the view transition between Home and AI Sessions, so it
|
||||
* glides between its two places, a send from Home included: that lands it under the
|
||||
* session's first message. Only one chat on the page may carry the name: a duplicate
|
||||
* makes the browser skip the transition. */
|
||||
viewTransitionNames?: boolean
|
||||
/** After the composer hands a new turn to the host. */
|
||||
onSent?: () => void
|
||||
inputPreface?: Snippet
|
||||
@@ -865,10 +859,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
has none: there the composer is one control among the page's. -->
|
||||
{#if centreComposer}
|
||||
<div class="flex-1 min-h-0 flex flex-col justify-end">
|
||||
<div
|
||||
class={columnClass}
|
||||
style:view-transition-name={viewTransitionNames ? 'greeting' : undefined}
|
||||
>
|
||||
<div class={columnClass}>
|
||||
<SessionGreeting />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1003,7 +994,6 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
aria-label="Message composer"
|
||||
bind:this={composerEl}
|
||||
class="relative {columnClass} pb-2"
|
||||
style:view-transition-name={viewTransitionNames ? 'composer' : undefined}
|
||||
onfocusin={(e) => {
|
||||
if (homeComposer && e.target instanceof HTMLTextAreaElement) composerOpened = true
|
||||
}}
|
||||
@@ -1450,7 +1440,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
Always mounted and shrunk on the first message rather than removed: flex-grow
|
||||
transitions, so the composer slides down to the bottom instead of jumping. -->
|
||||
<div
|
||||
class="min-h-0 basis-0 transition-[flex-grow] duration-[var(--composer-glide-duration)] ease-[var(--composer-glide-easing)] motion-reduce:transition-none {centreComposer
|
||||
class="min-h-0 basis-0 transition-[flex-grow] duration-[380ms] ease-[cubic-bezier(0.2,0.8,0.2,1)] motion-reduce:transition-none {centreComposer
|
||||
? 'grow'
|
||||
: 'grow-0'}"
|
||||
></div>
|
||||
|
||||
@@ -45,8 +45,8 @@ export function smoothHeight(node: HTMLElement, params: SmoothHeightParams) {
|
||||
ro.observe(inner)
|
||||
}
|
||||
// No measuring at mount: the content's height is not settled yet (a newly mounted session
|
||||
// composer reads ~60px too tall), and a view transition captures the box at that height.
|
||||
// The observer's first callback comes after layout and sets the right one.
|
||||
// composer reads ~60px too tall). The observer's first callback comes after layout and sets
|
||||
// the right one.
|
||||
observe()
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import type HomeSessionStarterComponent from '../sessions/HomeSessionStarter.svelte'
|
||||
|
||||
// The starter is a full AIChat, which reaches monaco, and the build refuses monaco in Home's
|
||||
// static imports. Kept once loaded, so a return to Home renders it in its first frame: the
|
||||
// AI Sessions → Home view transition snapshots that frame.
|
||||
// static imports. Kept once loaded, so a return to Home renders it in its first frame
|
||||
// instead of a skeleton.
|
||||
let loadedStarter: typeof HomeSessionStarterComponent | undefined
|
||||
function loadStarter() {
|
||||
return import('../sessions/HomeSessionStarter.svelte').then((m) => (loadedStarter = m.default))
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
import SessionWorkspaceBar from './SessionWorkspaceBar.svelte'
|
||||
import { useWorkModeLock } from './workModeLock.svelte'
|
||||
import { AIWorkMode } from '$lib/components/copilot/chat/workMode'
|
||||
import { composerSentFromHome, takeComposerFocus } from './homeSessionsTransition.svelte'
|
||||
import { takeComposerFocus } from './homeComposerFocus'
|
||||
import { homeComposerContext } from './homeComposerContext.svelte'
|
||||
|
||||
// Home's block is the empty session's own chat, run by that session's manager: the
|
||||
@@ -68,7 +68,6 @@
|
||||
|
||||
// The turn is already running on the session's manager; the page it belongs to takes over.
|
||||
function onSent() {
|
||||
composerSentFromHome()
|
||||
selectSession(sessionId)
|
||||
void goto(sessionPageHref(sessionId))
|
||||
}
|
||||
@@ -148,7 +147,6 @@
|
||||
suggestions: starterPrompts.suggestions,
|
||||
fewItems: starterPrompts.fewItems
|
||||
}}
|
||||
viewTransitionNames
|
||||
{onSent}
|
||||
{actingOn}
|
||||
workModeLock={workModeLock.current}
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
X
|
||||
} from 'lucide-svelte'
|
||||
import SessionWorkspaceBar from './SessionWorkspaceBar.svelte'
|
||||
import { arrivingFromHomeSend } from './homeSessionsTransition.svelte'
|
||||
import SessionChangesBar from './SessionChangesBar.svelte'
|
||||
import SessionShareButton from './SessionShareButton.svelte'
|
||||
import { clearSessionRecovered, isSessionRecovered } from './sessionRecoveryNotice.svelte'
|
||||
@@ -628,7 +627,6 @@
|
||||
starter={greets
|
||||
? { where: 'session', suggestions: starterPrompts.suggestions }
|
||||
: undefined}
|
||||
viewTransitionNames={headerInPage && (opensSession || arrivingFromHomeSend())}
|
||||
emptyHint={sessionEmptyHint}
|
||||
{inputPreface}
|
||||
actingOn={hasFirstUserMessage ? undefined : actingOn}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { composerFocusAfter } from './homeSessionsTransition.svelte'
|
||||
import { composerFocusAfter } from './homeComposerFocus'
|
||||
|
||||
describe('composerFocusAfter', () => {
|
||||
it('hands focus on only for the return from AI Sessions to Home', () => {
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { OnNavigate } from '@sveltejs/kit'
|
||||
|
||||
// Leaving AI Sessions for Home with the composer focused hands that focus on to Home's
|
||||
// composer, so typing carries on across the move.
|
||||
|
||||
const HOME = '/'
|
||||
const SESSIONS = '/sessions'
|
||||
|
||||
/** Whether the composer had focus when the user left AI Sessions for Home. */
|
||||
let composerFocused = false
|
||||
|
||||
/** The focus flag to keep after a navigation from `from` to `to`. Only the return to Home
|
||||
* hands focus on — the sessions page focuses its composer on its own — and every other
|
||||
* navigation clears the flag, so a later arrival on Home never acts on a stale one. */
|
||||
export function composerFocusAfter(
|
||||
from: string | undefined,
|
||||
to: string | undefined,
|
||||
composerHadFocus: boolean
|
||||
): boolean {
|
||||
return from === SESSIONS && to === HOME && composerHadFocus
|
||||
}
|
||||
|
||||
/** Whether Home should give its composer the focus the sessions page's had. Read once:
|
||||
* a later visit starts unfocused. */
|
||||
export function takeComposerFocus(): boolean {
|
||||
const focused = composerFocused
|
||||
composerFocused = false
|
||||
return focused
|
||||
}
|
||||
|
||||
/** For SvelteKit's `onNavigate`: records whether the composer had focus as the page left. */
|
||||
export function recordComposerFocus(navigation: OnNavigate): void {
|
||||
composerFocused = composerFocusAfter(
|
||||
navigation.from?.url.pathname,
|
||||
navigation.to?.url.pathname,
|
||||
document.activeElement?.closest('[data-session-composer]') != null
|
||||
)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { OnNavigate } from '@sveltejs/kit'
|
||||
import { sessionState } from './sessionState.svelte'
|
||||
|
||||
// Moving between Home and AI Sessions keeps the composer in sight: the browser
|
||||
// snapshots the page before and after the navigation and glides the element that
|
||||
// carries the `composer` view-transition-name on both sides (AIChatDisplay names it);
|
||||
// the rest of the page cross-fades. The timing lives in app.css, keyed on the
|
||||
// `data-home-sessions-transition` attribute set here for the length of the move.
|
||||
|
||||
const HOME = '/'
|
||||
const SESSIONS = '/sessions'
|
||||
|
||||
/** Whether the composer had focus when the user left AI Sessions for Home. */
|
||||
let composerFocused = false
|
||||
|
||||
/** The focus flag to keep after a navigation from `from` to `to`. Only the return to Home
|
||||
* hands focus on — the sessions page focuses its composer on its own — and every other
|
||||
* navigation clears the flag, so a later arrival on Home never acts on a stale one. */
|
||||
export function composerFocusAfter(
|
||||
from: string | undefined,
|
||||
to: string | undefined,
|
||||
composerHadFocus: boolean
|
||||
): boolean {
|
||||
return from === SESSIONS && to === HOME && composerHadFocus
|
||||
}
|
||||
|
||||
/** Whether Home should give its composer the focus the sessions page's had. Read once:
|
||||
* a later visit starts unfocused. */
|
||||
export function takeComposerFocus(): boolean {
|
||||
const focused = composerFocused
|
||||
composerFocused = false
|
||||
return focused
|
||||
}
|
||||
|
||||
// A session that has sent its first message has a composer of its own, not Home's draft, so
|
||||
// a move between it and Home is not one composer moving: its page names no composer. The one
|
||||
// exception is the send from Home itself, which lands on that session already under way.
|
||||
let sentFromHome = $state(false)
|
||||
|
||||
/** Called by Home's composer as its send hands over to the session page. */
|
||||
export function composerSentFromHome(): void {
|
||||
sentFromHome = true
|
||||
}
|
||||
|
||||
/** Whether the session page arriving now came from a send on Home, so its composer is the
|
||||
* one Home just sent from. */
|
||||
export function arrivingFromHomeSend(): boolean {
|
||||
return sentFromHome
|
||||
}
|
||||
|
||||
/** For SvelteKit's `onNavigate`: runs a move between Home and AI Sessions as a view
|
||||
* transition. Any other navigation, or a browser without the API, is left alone. */
|
||||
export function homeSessionsTransition(navigation: OnNavigate): Promise<void> | undefined {
|
||||
const from = navigation.from?.url.pathname
|
||||
const to = navigation.to?.url.pathname
|
||||
composerFocused = composerFocusAfter(
|
||||
from,
|
||||
to,
|
||||
document.activeElement?.closest('[data-session-composer]') != null
|
||||
)
|
||||
const direction =
|
||||
from === HOME && to === SESSIONS
|
||||
? 'enter'
|
||||
: from === SESSIONS && to === HOME
|
||||
? 'leave'
|
||||
: undefined
|
||||
// Good for the navigation the send started, and only that one.
|
||||
if (direction !== 'enter') sentFromHome = false
|
||||
if (!direction || navigation.willUnload || !document.startViewTransition) return
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
const root = document.documentElement
|
||||
// To or from a session under way there is no composer to carry over: Home's would only
|
||||
// fade out or in on its own, so the move drops every name and the page cross-fades as
|
||||
// one. Leaving, the page says so; arriving, the session's record does (it has sent once
|
||||
// it has a workspace), short of the send from Home that just started it.
|
||||
const composerOnScreen = Array.from(document.querySelectorAll('[data-session-composer]')).some(
|
||||
(el) => getComputedStyle(el).viewTransitionName === 'composer'
|
||||
)
|
||||
const arrivingId = navigation.to?.url.searchParams.get('session') ?? sessionState.currentSessionId
|
||||
const arrivingUnderWay =
|
||||
!sentFromHome && !!sessionState.sessions.find((s) => s.id === arrivingId)?.workspace_id
|
||||
const plain = direction === 'leave' ? !composerOnScreen : arrivingUnderWay
|
||||
root.dataset.homeSessionsTransition = plain ? 'fade' : direction
|
||||
return new Promise((resolve) => {
|
||||
const transition = document.startViewTransition(async () => {
|
||||
resolve()
|
||||
await navigation.complete
|
||||
})
|
||||
// Rejects when the navigation is cancelled or fails; the attribute goes either way.
|
||||
const done = () => {
|
||||
delete root.dataset.homeSessionsTransition
|
||||
sentFromHome = false
|
||||
}
|
||||
void transition.finished.then(done, done)
|
||||
})
|
||||
}
|
||||
@@ -59,7 +59,7 @@
|
||||
} from '$lib/stores'
|
||||
import CenteredModal from '$lib/components/CenteredModal.svelte'
|
||||
import { afterNavigate, beforeNavigate, onNavigate } from '$app/navigation'
|
||||
import { homeSessionsTransition } from '$lib/components/sessions/homeSessionsTransition.svelte'
|
||||
import { recordComposerFocus } from '$lib/components/sessions/homeComposerFocus'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { registerToolDisplayActionHandler } from '$lib/components/copilot/chat/createdResourceActions.svelte'
|
||||
import type UserSettings from '$lib/components/UserSettings.svelte'
|
||||
@@ -450,7 +450,7 @@
|
||||
return kind ? { kind, path: route.itemPath } : undefined
|
||||
}
|
||||
|
||||
onNavigate(homeSessionsTransition)
|
||||
onNavigate(recordComposerFocus)
|
||||
|
||||
beforeNavigate((navigation) => {
|
||||
if (preserveMenuOnNextNav) {
|
||||
|
||||
@@ -99,11 +99,6 @@
|
||||
// workspace: a remount would drop the list's own state and refetch what it is already loading.
|
||||
let pageEl: HTMLDivElement | undefined = $state(undefined)
|
||||
|
||||
// Back from AI Sessions the view transition (homeSessionsTransition.svelte.ts) carries the arrival, so
|
||||
// the page skips its own fade-in. Decided once at mount: gating the class on the attribute
|
||||
// instead would start the fade from transparent the moment the transition removes it.
|
||||
const fadeIn =
|
||||
typeof document === 'undefined' || !document.documentElement.dataset.homeSessionsTransition
|
||||
const reducedMotion = useReducedMotion()
|
||||
let animatedWorkspace = $workspaceStore
|
||||
$effect(() => {
|
||||
@@ -292,7 +287,7 @@
|
||||
|
||||
<div
|
||||
bind:this={pageEl}
|
||||
class="{fadeIn ? 'wm-page-in' : ''} flex flex-col w-full h-full overflow-y-auto items-center"
|
||||
class="wm-page-in flex flex-col w-full h-full overflow-y-auto items-center"
|
||||
style="scrollbar-gutter: stable both-edges;"
|
||||
>
|
||||
<!-- Where edits to this workspace are made is a fact about the workspace, so it sits in the
|
||||
|
||||
Reference in New Issue
Block a user