From a5a4caf3543124ce1ed55bf0dc991cf4575b097c Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 27 Oct 2026 00:43:21 +0100 Subject: [PATCH] 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) * docs: drop view transition rationale from composer comments Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- frontend/src/lib/assets/app.css | 43 --------- .../lib/components/copilot/chat/AIChat.svelte | 3 - .../copilot/chat/AIChatDisplay.svelte | 14 +-- .../copilot/chat/starter/smoothHeight.ts | 4 +- .../src/lib/components/home/HomeAIChat.svelte | 4 +- .../sessions/HomeSessionStarter.svelte | 4 +- .../components/sessions/SessionWrapper.svelte | 2 - ...tion.test.ts => homeComposerFocus.test.ts} | 2 +- .../components/sessions/homeComposerFocus.ts | 38 ++++++++ .../sessions/homeSessionsTransition.svelte.ts | 96 ------------------- .../src/routes/(root)/(logged)/+layout.svelte | 4 +- .../src/routes/(root)/(logged)/+page.svelte | 7 +- 12 files changed, 49 insertions(+), 172 deletions(-) rename frontend/src/lib/components/sessions/{homeSessionsTransition.test.ts => homeComposerFocus.test.ts} (90%) create mode 100644 frontend/src/lib/components/sessions/homeComposerFocus.ts delete mode 100644 frontend/src/lib/components/sessions/homeSessionsTransition.svelte.ts diff --git a/frontend/src/lib/assets/app.css b/frontend/src/lib/assets/app.css index f7f9b53972..4217c95831 100644 --- a/frontend/src/lib/assets/app.css +++ b/frontend/src/lib/assets/app.css @@ -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; -} diff --git a/frontend/src/lib/components/copilot/chat/AIChat.svelte b/frontend/src/lib/components/copilot/chat/AIChat.svelte index 51a8dfa55b..9f284e5f98 100644 --- a/frontend/src/lib/components/copilot/chat/AIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChat.svelte @@ -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} diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index a740c5fb5f..63a9989d9c 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -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}
-
+
@@ -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. -->
diff --git a/frontend/src/lib/components/copilot/chat/starter/smoothHeight.ts b/frontend/src/lib/components/copilot/chat/starter/smoothHeight.ts index 57336ef9b8..b6b9c654a9 100644 --- a/frontend/src/lib/components/copilot/chat/starter/smoothHeight.ts +++ b/frontend/src/lib/components/copilot/chat/starter/smoothHeight.ts @@ -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 { diff --git a/frontend/src/lib/components/home/HomeAIChat.svelte b/frontend/src/lib/components/home/HomeAIChat.svelte index 3816a18898..9660cca149 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -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)) diff --git a/frontend/src/lib/components/sessions/HomeSessionStarter.svelte b/frontend/src/lib/components/sessions/HomeSessionStarter.svelte index 942025c21c..fd77de15bd 100644 --- a/frontend/src/lib/components/sessions/HomeSessionStarter.svelte +++ b/frontend/src/lib/components/sessions/HomeSessionStarter.svelte @@ -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} diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index fc128584ba..bedc7c9167 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -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} diff --git a/frontend/src/lib/components/sessions/homeSessionsTransition.test.ts b/frontend/src/lib/components/sessions/homeComposerFocus.test.ts similarity index 90% rename from frontend/src/lib/components/sessions/homeSessionsTransition.test.ts rename to frontend/src/lib/components/sessions/homeComposerFocus.test.ts index 4be3f18fce..1574381701 100644 --- a/frontend/src/lib/components/sessions/homeSessionsTransition.test.ts +++ b/frontend/src/lib/components/sessions/homeComposerFocus.test.ts @@ -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', () => { diff --git a/frontend/src/lib/components/sessions/homeComposerFocus.ts b/frontend/src/lib/components/sessions/homeComposerFocus.ts new file mode 100644 index 0000000000..470c355b0c --- /dev/null +++ b/frontend/src/lib/components/sessions/homeComposerFocus.ts @@ -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 + ) +} diff --git a/frontend/src/lib/components/sessions/homeSessionsTransition.svelte.ts b/frontend/src/lib/components/sessions/homeSessionsTransition.svelte.ts deleted file mode 100644 index 74a72ec0ce..0000000000 --- a/frontend/src/lib/components/sessions/homeSessionsTransition.svelte.ts +++ /dev/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 | 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) - }) -} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 6b79582958..a2170657e3 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -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) { diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index d23f92de96..e066761069 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -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 @@