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
108 changes: 108 additions & 0 deletions packages/ui/src/__tests__/transient-placement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import {
selectTailTransientMessages,
turnRendersUserMessage,
type TurnUserRowSource,
} from '../transient-placement.js';

function turn(overrides: Partial<TurnUserRowSource> = {}): TurnUserRowSource {
return { timeline: [], ...overrides };
}

function timelineUserItem(messageId: string): TurnUserRowSource['timeline'][number] {
return { kind: 'user', messageId };
}

function timelineAssistantItem(messageId: string): TurnUserRowSource['timeline'][number] {
return { kind: 'text', messageId };
}

describe('turnRendersUserMessage', () => {
test('matches the Turn primary prompt id', () => {
const turns = [turn({ user: { id: 'msg-1' } })];
assert.equal(turnRendersUserMessage(turns, 'msg-1'), true);
assert.equal(turnRendersUserMessage(turns, 'msg-2'), false);
});

test('matches an admitted user timeline item id', () => {
const turns = [turn({ timeline: [timelineUserItem('msg-1')] })];
assert.equal(turnRendersUserMessage(turns, 'msg-1'), true);
});

test('does not match a non-user timeline item that reuses the id', () => {
const turns = [turn({ timeline: [timelineAssistantItem('msg-1')] })];
assert.equal(turnRendersUserMessage(turns, 'msg-1'), false);
});

test('matches across every rendered Turn, not only the tail', () => {
const turns = [
turn({ user: { id: 'older-prompt' } }),
turn({ user: { id: 'newer-prompt' } }),
];
assert.equal(turnRendersUserMessage(turns, 'older-prompt'), true);
});

test('without Turns nothing is already rendered', () => {
assert.equal(turnRendersUserMessage([], 'msg-1'), false);
assert.equal(turnRendersUserMessage([turn()], 'msg-1'), false);
});
});

