Skip to content
Open
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
64 changes: 61 additions & 3 deletions apps/web/src/components/app-builder/AppBuilderChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ import {
paginateMessages,
getMessageRole,
DEFAULT_VISIBLE_SESSIONS,
tailFromLastUserTurn,
loadEarlierUserTurn,
lastUserIndex,
} from './utils/filterMessages';
import { PromptInput } from '@/components/app-builder/PromptInput';
import { useProject } from './ProjectSession';
Expand Down Expand Up @@ -358,6 +361,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,
Expand All @@ -368,15 +376,58 @@ 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.
const [visibleCount, setVisibleCount] = useState(
() => tailFromLastUserTurn(sessionState.messages, getRole).visibleMessages.length
);

// 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 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<string | null>(null);

useEffect(() => {
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 => Math.min(messages.length, Math.max(prev, messages.length - lastIdx)));
}, [sessionState.messages, getRole]);

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.
Expand All @@ -387,7 +438,7 @@ function V2SessionMessages({
[session, sessionState.childSessionMessages]
);

if (sessionState.messages.length === 0 && !sessionState.isStreaming) {
if (visibleMessages.length === 0 && !sessionState.isStreaming) {
return null;
}

Expand All @@ -397,6 +448,13 @@ function V2SessionMessages({
cloudAgentSessionId={session.info.cloud_agent_session_id}
organizationId={organizationId ?? null}
>
{hasEarlierMessages && (
<div className="flex justify-center py-2">
<Button variant="ghost" size="sm" onClick={handleLoadEarlier}>
Load earlier messages
</Button>
</div>
)}
<V2StaticMessages messages={v2Static} getChildMessages={getChildMessages} />
<V2DynamicMessages messages={v2Dynamic} getChildMessages={getChildMessages} />
{sessionState.isStreaming && v2Dynamic.length === 0 && <TypingIndicator />}
Expand Down
162 changes: 162 additions & 0 deletions apps/web/src/components/app-builder/utils/filterMessages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* Tests for V2 tail-pagination helpers in filterMessages.ts.
*/

import type { CloudMessage } from '@/components/cloud-agent/types';
import {
tailFromLastUserTurn,
loadEarlierUserTurn,
paginateMessages,
getMessageRole,
lastUserIndex,
} 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('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);
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('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(
'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);
});
});
79 changes: 79 additions & 0 deletions apps/web/src/components/app-builder/utils/filterMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,85 @@ export function filterAppBuilderMessages(messages: CloudMessage[]): CloudMessage
return deduplicated;
}

/**
* Result type for tail-based pagination (V2 sessions).
*/
export type TailPaginationResult<T> = {
visibleMessages: T[];
hasEarlierMessages: boolean;
};

type RoleChecker<T> = (msg: T) => 'user' | 'assistant' | 'system';

/**
* Find the index of the last user-role message in the array, or -1 if none.
*/
export function lastUserIndex<T>(messages: T[], isUser: RoleChecker<T>): 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<CloudMessage>;
export function tailFromLastUserTurn<T>(
messages: T[],
isUser: RoleChecker<T>
): TailPaginationResult<T>;
export function tailFromLastUserTurn<T>(
messages: T[],
isUser?: RoleChecker<T>
): TailPaginationResult<T> {
const checker: RoleChecker<T> = isUser ?? (getMessageRole as RoleChecker<T>);
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<T>(
messages: T[],
currentVisibleCount: number,
isUser: RoleChecker<T>
): { visibleCount: number; hasEarlierMessages: boolean };
export function loadEarlierUserTurn<T>(
messages: T[],
currentVisibleCount: number,
isUser?: RoleChecker<T>
): { visibleCount: number; hasEarlierMessages: boolean } {
const checker: RoleChecker<T> = isUser ?? (getMessageRole as RoleChecker<T>);
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.
Expand Down