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
+});