From 87b606d4f5ab8626cd964fa3aca0c77c59c3e2cc Mon Sep 17 00:00:00 2001 From: wombatepiclandingstudio <163024221+wombatepiclandingstudio@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:12:33 +0000 Subject: [PATCH 1/3] fix(app-builder): show newest V2 messages first with load-earlier option Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../components/app-builder/AppBuilderChat.tsx | 48 ++++++- .../app-builder/utils/filterMessages.test.ts | 127 ++++++++++++++++++ .../app-builder/utils/filterMessages.ts | 79 +++++++++++ 3 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/app-builder/utils/filterMessages.test.ts diff --git a/apps/web/src/components/app-builder/AppBuilderChat.tsx b/apps/web/src/components/app-builder/AppBuilderChat.tsx index 55f6713c4f..9570c794b7 100644 --- a/apps/web/src/components/app-builder/AppBuilderChat.tsx +++ b/apps/web/src/components/app-builder/AppBuilderChat.tsx @@ -41,6 +41,8 @@ import { paginateMessages, getMessageRole, DEFAULT_VISIBLE_SESSIONS, + tailFromLastUserTurn, + loadEarlierUserTurn, } from './utils/filterMessages'; import { PromptInput } from '@/components/app-builder/PromptInput'; import { useProject } from './ProjectSession'; @@ -358,6 +360,11 @@ function V1SessionMessages({ /** * Renders a V2 session's messages. + * + * Renders only the tail of the conversation (from the last user turn onwards) + * with a 'Load earlier messages' button at the top that reveals one older + * user turn per click. The visible window is local component state, not part + * of the session store, so it resets to the tail on session change. */ function V2SessionMessages({ session, @@ -368,15 +375,43 @@ function V2SessionMessages({ }) { const sessionState = useSyncExternalStore(session.subscribe, session.getState); + const getRole = useCallback((m: StoredMessage) => m.info.role, []); + + // Visible window = last N messages. Initialized to "from the last user + // turn to the end" so the user lands on the latest exchange. Re-anchored + // to the current tail whenever the active session id changes. + const [visibleCount, setVisibleCount] = useState( + () => tailFromLastUserTurn(sessionState.messages, getRole).visibleMessages.length + ); + + useEffect(() => { + setVisibleCount(tailFromLastUserTurn(sessionState.messages, getRole).visibleMessages.length); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session.info.id]); + + useEffect(() => { + setVisibleCount(prev => Math.min(prev, sessionState.messages.length)); + }, [sessionState.messages.length]); + + const handleLoadEarlier = useCallback(() => { + setVisibleCount(prev => loadEarlierUserTurn(sessionState.messages, prev, getRole).visibleCount); + }, [sessionState.messages, getRole]); + + const visibleMessages = useMemo( + () => sessionState.messages.slice(-visibleCount), + [sessionState.messages, visibleCount] + ); + const hasEarlierMessages = visibleCount < sessionState.messages.length; + // Contiguous-prefix split: prevents stale streaming messages from old // executions from being reordered below newer complete messages. const { v2Static, v2Dynamic } = useMemo(() => { const { staticItems, dynamicItems } = splitByContiguousPrefix( - sessionState.messages, + visibleMessages, msg => !isMessageStreaming(msg) ); return { v2Static: staticItems, v2Dynamic: dynamicItems }; - }, [sessionState.messages]); + }, [visibleMessages]); // Identity changes when childSessionMessages changes, which forces memo'd // V2StaticMessages to re-render with updated child session data. @@ -387,7 +422,7 @@ function V2SessionMessages({ [session, sessionState.childSessionMessages] ); - if (sessionState.messages.length === 0 && !sessionState.isStreaming) { + if (visibleMessages.length === 0 && !sessionState.isStreaming) { return null; } @@ -397,6 +432,13 @@ function V2SessionMessages({ cloudAgentSessionId={session.info.cloud_agent_session_id} organizationId={organizationId ?? null} > + {hasEarlierMessages && ( +
+ +
+ )} {sessionState.isStreaming && v2Dynamic.length === 0 && } diff --git a/apps/web/src/components/app-builder/utils/filterMessages.test.ts b/apps/web/src/components/app-builder/utils/filterMessages.test.ts new file mode 100644 index 0000000000..8b102cca57 --- /dev/null +++ b/apps/web/src/components/app-builder/utils/filterMessages.test.ts @@ -0,0 +1,127 @@ +/** + * Tests for V2 tail-pagination helpers in filterMessages.ts. + */ + +import type { CloudMessage } from '@/components/cloud-agent/types'; +import { + tailFromLastUserTurn, + loadEarlierUserTurn, + paginateMessages, + getMessageRole, +} from './filterMessages'; + +function msg(ts: number, type: 'user' | 'assistant' | 'system', say?: string): CloudMessage { + return { + ts, + type, + say, + text: `${type}-${ts}`, + partial: false, + }; +} + +function userMsg(ts: number, say = 'user_feedback'): CloudMessage { + return { ts, type: 'user', say, text: `user-${ts}`, partial: false }; +} + +describe('tailFromLastUserTurn', () => { + it('returns full list when there is no user message', () => { + const messages = [msg(1, 'assistant'), msg(2, 'assistant')]; + const result = tailFromLastUserTurn(messages); + expect(result.visibleMessages).toEqual(messages); + expect(result.hasEarlierMessages).toBe(false); + }); + + it('returns full list when the first message is a user message', () => { + const messages = [userMsg(1), msg(2, 'assistant')]; + const result = tailFromLastUserTurn(messages); + expect(result.visibleMessages).toEqual(messages); + expect(result.hasEarlierMessages).toBe(false); + }); + + it('slices from the last user message when earlier messages exist', () => { + const messages = [ + userMsg(1), + msg(2, 'assistant'), + userMsg(3), + msg(4, 'assistant'), + msg(5, 'assistant'), + ]; + const result = tailFromLastUserTurn(messages); + expect(result.visibleMessages).toEqual(messages.slice(2)); + expect(result.hasEarlierMessages).toBe(true); + }); + + it('treats user_feedback as a user message', () => { + const messages = [msg(1, 'user', 'text'), msg(2, 'assistant')]; + const result = tailFromLastUserTurn(messages); + expect(result.visibleMessages).toEqual(messages); + expect(result.hasEarlierMessages).toBe(false); + }); +}); + +describe('loadEarlierUserTurn', () => { + it('is a no-op when nothing is hidden', () => { + const messages = [userMsg(1), msg(2, 'assistant')]; + const result = loadEarlierUserTurn(messages, messages.length); + expect(result.visibleCount).toBe(messages.length); + expect(result.hasEarlierMessages).toBe(false); + }); + + it('grows the visible window by exactly one prior user turn', () => { + const messages = [ + userMsg(1), + msg(2, 'assistant'), + userMsg(3), + msg(4, 'assistant'), + userMsg(5), + msg(6, 'assistant'), + ]; + const initial = tailFromLastUserTurn(messages).visibleMessages.length; + expect(initial).toBe(2); + + const step1 = loadEarlierUserTurn(messages, initial); + expect(step1.visibleCount).toBe(4); + expect(step1.hasEarlierMessages).toBe(true); + + const step2 = loadEarlierUserTurn(messages, step1.visibleCount); + expect(step2.visibleCount).toBe(messages.length); + expect(step2.hasEarlierMessages).toBe(false); + }); + + it('falls back to the full list when no prior user turn exists', () => { + const messages = [msg(1, 'assistant'), userMsg(2), msg(3, 'assistant')]; + const result = loadEarlierUserTurn(messages, 2); + expect(result.visibleCount).toBe(messages.length); + expect(result.hasEarlierMessages).toBe(false); + }); +}); + +describe('getMessageRole', () => { + it('returns user for user_feedback messages', () => { + expect(getMessageRole({ ts: 1, type: 'assistant', say: 'user_feedback' } as CloudMessage)).toBe( + 'user' + ); + }); + + it('returns the raw type otherwise', () => { + expect(getMessageRole({ ts: 1, type: 'assistant' } as CloudMessage)).toBe('assistant'); + expect(getMessageRole({ ts: 1, type: 'system' } as CloudMessage)).toBe('system'); + }); +}); + +describe('paginateMessages (V1 helper, regression)', () => { + it('keeps the last N user sessions when more exist', () => { + const messages = [ + userMsg(1), + msg(2, 'assistant'), + userMsg(3), + msg(4, 'assistant'), + userMsg(5), + msg(6, 'assistant'), + ]; + const result = paginateMessages(messages, 2); + expect(result.visibleMessages).toEqual(messages.slice(4)); + expect(result.hasOlderMessages).toBe(true); + }); +}); diff --git a/apps/web/src/components/app-builder/utils/filterMessages.ts b/apps/web/src/components/app-builder/utils/filterMessages.ts index 1cddba83f6..5528cacc73 100644 --- a/apps/web/src/components/app-builder/utils/filterMessages.ts +++ b/apps/web/src/components/app-builder/utils/filterMessages.ts @@ -162,6 +162,85 @@ export function filterAppBuilderMessages(messages: CloudMessage[]): CloudMessage return deduplicated; } +/** + * Result type for tail-based pagination (V2 sessions). + */ +export type TailPaginationResult = { + visibleMessages: T[]; + hasEarlierMessages: boolean; +}; + +type RoleChecker = (msg: T) => 'user' | 'assistant' | 'system'; + +/** + * Find the index of the last user-role message in the array, or -1 if none. + */ +function lastUserIndex(messages: T[], isUser: RoleChecker): number { + for (let i = messages.length - 1; i >= 0; i--) { + if (isUser(messages[i]) === 'user') return i; + } + return -1; +} + +/** + * Initial visible window for V2 sessions: from the last user message to the + * end of the array. If no user message exists, the full list is shown. + */ +export function tailFromLastUserTurn(messages: CloudMessage[]): TailPaginationResult; +export function tailFromLastUserTurn( + messages: T[], + isUser: RoleChecker +): TailPaginationResult; +export function tailFromLastUserTurn( + messages: T[], + isUser?: RoleChecker +): TailPaginationResult { + const checker: RoleChecker = isUser ?? (getMessageRole as RoleChecker); + const idx = lastUserIndex(messages, checker); + if (idx < 0) { + return { visibleMessages: messages, hasEarlierMessages: false }; + } + return { + visibleMessages: messages.slice(idx), + hasEarlierMessages: idx > 0, + }; +} + +/** + * Grow the visible window backwards by exactly one user turn. + * Returns the new visible count and whether older messages remain. + */ +export function loadEarlierUserTurn( + messages: CloudMessage[], + currentVisibleCount: number +): { visibleCount: number; hasEarlierMessages: boolean }; +export function loadEarlierUserTurn( + messages: T[], + currentVisibleCount: number, + isUser: RoleChecker +): { visibleCount: number; hasEarlierMessages: boolean }; +export function loadEarlierUserTurn( + messages: T[], + currentVisibleCount: number, + isUser?: RoleChecker +): { visibleCount: number; hasEarlierMessages: boolean } { + const checker: RoleChecker = isUser ?? (getMessageRole as RoleChecker); + const hiddenCount = Math.max(0, messages.length - currentVisibleCount); + if (hiddenCount <= 0) { + return { visibleCount: currentVisibleCount, hasEarlierMessages: false }; + } + const hidden = messages.slice(0, hiddenCount); + const priorUserIdx = lastUserIndex(hidden, checker); + if (priorUserIdx < 0) { + return { visibleCount: messages.length, hasEarlierMessages: false }; + } + const newVisibleCount = messages.length - priorUserIdx; + return { + visibleCount: newVisibleCount, + hasEarlierMessages: priorUserIdx > 0, + }; +} + /** * Paginate messages by "sessions" - each session is a user message * plus all assistant/system messages that follow until the next user message. From e841fc97882cf0c16bf1bd9708b08df417ae5292 Mon Sep 17 00:00:00 2001 From: wombatepiclandingstudio <163024221+wombatepiclandingstudio@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:52:39 +0000 Subject: [PATCH 2/3] fix(app-builder): re-anchor V2 tail window on new user turn The previous effect clamped visibleCount downward when messages shrank but never re-derived it on growth, so the window's left edge stayed fixed at the previous turn's boundary. When the user sent a new message, the optimistic user message was appended to the message list but messages.slice(-visibleCount) still showed a stale mix of the previous turn's tail plus the new user message until the assistant reply happened to push the fixed-size window past the boundary. Track the id of the tail-most user message in a ref and re-anchor the window (visibleCount = messages.length - lastUserIdx) whenever that id changes, which only happens when a new user turn is appended. Assistant streaming does not change the last-user-message id, so the window correctly stays anchored to the current turn as the reply streams in. The shrink clamp is preserved via a functional setter so a sudden shrink of the message list cannot leave visibleCount pointing past the end. Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../components/app-builder/AppBuilderChat.tsx | 30 +++++++++---- .../app-builder/utils/filterMessages.test.ts | 43 ++++++++++++++++++- .../app-builder/utils/filterMessages.ts | 2 +- 3 files changed, 64 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/app-builder/AppBuilderChat.tsx b/apps/web/src/components/app-builder/AppBuilderChat.tsx index 9570c794b7..6807c0a7ef 100644 --- a/apps/web/src/components/app-builder/AppBuilderChat.tsx +++ b/apps/web/src/components/app-builder/AppBuilderChat.tsx @@ -43,6 +43,7 @@ import { DEFAULT_VISIBLE_SESSIONS, tailFromLastUserTurn, loadEarlierUserTurn, + lastUserIndex, } from './utils/filterMessages'; import { PromptInput } from '@/components/app-builder/PromptInput'; import { useProject } from './ProjectSession'; @@ -378,20 +379,33 @@ function V2SessionMessages({ const getRole = useCallback((m: StoredMessage) => m.info.role, []); // Visible window = last N messages. Initialized to "from the last user - // turn to the end" so the user lands on the latest exchange. Re-anchored - // to the current tail whenever the active session id changes. + // turn to the end" so the user lands on the latest exchange. const [visibleCount, setVisibleCount] = useState( () => tailFromLastUserTurn(sessionState.messages, getRole).visibleMessages.length ); - useEffect(() => { - setVisibleCount(tailFromLastUserTurn(sessionState.messages, getRole).visibleMessages.length); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [session.info.id]); + // Track the id of the tail-most user message. When it changes, a new user + // turn has been appended (e.g. the optimistic user message on send), so + // re-anchor the window to that new turn. Otherwise just clamp the window + // if the message list shrank below it. + const lastUserMsgIdRef = useRef(null); useEffect(() => { - setVisibleCount(prev => Math.min(prev, sessionState.messages.length)); - }, [sessionState.messages.length]); + const messages = sessionState.messages; + if (messages.length === 0) { + lastUserMsgIdRef.current = null; + setVisibleCount(0); + return; + } + const lastIdx = lastUserIndex(messages, getRole); + const currentLastUserId = lastIdx >= 0 ? messages[lastIdx].info.id : null; + if (currentLastUserId !== null && currentLastUserId !== lastUserMsgIdRef.current) { + lastUserMsgIdRef.current = currentLastUserId; + setVisibleCount(messages.length - lastIdx); + return; + } + setVisibleCount(prev => (prev > messages.length ? messages.length : prev)); + }, [sessionState.messages, getRole]); const handleLoadEarlier = useCallback(() => { setVisibleCount(prev => loadEarlierUserTurn(sessionState.messages, prev, getRole).visibleCount); diff --git a/apps/web/src/components/app-builder/utils/filterMessages.test.ts b/apps/web/src/components/app-builder/utils/filterMessages.test.ts index 8b102cca57..60bee0e7f3 100644 --- a/apps/web/src/components/app-builder/utils/filterMessages.test.ts +++ b/apps/web/src/components/app-builder/utils/filterMessages.test.ts @@ -8,9 +8,14 @@ import { loadEarlierUserTurn, paginateMessages, getMessageRole, + lastUserIndex, } from './filterMessages'; -function msg(ts: number, type: 'user' | 'assistant' | 'system', say?: string): CloudMessage { +function msg( + ts: number, + type: 'user' | 'assistant' | 'system', + say?: string +): CloudMessage { return { ts, type, @@ -52,6 +57,21 @@ describe('tailFromLastUserTurn', () => { expect(result.hasEarlierMessages).toBe(true); }); + it('re-anchors to a newly appended user turn', () => { + // Simulates the optimistic user message landing mid-conversation: + // the previous turn is complete, then a brand-new user message is appended + // before any assistant reply exists yet. The tail should be exactly that + // new user message, not a stale mix of the previous tail. + const messages = [ + userMsg(1), + msg(2, 'assistant'), + userMsg(3), // new turn (optimistic) + ]; + const result = tailFromLastUserTurn(messages); + expect(result.visibleMessages).toEqual([userMsg(3)]); + expect(result.hasEarlierMessages).toBe(true); + }); + it('treats user_feedback as a user message', () => { const messages = [msg(1, 'user', 'text'), msg(2, 'assistant')]; const result = tailFromLastUserTurn(messages); @@ -97,6 +117,25 @@ describe('loadEarlierUserTurn', () => { }); }); +describe('lastUserIndex', () => { + it('returns -1 when no user message exists', () => { + expect(lastUserIndex([msg(1, 'assistant'), msg(2, 'system')], getMessageRole)).toBe(-1); + }); + + it('returns the index of the last user message', () => { + const messages = [userMsg(1), msg(2, 'assistant'), userMsg(3), msg(4, 'assistant')]; + expect(lastUserIndex(messages, getMessageRole)).toBe(2); + }); + + it('treats user_feedback as user', () => { + const messages = [ + msg(1, 'assistant'), + { ts: 2, type: 'user', say: 'user_feedback' } as CloudMessage, + ]; + expect(lastUserIndex(messages, getMessageRole)).toBe(1); + }); +}); + describe('getMessageRole', () => { it('returns user for user_feedback messages', () => { expect(getMessageRole({ ts: 1, type: 'assistant', say: 'user_feedback' } as CloudMessage)).toBe( @@ -124,4 +163,4 @@ describe('paginateMessages (V1 helper, regression)', () => { expect(result.visibleMessages).toEqual(messages.slice(4)); expect(result.hasOlderMessages).toBe(true); }); -}); +}); \ No newline at end of file diff --git a/apps/web/src/components/app-builder/utils/filterMessages.ts b/apps/web/src/components/app-builder/utils/filterMessages.ts index 5528cacc73..41b4b13c43 100644 --- a/apps/web/src/components/app-builder/utils/filterMessages.ts +++ b/apps/web/src/components/app-builder/utils/filterMessages.ts @@ -175,7 +175,7 @@ type RoleChecker = (msg: T) => 'user' | 'assistant' | 'system'; /** * Find the index of the last user-role message in the array, or -1 if none. */ -function lastUserIndex(messages: T[], isUser: RoleChecker): number { +export function lastUserIndex(messages: T[], isUser: RoleChecker): number { for (let i = messages.length - 1; i >= 0; i--) { if (isUser(messages[i]) === 'user') return i; } From 430b1dd3de4d880119a80c1da0d158919602505e Mon Sep 17 00:00:00 2001 From: wombatepiclandingstudio <163024221+wombatepiclandingstudio@users.noreply.github.com> Date: Thu, 9 Jul 2026 06:38:16 +0000 Subject: [PATCH 3/3] fix(app-builder): grow V2 tail window during assistant streaming The previous effect clamped visibleCount downward but never grew it, so on a turn boundary visibleCount was reset to messages.length - lastIdx (typically 1, just the new user message). When the assistant reply then streamed additional message entries (thinking/tool-call/text chunks) at the tail, visibleCount stayed frozen at 1 and slice(-1) returned only the newest chunk, dropping the user's own prompt and earlier chunks of the current turn out of view. Clamp visibleCount to [messages.length - lastIdx, messages.length] during streaming so the window grows with the tail as new chunks arrive, while still preserving any prior Load-earlier expansion and resetting to the new turn's natural tail when a new user message lands. Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- apps/web/src/components/app-builder/AppBuilderChat.tsx | 8 +++++--- .../components/app-builder/utils/filterMessages.test.ts | 8 ++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/app-builder/AppBuilderChat.tsx b/apps/web/src/components/app-builder/AppBuilderChat.tsx index 6807c0a7ef..d77ba5a8be 100644 --- a/apps/web/src/components/app-builder/AppBuilderChat.tsx +++ b/apps/web/src/components/app-builder/AppBuilderChat.tsx @@ -386,8 +386,10 @@ function V2SessionMessages({ // Track the id of the tail-most user message. When it changes, a new user // turn has been appended (e.g. the optimistic user message on send), so - // re-anchor the window to that new turn. Otherwise just clamp the window - // if the message list shrank below it. + // re-anchor the window to that new turn. Otherwise grow the window along + // with the streaming assistant reply (clamped to messages.length and + // never below the natural tail), so the user's prompt and earlier chunks + // of the current turn stay in view. const lastUserMsgIdRef = useRef(null); useEffect(() => { @@ -404,7 +406,7 @@ function V2SessionMessages({ setVisibleCount(messages.length - lastIdx); return; } - setVisibleCount(prev => (prev > messages.length ? messages.length : prev)); + setVisibleCount(prev => Math.min(messages.length, Math.max(prev, messages.length - lastIdx))); }, [sessionState.messages, getRole]); const handleLoadEarlier = useCallback(() => { diff --git a/apps/web/src/components/app-builder/utils/filterMessages.test.ts b/apps/web/src/components/app-builder/utils/filterMessages.test.ts index 60bee0e7f3..8be012af18 100644 --- a/apps/web/src/components/app-builder/utils/filterMessages.test.ts +++ b/apps/web/src/components/app-builder/utils/filterMessages.test.ts @@ -11,11 +11,7 @@ import { lastUserIndex, } from './filterMessages'; -function msg( - ts: number, - type: 'user' | 'assistant' | 'system', - say?: string -): CloudMessage { +function msg(ts: number, type: 'user' | 'assistant' | 'system', say?: string): CloudMessage { return { ts, type, @@ -163,4 +159,4 @@ describe('paginateMessages (V1 helper, regression)', () => { expect(result.visibleMessages).toEqual(messages.slice(4)); expect(result.hasOlderMessages).toBe(true); }); -}); \ No newline at end of file +});