From 0abd7f7252b5c457982ba362c1c428cdb735aa0c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 13:54:19 -0700 Subject: [PATCH 1/3] fix(chat): clarify activity spacing and unify overflow fades --- .../chat/components/message/message.test.tsx | 2 +- .../hero-chat-loop/hero-chat-loop.test.tsx | 2 +- .../components/hero-chat-loop/hero-chat-loop.tsx | 2 +- .../components/agent-group/activity-viewport.tsx | 11 +++++++++-- .../components/agent-group/agent-group-view.tsx | 6 ++++-- .../components/agent-group/main-agent-activity.tsx | 6 +++++- .../agent-group/search-activity-results.tsx | 7 ++++++- .../components/chat-content/chat-content.tsx | 6 ++++-- .../message-content/components/interaction-card.tsx | 1 + .../components/watch-activity/watch-activity.tsx | 6 +++++- .../message-content/message-content.test.ts | 12 ++++++------ .../components/message-content/message-content.tsx | 13 ++++++++++--- .../components/mothership-chat/mothership-chat.tsx | 8 ++------ .../agent-stream/agent-stream-chrome.test.tsx | 6 +++--- .../components/agent-stream/agent-stream-chrome.tsx | 2 +- apps/sim/components/ui/activity-status.tsx | 2 +- apps/sim/components/ui/shimmer-text.tsx | 2 +- apps/sim/components/ui/thinking-loader.tsx | 6 +++--- packages/emcn/src/components/index.ts | 1 + .../src/components/overflow-text/overflow-text.tsx | 9 +++++++-- .../components/scroll-fade/scroll-fade.module.css | 4 ++-- .../emcn/src/components/scroll-fade/scroll-fade.ts | 5 +++-- 22 files changed, 77 insertions(+), 42 deletions(-) diff --git a/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx b/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx index 1726c423397..de314ad19f7 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx @@ -154,7 +154,7 @@ describe('ClientChatMessage thinking chrome (Step 6)', () => { }) mounts.push(unmount) - expect(container.textContent).toContain('Thinking…') + expect(container.textContent).toContain('Thinking') expect(container.textContent).toContain('Internal reasoning') expect(container.textContent).toContain('Answer text') }) diff --git a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-loop.test.tsx b/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-loop.test.tsx index d6139257829..a4c13692555 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-loop.test.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-loop.test.tsx @@ -139,7 +139,7 @@ describe('HeroChatLoop production thinking handoff', () => { }) it.each([ - ['thinking', 'Thinking…'], + ['thinking', 'Thinking'], ['dispatching', 'Dispatching…'], ] as const)('shows the production activity indicator alone during %s', (phase, label) => { renderPhase(phase) diff --git a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-loop.tsx b/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-loop.tsx index 37fdc203189..c02f862bce5 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-loop.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-loop.tsx @@ -208,7 +208,7 @@ export function HeroChatLoop({ )} > {showThinking && ( - + )} {showBuilding && ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx index e2d7b289290..17c778de42d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx @@ -280,7 +280,9 @@ export function AgentGroupView({ liveToolId={liveCall?.id} /> ) : ( -
{items.map(renderItem)}
+
+ {items.map(renderItem)} +
) const headerText = error ? agentLabel @@ -355,7 +357,7 @@ function NarrationText({ content, isStreaming }: NarrationTextProps) { const revealed = useSmoothText(content, isStreaming) return ( - + {renderInlineMarkdown(revealed.trim())} ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx index 15d8939d4fc..1417b0cedb6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx @@ -49,5 +49,9 @@ export function MainAgentActivity({ ) }) - return
{activity}
+ return ( +
+ {activity} +
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results.tsx index 0b86d12ac7b..658d2c46704 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results.tsx @@ -5,6 +5,7 @@ import { chipRadiusClass, cn, OverflowText, + overflowFadeSizeClass, scrollFadeAttributes, scrollFadeClass, useScrollEdges, @@ -40,7 +41,11 @@ export function SearchActivityResults({ sources, label }: SearchActivityResultsP ref={scrollRef} role='region' aria-label={label} - className={cn('max-h-[152px] overflow-y-auto overscroll-contain p-1', scrollFadeClass)} + className={cn( + 'max-h-[152px] overflow-y-auto overscroll-contain p-1', + scrollFadeClass, + overflowFadeSizeClass + )} {...scrollFadeAttributes(edges)} >
    diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 875df53d49b..34aa6832aed 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -28,6 +28,7 @@ import { cn, Lightbox, languages, + overflowFadeSizeClass, highlight as prismHighlight, scrollFadeAttributes, scrollFadeXClass, @@ -287,7 +288,8 @@ function MarkdownTable({ children }: MarkdownTableProps) { tabIndex={isOverflowing ? 0 : undefined} className={cn( 'not-prose my-4 w-full overflow-x-auto [&_strong]:font-semibold', - scrollFadeXClass + scrollFadeXClass, + overflowFadeSizeClass )} {...scrollFadeAttributes(edges)} > @@ -754,7 +756,7 @@ function ChatContentInner({ -
    +
    {groups.map((group, i) => { if (group.kind === 'inline') { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card.tsx index 9fe74690780..ffe5bcf7726 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card.tsx @@ -27,6 +27,7 @@ interface InteractionCardProps { export function InteractionCard({ children, title, actions, className }: InteractionCardProps) { return (
    +
    ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index 20da3a1cf51..c25e9242cba 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -1241,14 +1241,14 @@ describe('parseBlocks main activity controls', () => { describe('deriveThinkingLabel', () => { it('maps the most recent block to an activity phrase', () => { - expect(deriveThinkingLabel([])).toBe('Thinking…') + expect(deriveThinkingLabel([])).toBe('Thinking') expect(deriveThinkingLabel([{ type: 'thinking', content: 'hm', timestamp: 1 }])).toBe( - 'Thinking…' + 'Thinking' ) // A stall after streamed text is the agent deciding what's next, not generating. - expect(deriveThinkingLabel([mainText('hi')])).toBe('Thinking…') + expect(deriveThinkingLabel([mainText('hi')])).toBe('Thinking') expect(deriveThinkingLabel([{ type: 'subagent_text', content: 'x', timestamp: 1 }])).toBe( - 'Thinking…' + 'Thinking' ) expect(deriveThinkingLabel([{ type: 'subagent_end', spanId: 'S1', timestamp: 1 }])).toBe( 'Returning…' @@ -1258,8 +1258,8 @@ describe('deriveThinkingLabel', () => { it('shows Dispatching for the dispatch call, then yields to the opened lane', () => { expect(deriveThinkingLabel([mainToolCall('t1', 'workflow')])).toBe('Dispatching…') expect(deriveThinkingLabel([mainToolCall('t1', 'prepare_file_edit')])).toBe('Dispatching…') - expect(deriveThinkingLabel([mainToolCall('t1', 'grep')])).toBe('Thinking…') - expect(deriveThinkingLabel([subagentStart('workflow', 'S1', 'main')])).toBe('Thinking…') + expect(deriveThinkingLabel([mainToolCall('t1', 'grep')])).toBe('Thinking') + expect(deriveThinkingLabel([subagentStart('workflow', 'S1', 'main')])).toBe('Thinking') }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index b4761b515db..8ad547fda69 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -933,9 +933,9 @@ export function deriveThinkingLabel(blocks: ContentBlock[]): string { case 'tool_call': return last.toolCall && DISPATCH_TOOL_NAMES.has(last.toolCall.name) ? 'Dispatching…' - : 'Thinking…' + : 'Thinking' default: - return 'Thinking…' + return 'Thinking' } } @@ -1109,7 +1109,13 @@ function MessageContentInner({ return (
    -
    +
    :empty]:hidden [&>:has(~:not(:empty))]:mb-4', + '[&>[data-chat-activity]:has(+[data-chat-activity],+:empty+[data-chat-activity])]:mb-2', + '[&>[data-agent-group]:has(>div:last-child>div:last-child>[data-interaction-card]:last-child):has(~:not(:empty))]:mb-4' + )} + > {segments.map((segment, i) => { switch (segment.type) { case 'text': @@ -1149,6 +1155,7 @@ function MessageContentInner({
    +
    { } }) - it('opens while streaming with Thinking… label and scrollable body', () => { + it('opens while streaming with Thinking label and scrollable body', () => { const { container, unmount } = renderChrome({ thinking: 'step one', isStreaming: true, @@ -94,7 +94,7 @@ describe('AgentStreamThinkingChrome', () => { ) as HTMLDivElement expect(toggle.getAttribute('aria-expanded')).toBe('true') - expect(toggle.textContent).toContain('Thinking…') + expect(toggle.textContent).toContain('Thinking') expect( container .querySelector('[data-testid="agent-stream-thinking-label"]') @@ -185,7 +185,7 @@ describe('AgentStreamThinkingChrome', () => { rerender({ thinking: 'first then more', isStreaming: true }) expect(toggle.getAttribute('aria-expanded')).toBe('true') - expect(toggle.textContent).toContain('Thinking…') + expect(toggle.textContent).toContain('Thinking') }) }) diff --git a/apps/sim/components/agent-stream/agent-stream-chrome.tsx b/apps/sim/components/agent-stream/agent-stream-chrome.tsx index 3e264f716ef..ed91d7d1ae1 100644 --- a/apps/sim/components/agent-stream/agent-stream-chrome.tsx +++ b/apps/sim/components/agent-stream/agent-stream-chrome.tsx @@ -113,7 +113,7 @@ export function AgentStreamThinkingChrome({ setOverflowing(el.scrollHeight > el.clientHeight + 1) } - const label = isStreaming ? 'Thinking…' : 'Thought for a moment' + const label = isStreaming ? 'Thinking' : 'Thought for a moment' return (
    diff --git a/apps/sim/components/ui/activity-status.tsx b/apps/sim/components/ui/activity-status.tsx index 5c45b2b1b14..0d9a023cdfa 100644 --- a/apps/sim/components/ui/activity-status.tsx +++ b/apps/sim/components/ui/activity-status.tsx @@ -10,7 +10,7 @@ export const ACTIVITY_ROW_CLASS = 'flex min-w-0 items-center gap-2' /** Activity label text, muted until the row is hovered. */ export const ACTIVITY_LABEL_CLASS = - 'text-[var(--text-tertiary)] text-base leading-5 group-hover/agent:text-[var(--text-body)]' + 'text-[var(--text-tertiary)] text-sm leading-5 group-hover/agent:text-[var(--text-body)]' export interface ActivityStatusProps { label: string diff --git a/apps/sim/components/ui/shimmer-text.tsx b/apps/sim/components/ui/shimmer-text.tsx index 56f2b37662c..0b6bc621a85 100644 --- a/apps/sim/components/ui/shimmer-text.tsx +++ b/apps/sim/components/ui/shimmer-text.tsx @@ -10,7 +10,7 @@ type ShimmerTextProps = { /** * Sweeping-highlight shimmer over a text phrase — the same treatment as the - * ThinkingLoader's "Thinking…" label, reusable on any active/streaming row. + * ThinkingLoader's "Thinking" label, reusable on any active/streaming row. * Size and weight come from the consumer's className; the gradient replaces * the text color, so color classes are ignored while shimmering. */ diff --git a/apps/sim/components/ui/thinking-loader.tsx b/apps/sim/components/ui/thinking-loader.tsx index 2aafa83915d..660a9d476ad 100644 --- a/apps/sim/components/ui/thinking-loader.tsx +++ b/apps/sim/components/ui/thinking-loader.tsx @@ -215,7 +215,7 @@ const VARIANT_PHRASE: Record = { compass: 'In formation…', // Formation — many Pods in parallel metaballs: 'Dispatching…', // Dispatch — sending work out relay: 'Returning…', // Return — work coming back, consolidated - burst: 'Thinking…', // Thinking — the Core deliberating + burst: 'Thinking', // Thinking — the Core deliberating thinking: 'Standing by…', // Waiting · Sentinel — holding for a condition orb: 'Ready', // Orb — settled, ready to act } @@ -244,7 +244,7 @@ export interface ThinkingLoaderProps { size?: number /** Shape-to-shape goo overlap duration. Defaults to the loader's CSS timing. */ morphDurationMs?: number - /** Optional status text (e.g. "Thinking…") rendered beside the goo with a shimmer sweep. */ + /** Optional status text (e.g. "Thinking") rendered beside the goo with a shimmer sweep. */ label?: string /** * Show a world-aligned status phrase that matches the current shape — e.g. @@ -288,7 +288,7 @@ export interface ThinkingLoaderProps { * * @example * ```tsx - * + * * ``` */ export function ThinkingLoader({ diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index a7bbaccfe2d..f99d258b95e 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -194,6 +194,7 @@ export { export { OverflowText, type OverflowTextProps, + overflowFadeSizeClass, overflowTextClipClass, overflowTextFadeClass, } from './overflow-text/overflow-text' diff --git a/packages/emcn/src/components/overflow-text/overflow-text.tsx b/packages/emcn/src/components/overflow-text/overflow-text.tsx index c87d5d105e5..19c84a0b433 100644 --- a/packages/emcn/src/components/overflow-text/overflow-text.tsx +++ b/packages/emcn/src/components/overflow-text/overflow-text.tsx @@ -10,9 +10,14 @@ import { useIsOverflowing, } from '../tooltip/tooltip' +/** Shared fade width for clipped labels and scroll regions that use the same treatment. */ +export const overflowFadeSizeClass = '[--overflow-fade-size:16px]' + /** Complete fade-only clipping treatment for measured special cases. */ -export const overflowTextFadeClass = - 'overflow-hidden text-clip whitespace-nowrap [-webkit-mask-image:linear-gradient(to_right,black_calc(100%_-_16px),transparent)] [mask-image:linear-gradient(to_right,black_calc(100%_-_16px),transparent)]' +export const overflowTextFadeClass = cn( + overflowFadeSizeClass, + 'overflow-hidden text-clip whitespace-nowrap [-webkit-mask-image:linear-gradient(to_right,black_calc(100%_-_var(--overflow-fade-size)),transparent)] [mask-image:linear-gradient(to_right,black_calc(100%_-_var(--overflow-fade-size)),transparent)]' +) /** Fade-free clipping for externally measured labels and rich-content overflow exceptions. */ export const overflowTextClipClass = 'block min-w-0 overflow-hidden text-clip whitespace-nowrap' diff --git a/packages/emcn/src/components/scroll-fade/scroll-fade.module.css b/packages/emcn/src/components/scroll-fade/scroll-fade.module.css index e391b96fa8e..ad32c7ec9a6 100644 --- a/packages/emcn/src/components/scroll-fade/scroll-fade.module.css +++ b/packages/emcn/src/components/scroll-fade/scroll-fade.module.css @@ -7,7 +7,7 @@ * floating control (the command palette's search field) start the band below it. */ .root { - --scroll-fade-band: 12px; + --scroll-fade-band: var(--overflow-fade-size, 12px); --scroll-fade-top-start: 0px; --scroll-fade-top-end: 0px; --scroll-fade-bottom-start: 100%; @@ -43,7 +43,7 @@ * Same band, same rest state; the stops run left to right instead. */ .rootX { - --scroll-fade-band: 12px; + --scroll-fade-band: var(--overflow-fade-size, 12px); --scroll-fade-left-start: 0px; --scroll-fade-left-end: 0px; --scroll-fade-right-start: 100%; diff --git a/packages/emcn/src/components/scroll-fade/scroll-fade.ts b/packages/emcn/src/components/scroll-fade/scroll-fade.ts index 1c529a64142..aded350bc9d 100644 --- a/packages/emcn/src/components/scroll-fade/scroll-fade.ts +++ b/packages/emcn/src/components/scroll-fade/scroll-fade.ts @@ -12,7 +12,7 @@ export interface ScrollEdgesX { right: boolean } -/** Height of the fade band at an active edge, in pixels. */ +/** Default scroll-edge fade size in pixels, before an explicit override. */ export const SCROLL_FADE_BAND_PX = 12 /** @@ -21,7 +21,8 @@ export const SCROLL_FADE_BAND_PX = 12 * band at any edge that hides content, and stays fully opaque at every edge that * does not — so a list that fits, or sits at its top, is never fogged. * - * The band is a fixed {@link SCROLL_FADE_BAND_PX}. A consumer whose top edge is + * The default band is {@link SCROLL_FADE_BAND_PX}; pair with `overflowFadeSizeClass` + * to match OverflowText. A consumer whose top edge is * covered by a floating control sets `--scroll-fade-inset` to that control's * height (e.g. `[--scroll-fade-inset:3rem]`) so the band starts beneath it. * From 3a37ae173841f19600d30aa7f7e10451b0791adb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 13:57:47 -0700 Subject: [PATCH 2/3] fix(chat): retain response-sized activity text --- .../message-content/components/agent-group/agent-group-view.tsx | 2 +- apps/sim/components/ui/activity-status.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx index 17c778de42d..7efbdf03aa6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx @@ -357,7 +357,7 @@ function NarrationText({ content, isStreaming }: NarrationTextProps) { const revealed = useSmoothText(content, isStreaming) return ( - + {renderInlineMarkdown(revealed.trim())} ) diff --git a/apps/sim/components/ui/activity-status.tsx b/apps/sim/components/ui/activity-status.tsx index 0d9a023cdfa..5c45b2b1b14 100644 --- a/apps/sim/components/ui/activity-status.tsx +++ b/apps/sim/components/ui/activity-status.tsx @@ -10,7 +10,7 @@ export const ACTIVITY_ROW_CLASS = 'flex min-w-0 items-center gap-2' /** Activity label text, muted until the row is hovered. */ export const ACTIVITY_LABEL_CLASS = - 'text-[var(--text-tertiary)] text-sm leading-5 group-hover/agent:text-[var(--text-body)]' + 'text-[var(--text-tertiary)] text-base leading-5 group-hover/agent:text-[var(--text-body)]' export interface ActivityStatusProps { label: string From 04ba34803b2bd5f44079a0770de61c5b2a40aa01 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 14:19:50 -0700 Subject: [PATCH 3/3] chore(chat): cover interaction spacing boundaries --- .../message-content-spacing.test.tsx | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-spacing.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-spacing.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-spacing.test.tsx new file mode 100644 index 00000000000..8f1a65777c2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-spacing.test.tsx @@ -0,0 +1,218 @@ +/** @vitest-environment jsdom */ +import { act, type ComponentProps } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MessageContent } from '@/app/workspace/[workspaceId]/home/components/message-content/message-content' +import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types' + +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: () => ({ data: null, isPending: false }), +})) +vi.mock('@/hooks/use-smooth-text', () => ({ useSmoothText: (text: string) => text })) +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: 'workspace-test' }), + useRouter: () => ({ prefetch: vi.fn(), push: vi.fn() }), +})) + +const QUESTION = `${JSON.stringify([ + { + type: 'single_select', + prompt: 'Choose an account', + options: [ + { id: 'personal', label: 'Personal account' }, + { id: 'team', label: 'Team account' }, + ], + }, +])}` + +function activity(id: string): ContentBlock { + return { + type: 'tool_call', + spanId: 'main', + toolCall: { + id, + name: 'read', + status: 'success', + params: { activity: { id, title: `Reading ${id}`, completedTitle: `Read ${id}` } }, + }, + } +} + +function text(content: string): ContentBlock { + return { type: 'text', content } +} + +/** + * These render tests protect the adjacency and card boundaries consumed by the + * spacing selectors. Pixel gaps are checked in the browser; jsdom has no layout. + */ +describe('message activity and card boundaries', () => { + let container: HTMLDivElement + let root: Root + let queryClient: QueryClient + const onSelect = vi.fn() + const onDismiss = vi.fn() + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: false })) + vi.clearAllMocks() + queryClient = new QueryClient() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + queryClient.clear() + vi.unstubAllGlobals() + }) + + async function render( + blocks: ContentBlock[], + props: Partial> = {} + ) { + await act(async () => { + root.render( + + + + ) + }) + } + + function activities() { + return [...container.querySelectorAll('[data-chat-activity]')] + } + + function card() { + const element = container.querySelector('[data-interaction-card]') + expect(element).not.toBeNull() + return element! + } + + function button(label: string) { + const element = [...container.querySelectorAll('button')].find( + (node) => node.textContent === label || node.getAttribute('aria-label') === label + ) + expect(element).toBeDefined() + return element! + } + + it('keeps consecutive activity rows adjacent and prose on its own boundary', async () => { + await render([ + text('Starting the review.'), + activity('first'), + activity('second'), + text('Done.'), + ]) + const [first, second] = activities() + expect(activities()).toHaveLength(2) + expect(first.nextElementSibling).toBe(second) + expect(first.previousElementSibling?.textContent).toBe('Starting the review.') + expect(second.nextElementSibling?.textContent).toBe('Done.') + expect(first.parentElement).toBe(second.parentElement) + expect(first.previousElementSibling?.hasAttribute('data-chat-activity')).toBe(false) + expect(second.nextElementSibling?.hasAttribute('data-chat-activity')).toBe(false) + }) + + it.each(['active', 'answered'] as const)( + 'keeps a rehydrated %s card between the same activity boundaries', + async (state) => { + await render([activity('first'), text(QUESTION), activity('second')], { + questionAnswers: state === 'answered' ? ['Personal account'] : undefined, + }) + const [first, second] = activities() + const cardRoot = card().parentElement! + expect(first.nextElementSibling).toBe(cardRoot) + expect(cardRoot.nextElementSibling).toBe(second) + expect(cardRoot.lastElementChild).toBe(card()) + expect(cardRoot.children).toHaveLength(1) + expect(card().textContent).toContain('Choose an account') + expect(card().querySelector('input') !== null).toBe(state === 'active') + } + ) + + it('answers in place without remounting adjacent tool activity', async () => { + await render([activity('first'), text(QUESTION), activity('second')]) + const [first, second] = activities() + const cardRoot = card().parentElement! + act(() => button('Personal account').click()) + expect(onSelect).toHaveBeenCalledWith('Choose an account — Personal account') + expect(card().textContent).toContain('Personal account') + expect(card().querySelector('input')).toBeNull() + expect(card().parentElement).toBe(cardRoot) + expect(activities()[0]).toBe(first) + expect(activities()[1]).toBe(second) + expect(first.nextElementSibling).toBe(cardRoot) + expect(cardRoot.nextElementSibling).toBe(second) + }) + + it('leaves only an empty boundary when a card between activities is dismissed', async () => { + await render([activity('first'), text(QUESTION), activity('second')]) + const [first, second] = activities() + const cardRoot = card().parentElement! + act(() => button('Dismiss').click()) + expect(onDismiss).toHaveBeenCalledOnce() + expect(container.querySelector('[data-interaction-card]')).toBeNull() + expect(cardRoot.matches(':empty')).toBe(true) + expect(first.nextElementSibling).toBe(cardRoot) + expect(cardRoot.nextElementSibling).toBe(second) + expect(activities()[0]).toBe(first) + expect(activities()[1]).toBe(second) + }) + + it('keeps the terminal action region outside the empty dismissed-card boundary', async () => { + await render([activity('first'), text(QUESTION)], { + actions: , + }) + const first = activities()[0] + const cardRoot = card().parentElement! + const actionRegion = button('Copy').parentElement!.parentElement! + const stack = first.parentElement! + expect(stack.nextElementSibling).toBe(actionRegion) + act(() => button('Dismiss').click()) + expect(cardRoot.matches(':empty')).toBe(true) + expect(cardRoot.nextElementSibling).toBeNull() + expect(first.nextElementSibling).toBe(cardRoot) + expect(stack.nextElementSibling).toBe(actionRegion) + expect(stack.contains(button('Copy'))).toBe(false) + }) + + it('keeps a trailing activity recap on the card boundary before the next activity', async () => { + const takeover: ContentBlock = { + type: 'tool_call', + spanId: 'main', + toolCall: { + id: 'takeover', + name: 'browser_request_takeover', + status: 'success', + params: { reason: 'Review the browser step' }, + result: { success: true, output: { userInstruction: 'Continue' } }, + }, + } + await render([activity('first'), takeover, activity('second')]) + const [first, second] = activities() + expect(first.nextElementSibling).toBe(second) + expect(first.lastElementChild?.lastElementChild?.lastElementChild).toBe(card()) + expect(card().nextElementSibling).toBeNull() + }) + + it('does not treat a card followed by prose as the final content of its segment', async () => { + await render([text(`${QUESTION}\n\nThe draft is ready.`), activity('next')]) + const cardRoot = card().parentElement! + expect(cardRoot.lastElementChild).not.toBe(card()) + expect(cardRoot.lastElementChild?.textContent).toBe('The draft is ready.') + expect(cardRoot.nextElementSibling).toBe(activities()[0]) + }) +})