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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 26 additions & 118 deletions apps/web/src/domains/chat/transcript/latest-turn-row.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Static-markup tests for `LatestTurnRow`'s avatar slot behavior.
* Static-markup tests for `LatestTurnRow`'s render structure.
*
* The repo doesn't run DOM-based tests (no `@testing-library/react`). We
* exercise the component via `renderToStaticMarkup` and mock the LEAF
Expand All @@ -9,6 +9,10 @@
* `./TranscriptRow` — `mock.module()` is process-global in bun:test and
* stubbing TranscriptRow at the module level here leaks into other test
* files (e.g. `Transcript.test.tsx`) that still need the real component.
*
* The latest-edge region's avatar slot, flex-1 spacer, and
* `data-latest-edge` sentinel all live in `Transcript` itself — see
* `transcript.test.tsx` for their tests.
*/

import { describe, expect, mock, test } from "bun:test";
Expand Down Expand Up @@ -44,7 +48,6 @@ import { renderToStaticMarkup } from "react-dom/server";
import type { DisplayMessage } from "@/domains/chat/utils/reconcile";
import type {
MessageItem,
ThinkingItem,
TranscriptItem,
} from "@/domains/chat/transcript/types";

Expand All @@ -68,14 +71,9 @@ function assistantMessageItem(id: string, content: string): MessageItem {
return { kind: "message", key: id, message: msg };
}

function thinkingItem(id: string): ThinkingItem {
return { kind: "thinking", key: id };
}

const noop = () => {};

