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 @@