describe('selectTailTransientMessages', () => {
const turnsWithPrompt = [
turn({ user: { id: 'msg-1' }, timeline: [timelineUserItem('msg-1')] }),
];

test('drops a duplicate the tail Turn already renders as its prompt', () => {
const transients = [{ id: 'msg-1' }];
assert.deepEqual(
selectTailTransientMessages(transients, new Set(), turnsWithPrompt),
[],
);
});

test('keeps a distinct admitted message queued behind the live prompt', () => {
const transients = [{ id: 'msg-1' }, { id: 'msg-2' }];
assert.deepEqual(
selectTailTransientMessages(transients, new Set(), turnsWithPrompt),
[{ id: 'msg-2' }],
);
});

test('keeps every transient while the Turn has no user row yet', () => {
const transients = [{ id: 'msg-1' }, { id: 'msg-2' }];
const pendingTurns = [turn({ user: undefined, timeline: [] })];
assert.deepEqual(
selectTailTransientMessages(transients, new Set(), pendingTurns),
transients,
);
});

test('excludes messages already routed into the inline slot', () => {
const transients = [{ id: 'msg-1' }, { id: 'msg-2' }];
assert.deepEqual(
selectTailTransientMessages(transients, new Set(['msg-2']), []),
[{ id: 'msg-1' }],
);
});
});
47 changes: 34 additions & 13 deletions packages/ui/src/chat-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner, Text
import { useChatLayoutContext } from '@astryxdesign/core/Chat';
import { useLayer } from '@astryxdesign/core/Layer';
import { materializeChat } from './materialize.js';
import { selectTailTransientMessages } from './transient-placement.js';
import { useTranscriptProjection } from './use-transcript-projection.js';
import type { LiveTurnProjection } from './live-turn-projection.js';
import {
Expand Down Expand Up @@ -551,6 +552,13 @@ export function ChatView(props: {
inlineTransientMessagesByTurn.set(turn.turnId, messages);
inlineTransientMessageIds.add(message.id);
}
// The tail slot renders what no Turn took inline; a durable local copy the
// transcript already shows as a Turn's own user row must not render again.
const tailTransientMessages = selectTailTransientMessages(
transientMessages,
inlineTransientMessageIds,
turns,
);
const { highlightedTurnId } = useChatScroll({
scrollRef,
sessionId: props.activeSession?.id,
Expand Down Expand Up @@ -638,10 +646,17 @@ export function ChatView(props: {
))}
</>
) : null}
{transientMessages.map((message) => (
<TransientUserMessage key={message.id} message={message}
status={message.hostTurnId ? props.turnDecorations?.get(message.hostTurnId)?.promptStatus : undefined} />
))}
{/* Tail rows have no Turn ancestor, so the reading measure that
`.maka-turn` owns would not reach them: without the wrapper
the bubble stretches across the full window width. */}
{transientMessages.length > 0 && (
<section className="maka-turn">
{transientMessages.map((message) => (
<TransientUserMessage key={message.id} message={message}
status={message.hostTurnId ? props.turnDecorations?.get(message.hostTurnId)?.promptStatus : undefined} />
))}
</section>
)}
{/* The optimistic message supplies the clock while the session is created. */}
{runningStatus && (
<section className="maka-turn" data-live-streaming="true">
Expand Down Expand Up @@ -821,15 +836,21 @@ export function ChatView(props: {
</div>
);
})}
{transientMessages.filter(
(message) => !inlineTransientMessageIds.has(message.id),
).map((message) => (
<TransientUserMessage
key={message.id}
message={message}
status={message.hostTurnId ? props.turnDecorations?.get(message.hostTurnId)?.promptStatus : undefined}
/>
))}
{/* A local copy the transcript already shows as the tail Turn's
own user row must not render again below the running status;
the inline slot drops it, so the tail slot drops it too.
Same reading-measure reasoning as the optimistic path above. */}
{tailTransientMessages.length > 0 && (
<section className="maka-turn">
{tailTransientMessages.map((message) => (
<TransientUserMessage
key={message.id}
message={message}
status={message.hostTurnId ? props.turnDecorations?.get(message.hostTurnId)?.promptStatus : undefined}
/>
))}
</section>
)}
{/* A send arm already names its Turn, but the transcript may not
contain it yet. Keep feedback below the pending prompt until
that same TurnView can take over. */}
Expand Down
71 changes: 71 additions & 0 deletions packages/ui/src/transient-placement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

/**
* The slice of a materialized Turn that transient placement needs: whether
* the Turn already renders a given message id as its own user row. Turn view
* models satisfy this structurally; tests construct the slice directly.
*/
export interface TurnUserRowSource {
readonly user?: { readonly id: string };
readonly timeline: ReadonlyArray<{ readonly kind: string; readonly messageId?: string }>;
}

/**
* True when the transcript already renders this message id as a Turn's own
* user row: the Turn's primary prompt (`user.id`) or an admitted `user`
* timeline item (steering) carrying the same id.
*
* The durable local copy of a Host-accepted message keeps rendering while its
* Turn is live. Once the Turn carries the message itself, that copy must
* disappear: the inline slot already drops it, and the tail slot has to drop
* it too, or the same text shows twice — once inside the Turn and once below
* the running status with the delivery chip.
*
* Sound only because transcript ids and durable local-copy ids share one id
* space (the Host echoes the client-generated message id); the inline slot
* has always relied on the same equality for its `user` timeline check.
*/
export function turnRendersUserMessage(
turns: ReadonlyArray<TurnUserRowSource>,
messageId: string,
): boolean {
return turns.some((turn) =>
turn.user?.id === messageId
|| turn.timeline.some((item) => item.kind === 'user' && item.messageId === messageId),
);
}

/**
* The transients that still render after the last Turn: not already routed
* into the tail Turn's inline slot, and not already visible as a Turn's own
* user row. A distinct message admitted into the live Turn while its user row
* is on screen stays here, so queued prompts keep showing below the Turn —
* only true duplicates disappear.
*/
export function selectTailTransientMessages<Message extends { readonly id: string }>(
transientMessages: ReadonlyArray<Message>,
inlineTransientMessageIds: ReadonlySet<string>,
turns: ReadonlyArray<TurnUserRowSource>,
): ReadonlyArray<Message> {
return transientMessages.filter((message) =>
!inlineTransientMessageIds.has(message.id)
&& !turnRendersUserMessage(turns, message.id),
);
}