diff --git a/frontend/src/v2/__tests__/ConnectorDrawer.motion.test.tsx b/frontend/src/v2/__tests__/ConnectorDrawer.motion.test.tsx new file mode 100644 index 0000000000..6d170f080b --- /dev/null +++ b/frontend/src/v2/__tests__/ConnectorDrawer.motion.test.tsx @@ -0,0 +1,103 @@ +// @ts-nocheck +// The connector drawer slides in from the right and back out (the motion spec): +// on close it stays mounted, inert, until its exit animation ends, and it can +// never be stranded there. +import React from 'react'; +import { fireEvent, screen, waitFor, within } from '@testing-library/react'; +import { mockApi, renderPage, findDrawer, clickTile } from '../__testkit__/connectorsKit'; +import { EXIT_SAFETY_MS } from '../utils/motion'; + +jest.mock('axios', () => require('../__testkit__/axiosMock').axiosFactory()); + +const pods = [{ _id: 'p1', name: 'Rewire Live Demo', type: 'chat', members: [] }]; +const liveSlack = { + installableId: 'slack', label: 'Slack', description: 'Slack copy.', available: true, offered: true, + installation: { status: 'active', updatedAt: new Date().toISOString(), boundPodId: 'p1', components: [] }, + integration: { + _id: 'i-live', installationId: 'install-slack-u1', type: 'slack', status: 'connected', scope: 'user', isActive: true, + createdAt: new Date(Date.now() - 3_600_000).toISOString(), updatedAt: new Date().toISOString(), + config: { chatTitle: 'Rewire crew', liveRelay: true, gates: {} }, podId: 'p1', + }, +}; + +const realMatchMedia = window.matchMedia; +const realGetComputedStyle = window.getComputedStyle; +let reduce = false; + +beforeEach(() => { + jest.clearAllMocks(); + reduce = false; + window.history.replaceState({}, '', '/v2/connectors'); + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: query.includes('reduced-motion') ? reduce : false, + media: query, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })); +}); + +afterEach(() => { + window.matchMedia = realMatchMedia; + window.getComputedStyle = realGetComputedStyle; +}); + +// jsdom runs no animations; report the ones connectors.css would apply. +const withDrawerAnimations = () => { + window.getComputedStyle = (node, pseudo) => new Proxy(realGetComputedStyle(node, pseudo), { + get(target, key) { + if (key === 'animationName' && node.classList?.contains('v2-cx-drawer')) { + return node.classList.contains('is-leaving') ? 'v2-cx-drawer-out' : 'v2-cx-drawer-in'; + } + const value = target[key]; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +}; + +const openSlack = async () => { + mockApi({ installables: [liveSlack], pods }); + renderPage(); + await clickTile('Slack'); + return findDrawer('Slack'); +}; +const drawerNode = () => document.querySelector('section.v2-cx-drawer'); + +describe('connector drawer motion', () => { + it('opens without the leaving state and, on close, stays mounted and inert until its slide out ends', async () => { + withDrawerAnimations(); + const drawer = await openSlack(); + expect(drawer).not.toHaveClass('is-leaving'); + expect(drawer).not.toHaveAttribute('inert'); + + fireEvent.click(within(drawer).getByRole('button', { name: 'Close' })); + expect(drawerNode()).toHaveClass('is-leaving'); + expect(drawerNode()).toHaveAttribute('inert'); + // Leaving, it is out of the accessibility tree: the page reads as closed. + expect(screen.queryByRole('region', { name: 'Slack' })).toBeNull(); + + fireEvent.animationEnd(drawerNode()); + expect(drawerNode()).toBeNull(); + }); + + it('unmounts after the safety net when no animationend arrives', async () => { + withDrawerAnimations(); + const drawer = await openSlack(); + fireEvent.click(within(drawer).getByRole('button', { name: 'Close' })); + expect(drawerNode()).not.toBeNull(); + await waitFor(() => expect(drawerNode()).toBeNull(), { timeout: EXIT_SAFETY_MS + 500 }); + }); + + it('closes at once under reduced motion', async () => { + withDrawerAnimations(); + reduce = true; + const drawer = await openSlack(); + fireEvent.click(within(drawer).getByRole('button', { name: 'Close' })); + expect(drawerNode()).toBeNull(); + }); + + it('closes at once when no exit animation applies at all', async () => { + const drawer = await openSlack(); + fireEvent.click(within(drawer).getByRole('button', { name: 'Close' })); + expect(drawerNode()).toBeNull(); + }); +}); diff --git a/frontend/src/v2/__tests__/V2ChatMotion.test.tsx b/frontend/src/v2/__tests__/V2ChatMotion.test.tsx new file mode 100644 index 0000000000..8f7a645fc4 --- /dev/null +++ b/frontend/src/v2/__tests__/V2ChatMotion.test.tsx @@ -0,0 +1,242 @@ +// @ts-nocheck +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { useV2PodDetail } from '../hooks/useV2PodDetail'; +import V2MessageRow, { CollapsiblePre, CollapsibleProse, LIVE_ENTER_WINDOW_MS } from '../components/V2MessageRow'; +import V2ThreadMessages from '../components/V2ThreadMessages'; +import { buildThreadView } from '../utils/threadView'; + +// Chat motion: a live arrival enters, history appears at once, Show more folds +// by class, and the content that replaces the loader fades in once. + +const mockApi = { get: jest.fn(), post: jest.fn(), patch: jest.fn(), del: jest.fn() }; +const mockSocket = { on: jest.fn(), off: jest.fn() }; + +jest.mock('../hooks/useV2Api', () => ({ useV2Api: () => mockApi })); +jest.mock('../../context/SocketContext', () => ({ + useSocket: () => ({ socket: mockSocket, connected: true, joinPod: jest.fn(), leavePod: jest.fn() }), +})); +jest.mock('../../context/AuthContext', () => ({ + useAuth: () => ({ currentUser: { _id: 'human-1', username: 'viewer' } }), +})); + +const message = (id, minute, extra = {}) => ({ + id, + pod_id: 'pod-1', + user_id: 'agent-1', + content: `text ${id}`, + message_type: 'text', + created_at: new Date(Date.UTC(2026, 9, 19, 12, minute)).toISOString(), + user: { username: 'scout' }, + ...extra, +}); + +const latestNewMessageHandler = () => mockSocket.on.mock.calls + .filter(([event]) => event === 'newMessage') + .at(-1)[1]; + +// The real hook feeding real rows, so the test exercises the live path the +// socket takes rather than a hand-set flag. +const Transcript = () => { + const { messages } = useV2PodDetail('pod-1'); + return {messages.map((m) => )}; +}; + +const row = (id) => document.getElementById(`message-${id}`); + +describe('chat motion: a live message enters, history does not', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockApi.get.mockImplementation((url) => { + if (url === '/api/messages/pod-1?limit=50') return Promise.resolve([message('history', 0)]); + if (url.startsWith('/api/messages/pod-1?limit=50&before=')) return Promise.resolve([message('older', -5)]); + if (url.includes('/agents')) return Promise.resolve({ agents: [] }); + return Promise.resolve({ _id: 'pod-1', name: 'Pod', members: [] }); + }); + }); + + it('gives the enter class to a socket arrival and never to the opened history', async () => { + render(); + await waitFor(() => expect(row('history')).not.toBeNull()); + expect(row('history')).not.toHaveClass('v2-msg--enter'); + + act(() => { latestNewMessageHandler()(message('live', 1)); }); + expect(row('live')).toHaveClass('v2-msg--enter'); + expect(row('history')).not.toHaveClass('v2-msg--enter'); + }); + + it('does not animate a socket copy of a row that is already on screen', async () => { + render(); + await waitFor(() => expect(row('history')).not.toBeNull()); + const before = row('history'); + act(() => { latestNewMessageHandler()(message('history', 0)); }); + expect(row('history')).toBe(before); + expect(row('history')).not.toHaveClass('v2-msg--enter'); + }); + + it('marks only live arrivals in the hook; history and older pages carry no flag', async () => { + let detail; + const Probe = () => { detail = useV2PodDetail('pod-1'); return null; }; + render(); + await waitFor(() => expect(detail.initialLoadComplete).toBe(true)); + act(() => { latestNewMessageHandler()(message('live', 1)); }); + await act(async () => { await detail.loadOlder(); }); + const flags = Object.fromEntries(detail.messages.map((m) => [m.id, typeof m.liveArrivedAt === 'number'])); + expect(flags).toEqual({ older: false, history: false, live: true }); + }); + + it('marks your own sent message live, so it gets the same 4px enter and nothing more', async () => { + mockApi.post.mockResolvedValue(message('mine', 2, { user_id: 'human-1', user: { username: 'viewer' } })); + let detail; + const Probe = () => { detail = useV2PodDetail('pod-1'); return null; }; + render(); + await waitFor(() => expect(detail.initialLoadComplete).toBe(true)); + await act(async () => { await detail.sendMessage('hello'); }); + expect(typeof detail.messages.find((m) => m.id === 'mine').liveArrivedAt).toBe('number'); + }); +}); + +describe('chat motion: the row decides once, at mount', () => { + const renderRow = (m) => render(); + + it('a re-render that drops the live flag keeps the row as it is and does not restart the enter', () => { + const live = message('a', 1, { liveArrivedAt: Date.now() }); + const view = renderRow(live); + const node = row('a'); + expect(node).toHaveClass('v2-msg--enter'); + view.rerender(); + expect(row('a')).toBe(node); + expect(node).toHaveClass('v2-msg--enter'); + }); + + it('a history row that later gains a flag does not start entering', () => { + const history = message('b', 1); + const view = renderRow(history); + view.rerender(); + expect(row('b')).not.toHaveClass('v2-msg--enter'); + }); + + it('a row that remounts after the enter window (a root moving into a thread) does not replay', () => { + renderRow(message('c', 1, { liveArrivedAt: Date.now() - LIVE_ENTER_WINDOW_MS - 1 })); + expect(row('c')).not.toHaveClass('v2-msg--enter'); + }); +}); + +describe('chat motion: Show more folds by class, never by measured height', () => { + const setScrollHeight = (px) => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, get: () => px }); + }; + afterEach(() => setScrollHeight(0)); + + it('a folding prose keeps its fold class open and closed, toggles only the closed class, and sets no inline size', () => { + setScrollHeight(900); + const { container } = render( +
long reply
, + ); + const wrapper = container.querySelector('.v2-msg__prose'); + expect(wrapper).toHaveClass('v2-msg__prose--fold', 'v2-msg__prose--closed'); + const button = screen.getByRole('button', { name: 'Show more' }); + + fireEvent.click(button); + expect(wrapper).toHaveClass('v2-msg__prose--fold'); + expect(wrapper).not.toHaveClass('v2-msg__prose--closed'); + expect(button).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.click(button); + expect(wrapper).toHaveClass('v2-msg__prose--fold', 'v2-msg__prose--closed'); + expect(button).toHaveAttribute('aria-expanded', 'false'); + // The motion is CSS sizing to auto; JS never writes a height. + expect(wrapper.getAttribute('style')).toBeNull(); + }); + + it('a short prose gets no fold class, so it never transitions', () => { + setScrollHeight(60); + const { container } = render( +
short
, + ); + expect(container.querySelector('.v2-msg__prose')).not.toHaveClass('v2-msg__prose--fold'); + }); + + it('long code opens and closes by the collapse classes', () => { + const code = Array.from({ length: 10 }, (_, i) => `line ${i}`).join('\n'); + const { container } = render({`${code}\n`}); + const box = container.querySelector('.v2-msg__collapse'); + expect(box).toHaveClass('v2-msg__collapse--closed'); + fireEvent.click(screen.getByRole('button', { expanded: false })); + expect(box).toHaveClass('v2-msg__collapse--open'); + fireEvent.click(screen.getByRole('button', { expanded: true })); + expect(box).toHaveClass('v2-msg__collapse--closed'); + expect(container.querySelector('pre').getAttribute('style')).toBeNull(); + }); +}); + +describe('chat motion: content replacing the loader fades in once', () => { + const renderTranscript = (props) => { + const threadView = buildThreadView(props.messages, new Map()); + return ( + + + + ); + }; + const scroller = (container) => container.querySelector('.v2-chat__messages'); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('a load under 300ms shows no spinner, and its content appears without a fade', () => { + jest.useFakeTimers(); + const view = render(renderTranscript({ messages: [], loading: true })); + act(() => { jest.advanceTimersByTime(299); }); + expect(view.container.querySelector('.v2-spinner')).toBeNull(); + view.rerender(renderTranscript({ messages: [message('h', 0)], loading: false })); + act(() => { jest.advanceTimersByTime(400); }); + expect(view.container.querySelector('.v2-spinner')).toBeNull(); + expect(scroller(view.container)).not.toHaveClass('v2-chat__messages--reveal'); + }); + + it('fades the transcript in when it replaces the spinner, and a later render does not restart it', () => { + jest.useFakeTimers(); + const view = render(renderTranscript({ messages: [], loading: true })); + expect(view.container.querySelector('.v2-spinner')).toBeNull(); + act(() => { jest.advanceTimersByTime(300); }); + expect(view.container.querySelector('.v2-spinner')).not.toBeNull(); + expect(scroller(view.container)).not.toHaveClass('v2-chat__messages--reveal'); + + view.rerender(renderTranscript({ messages: [message('h', 0)], loading: false })); + expect(view.container.querySelector('.v2-spinner')).toBeNull(); + expect(scroller(view.container)).toHaveClass('v2-chat__messages--reveal'); + + // A re-render keeps the same class on the same node, so the animation does not restart. + const node = scroller(view.container); + view.rerender(renderTranscript({ messages: [message('h', 0), message('n', 1)], loading: false })); + expect(scroller(view.container)).toBe(node); + expect(node).toHaveClass('v2-chat__messages--reveal'); + }); + + it('content that never waited on a spinner appears at once', () => { + const view = render(renderTranscript({ messages: [message('h', 0)], loading: false })); + expect(scroller(view.container)).not.toHaveClass('v2-chat__messages--reveal'); + // A refresh with rows already on screen shows no spinner, so nothing fades. + view.rerender(renderTranscript({ messages: [message('h', 0)], loading: true })); + view.rerender(renderTranscript({ messages: [message('h', 0)], loading: false })); + expect(scroller(view.container)).not.toHaveClass('v2-chat__messages--reveal'); + }); +}); diff --git a/frontend/src/v2/__tests__/V2ClaimRun.test.tsx b/frontend/src/v2/__tests__/V2ClaimRun.test.tsx index a21770a583..4f2f47fb2d 100644 --- a/frontend/src/v2/__tests__/V2ClaimRun.test.tsx +++ b/frontend/src/v2/__tests__/V2ClaimRun.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { act, render, screen, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; import i18n from '../../i18n'; @@ -75,6 +75,11 @@ describe('claim run summary control (TASK-692)', () => { expect(claimRowTaskIds(container)).toEqual(['TASK-674', 'TASK-675', 'TASK-676', 'TASK-678']); act(() => toggle.click()); expect(toggle).toHaveAttribute('aria-expanded', 'false'); + // The rows stay mounted, inert, while the fold closes, then leave. + const closing = container.querySelector('.v2-claim-run .v2-fold') as Element; + expect(closing).toHaveClass('v2-fold--closing'); + expect(closing).toHaveAttribute('inert'); + fireEvent.animationEnd(closing); expect(claimRowTaskIds(container)).toEqual([]); }); @@ -88,6 +93,7 @@ describe('claim run summary control (TASK-692)', () => { expect(claimRowTaskIds(container)).toHaveLength(4); await user.keyboard(' '); expect(toggle).toHaveAttribute('aria-expanded', 'false'); + fireEvent.animationEnd(container.querySelector('.v2-claim-run .v2-fold') as Element); expect(claimRowTaskIds(container)).toEqual([]); }); diff --git a/frontend/src/v2/__tests__/V2Fold.test.tsx b/frontend/src/v2/__tests__/V2Fold.test.tsx new file mode 100644 index 0000000000..0081ace889 --- /dev/null +++ b/frontend/src/v2/__tests__/V2Fold.test.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import V2Fold from '../components/V2Fold'; + +const fold = () => screen.queryByText('Body')?.closest('.v2-fold'); + +describe('V2Fold', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + test('a fold that starts open renders without an animation class', () => { + render(Body); + expect(fold()).toHaveClass('v2-fold'); + expect(fold()).not.toHaveClass('v2-fold--opening'); + }); + + test('a fold that starts closed renders nothing', () => { + render(Body); + expect(screen.queryByText('Body')).not.toBeInTheDocument(); + }); + + test('opening mounts the body with the opening class until its animation ends', () => { + const { rerender } = render(Body); + rerender(Body); + expect(fold()).toHaveClass('v2-fold--opening'); + expect(screen.getByText('Body')).toHaveAttribute('id', 'b'); + expect(screen.getByText('Body')).toHaveClass('x'); + // The caller's box sits inside the clipping inner, so its padding folds away too. + expect(screen.getByText('Body').parentElement).toHaveClass('v2-fold__inner'); + fireEvent.animationEnd(fold() as Element); + expect(fold()).not.toHaveClass('v2-fold--opening'); + }); + + test('closing keeps the body mounted and inert until the close animation ends', () => { + const { rerender } = render(Body); + rerender(Body); + expect(fold()).toHaveClass('v2-fold--closing'); + expect(fold()).toHaveAttribute('inert'); + fireEvent.animationEnd(fold() as Element); + expect(screen.queryByText('Body')).not.toBeInTheDocument(); + }); + + test('an animation event from inside the body does not close the fold early', () => { + const { rerender } = render(Body); + rerender(Body); + fireEvent.animationEnd(screen.getByText('Body')); + expect(screen.getByText('Body')).toBeInTheDocument(); + }); + + test('a closing fold unmounts on a timer when no animation event comes', () => { + jest.useFakeTimers(); + const { rerender } = render(Body); + rerender(Body); + expect(screen.getByText('Body')).toBeInTheDocument(); + act(() => { jest.advanceTimersByTime(200); }); + expect(screen.queryByText('Body')).not.toBeInTheDocument(); + }); + + test('reopening while closing cancels the close', () => { + jest.useFakeTimers(); + const { rerender } = render(Body); + rerender(Body); + rerender(Body); + act(() => { jest.advanceTimersByTime(400); }); + expect(fold()).toHaveClass('v2-fold--opening'); + expect(fold()).not.toHaveAttribute('inert'); + }); +}); diff --git a/frontend/src/v2/__tests__/V2Inspector.test.tsx b/frontend/src/v2/__tests__/V2Inspector.test.tsx index 3359e2d2ff..04cd48aa3d 100644 --- a/frontend/src/v2/__tests__/V2Inspector.test.tsx +++ b/frontend/src/v2/__tests__/V2Inspector.test.tsx @@ -93,6 +93,10 @@ describe('V2Inspector', () => { expect(await screen.findByText('Slack default mode')).toBeInTheDocument(); expect(JSON.parse(sessionStorage.getItem(INSPECTOR_OPEN_KEY) || '[]')).toEqual(['join', 'needs-you']); fireEvent.click(folds[0]); + // The group body closes by size, inert, then unmounts when its animation ends. + const closing = screen.getByText('Slack default mode').closest('.v2-fold') as Element; + expect(closing).toHaveClass('v2-fold--closing'); + fireEvent.animationEnd(closing); expect(screen.queryByText('Slack default mode')).not.toBeInTheDocument(); }); diff --git a/frontend/src/v2/__tests__/V2PodBoard.motion.test.tsx b/frontend/src/v2/__tests__/V2PodBoard.motion.test.tsx new file mode 100644 index 0000000000..40acc54cf0 --- /dev/null +++ b/frontend/src/v2/__tests__/V2PodBoard.motion.test.tsx @@ -0,0 +1,318 @@ +// @ts-nocheck +// The board's motion moments from the motion spec: a live column change +// glides through a view transition at 1440, fades out of the open tab at 390, and +// the task drawer stays mounted until its slide out ends. +import React from 'react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import V2PodBoard from '../components/V2PodBoard'; +import { AuthContext } from '../../context/AuthContext'; +import { EXIT_SAFETY_MS, viewTransitionName } from '../utils/motion'; + +let mockSocketValue = { socket: null, connected: false, joinPod: jest.fn(), leavePod: jest.fn() }; +jest.mock('../../context/SocketContext', () => ({ + useSocket: () => mockSocketValue, +})); + +jest.mock('axios', () => { + const mock = { + get: jest.fn(), + post: jest.fn(() => Promise.resolve({ data: {} })), + patch: jest.fn(() => Promise.resolve({ data: {} })), + delete: jest.fn(), + defaults: { baseURL: '', headers: { common: {} } }, + interceptors: { + request: { use: jest.fn(), eject: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn() }, + }, + }; + return { __esModule: true, default: mock, ...mock }; +}); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const axios = require('axios').default; + +const authValue = { + currentUser: { _id: 'u1', username: 'alice' }, + user: { _id: 'u1', username: 'alice' }, + token: 't', + loading: false, + error: null, + isAuthenticated: true, + register: jest.fn(), + login: jest.fn(), + logout: jest.fn(), + updateProfile: jest.fn(), +}; + +const PENDING = [ + { taskId: 'TASK-1', title: 'Claim me', status: 'pending', updates: [] }, + { taskId: 'TASK-2', title: 'Stay put', status: 'pending', updates: [] }, +]; +const CLAIMED = [ + { ...PENDING[0], status: 'claimed', claimedBy: 'u1', claimExpiresAt: new Date(Date.now() + 30 * 60_000).toISOString() }, + PENDING[1], +]; + +let taskRows = PENDING; +const wireAxios = () => { + axios.get.mockImplementation((url) => { + if (url.startsWith('/api/v1/tasks/')) return Promise.resolve({ data: { tasks: taskRows, permissions: { canWrite: true } } }); + if (url.startsWith('/api/pods/')) return Promise.resolve({ data: { name: 'Pod', members: [{ _id: 'u1', username: 'alice' }] } }); + return Promise.resolve({ data: {} }); + }); +}; + +let media = { phone: false, reduce: false }; +const realMatchMedia = window.matchMedia; +const realGetComputedStyle = window.getComputedStyle; + +const handlers = {}; +const liveSocket = () => { + mockSocketValue = { + socket: { on: (event, fn) => { handlers[event] = fn; }, off: jest.fn(), emit: jest.fn() }, + connected: true, + joinPod: jest.fn(), + leavePod: jest.fn(), + }; +}; + +const renderBoard = () => render( + + + + } /> + + + , +); + +const column = (name) => screen.getByRole('region', { name }); +const liveUpdate = async (rows) => { + taskRows = rows; + await act(async () => { handlers.task_updated({ podId: 'pod-1' }); }); +}; + +beforeEach(() => { + jest.clearAllMocks(); + taskRows = PENDING; + media = { phone: false, reduce: false }; + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: query.includes('reduced-motion') ? media.reduce : query.includes('max-width') ? media.phone : false, + media: query, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })); + liveSocket(); + wireAxios(); + delete document.startViewTransition; + document.documentElement.classList.remove('v2-board-glide'); +}); + +afterEach(() => { + window.matchMedia = realMatchMedia; + window.getComputedStyle = realGetComputedStyle; + delete document.startViewTransition; +}); + +const stubViewTransitions = () => { + const seen = []; + document.startViewTransition = jest.fn((update) => { + // What the browser captures as the old state: the glide class and the named columns. + seen.push({ + glide: document.documentElement.classList.contains('v2-board-glide'), + named: Array.from(document.querySelectorAll('.v2-board__col[data-glide]')).map((node) => node.getAttribute('aria-label')), + }); + update(); + return { finished: Promise.resolve() }; + }); + return seen; +}; + +describe('board motion: an agent claims a task', () => { + test('each card carries a CSS-safe task- view-transition name', async () => { + taskRows = [{ taskId: 'odd id/1.2_x', title: 'Odd', status: 'pending', updates: [] }]; + renderBoard(); + const card = (await screen.findByText('Odd')).closest('article'); + expect(card.style.getPropertyValue('--v2-task-vt')).toBe(viewTransitionName('task', 'odd id/1.2_x')); + expect(viewTransitionName('task', 'odd id/1.2_x')).toMatch(/^task-[A-Za-z0-9_-]+$/); + // Escaping the underscore keeps two ids from sharing a name. + expect(viewTransitionName('task', 'a_2e_')).not.toBe(viewTransitionName('task', 'a.')); + }); + + test('a live column change at 1440 runs inside startViewTransition and the card lands in its new column', async () => { + const seen = stubViewTransitions(); + renderBoard(); + await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Claim me'); + expect(document.startViewTransition).not.toHaveBeenCalled(); + + await liveUpdate(CLAIMED); + + expect(document.startViewTransition).toHaveBeenCalledTimes(1); + expect(seen[0].glide).toBe(true); + expect(seen[0].named.sort()).toEqual(['In Progress', 'Pending']); + expect(within(column('In Progress')).getByText('Claim me')).toBeInTheDocument(); + expect(within(column('Pending')).queryByText('Claim me')).toBeNull(); + // Once it has landed the class goes, so the lease ring starts pulsing. + await waitFor(() => expect(document.documentElement.classList.contains('v2-board-glide')).toBe(false)); + expect(document.querySelector('.v2-board__col[data-glide]')).toBeNull(); + }); + + test('an edit that lands while a glide waits is not overwritten by the older rows', async () => { + let pending = null; + document.startViewTransition = jest.fn((update) => { pending = update; return { finished: Promise.resolve() }; }); + renderBoard(); + fireEvent.click(await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Stay put')); + const priority = await screen.findByLabelText(/priority/i); + + await liveUpdate(CLAIMED); + expect(pending).not.toBeNull(); + + // The edit resolves inside the gap; the server now holds P0 for TASK-2. + taskRows = [CLAIMED[0], { ...PENDING[1], priority: 'P0' }]; + axios.patch.mockResolvedValueOnce({ data: { task: { ...PENDING[1], priority: 'P0', updatedAt: new Date().toISOString() } } }); + await act(async () => { fireEvent.change(priority, { target: { value: 'P0' } }); }); + + await act(async () => { pending(); }); + await waitFor(() => expect(screen.getByLabelText(/priority/i)).toHaveValue('P0')); + }); + + test('no glide under reduced motion: the card is simply in its new column', async () => { + stubViewTransitions(); + media.reduce = true; + renderBoard(); + await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Claim me'); + await liveUpdate(CLAIMED); + expect(document.startViewTransition).not.toHaveBeenCalled(); + expect(within(column('In Progress')).getByText('Claim me')).toBeInTheDocument(); + }); + + test('no glide on first load, even for a claim already made elsewhere', async () => { + stubViewTransitions(); + taskRows = CLAIMED; + renderBoard(); + await within(await screen.findByRole('region', { name: 'In Progress' })).findByText('Claim me'); + expect(document.startViewTransition).not.toHaveBeenCalled(); + }); + + test('no glide for a task first seen already claimed: it was never on this board to move', async () => { + stubViewTransitions(); + taskRows = [PENDING[1]]; + renderBoard(); + await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Stay put'); + await liveUpdate(CLAIMED); + expect(within(column('In Progress')).getByText('Claim me')).toBeInTheDocument(); + expect(document.startViewTransition).not.toHaveBeenCalled(); + }); + + test('no glide on a refetch the user started (creating a task)', async () => { + stubViewTransitions(); + renderBoard(); + await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Claim me'); + taskRows = CLAIMED; + fireEvent.click(screen.getAllByRole('button', { name: 'New task' })[0]); + fireEvent.change(screen.getByPlaceholderText(/what needs/i), { target: { value: 'Another' } }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Create task' })); }); + await within(column('In Progress')).findByText('Claim me'); + expect(document.startViewTransition).not.toHaveBeenCalled(); + }); + + test('without the API a live change still lands, at once', async () => { + renderBoard(); + await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Claim me'); + await liveUpdate(CLAIMED); + expect(within(column('In Progress')).getByText('Claim me')).toBeInTheDocument(); + expect(document.documentElement.classList.contains('v2-board-glide')).toBe(false); + }); +}); + +describe('board motion at 390: a card leaves the open tab', () => { + test('it fades toward its new tab, that tab ticks, and the open tab stays put', async () => { + stubViewTransitions(); + media.phone = true; + renderBoard(); + await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Claim me'); + + await liveUpdate(CLAIMED); + + expect(document.startViewTransition).not.toHaveBeenCalled(); + const ghost = within(column('Pending')).getByText('Claim me').closest('article'); + expect(ghost).toHaveClass('is-leaving', 'is-leaving--next'); + expect(ghost).toHaveAttribute('inert'); + expect(screen.getByRole('tab', { name: /Pending/ })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: /In Progress/ }).querySelector('.v2-board__tab-count')).toHaveClass('is-ticking'); + expect(screen.getByRole('tab', { name: /Pending/ }).querySelector('.v2-board__tab-count')).not.toHaveClass('is-ticking'); + + fireEvent.animationEnd(ghost); + expect(within(column('Pending')).queryByText('Claim me')).toBeNull(); + }); + + test('a card going back a tab fades the other way, and leaves even with no animationend', async () => { + media.phone = true; + taskRows = CLAIMED; + renderBoard(); + await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Stay put'); + fireEvent.click(screen.getByRole('tab', { name: /In Progress/ })); + await liveUpdate(PENDING); + const ghost = within(column('In Progress')).getByText('Claim me').closest('article'); + expect(ghost).toHaveClass('is-leaving--prev'); + await waitFor(() => expect(within(column('In Progress')).queryByText('Claim me')).toBeNull(), { timeout: EXIT_SAFETY_MS + 500 }); + }); + + test('no leaving card under reduced motion', async () => { + media.phone = true; + media.reduce = true; + renderBoard(); + await within(await screen.findByRole('region', { name: 'Pending' })).findByText('Claim me'); + await liveUpdate(CLAIMED); + expect(within(column('Pending')).queryByText('Claim me')).toBeNull(); + }); +}); + +describe('board motion: the task drawer', () => { + // jsdom has no animations; report the exit animation the stylesheet would apply. + const withExitAnimation = () => { + window.getComputedStyle = (node, pseudo) => new Proxy(realGetComputedStyle(node, pseudo), { + get(target, key) { + if (key === 'animationName') return node.hasAttribute?.('data-leaving') ? 'v2-board-drawer-out' : 'v2-board-drawer-in'; + const value = target[key]; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }; + const openDrawer = async () => { + renderBoard(); + fireEvent.click(await screen.findByText('Claim me')); + return screen.getByRole('dialog', { name: 'Claim me' }); + }; + + test('closing keeps it mounted and inert until its slide out ends', async () => { + withExitAnimation(); + const drawer = await openDrawer(); + expect(drawer).not.toHaveAttribute('data-leaving'); + fireEvent.click(within(drawer).getByRole('button', { name: 'Close' })); + + const leaving = document.querySelector('.v2-board__drawer'); + expect(leaving).toHaveAttribute('data-leaving'); + expect(leaving.parentElement).toHaveAttribute('inert'); + expect(leaving.parentElement).toHaveAttribute('data-leaving'); + fireEvent.animationEnd(leaving); + expect(document.querySelector('.v2-board__drawer')).toBeNull(); + }); + + test('a drawer whose exit never reports back still unmounts', async () => { + withExitAnimation(); + const drawer = await openDrawer(); + fireEvent.click(within(drawer).getByRole('button', { name: 'Close' })); + expect(document.querySelector('.v2-board__drawer')).not.toBeNull(); + await waitFor(() => expect(document.querySelector('.v2-board__drawer')).toBeNull(), { timeout: EXIT_SAFETY_MS + 500 }); + }); + + test('under reduced motion it closes at once', async () => { + withExitAnimation(); + media.reduce = true; + const drawer = await openDrawer(); + fireEvent.click(within(drawer).getByRole('button', { name: 'Close' })); + expect(document.querySelector('.v2-board__drawer')).toBeNull(); + }); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 88844d91ac..8139040d37 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -2515,11 +2515,13 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(v2).not.toMatch(/\.v2-msg__prose--closed::after/); }); - it('the long-prose fold keeps both its max-height and overflow clip', () => { + it('the long-prose fold keeps both its 132px height and overflow clip', () => { + // The fold sizes between 132px and auto (motion pass), so the six-line cap is a + // block-size rather than a max-height; it must still be 132px and still clip. const block = v2.match(/\.v2-msg__prose--closed \{[^}]*\}/); expect(block).not.toBeNull(); - expect(block![0]).toMatch(/max-height:\s*132px/); - expect(block![0]).toMatch(/overflow:\s*hidden/); + expect(block![0]).toMatch(/block-size:\s*132px/); + expect(block![0]).toMatch(/overflow:\s*clip/); }); it('the BEND-1 feature type step exists in v2.css and tokens.css together', () => { diff --git a/frontend/src/v2/__tests__/v2-motion.test.ts b/frontend/src/v2/__tests__/v2-motion.test.ts new file mode 100644 index 0000000000..d6c7f6ccdb --- /dev/null +++ b/frontend/src/v2/__tests__/v2-motion.test.ts @@ -0,0 +1,168 @@ +import fs from 'fs'; +import path from 'path'; + +// D220: the motion contract from the d215 motion spec. Tokens, one global +// reduced-motion switch, folds by size, and no blanket transitions. +const v2Dir = path.join(__dirname, '..'); +const v2css = fs.readFileSync(path.join(v2Dir, 'v2.css'), 'utf8'); + +const cssFiles = (dir: string): string[] => fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return entry.name === '__tests__' ? [] : cssFiles(full); + return entry.name.endsWith('.css') ? [full] : []; +}); + +describe('v2 motion (D220)', () => { + test('the tokens are one curve and three durations on :root', () => { + const root = /:root \{\n --v2-dur-fast: 120ms;\n --v2-dur: 200ms;\n --v2-dur-slow: 320ms;\n --v2-ease: cubic-bezier\(0\.2, 0, 0, 1\);\n\}/; + expect(v2css).toMatch(root); + }); + + test('reduced motion is one global switch that also reaches portals and view transitions', () => { + const block = v2css.slice(v2css.lastIndexOf('@media (prefers-reduced-motion: reduce) {')); + // Unscoped: a .v2-root prefix would miss portals such as the avatar crop dialog. + expect(block).toMatch(/^@media \(prefers-reduced-motion: reduce\) \{\n \*,\n \*::before,\n \*::after \{/); + expect(block).toContain('animation-duration: 0.01ms !important;'); + expect(block).toContain('animation-iteration-count: 1 !important;'); + expect(block).toContain('transition-duration: 0.01ms !important;'); + expect(block).toContain('::view-transition-old(*),'); + expect(block).toContain('animation: none !important;'); + // The fold body is a pseudo-element the wildcards miss; it needs its own rule. + expect(block).toContain(' ::details-content {\n transition-duration: 0.01ms !important;\n }'); + }); + + test('details folds open by size over --v2-dur and close over --v2-dur-fast', () => { + expect(v2css).toContain('.v2-root details {\n interpolate-size: allow-keywords;\n}'); + const closed = v2css.slice(v2css.indexOf('.v2-root details::details-content {')); + expect(closed).toContain('block-size: 0;'); + expect(closed).toContain('block-size var(--v2-dur-fast) var(--v2-ease)'); + expect(closed).toContain('content-visibility var(--v2-dur-fast) var(--v2-ease) allow-discrete'); + const open = v2css.slice(v2css.indexOf('.v2-root details[open]::details-content {')); + expect(open).toContain('block-size: auto;'); + expect(open).toContain('transition-duration: var(--v2-dur);'); + }); + + test('no v2 stylesheet uses a blanket transition', () => { + const offenders = cssFiles(v2Dir).filter((file) => /transition(-property)?:\s*all\b/.test(fs.readFileSync(file, 'utf8'))); + expect(offenders.map((file) => path.relative(v2Dir, file))).toEqual([]); + }); +}); + +describe('v2 motion: chat', () => { + const chatMessageCss = fs.readFileSync(path.join(v2Dir, 'chat', 'chat-message.css'), 'utf8'); + const chatPaneCss = fs.readFileSync(path.join(v2Dir, 'chat', 'chat-pane.css'), 'utf8'); + const rule = (css: string, selector: string): string => { + const start = css.indexOf(`${selector} {`); + expect(start).toBeGreaterThan(-1); + return css.slice(start, css.indexOf('}', start) + 1); + }; + const keyframes = (css: string, name: string): string => { + const start = css.indexOf(`@keyframes ${name} {`); + expect(start).toBeGreaterThan(-1); + return css.slice(start, css.indexOf('}\n}', start) + 3); + }; + + test('a live message enters over --v2-dur on the one curve, by opacity and 4px of transform only', () => { + expect(rule(chatMessageCss, '.v2-msg--enter')).toContain('animation: v2-msg-enter var(--v2-dur) var(--v2-ease) backwards;'); + const frames = keyframes(chatMessageCss, 'v2-msg-enter'); + expect(frames).toContain('opacity: 0;'); + expect(frames).toContain('transform: translateY(4px);'); + expect(frames.replace(/opacity: 0;|transform: translateY\(4px\);/g, '')).not.toMatch(/[a-z-]+:\s*[^;]+;/); + }); + + test('content replacing the loader fades in over --v2-dur, opacity only', () => { + expect(rule(chatPaneCss, '.v2-chat__messages--reveal')).toContain('animation: v2-chat-reveal var(--v2-dur) var(--v2-ease) backwards;'); + expect(keyframes(chatPaneCss, 'v2-chat-reveal')).toMatch(/from \{ opacity: 0; \}/); + }); + + test('Show more opens by size over --v2-dur and closes over --v2-dur-fast, with no max-height', () => { + const fold = rule(chatMessageCss, '.v2-msg__prose--fold'); + expect(fold).toContain('interpolate-size: allow-keywords;'); + expect(fold).toContain('block-size: auto;'); + expect(fold).toContain('transition: block-size var(--v2-dur) var(--v2-ease);'); + const closed = rule(chatMessageCss, '.v2-msg__prose--closed'); + expect(closed).toContain('block-size: 132px;'); + expect(closed).toContain('transition-duration: var(--v2-dur-fast);'); + + const code = rule(chatMessageCss, '.v2-msg__collapse pre'); + expect(code).toContain('block-size: auto;'); + expect(code).toContain('transition: block-size var(--v2-dur) var(--v2-ease);'); + expect(rule(chatMessageCss, '.v2-msg__collapse--closed pre')).toContain('transition-duration: var(--v2-dur-fast);'); + + const folds = [fold, closed, code, rule(chatMessageCss, '.v2-msg__collapse--closed pre')].join('\n'); + expect(folds).not.toMatch(/max-height/); + }); + + test('the Show more chevron turns on the same tokens: open over --v2-dur, back over --v2-dur-fast', () => { + expect(rule(chatMessageCss, '.v2-msg__showmore svg')).toContain('transition: transform var(--v2-dur-fast) var(--v2-ease);'); + expect(rule(chatMessageCss, ".v2-msg__showmore[aria-expanded='true'] svg")).toContain('transition-duration: var(--v2-dur);'); + }); +}); + +// The board and drawer moments: the claim glide, the 390 leave, the lease ring +// that waits for the landing, and drawers that slide in from the right. +describe('v2 motion: board and drawers', () => { + const boardCss = fs.readFileSync(path.join(v2Dir, 'components', 'V2PodBoard.css'), 'utf8'); + const cxCss = fs.readFileSync(path.join(v2Dir, 'connectors', 'connectors.css'), 'utf8'); + const keyframes = (css: string, name: string): string => { + const start = css.indexOf(`@keyframes ${name} {`); + if (start < 0) return ''; + let depth = 0; + for (let i = css.indexOf('{', start); i < css.length; i += 1) { + if (css[i] === '{') depth += 1; + if (css[i] === '}') { depth -= 1; if (!depth) return css.slice(start, i + 1); } + } + return ''; + }; + + test('a live claim names the cards of the columns involved and glides on the slow token', () => { + expect(boardCss).toContain(':root.v2-board-glide .v2-feature--board .v2-board__col[data-glide] .v2-board__card { view-transition-name: var(--v2-task-vt); }'); + expect(boardCss).toContain(':root.v2-board-glide::view-transition-group(*) { animation-duration: var(--v2-dur-slow); animation-timing-function: var(--v2-ease); }'); + }); + + test('the lease ring waits for the landing and stops under reduced motion', () => { + expect(boardCss).toMatch(/:root\.v2-board-glide \.v2-feature--board \.v2-board__owner-dot--live::after,\n:root\.v2-board-glide \.v2-feature--board \.v2-board__col-dot--claimed\.is-live::after \{ animation: none; \}/); + expect(boardCss).toMatch(/@media \(prefers-reduced-motion: reduce\) \{\n \.v2-feature--board \.v2-board__col-dot--claimed\.is-live::after,\n \.v2-feature--board \.v2-board__owner-dot--live::after \{ animation: none; \}/); + // A column dot loops only while a lease in it is live. + expect(boardCss).not.toMatch(/\.v2-board__col-dot--claimed::after/); + }); + + test('at 390 a card leaves its tab by 8px toward the new one over --v2-dur, and the count ticks', () => { + expect(boardCss).toContain('.v2-feature--board .v2-board__card.is-leaving { pointer-events: none; animation: v2-board-leave-next var(--v2-dur) var(--v2-ease) forwards; }'); + expect(keyframes(boardCss, 'v2-board-leave-next')).toContain('to { opacity: 0; transform: translateX(8px); }'); + expect(keyframes(boardCss, 'v2-board-leave-prev')).toContain('to { opacity: 0; transform: translateX(-8px); }'); + expect(boardCss).toContain('.v2-board__tab-count.is-ticking { display: inline-block; animation: v2-board-tick var(--v2-dur-fast) var(--v2-ease); }'); + }); + + test('the board drawer slides from the right, slow in and fast out, and its phone scrim fades', () => { + expect(boardCss).toContain('.v2-feature--board .v2-modal.v2-board__drawer { animation: v2-board-drawer-in var(--v2-dur-slow) var(--v2-ease); }'); + expect(boardCss).toContain('.v2-feature--board .v2-modal.v2-board__drawer[data-leaving] { animation: v2-board-drawer-out var(--v2-dur-fast) var(--v2-ease) forwards; }'); + expect(keyframes(boardCss, 'v2-board-drawer-in')).toContain('from { transform: translateX(100%); }'); + expect(keyframes(boardCss, 'v2-board-drawer-out')).toContain('to { transform: translateX(100%); }'); + expect(boardCss).toContain('.v2-board__overlay--drawer::before { content: \'\'; position: absolute; inset: 0; background: var(--v2-scrim); animation: v2-board-scrim-in var(--v2-dur-slow) var(--v2-ease); }'); + expect(boardCss).toContain('.v2-board__overlay--drawer[data-leaving]::before { animation: v2-board-scrim-out var(--v2-dur-fast) var(--v2-ease) forwards; }'); + }); + + test('the connector drawer slides from the right, slow in and fast out, without scrolling sideways', () => { + expect(cxCss).toContain('.v2-cx-drawer { animation: v2-cx-drawer-in var(--v2-dur-slow) var(--v2-ease); }'); + expect(cxCss).toContain('.v2-cx-drawer.is-leaving { pointer-events: none; animation: v2-cx-drawer-out var(--v2-dur-fast) var(--v2-ease) forwards; }'); + expect(keyframes(cxCss, 'v2-cx-drawer-in')).toContain('from { transform: translateX(100%); }'); + expect(keyframes(cxCss, 'v2-cx-drawer-out')).toContain('to { transform: translateX(100%); }'); + expect(cxCss).toContain('.v2-cx-body { overflow-x: hidden; }'); + }); + + test('these moments move transform and opacity only', () => { + const names = ['v2-board-leave-next', 'v2-board-leave-prev', 'v2-board-tick', 'v2-board-drawer-in', 'v2-board-drawer-out', 'v2-board-scrim-in', 'v2-board-scrim-out']; + const blocks = [...names.map((name) => keyframes(boardCss, name)), keyframes(cxCss, 'v2-cx-drawer-in'), keyframes(cxCss, 'v2-cx-drawer-out')]; + blocks.forEach((block) => { + expect(block).not.toBe(''); + const props = Array.from(block.matchAll(/([a-z-]+)\s*:/g)).map((match) => match[1]); + expect(props.filter((prop) => prop !== 'transform' && prop !== 'opacity')).toEqual([]); + }); + }); + + test('board and connector transitions use the tokens, never a hard-coded time', () => { + const transitions = `${boardCss}\n${cxCss}`.match(/transition:[^;}]*/g) || []; + transitions.forEach((decl) => expect(decl).not.toMatch(/\d(ms|s)\b/)); + }); +}); diff --git a/frontend/src/v2/chat/chat-message.css b/frontend/src/v2/chat/chat-message.css index 74647d7ba9..b005ca6360 100644 --- a/frontend/src/v2/chat/chat-message.css +++ b/frontend/src/v2/chat/chat-message.css @@ -880,17 +880,46 @@ font: 400 11px/16px var(--v2-font-mono); } -/* Long code collapses after six lines; expands in place. */ +/* Long code collapses after six lines; expands in place. It sizes to auto over + --v2-dur and back to six lines over --v2-dur-fast, with no measured height and + no max-height. Without interpolate-size it opens and closes at once, as before. + overflow-y stays hidden while it sizes, so no vertical scrollbar flashes. */ .v2-msg__collapse { position: relative; } -.v2-msg__collapse--closed pre { max-height: calc(6 * 1.5 * 12.5px + 20px); overflow: hidden; } +.v2-msg__collapse pre { + interpolate-size: allow-keywords; + block-size: auto; + overflow-y: hidden; + transition: block-size var(--v2-dur) var(--v2-ease); +} + +.v2-msg__collapse--closed pre { + block-size: calc(6 * 1.5 * 12.5px + 20px); + overflow: hidden; + transition-duration: var(--v2-dur-fast); +} -/* Long agent prose folds after six lines (6 × 22px). The fade masks the prose itself, so a hovered row keeps its own surface. */ +/* Long agent prose folds after six lines (6 × 22px). The fade masks the prose itself, so a hovered row keeps its own surface. + A prose that folds sizes between 132px and auto: open over --v2-dur, closed over --v2-dur-fast, with + no measured height and no max-height. flow-root keeps the first child's margin inside the box in both + states, so the six-line preview matches the old overflow:hidden fold exactly. clip (not hidden) keeps + the box from becoming a scroller a focused link could scroll. Without interpolate-size it jumps, as before. */ .v2-msg__prose { position: relative; } +.v2-msg__prose--fold { + display: flow-root; + interpolate-size: allow-keywords; + block-size: auto; + overflow: clip; + overflow-clip-margin: 6px; + transition: block-size var(--v2-dur) var(--v2-ease); +} + .v2-msg__prose--closed { - max-height: 132px; - overflow: hidden; + block-size: 132px; + overflow: clip; + overflow-clip-margin: 0px; + transition-duration: var(--v2-dur-fast); -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 44px), transparent); mask-image: linear-gradient(to bottom, #000 calc(100% - 44px), transparent); } @@ -915,9 +944,9 @@ .v2-root button.v2-msg__showmore:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--v2-accent-text); } -.v2-msg__showmore svg { width: 14px; height: 14px; flex: none; transition: transform 160ms; } +.v2-msg__showmore svg { width: 14px; height: 14px; flex: none; transition: transform var(--v2-dur-fast) var(--v2-ease); } -.v2-msg__showmore[aria-expanded='true'] svg { transform: rotate(180deg); } +.v2-msg__showmore[aria-expanded='true'] svg { transform: rotate(180deg); transition-duration: var(--v2-dur); } .v2-root button.v2-msg__more { display: inline-block; @@ -1108,3 +1137,17 @@ .v2-root .v2-thread button.v2-msg__action::after { content: ''; position: absolute; inset: -8px; } .v2-thread .v2-msg__gutter-time { display: none; } } + +/* A message that arrives live enters once: opacity 0→1 and 4px up, over --v2-dur. + History, a pod just opened and older pages carry no enter class and appear at + once. backwards fill only, so no transform lingers on the row after it lands. */ +.v2-msg--enter { + animation: v2-msg-enter var(--v2-dur) var(--v2-ease) backwards; +} + +@keyframes v2-msg-enter { + from { + opacity: 0; + transform: translateY(4px); + } +} diff --git a/frontend/src/v2/chat/chat-pane.css b/frontend/src/v2/chat/chat-pane.css index 759f126e8b..26b0381cbc 100644 --- a/frontend/src/v2/chat/chat-pane.css +++ b/frontend/src/v2/chat/chat-pane.css @@ -52,6 +52,16 @@ overflow-anchor: none; } +/* Loading → content: what replaces the spinner fades in once over --v2-dur, + opacity only. Messages inside it appear together, with no per-row enter. */ +.v2-chat__messages--reveal { + animation: v2-chat-reveal var(--v2-dur) var(--v2-ease) backwards; +} + +@keyframes v2-chat-reveal { + from { opacity: 0; } +} + /* The transcript is the chat viewport's positioning context. History recovery belongs to this viewport rather than the top of the scrollable message list, otherwise a failure after a deep landing is stranded above diff --git a/frontend/src/v2/components/V2CatchUpStrip.tsx b/frontend/src/v2/components/V2CatchUpStrip.tsx index 525cb9992f..707414201c 100644 --- a/frontend/src/v2/components/V2CatchUpStrip.tsx +++ b/frontend/src/v2/components/V2CatchUpStrip.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import axios from 'axios'; +import V2Fold from './V2Fold'; // Sam's 2026-09-01 ruling: a constant TL;DR of what the agents are doing, // living where the question is actually felt — pinned above the messages of @@ -148,9 +149,9 @@ const V2CatchUpStrip: React.FC = ({ podId }) => { × - {expanded && summary?.content && ( -
{summary.content}
- )} + +
{summary?.content}
+
); }; diff --git a/frontend/src/v2/components/V2Fold.tsx b/frontend/src/v2/components/V2Fold.tsx new file mode 100644 index 0000000000..2cbf4ab8d5 --- /dev/null +++ b/frontend/src/v2/components/V2Fold.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useRef, useState } from 'react'; + +// A fold body that opens and closes by size: its grid row goes 0fr <-> 1fr, so no +// pixel height is ever measured. A closing body stays mounted, inert, until the +// close animation ends; a timer backs that up so it can never stay stuck open +// (reduced motion, a hidden tab, or a browser that fires no animation events). +// Only a change the reader makes animates. The first render lands as it is. +const CLOSE_FALLBACK_MS = 200; + +type V2FoldProps = { + open: boolean; + id?: string; + className?: string; + children: React.ReactNode; +}; + +const V2Fold: React.FC = ({ open, id, className, children }) => { + const [rendered, setRendered] = useState(open); + const [phase, setPhase] = useState<'idle' | 'opening' | 'closing'>('idle'); + const first = useRef(true); + + useEffect(() => { + if (first.current) { + first.current = false; + return undefined; + } + if (open) { + setRendered(true); + setPhase('opening'); + return undefined; + } + setPhase('closing'); + const timer = window.setTimeout(() => { + setRendered(false); + setPhase('idle'); + }, CLOSE_FALLBACK_MS); + return () => window.clearTimeout(timer); + }, [open]); + + if (!rendered) return null; + const closing = phase === 'closing'; + return ( +
{ + if (event.target !== event.currentTarget) return; + if (closing) setRendered(false); + setPhase('idle'); + }} + > + {/* The caller's class sits one level in, so its padding is clipped with the + content instead of holding the 0fr row open. */} +
+
{children}
+
+
+ ); +}; + +export default V2Fold; diff --git a/frontend/src/v2/components/V2Inspector.tsx b/frontend/src/v2/components/V2Inspector.tsx index 2b9675d7cf..ce56e9969a 100644 --- a/frontend/src/v2/components/V2Inspector.tsx +++ b/frontend/src/v2/components/V2Inspector.tsx @@ -12,6 +12,7 @@ import V2Segmented from './V2Segmented'; import { ShellGlyph, ShellGlyphName } from '../icons/glyphs'; import { getSignedAttachmentUrl } from '../../utils/signedAttachmentUrl'; import { buildAgentUsername, rawAgentName } from '../utils/threadAgentIdentity'; +import V2Fold from './V2Fold'; interface V2InspectorProps { detail: UseV2PodDetailResult; @@ -294,7 +295,7 @@ const V2Inspector: React.FC = ({ detail, currentUserId, onPodC - {open &&
{content}
} + {content} ); }; diff --git a/frontend/src/v2/components/V2MessageRow.tsx b/frontend/src/v2/components/V2MessageRow.tsx index fb6a044624..ac8c062236 100644 --- a/frontend/src/v2/components/V2MessageRow.tsx +++ b/frontend/src/v2/components/V2MessageRow.tsx @@ -374,7 +374,8 @@ export const CollapsiblePre: React.FC> = ({ }; // Agent prose past six lines (6 × 22px) folds in place; Show more expands it, Show less folds it. -// Only the fold is measured, so a short reply renders exactly as before. +// Only the fold is measured, so a short reply renders exactly as before. The measure only +// decides WHETHER it folds; the open and close motion is CSS sizing to auto, never a JS height. const PROSE_COLLAPSE_PX = 132; export const CollapsibleProse: React.FC<{ collapsible: boolean; children: React.ReactNode }> = ({ collapsible, children }) => { const { t } = useTranslation(); @@ -398,7 +399,7 @@ export const CollapsibleProse: React.FC<{ collapsible: boolean; children: React. {/* Focus reaching a link below the fold opens the message, so the hidden link is never focused blind. */}
{ if (!folded) return; setOpen(true); @@ -460,8 +461,20 @@ export const landOnMessage = (id: string | number | null | undefined): boolean = return true; }; +// A live arrival enters once, when its row first mounts. The window keeps a +// row that remounts later (a root gaining its first reply moves into a thread +// block, a collapsed thread opens) from playing the motion a second time. +export const LIVE_ENTER_WINDOW_MS = 1000; +const arrivedJustNow = (message: V2Message): boolean => ( + typeof message.liveArrivedAt === 'number' && Date.now() - message.liveArrivedAt < LIVE_ENTER_WINDOW_MS +); + const V2MessageRow: React.FC = ({ message, sourceMessageId, decision, onDecisionRuled, isDecisionRuling = false, isLead, agentDisplayNames, agentTags, agentAuthorKeys, onAuthorClick, onOpenFile, onReply, onThread, onQuoteNavigate, grouped, insideThreadRoot }) => { const { currentUser } = useAuth(); + // Decided at mount and never again, so a re-render or a refetch that drops + // the flag cannot replay the enter. + const [enters] = useState(() => arrivedJustNow(message)); + const enterClass = enters ? ' v2-msg--enter' : ''; const { t } = useTranslation(); const navigate = useNavigate(); const api = useV2Api(); @@ -526,7 +539,7 @@ const V2MessageRow: React.FC = ({ message, sourceMessageId, d && message.payload.action === 'task-claimed') { const actorLabel = overriddenDisplay || message.payload.actorName || author; return ( -
+
= ({ message, sourceMessageId, d if (decision) { return ( -
+
= ({ message, sourceMessageId, d // wrapper, so landOnMessage (a Threads row, a Needs-you link) can reach a // card that roots a thread. if (message.payload?.kind === 'approval-card') { - return <>
; + return <>
; } if (message.payload?.kind === 'byo-handoff') { - return <>
; + return <>
; } if (message.payload?.kind === 'ui') { return {message.content}
}>{message.content}
}>; } if (message.payload?.kind === 'gig-card') { - return <>
; + return <>
; } // §3.8 system event card. Detected by content shape (commonly-bot only @@ -582,7 +595,7 @@ const V2MessageRow: React.FC = ({ message, sourceMessageId, d const dmEvent = parseAgentDmEvent(message.content); if (dmEvent) { return ( -
+