Skip to content

Commit ab9855c

Browse files
authored
Open a conversation at the bottom, keep it still while a Bot writes, and add a rail of turns (#882)
* Show a channel's history once it is all in, already at the bottom * Wait for a channel's voice chats before showing its history * Open a conversation without a cascade; animate only what arrives * Glide only for messages sent after a conversation is open * Open group chats at the bottom and follow only from there * Show a new conversation's first message without waiting for voice chats * Show a message typed while history loads under the placeholder * Bring a person's own group message into view even when scrolled up * Render a conversation's newest rows in with a short stagger as it opens * Pass the opening stagger delay as a variable so reduced motion can drop it * Keep a tool row and its key for its whole life as calls join and leave it A lone foldable call is now a group of one under a group: key and is drawn as the same line it was. Existing grouping tests that asserted a lone call's own id or an unprefixed group id now assert the group: id. * Draw gallery cards once and wait for grants instead of refusing * Let a card that failed on half-sent arguments try again on the next chunk * Keep gallery items in place while their labels stream, and hold a place for missing numbers * Hold back a half-arrived number, link or colour until it is complete * Keep a lone tool call's row when its answer turns it into a card * Register decisions with one render so they are not rebuilt as grants load * Draw a streaming table's complete rows while its last row is still arriving * Draw a conversation's newest rows first and older ones as the reader scrolls up * Decide whether to follow a reply from what the person does, not from where the view is * Follow a streaming reply until the person scrolls up or selects text, and pick it up again when they come back * Draw one older page per frame, after the scroller has put the reader back * Don't stop following for keys typed into a field inside the transcript * Count an upward scroll as the person's only when they are dragging the scrollbar * Say which case drawing pages above does not cover * Reveal a streaming reply at a steady pace and draw history as finished text * Keep finished rows from re-rendering while a reply streams * Draw generative UI that arrived before its renderer once the renderer is there * Note the conversation polish in the changelog * Redraw a running tool row as its arguments stream in * Show a reply's last word once it stops growing, and text with no spaces as it streams * Let a jump draw every page down to a turn and stop following * Add a rail of turns beside a conversation, with a preview and a jump * Mark the last turn as current at the end of a conversation, however short it is * Hold back a card's last value only while its row can still receive arguments * Hold the page boundary on rows, so rows added above never cross it * Put the scroller back to following when somebody scrolls down at the end * Count a press on an overlay or left-hand scrollbar as the person's * Keep following when a jump from the rail leaves the view at the end * Pick following up on a person's first message in a channel of firings * Show a channel's history without its voice chats when they are slow or fail * Draw nothing for a card that fails on arguments still arriving * Keep the rail and its marks still while a reply streams, and read the current turn once a frame * Listen for late activity renderers once per transcript, and look again for a few frames * Remove a decision's renderer when it unmounts, as the SDK's own hook does * Fix three stale comments and draw both kinds of tool row from one branch * Keep one reveal loop across tokens, so each frame counts the time since the last frame * Show a decision's failure while the run waits on it: a call is final once its arguments are complete * Put two doc comments back above what they describe * Draw a late voice chat from before the conversation opened at rest
1 parent 9d80da0 commit ab9855c

51 files changed

Lines changed: 5741 additions & 471 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,17 @@ for its default of thirty minutes, and `browserIdleMs: 0` (keep browsers open) w
122122
of these settings now also reach the computers `computers.mode: sandbox` makes, which had none of
123123
them.
124124

125+
### A conversation opens at the bottom and stays still while a Bot writes
126+
127+
Opening a conversation shows its newest messages at once, already at the bottom, instead of loading
128+
and then gliding down; a long one draws older messages as somebody scrolls up to them, without
129+
moving what they are reading. Charts, tables, cards and decisions a Bot draws grow in place while
130+
they stream, and no longer flash "not available" before they appear. A reply is revealed at a steady
131+
pace, and the view follows it until the person scrolls up or selects some of it, and picks it up
132+
again when they come back to the end or press the arrow. A rail beside a conversation has a mark
133+
per turn: hovering one previews what was asked and answered, and choosing it goes there. Nothing
134+
changes for a deployment.
135+
125136
### Generated interfaces use A2UI 1.0
126137

127138
With generative UI on, a Bot builds an interface from the A2UI 1.0 basic components instead of the

‎app/src/components/channels/channel-chat.tsx‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,48 @@ function sameActivity(
9191
);
9292
}
9393

