From efc3cd3f9610f612fd07f8c19ce396d363ce8193 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:55:16 -0800 Subject: [PATCH 1/7] feat(v2): motion tokens, global reduced motion, theme crossfade, details folds Base of the motion pass. Tokens on :root (120/200/320ms and one curve), one unscoped reduced-motion switch that also stops view transitions, the theme switch crossfading through startViewTransition, and every
body opening by size over --v2-dur and closing over --v2-dur-fast. Carries the tokens and the theme crossfade from #3102. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- frontend/src/v2/__tests__/v2-motion.test.ts | 47 ++++++++++++++ frontend/src/v2/utils/__tests__/theme.test.ts | 31 ++++++++++ frontend/src/v2/utils/theme.ts | 10 +++ frontend/src/v2/v2.css | 62 +++++++++++++++++++ 4 files changed, 150 insertions(+) create mode 100644 frontend/src/v2/__tests__/v2-motion.test.ts 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..d240458696 --- /dev/null +++ b/frontend/src/v2/__tests__/v2-motion.test.ts @@ -0,0 +1,47 @@ +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;'); + }); + + 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([]); + }); +}); diff --git a/frontend/src/v2/utils/__tests__/theme.test.ts b/frontend/src/v2/utils/__tests__/theme.test.ts index 6fafb97b1c..2c7f1d5936 100644 --- a/frontend/src/v2/utils/__tests__/theme.test.ts +++ b/frontend/src/v2/utils/__tests__/theme.test.ts @@ -49,6 +49,37 @@ describe('v2 appearance preference (D216)', () => { expect(decls(explicit![1])).toContain('--v2-focus-ring: 0 0 0 2px var(--v2-bg), 0 0 0 4px #8aa2ff'); }); + test('setV2Theme crossfades inside a view transition when the browser has one', () => { + const doc = document as Document & { startViewTransition?: (cb: () => void) => unknown }; + const startViewTransition = jest.fn((cb: () => void) => cb()); + doc.startViewTransition = startViewTransition as unknown as Document['startViewTransition']; + try { + setV2Theme('dark'); + expect(startViewTransition).toHaveBeenCalledTimes(1); + expect(document.documentElement.getAttribute('data-v2-theme')).toBe('dark'); + } finally { + delete doc.startViewTransition; + } + }); + + test('setV2Theme switches at once under reduced motion and without the API', () => { + const doc = document as Document & { startViewTransition?: (cb: () => void) => unknown }; + const startViewTransition = jest.fn((cb: () => void) => cb()); + doc.startViewTransition = startViewTransition as unknown as Document['startViewTransition']; + const original = window.matchMedia; + window.matchMedia = jest.fn().mockImplementation((q: string) => ({ matches: q.includes('reduce'), media: q })) as unknown as typeof window.matchMedia; + try { + setV2Theme('light'); + expect(startViewTransition).not.toHaveBeenCalled(); + expect(document.documentElement.getAttribute('data-v2-theme')).toBe('light'); + } finally { + window.matchMedia = original; + delete doc.startViewTransition; + } + setV2Theme('dark'); + expect(document.documentElement.getAttribute('data-v2-theme')).toBe('dark'); + }); + test('index.html applies a stored Light or Dark choice before first paint (code-gate HOLD, rhea)', () => { const html = fs.readFileSync(path.join(__dirname, '../../../../index.html'), 'utf8'); const head = html.slice(0, html.indexOf('')); diff --git a/frontend/src/v2/utils/theme.ts b/frontend/src/v2/utils/theme.ts index c8ef10429b..69d61a3ae8 100644 --- a/frontend/src/v2/utils/theme.ts +++ b/frontend/src/v2/utils/theme.ts @@ -41,12 +41,22 @@ export function resolveV2Theme(pref: V2ThemePref): 'light' | 'dark' { ? 'dark' : 'light'; } +type ViewTransitionDocument = Document & { startViewTransition?: (update: () => void) => unknown }; + export function setV2Theme(pref: V2ThemePref): void { try { window.localStorage.setItem(V2_THEME_KEY, pref); } catch { // Storage can be blocked; the choice still applies for this page view. } + // The whole page crossfades once where the browser has view transitions and the + // user hasn't asked for reduced motion. Everywhere else the switch is instant. + const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + const doc = document as ViewTransitionDocument; + if (doc.startViewTransition && !reduce) { + doc.startViewTransition(() => applyV2Theme(pref)); + return; + } applyV2Theme(pref); listeners.forEach((listener) => listener()); } diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index c47ebe973f..3b4c5364bc 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -1,3 +1,16 @@ +/* + * Motion tokens (D220). One curve and three durations, on :root so portals and + * the view-transition pseudo-elements can read them. Enters take --v2-dur, + * exits and closing take --v2-dur-fast, anything that travels takes --v2-dur-slow. + * Animate transform and opacity only; folds are the one height exception. + */ +:root { + --v2-dur-fast: 120ms; + --v2-dur: 200ms; + --v2-dur-slow: 320ms; + --v2-ease: cubic-bezier(0.2, 0, 0, 1); +} + /* * V2 design tokens + base styles. All v2 styles are scoped under .v2-root * so the existing app's global CSS does not bleed in (and vice versa). @@ -5517,3 +5530,52 @@ body.modern-ui.v2-canvas { .v2-feature__header .v2-page-head__meta { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .v2-feature__body { padding: 12px 12px 16px; } } + +/* ---------- Motion (D220) ---------- */ + +/* Theme switch: utils/theme.ts makes the change inside document.startViewTransition, + and the root snapshots crossfade once. No per-element colour transitions. */ +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: var(--v2-dur); + animation-timing-function: var(--v2-ease); +} + +/* Folds: every
body opens over --v2-dur and closes over --v2-dur-fast, by + size, with no measured pixel height. Browsers without interpolate-size or + ::details-content open and close at once. The clip margin keeps focus rings. */ +.v2-root details { + interpolate-size: allow-keywords; +} +.v2-root details::details-content { + block-size: 0; + overflow: clip; + overflow-clip-margin: 6px; + opacity: 0; + transition: + block-size var(--v2-dur-fast) var(--v2-ease), + opacity var(--v2-dur-fast) var(--v2-ease), + content-visibility var(--v2-dur-fast) var(--v2-ease) allow-discrete; +} +.v2-root details[open]::details-content { + block-size: auto; + opacity: 1; + transition-duration: var(--v2-dur); +} + +/* One reduced-motion switch, global rather than scoped to .v2-root, so it reaches + portals too. Every moment lands in its end state at once. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + ::view-transition-old(*), + ::view-transition-new(*), + ::view-transition-group(*) { + animation: none !important; + } +} From ac540145e836336e9bffcc70ad29b4508e5d98fb Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:57:39 -0800 Subject: [PATCH 2/7] fix(v2): reduced motion reaches the details fold body The global switch's wildcards don't match ::details-content, so folds still animated under reduced motion. It gets a rule of its own, separate so a browser without the pseudo-element keeps the rest of the block. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- frontend/src/v2/__tests__/v2-motion.test.ts | 2 ++ frontend/src/v2/v2.css | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/frontend/src/v2/__tests__/v2-motion.test.ts b/frontend/src/v2/__tests__/v2-motion.test.ts index d240458696..27c2df905c 100644 --- a/frontend/src/v2/__tests__/v2-motion.test.ts +++ b/frontend/src/v2/__tests__/v2-motion.test.ts @@ -27,6 +27,8 @@ describe('v2 motion (D220)', () => { 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', () => { diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 3b4c5364bc..0bee253aa8 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -5578,4 +5578,9 @@ body.modern-ui.v2-canvas { ::view-transition-group(*) { animation: none !important; } + /* A rule of its own: the wildcards above don't reach this pseudo-element, and a + browser without it would drop any selector list that named it. */ + ::details-content { + transition-duration: 0.01ms !important; + } } From 4dd6626fffd4663c552da3e77246692505321215 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:07:40 -0800 Subject: [PATCH 3/7] feat(v2): custom folds open and close by size V2Fold wraps a fold body in a one-row grid that goes 0fr to 1fr over --v2-dur and back over --v2-dur-fast. A closing body stays mounted and inert until its animation ends, with a timer behind that, and the first render never animates. The caller's box sits inside the clipping layer, so its padding and margins fold away with it. Used by the Inspector groups, the Your Team internal list, the claim-run fold and the catch-up summary. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- frontend/src/v2/__tests__/V2ClaimRun.test.tsx | 8 ++- frontend/src/v2/__tests__/V2Fold.test.tsx | 69 +++++++++++++++++++ .../src/v2/__tests__/V2Inspector.test.tsx | 4 ++ frontend/src/v2/components/V2CatchUpStrip.tsx | 7 +- frontend/src/v2/components/V2Fold.tsx | 61 ++++++++++++++++ frontend/src/v2/components/V2Inspector.tsx | 3 +- .../src/v2/components/V2ThreadMessages.tsx | 15 ++-- frontend/src/v2/components/V2YourTeamPage.tsx | 13 ++-- frontend/src/v2/v2.css | 31 +++++++++ 9 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 frontend/src/v2/__tests__/V2Fold.test.tsx create mode 100644 frontend/src/v2/components/V2Fold.tsx 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/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/V2ThreadMessages.tsx b/frontend/src/v2/components/V2ThreadMessages.tsx index cec8060bfe..0be344fbb5 100644 --- a/frontend/src/v2/components/V2ThreadMessages.tsx +++ b/frontend/src/v2/components/V2ThreadMessages.tsx @@ -11,6 +11,7 @@ import V2Avatar from './V2Avatar'; import { ShellGlyph } from '../icons/glyphs'; import { formatRelativeTime } from '../utils/grouping'; import { V2DecisionCardData, V2DecisionRuling } from './V2DecisionCard'; +import V2Fold from './V2Fold'; // Expanded threads show the newest eight; a `N more replies` line reveals the rest. const MAX_EXPANDED_REPLIES = 8; @@ -416,14 +417,12 @@ const V2ThreadMessages: React.FC = ({ {lastAt && } - {open && ( -
- {item.messages.map((message, claimIndex) => renderMessage( - message, - claimIndex === 0 ? view[index - 1] : { kind: 'message', message: item.messages[claimIndex - 1] }, - ))} -
- )} + + {item.messages.map((message, claimIndex) => renderMessage( + message, + claimIndex === 0 ? view[index - 1] : { kind: 'message', message: item.messages[claimIndex - 1] }, + ))} + ); } diff --git a/frontend/src/v2/components/V2YourTeamPage.tsx b/frontend/src/v2/components/V2YourTeamPage.tsx index 3cddc21fb8..0b1e575056 100644 --- a/frontend/src/v2/components/V2YourTeamPage.tsx +++ b/frontend/src/v2/components/V2YourTeamPage.tsx @@ -6,6 +6,7 @@ import { useAuth } from '../../context/AuthContext'; import V2Avatar from './V2Avatar'; import './V2YourTeamPage.css'; import { AgentConnection, connectionLabel } from '../utils/agentConnection'; +import V2Fold from './V2Fold'; /** * Your Team — direction C, built to `YourTeam.dc.html` (canvas: Workspace in C) @@ -593,13 +594,11 @@ const V2YourTeamPage: React.FC = () => { > {t('yourTeam.tiers.internalCount', { count: internal.length })} - {internalOpen && ( -
- {internal.map((a) => ( - {a.displayName || a.name} - ))} -
- )} + + {internal.map((a) => ( + {a.displayName || a.name} + ))} + )} diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 0bee253aa8..bd5171a766 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -5563,6 +5563,37 @@ body.modern-ui.v2-canvas { transition-duration: var(--v2-dur); } +/* Custom folds (V2Fold): the body is a grid whose one row goes 0fr <-> 1fr, fading + on the same curve. It clips only while it moves, so an open fold keeps focus + rings and anything that hangs past its edge. */ +@keyframes v2-fold-open { + from { grid-template-rows: 0fr; opacity: 0; } + to { grid-template-rows: 1fr; opacity: 1; } +} +@keyframes v2-fold-close { + from { grid-template-rows: 1fr; opacity: 1; } + to { grid-template-rows: 0fr; opacity: 0; } +} +.v2-fold { + display: grid; + grid-template-rows: 1fr; +} +.v2-fold > .v2-fold__inner { + display: flow-root; + min-height: 0; +} +.v2-fold--opening { + animation: v2-fold-open var(--v2-dur) var(--v2-ease) both; +} +.v2-fold--closing { + animation: v2-fold-close var(--v2-dur-fast) var(--v2-ease) both; +} +.v2-fold--opening > .v2-fold__inner, +.v2-fold--closing > .v2-fold__inner { + overflow: clip; + overflow-clip-margin: 6px; +} + /* One reduced-motion switch, global rather than scoped to .v2-root, so it reaches portals too. Every moment lands in its end state at once. */ @media (prefers-reduced-motion: reduce) { From 1a42986c216b0b69291e5a4ee711f461297ad191 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:08:18 -0800 Subject: [PATCH 4/7] feat(v2): chat motion: live messages enter, Show more folds by size, loaded content fades in A message that arrives live (a socket broadcast, or this tab's own send) enters once: opacity 0 to 1 and 4px up over --v2-dur on --v2-ease. The hook stamps a client-only liveArrivedAt on appended rows only; history, a reconnect catch-up and older pages never carry it. The row decides at mount, so a re-render or refetch never replays it, and a one-second window keeps a row that remounts later (a root moving into a thread block) from replaying. Show more on agent prose and long code now sizes between its preview and auto with interpolate-size: open over --v2-dur, close over --v2-dur-fast, no measured height and no max-height. The preview stays exactly six lines: flow-root keeps the same margin containment the old overflow:hidden fold had. The chevron turns on the same tokens. Browsers without interpolate-size jump as before. Whatever replaces the transcript spinner fades in once over --v2-dur, opacity only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- .../src/v2/__tests__/V2ChatMotion.test.tsx | 224 ++++++++++++++++++ .../v2/__tests__/v2-layout-invariants.test.ts | 8 +- frontend/src/v2/__tests__/v2-motion.test.ts | 51 ++++ frontend/src/v2/chat/chat-message.css | 57 ++++- frontend/src/v2/chat/chat-pane.css | 10 + frontend/src/v2/components/V2MessageRow.tsx | 31 ++- .../src/v2/components/V2ThreadMessages.tsx | 21 +- frontend/src/v2/hooks/useV2PodDetail.ts | 10 +- 8 files changed, 388 insertions(+), 24 deletions(-) create mode 100644 frontend/src/v2/__tests__/V2ChatMotion.test.tsx diff --git a/frontend/src/v2/__tests__/V2ChatMotion.test.tsx b/frontend/src/v2/__tests__/V2ChatMotion.test.tsx new file mode 100644 index 0000000000..7ef92d10b5 --- /dev/null +++ b/frontend/src/v2/__tests__/V2ChatMotion.test.tsx @@ -0,0 +1,224 @@ +// @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'); + + it('fades the transcript in when it replaces the spinner, and a later render does not restart it', () => { + const view = render(renderTranscript({ messages: [], loading: true })); + 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__/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 index d240458696..66f558ca8f 100644 --- a/frontend/src/v2/__tests__/v2-motion.test.ts +++ b/frontend/src/v2/__tests__/v2-motion.test.ts @@ -45,3 +45,54 @@ describe('v2 motion (D220)', () => { 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);'); + }); +}); 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/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 ( -
+