diff --git a/apps/desktop/src/renderer/src/shell/chrome/chrome.tsx b/apps/desktop/src/renderer/src/shell/chrome/chrome.tsx index fd742bdc..4c855bd8 100644 --- a/apps/desktop/src/renderer/src/shell/chrome/chrome.tsx +++ b/apps/desktop/src/renderer/src/shell/chrome/chrome.tsx @@ -522,7 +522,9 @@ function MainChromeTitle({ {icon ?? } - {header.title} + + {header.title} + {chip} diff --git a/packages/client/workbench/src/surface/__tests__/session-switch-transition.test.ts b/packages/client/workbench/src/surface/__tests__/session-switch-transition.test.ts new file mode 100644 index 00000000..cc5c7745 --- /dev/null +++ b/packages/client/workbench/src/surface/__tests__/session-switch-transition.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment jsdom + +import type { SessionId } from '@linkcode/schema'; +import { wait } from 'foxts/wait'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { applySessionSwitchTransition } from '../session-switch-transition'; + +// The real store transitively imports the whole @linkcode/ui barrel (dnd-kit needs +// ResizeObserver); the transition only reads `reduceMotion`, so stub exactly that. +const prefs = vi.hoisted(() => ({ reduceMotion: false })); +vi.mock('../../settings/appearance-store', () => ({ + useAppearancePrefsStore: { getState: () => prefs }, +})); + +const SESSION = 'session-1' as SessionId; + +function installVt(impl: (update: () => void) => { finished: Promise }): void { + document.startViewTransition = impl as unknown as typeof document.startViewTransition; +} + +function mountPair(): { row: HTMLElement; header: HTMLElement } { + const row = document.createElement('span'); + row.dataset.threadTitle = SESSION; + const header = document.createElement('div'); + header.dataset.conversationTitle = ''; + document.body.append(row, header); + return { row, header }; +} + +afterEach(() => { + document.body.innerHTML = ''; + Reflect.deleteProperty(document, 'startViewTransition'); + prefs.reduceMotion = false; +}); + +describe('applySessionSwitchTransition', () => { + it('applies plainly when the API is missing', () => { + mountPair(); + const apply = vi.fn(); + applySessionSwitchTransition(SESSION, apply); + expect(apply).toHaveBeenCalledOnce(); + }); + + it('applies plainly under reduce-motion even with the API present', () => { + mountPair(); + const startViewTransition = vi.fn(); + installVt(startViewTransition); + prefs.reduceMotion = true; + const apply = vi.fn(); + applySessionSwitchTransition(SESSION, apply); + expect(apply).toHaveBeenCalledOnce(); + expect(startViewTransition).not.toHaveBeenCalled(); + }); + + it('applies plainly when the clicked row is not in the DOM', () => { + const startViewTransition = vi.fn(); + installVt(startViewTransition); + const apply = vi.fn(); + applySessionSwitchTransition(SESSION, apply); + expect(apply).toHaveBeenCalledOnce(); + expect(startViewTransition).not.toHaveBeenCalled(); + }); + + it('pairs the row and header names around the switch, then clears them', async () => { + const { row, header } = mountPair(); + let rowNameDuringCapture = ''; + let headerNameAfterUpdate = ''; + installVt((update) => { + rowNameDuringCapture = row.style.getPropertyValue('view-transition-name'); + update(); + headerNameAfterUpdate = header.style.getPropertyValue('view-transition-name'); + return { finished: Promise.resolve() }; + }); + const apply = vi.fn(); + applySessionSwitchTransition(SESSION, apply); + expect(apply).toHaveBeenCalledOnce(); + expect(rowNameDuringCapture).toBe('thread-title'); + expect(headerNameAfterUpdate).toBe('thread-title'); + expect(row.style.getPropertyValue('view-transition-name')).toBe(''); + // The cleanup sits behind finished → catch → finally; a macrotask flushes all of them. + await wait(0); + expect(header.style.getPropertyValue('view-transition-name')).toBe(''); + }); + + it('clears a stale header name even when the transition is interrupted', async () => { + const { header } = mountPair(); + installVt((update) => { + update(); + return { finished: Promise.reject(new Error('skipped')) }; + }); + applySessionSwitchTransition(SESSION, vi.fn()); + await wait(0); + expect(header.style.getPropertyValue('view-transition-name')).toBe(''); + }); +}); diff --git a/packages/client/workbench/src/surface/session-switch-transition.ts b/packages/client/workbench/src/surface/session-switch-transition.ts new file mode 100644 index 00000000..8e03d8b0 --- /dev/null +++ b/packages/client/workbench/src/surface/session-switch-transition.ts @@ -0,0 +1,43 @@ +import type { SessionId } from '@linkcode/schema'; +import { noop } from 'foxts/noop'; +import { flushSync } from 'react-dom'; +import { useAppearancePrefsStore } from '../settings/appearance-store'; + +/** The matched-geometry pair for a session switch: the clicked thread row's title + * (`data-thread-title`) travels to the conversation header title (`data-conversation-title`). */ +const PAIR_NAME = 'thread-title'; + +function headerTitle(): HTMLElement | null { + return document.querySelector('[data-conversation-title]'); +} + +/** + * Wrap a session switch in a View Transition. Falls back to a plain apply without the API, + * under reduce-motion, or when the clicked row is not in the DOM. Only the switching pair may + * carry the transition name: a duplicate name in either snapshot makes the browser skip the + * whole transition, so the header's name is cleared on entry and after every run. + */ +export function applySessionSwitchTransition(id: SessionId, apply: () => void): void { + // Session ids are daemon-generated identifiers (no quotes/backslashes) — safe to interpolate. + const source = document.querySelector(`[data-thread-title="${id}"]`); + if ( + !source || + typeof document.startViewTransition !== 'function' || + useAppearancePrefsStore.getState().reduceMotion + ) { + apply(); + return; + } + headerTitle()?.style.removeProperty('view-transition-name'); + source.style.setProperty('view-transition-name', PAIR_NAME); + const transition = document.startViewTransition(() => { + // eslint-disable-next-line @eslint-react/dom-no-flush-sync -- the browser captures the new snapshot when this callback returns, so the React commit must land synchronously + flushSync(apply); + source.style.removeProperty('view-transition-name'); + headerTitle()?.style.setProperty('view-transition-name', PAIR_NAME); + }); + // `finished` rejects when a newer transition interrupts this one; cleanup runs either way. + transition.finished + .catch(noop) + .finally(() => headerTitle()?.style.removeProperty('view-transition-name')); +} diff --git a/packages/client/workbench/src/surface/shell.tsx b/packages/client/workbench/src/surface/shell.tsx index 95915536..ddeca8cd 100644 --- a/packages/client/workbench/src/surface/shell.tsx +++ b/packages/client/workbench/src/surface/shell.tsx @@ -68,7 +68,9 @@ function DefaultTitleStrip({ return (
-
{header.title}
+
+ {header.title} +
{header.subtitle && (
{header.subtitle}
)} diff --git a/packages/client/workbench/src/surface/use-workbench-sessions.ts b/packages/client/workbench/src/surface/use-workbench-sessions.ts index f14e6236..560a9f6d 100644 --- a/packages/client/workbench/src/surface/use-workbench-sessions.ts +++ b/packages/client/workbench/src/surface/use-workbench-sessions.ts @@ -17,6 +17,7 @@ import { useNavigationHistoryStore } from '../navigation/store'; import { useData, useMutation } from '../runtime/tayori'; import type { WorkbenchSessionDraft } from './selection-store'; import { useSessionSelectionStore } from './selection-store'; +import { applySessionSwitchTransition } from './session-switch-transition'; export interface WorkbenchSessions { sessions: SessionInfo[]; @@ -142,7 +143,8 @@ export function useWorkbenchSessions(onError: (err: unknown) => void): Workbench function select(id: SessionId): void { recordNavigation(currentLocation, { surface: 'thread', sessionId: id }); - applySelection(id); + // Matched geometry: the clicked row's title travels to the conversation header. + applySessionSwitchTransition(id, () => applySelection(id)); } function startDraft(workspaceId?: WorkspaceId): void { diff --git a/packages/presentation/ui/src/shell/sidebar/thread-row.tsx b/packages/presentation/ui/src/shell/sidebar/thread-row.tsx index 02735a25..7c73287d 100644 --- a/packages/presentation/ui/src/shell/sidebar/thread-row.tsx +++ b/packages/presentation/ui/src/shell/sidebar/thread-row.tsx @@ -94,7 +94,9 @@ export function ThreadRow({ )} /> - {title} + + {title} +
diff --git a/packages/presentation/ui/src/styles.css b/packages/presentation/ui/src/styles.css index 6eb1a7b7..16876e8f 100644 --- a/packages/presentation/ui/src/styles.css +++ b/packages/presentation/ui/src/styles.css @@ -144,6 +144,15 @@ } } +/* Matched-geometry session switch (thread row title → conversation header): pace the paired + element and the root crossfade on the motion scale. Reduce-motion never starts a transition + (gated where the switch is applied), so no override is needed here. */ +::view-transition-group(thread-title), +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: var(--motion-normal); +} + @layer components { /* Read-only chat terminals mirror LiveTerminal's auto pair: GitHub Light Default / Dark+. Scope the palette so ANSI class names from other libraries cannot inherit it accidentally. */