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:
Guilhem
2026-10-27 00:43:21 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7b1dce5af0
commit a5a4caf354
12 changed files with 49 additions and 172 deletions
-43
View File
@@ -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,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