94+
/**
95+
* Whether the transcript still waits behind the placeholder.
96+
*
97+
* A channel opened from the roster waits for its whole history: the stored thread (`restoring`) and
98+
* its voice chats. A channel just started from the compose screen has no history; its first message
99+
* is already in hand and shows at once, not after a voice-chat request with nothing in it.
100+
*
101+
* Exported for its tests.
102+
*/
103+
export function historyIsLoading({
104+
restoring,
105+
seeded,
106+
voicePending,
107+
voiceWaitedOut = false,
108+
}: {
109+
restoring: boolean;
110+
seeded: boolean;
111+
voicePending: boolean;
112+
/** The voice chats have had their `VOICE_WAIT_MS` since the thread came in. */
113+
voiceWaitedOut?: boolean;
114+
}): boolean {
115+
return restoring || (!seeded && voicePending && !voiceWaitedOut);
116+
}
117+
118+
/**
119+
* How long, once the thread is in, the conversation waits for its voice chats before showing
120+
* without them. A slow or failing voice service must not hold up text chat; cards that arrive after
121+
* this join the transcript where they belong.
122+
*/
123+
export const VOICE_WAIT_MS = 1500;
124+
125+
/** Whether `VOICE_WAIT_MS` has passed since `waiting` became true. Reset whenever it is false. */
126+
function useWaitedOut(waiting: boolean, key: string): boolean {
127+
const [waitedOut, setWaitedOut] = useState<string | null>(null);
128+
useEffect(() => {
129+
if (!waiting) return;
130+
const timer = window.setTimeout(() => setWaitedOut(key), VOICE_WAIT_MS);
131+
return () => window.clearTimeout(timer);
132+
}, [waiting, key]);
133+
return waiting && waitedOut === key;
134+
}
135+
94136
export function channelHistoryNotice({
95137
restoring,
96138
messageCount,
@@ -549,8 +591,24 @@ export function ChannelChat({
549591
useActiveBot(runtimeAgentId);
550592

551593
const skillCommands = useSkillCommands(runtimeAgentId);
552-
const historyNotice = channelHistoryNotice({
594+
/*
595+
* History is the snapshot, the stored thread AND the voice archive. The transcript shows none of
596+
* it until all three are in, so it lands once at the bottom instead of drawing the conversation
597+
* and then splicing call cards into it; the notice waits for the same moment, so it appears in
598+
* the commit that shows the history rather than a render later, shrinking the view under it.
599+
*/
600+
const voiceWaitedOut = useWaitedOut(
601+
!restoring && seed === null && voiceArchive.pending,
602+
channel.id,
603+
);
604+
const historyLoading = historyIsLoading({
553605
restoring,
606+
seeded: seed !== null,
607+
voicePending: voiceArchive.pending,
608+
voiceWaitedOut,
609+
});
610+
const historyNotice = channelHistoryNotice({
611+
restoring: historyLoading,
554612
messageCount: agent.messages.length,
555613
lastMessageAt: channel.lastMessageAt,
556614
historyAvailability,
@@ -1091,7 +1149,7 @@ export function ChannelChat({
10911149
* above.
10921150
*/
10931151
queueWhileBusy
1094-
restoring={restoring}
1152+
restoring={historyLoading}
10951153
/*
10961154
* The run, not the turn. Stop reaches a run through the core's abort controller, and that
10971155
* controller does not exist until `say` has finished waiting for the runtime agent — so

‎app/src/components/channels/chat-messages.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,8 +320,7 @@ function isReadableMessage(message: unknown): message is Readonly<Message> {
320320
* row it builds is keyed on the id". `message.id` is keyed on harder and was checked nowhere: it
321321
* becomes `VisibleChatItem.id`, which is DECLARED `string`, and downstream that one value is the
322322
* React key, `MessageScrollerItem`'s `messageId`, the turn-grouping key `anchorRowIds` and `turnOf`
323-
* cut apart at the last colon, and the memo key `createFirstPaintDelays` hangs an entrance delay
324-
* off.
323+
* cut apart at the last colon, and the key `openingStagger` hangs a row's entrance delay off.
325324
*
326325
* IT SURVIVED BECAUSE IT DOES NOT THROW — the same reason `readAttachmentMetadata` gives for its
327326
* own fields having gone unchecked, and the claim there that `metadata` was "the last unchecked
@@ -419,7 +418,12 @@ export function toVisibleChatItems(
419418
kind: "tool",
420419
// One assistant message can carry multiple tool calls.
421420
id: toolCall.id,
422-
toolCall,
421+
/*
422+
* A copy, not the agent's object: the client appends argument chunks to the call it
423+
* already handed over, so a row comparing the call it drew with the next one would be
424+
* comparing an object with itself and never see the arguments grow.
425+
*/
426+
toolCall: { ...toolCall, function: { ...toolCall.function } },
423427
...(results.has(toolCall.id)
424428
? { result: results.get(toolCall.id) }
425429
: {}),

0 commit comments

Comments
 (0)