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
34 changes: 19 additions & 15 deletions assets/dist/roundtable.js

Large diffs are not rendered by default.

65 changes: 58 additions & 7 deletions assets/src/components.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from './api.js';
import { eventsToTurn } from './events.js';
import { canDraftTopic, turnsToConversation } from './conversation.js';
import { rehydratedTurns } from './rehydrate.js';
import { rehydratedTopicWorthy, rehydratedTurns } from './rehydrate.js';
import { PublishDialog } from './publish-dialog.jsx';
import { AgentAvatar } from './avatars.jsx';
import { agentDisplayName, initialMessage, projectDisplayName } from './config.js';
Expand Down Expand Up @@ -158,7 +158,11 @@ export function applyStreamEvent(turns, ev, now) {
const turn = turns[i];
let patch;
if (ev.type === 'guarded_text_delta') patch = { reply: turn.reply + (ev.text || '') };
else if (ev.type === 'assistant_text') patch = { finalText: (turn.finalText || '') + ev.text };
else if (ev.type === 'assistant_text') {
// One event per complete text block: blocks join with a blank line so they
// never concatenate mid-sentence.
patch = { finalText: turn.finalText ? `${turn.finalText}\n\n${ev.text}` : ev.text };
}
else if (ev.type === 'progress') patch = { steps: [...turn.steps, { summary: ev.summary, at: now }] };
else if (ev.type === 'result') {
const base = ev.is_error === true
Expand All @@ -174,6 +178,26 @@ export function applyStreamEvent(turns, ev, now) {
return next;
}

/**
* Reduce one thread scroll event onto the stick-to-bottom state.
*
* Scroll events fire asynchronously, after paint: when streamed content grows
* the thread between our own programmatic bottom-scroll and that scroll's
* event, a naive "near the bottom?" recompute reads the already-grown height
* and wrongly concludes the user scrolled away — freezing follow for the rest
* of the turn. Programmatic scrolls are therefore counted (`pending`) and
* their events consumed as "still sticking"; only genuinely user-initiated
* scroll events recompute stickiness from the distance to the bottom.
*
* @param {{pending:number, stick:boolean}} state Current stick state.
* @param {number} distanceFromBottom scrollHeight - scrollTop - clientHeight at event time.
* @returns {{pending:number, stick:boolean}} The next state.
*/
export function reduceScrollEvent(state, distanceFromBottom) {
if (state.pending > 0) return { pending: state.pending - 1, stick: true };
return { pending: 0, stick: distanceFromBottom < 80 };
}

/**
* Steps shown to the user: drops the "Working on it" placeholder frame, which
* only exists to drive the immediate thinking indicator (see Bubble) and is
Expand Down Expand Up @@ -452,7 +476,7 @@ export function ChatPanel({ resetNonce = 0, onTopicPublished }) {
const [draftBusy, setDraftBusy] = useState(false);
const composerRef = useRef(null);
const threadRef = useRef(null);
const stickToBottomRef = useRef(true);
const stickToBottomRef = useRef({ pending: 0, stick: true });
const bottomRef = useRef(null);
const userInteractedRef = useRef(false);

Expand All @@ -470,22 +494,44 @@ export function ChatPanel({ resetNonce = 0, onTopicPublished }) {
// Track whether the user is parked near the bottom, via real scroll events
// rather than recomputing from post-update scrollHeight (which would
// already reflect newly streamed content and read as "far from bottom").
// Programmatic bottom-scrolls are counted and consumed by reduceScrollEvent
// so a delta rendering between our scroll and its (async) event can't read
// as "the user scrolled away" and freeze follow mid-turn.
useEffect(() => {
const el = threadRef.current;
if (!el) return undefined;
const onScroll = () => {
stickToBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
stickToBottomRef.current = reduceScrollEvent(
stickToBottomRef.current,
el.scrollHeight - el.scrollTop - el.clientHeight,
);
};
// A wheel gesture is unambiguously the user: drop queued programmatic
// consumptions so the scroll event that follows recomputes stickiness.
const onWheel = () => {
stickToBottomRef.current = { ...stickToBottomRef.current, pending: 0 };
};
el.addEventListener('scroll', onScroll);
return () => el.removeEventListener('scroll', onScroll);
el.addEventListener('wheel', onWheel, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
el.removeEventListener('wheel', onWheel);
};
}, []);

// Stick to the bottom as new turns/deltas arrive, unless the user scrolled
// up to read history.
useEffect(() => {
const el = threadRef.current;
if (!el || !stickToBottomRef.current) return;
el.scrollTop = el.scrollHeight;
if (!el || !stickToBottomRef.current.stick) return;
const target = el.scrollHeight - el.clientHeight;
if (target - el.scrollTop > 1) {
stickToBottomRef.current = {
...stickToBottomRef.current,
pending: stickToBottomRef.current.pending + 1,
};
el.scrollTop = target;
}
// The thread may not be the scrolling ancestor (e.g. the admin page body
// scrolls instead); a sentinel scrollIntoView follows whichever it is.
bottomRef.current?.scrollIntoView({ block: 'nearest' });
Expand All @@ -501,6 +547,7 @@ export function ChatPanel({ resetNonce = 0, onTopicPublished }) {
const next = rehydratedTurns(cur[0], res);
return next ?? cur;
});
if (rehydratedTopicWorthy(res)) setTopicWorthy(true);
})();
return () => { cancelled = true; };
}, []);
Expand All @@ -515,13 +562,17 @@ export function ChatPanel({ resetNonce = 0, onTopicPublished }) {
userInteractedRef.current = true;
const text = (textOverride ?? composer).trim();
if (!text || busy || draftBusy) return;
// Sending a message is an unambiguous "I want to see the reply": re-stick
// regardless of any stale unstick from an earlier turn's scrolling.
stickToBottomRef.current = { pending: 0, stick: true };
setComposer('');
await sendTurn(text, { setTurns, setBusy, onSignal: handleSignal });
}

async function newTopic() {
userInteractedRef.current = true;
if (busy || draftBusy) return;
stickToBottomRef.current = { pending: 0, stick: true };
setBusy(true);
const res = await resetChat();
setBusy(false);
Expand Down
6 changes: 5 additions & 1 deletion assets/src/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ export function eventsToTurn(events) {
const turn = { reply: '', steps: [], outcome: null, error: false };
const list = Array.isArray(events) ? events : [];
for (const ev of list) {
if (ev.type === 'assistant_text' && typeof ev.data?.text === 'string') turn.reply += ev.data.text;
// Each assistant_text event is one complete text block; join blocks with a
// blank line so consecutive blocks never concatenate mid-sentence.
if (ev.type === 'assistant_text' && typeof ev.data?.text === 'string') {
turn.reply = turn.reply ? `${turn.reply}\n\n${ev.data.text}` : ev.data.text;
}
else if (ev.type === 'error') turn.error = true;
else if (ev.type === 'result') {
turn.outcome = ev.data || null;
Expand Down
20 changes: 20 additions & 0 deletions assets/src/rehydrate.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,23 @@ export function rehydratedTurns(greeting, res) {
if (!turns.length) return null;
return [{ ...greeting, introChips: false }, ...turns];
}

/**
* Whether the hub's topic-worthy affordance should be restored after a reload.
* The signal is a recorded chat event; without restoring it, a refresh silently
* drops the "Turn into topic" bar while the agent's reply still points at it —
* a dead end (persona-loop run 4). A later `topic_drafted` supersedes it (the
* draft was already made), so only an un-drafted signal restores.
* @param {{events?:Array, error?:object}} res The /history response.
* @returns {boolean}
*/
export function rehydratedTopicWorthy(res) {
if (!res || res.error) return false;
const list = Array.isArray(res.events) ? res.events : [];
let worthy = false;
for (const ev of list) {
if (ev.type === 'topic_worthy') worthy = true;
else if (ev.type === 'topic_drafted') worthy = false;
}
return worthy;
}
24 changes: 23 additions & 1 deletion assets/test/components.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { h } from 'preact';
import { renderToString } from 'preact-render-to-string';
import {
Thread, applyStreamEvent, sendTurn, visibleSteps, stepDuration, reduceSignal, computeShowDraftPrompt,
Thread, applyStreamEvent, sendTurn, visibleSteps, stepDuration, reduceSignal, computeShowDraftPrompt, reduceScrollEvent,
} from '../src/components.jsx';

// Node's test runner has no DOM; components.jsx reads window.RoundtableConfig
Expand Down Expand Up @@ -463,3 +463,25 @@ test('computeShowDraftPrompt in newTopicMode ignores topicWorthy and defers to c
topicDraft: null, newTopicMode: true, topicWorthy: true, turns: [],
}), false);
});