const sharedProps = {
viewportMinHeight: 0,
expandedToolCallIds: new Set<string>(),
expandedCardIds: new Map<string, boolean>(),
onSurfaceAction: noop,
Expand All @@ -84,145 +82,55 @@ const sharedProps = {
onRetryError: noop,
};

describe("LatestTurnRow avatar slot", () => {
test("with no avatarSlot → no avatar marker is rendered", () => {
const anchor = userMessageItem("u1", "hello");
const responseItems: TranscriptItem[] = [
assistantMessageItem("a1", "hi back"),
];
const html = renderToStaticMarkup(
<LatestTurnRow
anchorMessage={anchor}
responseItems={responseItems}
{...sharedProps}
/>,
);
expect(html).not.toContain('data-latest-assistant-avatar="true"');
});

test("with avatarSlot + responseItems ending in an assistant message → exactly one marker, after the assistant row", () => {
const anchor = userMessageItem("u1", "question");
const responseItems: TranscriptItem[] = [
thinkingItem("t1"),
assistantMessageItem("a1", "ASSISTANT_REPLY_MARKER"),
];
const html = renderToStaticMarkup(
<LatestTurnRow
anchorMessage={anchor}
responseItems={responseItems}
avatarSlot={<span data-testid="avatar-stub">AVATAR_SLOT_MARKER</span>}
{...sharedProps}
/>,
);
const matches = html.match(/data-latest-assistant-avatar="true"/g) ?? [];
expect(matches.length).toBe(1);

const avatarIdx = html.indexOf('data-latest-assistant-avatar="true"');
const assistantIdx = html.indexOf("ASSISTANT_REPLY_MARKER");
expect(assistantIdx).toBeGreaterThanOrEqual(0);
// Avatar appears after the assistant row's content in HTML order.
expect(avatarIdx).toBeGreaterThan(assistantIdx);
// The avatarSlot itself is rendered.
expect(html).toContain("AVATAR_SLOT_MARKER");
});

test("with multiple assistant messages and non-assistant items between → marker is after the LAST assistant row", () => {
const anchor = userMessageItem("u1", "question");
describe("LatestTurnRow render order", () => {
test("anchor first, then responseItems in order", () => {
const anchor = userMessageItem("u1", "ANCHOR_CONTENT");
const responseItems: TranscriptItem[] = [
assistantMessageItem("a1", "FIRST_REPLY_MARKER"),
thinkingItem("t1"),
assistantMessageItem("a2", "SECOND_REPLY_MARKER"),
thinkingItem("t2"),
assistantMessageItem("a1", "FIRST_RESPONSE"),
assistantMessageItem("a2", "SECOND_RESPONSE"),
];
const html = renderToStaticMarkup(
<LatestTurnRow
anchorMessage={anchor}
responseItems={responseItems}
avatarSlot={<span>AVATAR</span>}
{...sharedProps}
/>,
);

const matches = html.match(/data-latest-assistant-avatar="true"/g) ?? [];
expect(matches.length).toBe(1);

const avatarIdx = html.indexOf('data-latest-assistant-avatar="true"');
const firstReplyIdx = html.indexOf("FIRST_REPLY_MARKER");
const secondReplyIdx = html.indexOf("SECOND_REPLY_MARKER");

// After the second (last) assistant row's content.
expect(avatarIdx).toBeGreaterThan(secondReplyIdx);
// Definitely not just after the first.
expect(avatarIdx).toBeGreaterThan(firstReplyIdx);
// The first reply precedes the second (sanity check).
expect(firstReplyIdx).toBeLessThan(secondReplyIdx);
});

test("with avatarSlot + responseItems with no assistant message (only thinking) → marker appears once after all responseItems", () => {
const anchor = userMessageItem("u1", "question");
const responseItems: TranscriptItem[] = [thinkingItem("t1")];
const html = renderToStaticMarkup(
<LatestTurnRow
anchorMessage={anchor}
responseItems={responseItems}
avatarSlot={<span>AVATAR</span>}
{...sharedProps}
/>,
);
const anchorIdx = html.indexOf("ANCHOR_CONTENT");
const firstIdx = html.indexOf("FIRST_RESPONSE");
const secondIdx = html.indexOf("SECOND_RESPONSE");

const matches = html.match(/data-latest-assistant-avatar="true"/g) ?? [];
expect(matches.length).toBe(1);
expect(anchorIdx).toBeGreaterThanOrEqual(0);
expect(firstIdx).toBeGreaterThanOrEqual(0);
expect(secondIdx).toBeGreaterThanOrEqual(0);

const avatarIdx = html.indexOf('data-latest-assistant-avatar="true"');
const edgeIdx = html.indexOf('data-latest-edge="true"');
expect(edgeIdx).toBeGreaterThanOrEqual(0);
// Avatar comes before the edge sentinel.
expect(avatarIdx).toBeLessThan(edgeIdx);
expect(anchorIdx).toBeLessThan(firstIdx);
expect(firstIdx).toBeLessThan(secondIdx);
});

test("with avatarSlot + empty responseItems → avatar still renders so it persists across the user-send → response boundary without flicker", () => {
const anchor = userMessageItem("u1", "question");
test("no avatar slot / latest-edge sentinel rendered (both live in Transcript)", () => {
const anchor = userMessageItem("u1", "hello");
const html = renderToStaticMarkup(
<LatestTurnRow
anchorMessage={anchor}
responseItems={[]}
avatarSlot={<span>AVATAR_SLOT_MARKER</span>}
{...sharedProps}
/>,
);
// After a user sends, the new user message becomes the anchor and
// responseItems is empty until V streams. Keeping the avatar
// mounted in this window prevents the ChatAvatar entrance spring
// from replaying as a visible flicker.
expect(html).toContain('data-latest-assistant-avatar="true"');
expect(html).toContain("AVATAR_SLOT_MARKER");
expect(html).not.toContain('data-latest-assistant-avatar="true"');
expect(html).not.toContain('data-latest-edge="true"');
});
});

describe("LatestTurnRow spacer position", () => {
test("flex-1 spacer appears AFTER responses (fills remaining viewport height below content)", () => {
const anchor = userMessageItem("u1", "ANCHOR_CONTENT");
const responseItems: TranscriptItem[] = [
assistantMessageItem("a1", "RESPONSE_CONTENT"),
];
test("data-latest-turn marker stays on the root", () => {
const anchor = userMessageItem("u1", "hello");
const html = renderToStaticMarkup(
<LatestTurnRow
anchorMessage={anchor}
responseItems={responseItems}
responseItems={[]}
{...sharedProps}
/>,
);

const anchorIdx = html.indexOf("ANCHOR_CONTENT");
const responseIdx = html.indexOf("RESPONSE_CONTENT");
const edgeIdx = html.indexOf('data-latest-edge="true"');

expect(anchorIdx).toBeGreaterThanOrEqual(0);
expect(responseIdx).toBeGreaterThanOrEqual(0);
expect(edgeIdx).toBeGreaterThanOrEqual(0);

// Anchor first, then response, then edge sentinel at the very end.
expect(anchorIdx).toBeLessThan(responseIdx);
expect(responseIdx).toBeLessThan(edgeIdx);
expect(html).toContain('data-latest-turn="true"');
});
});
51 changes: 8 additions & 43 deletions apps/web/src/domains/chat/transcript/latest-turn-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,19 @@ import { TranscriptRow } from "@/domains/chat/transcript/transcript-row";
import type { ConfirmationDecision } from "@/domains/chat/api/event-types";

/**
* Renders the newest user message (the "anchor") plus any response items that
* have streamed in since it was sent. Sized to at least the scroll viewport
* height so the anchor stays pinned to the top of the viewport at the latest
* edge while short replies grow downward without hugging the bottom.
* Renders the newest user message (the "anchor") plus any response items
* that have streamed in since it was sent.
*
* Layout:
* 1. Anchor user message row
* 2. Response items
* 3. Avatar slot
* 4. flex-1 spacer (fills remaining viewport height below content)
* 5. data-latest-edge sentinel
* The viewport-min-height wrapper that pins the anchor to the top of the
* viewport — and the assistant avatar that pins to the bottom of the
* viewport — both live in `Transcript`. This component is just the
* anchor + response cluster; it has no awareness of where it sits inside
* the latest-edge region.
*/
export interface LatestTurnRowProps {
anchorMessage: MessageItem;
responseItems: TranscriptItem[];
assistantDisplayName?: string | null;
/** Current scroll container height — drives `minHeight`. Provided by the
* parent `Transcript` via `useViewportMinHeight`. */
viewportMinHeight: number;
expandedToolCallIds: Set<string>;
expandedCardIds: Map<string, boolean>;
onSurfaceAction: (
Expand Down Expand Up @@ -69,18 +63,12 @@ export interface LatestTurnRowProps {
onSubagentClick?: (subagentId: string) => void;
/** Callback to abort/stop a running subagent from an inline card. */
onStopSubagent?: (subagentId: string) => void;
/** Slot rendered after the latest assistant response inside the
* latest-turn cluster. Used by AssistantPageClient (PR 3) to mount
* the chat avatar at the bottom of the latest assistant message
* rather than at the bottom of the entire chat. */
avatarSlot?: ReactNode;
}

export const LatestTurnRow = memo(function LatestTurnRow({
anchorMessage,
responseItems,
assistantDisplayName,
viewportMinHeight,
expandedToolCallIds,
expandedCardIds,
onSurfaceAction,
Expand All @@ -105,14 +93,9 @@ export const LatestTurnRow = memo(function LatestTurnRow({
assistantId,
onSubagentClick,
onStopSubagent,
avatarSlot,
}: LatestTurnRowProps) {
return (
<div
className="flex flex-col"
style={{ minHeight: viewportMinHeight }}
data-latest-turn="true"
>
<div className="flex flex-col" data-latest-turn="true">
<TranscriptRow
item={anchorMessage}
assistantDisplayName={assistantDisplayName}
Expand Down Expand Up @@ -173,24 +156,6 @@ export const LatestTurnRow = memo(function LatestTurnRow({
/>
</Fragment>
))}
{avatarSlot && (
// Render whenever a slot is provided — including the "user just
// sent, response hasn't streamed yet" gap. Gating on
// `responseItems.length > 0` here causes the avatar to unmount
// and remount across the turn boundary, replaying the
// ChatAvatar entrance spring as a visible flicker. Keeping the
// slot mounted preserves DOM identity; the avatar's already-
// wired `isStreaming` prop drives the "thinking" beat while V
// composes a reply.
<div
data-latest-assistant-avatar="true"
className="flex justify-start pl-1 pt-3 pb-2"
>
{avatarSlot}
</div>
)}
<div className="flex-1" />
<div aria-hidden data-latest-edge="true" />
</div>
);
});
Loading