Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/src/shell/chrome/chrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,9 @@ function MainChromeTitle({
<span className="mr-1 flex shrink-0 items-center">
{icon ?? <FileTextIcon className="size-4 text-foreground" />}
</span>
<span className="min-w-0 flex-1 truncate font-semibold text-sm">{header.title}</span>
<span className="min-w-0 flex-1 truncate font-semibold text-sm" data-conversation-title="">
{header.title}
</span>
{chip}
<ShellIconButton label="More" disabled>
<EllipsisIcon className="size-4" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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> }): 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('');
});
});
43 changes: 43 additions & 0 deletions packages/client/workbench/src/surface/session-switch-transition.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('[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<HTMLElement>(`[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(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize overlapping session-switch transitions

When two session selections occur before the first View Transition update callback runs, both calls retain the same rendered currentLocation, leave multiple source titles temporarily named thread-title, and queue separate apply callbacks. This can make Chromium skip the matched transition because the old snapshot has duplicate names, while navigation history records the stale origin twice so Back skips the intermediate thread. Rapid consecutive clicks or shortcut/palette selections can trigger this; cancel or serialize the pending switch and ensure history is recorded with the selection that actually applies.

Useful? React with 👍 / 👎.

// 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'));
}
4 changes: 3 additions & 1 deletion packages/client/workbench/src/surface/shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ function DefaultTitleStrip({
return (
<TitleStrip className="border-border border-b">
<div className="min-w-0">
<div className="truncate font-medium text-sm">{header.title}</div>
<div className="truncate font-medium text-sm" data-conversation-title="">
{header.title}
</div>
{header.subtitle && (
<div className="truncate text-muted-foreground text-xs">{header.subtitle}</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion packages/presentation/ui/src/shell/sidebar/thread-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ export function ThreadRow({
)}
/>
</span>
<span className="min-w-0 flex-1 truncate">{title}</span>
<span className="min-w-0 flex-1 truncate" data-thread-title={session.sessionId}>
{title}
</span>
</PreviewCardTrigger>
<SidebarPreviewCardPopup>
<div className="flex min-w-0 flex-1 flex-col gap-2">
Expand Down
9 changes: 9 additions & 0 deletions packages/presentation/ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down