test('applyStreamEvent joins consecutive assistant_text blocks with a blank line in finalText', () => {
const turns = [{ role: 'agent', reply: '', steps: [], error: false }];
let t = applyStreamEvent(turns, { type: 'assistant_text', text: 'Part one.' }, 1000);
t = applyStreamEvent(t, { type: 'assistant_text', text: 'Part two.' }, 1010);
assert.equal(t[0].finalText, 'Part one.\n\nPart two.');
});

test('reduceScrollEvent consumes a pending programmatic scroll as still sticking', () => {
const next = reduceScrollEvent({ pending: 2, stick: true }, 500);
assert.deepEqual(next, { pending: 1, stick: true });
});

test('reduceScrollEvent re-sticks a user scroll near the bottom', () => {
const next = reduceScrollEvent({ pending: 0, stick: false }, 20);
assert.deepEqual(next, { pending: 0, stick: true });
});

test('reduceScrollEvent unsticks a user scroll away from the bottom', () => {
const next = reduceScrollEvent({ pending: 0, stick: true }, 300);
assert.deepEqual(next, { pending: 0, stick: false });
});
6 changes: 3 additions & 3 deletions assets/test/events.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { eventsToTurn } from '../src/events.js';

test('concatenates assistant_text into reply', () => {
test('joins assistant_text blocks into reply with a blank line', () => {
const turn = eventsToTurn([
{ type: 'assistant_text', data: { text: 'Hello ' } },
{ type: 'assistant_text', data: { text: 'Hello' } },
{ type: 'assistant_text', data: { text: 'world' } },
{ type: 'result', data: { subtype: 'success' } },
]);
assert.equal(turn.reply, 'Hello world');
assert.equal(turn.reply, 'Hello\n\nworld');
assert.equal(turn.error, false);
});

Expand Down
25 changes: 24 additions & 1 deletion assets/test/rehydrate.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// assets/test/rehydrate.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { eventsToTurns } from '../src/rehydrate.js';
import {eventsToTurns, rehydratedTopicWorthy } from '../src/rehydrate.js';

test('splits user_text and agent turns', () => {
const turns = eventsToTurns([
Expand Down Expand Up @@ -31,3 +31,26 @@ test('trailing agent events without a result still form a turn', () => {
assert.equal(turns.length, 2);
assert.equal(turns[1].reply, 'partial');
});

test('rehydratedTopicWorthy restores an un-drafted topic_worthy signal', () => {
const res = { events: [
{ type: 'user_text', data: { text: 'bug!' } },
{ type: 'topic_worthy', data: {} },
{ type: 'result', data: {} },
] };
assert.equal(rehydratedTopicWorthy(res), true);
});

test('rehydratedTopicWorthy is false when a later topic_drafted superseded it', () => {
const res = { events: [
{ type: 'topic_worthy', data: {} },
{ type: 'topic_drafted', data: { case_id: 'c1' } },
] };
assert.equal(rehydratedTopicWorthy(res), false);
});

test('rehydratedTopicWorthy is false on empty or error responses', () => {
assert.equal(rehydratedTopicWorthy({ events: [] }), false);
assert.equal(rehydratedTopicWorthy({ error: { kind: 'http' } }), false);
assert.equal(rehydratedTopicWorthy(null), false);
});
Loading