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
7 changes: 7 additions & 0 deletions apps/web/src/domains/chat/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,11 @@ export function ChatPage() {
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const messagesRef = useRef<DisplayMessage[]>(messages);
messagesRef.current = messages;
Comment on lines 249 to 250
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this ref anymore?

// Populated by `chat-route-content.tsx` right after the render-boundary
// `useMemo(() => sanitizeDisplayMessages(messages))`. Owned here so
// `useChatDebugApi` (mounted in this component) can read the same array
// the UI is rendering without re-running the pipeline.
Comment on lines +251 to +254
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete

const sanitizedMessagesRef = useRef<DisplayMessage[]>([]);
// Owned here so `useChatDebugApi` (also called from this component) can
// read scroll geometry directly via `transcriptRef.current.getScrollElement()`.
// Threaded down to ChatRouteContent through the `refs` prop and bound on
Expand Down Expand Up @@ -891,6 +896,7 @@ export function ChatPage() {
// by which point initialization is complete.
useChatDebugApi({
messagesRef,
sanitizedMessagesRef,
transcriptRef,
streamContextRef,
streamRef,
Expand Down Expand Up @@ -1559,6 +1565,7 @@ export function ChatPage() {
refs: {
inputRef,
messagesRef,
sanitizedMessagesRef,
activeConversationIdRef,
assistantIdRef,
streamContextRef,
Expand Down
20 changes: 16 additions & 4 deletions apps/web/src/domains/chat/components/chat-route-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,13 @@ export interface AvatarData {
export interface ChatRouteRefs {
inputRef: RefObject<HTMLTextAreaElement | null>;
messagesRef: MutableRefObject<DisplayMessage[]>;
/**
* Mirror of the post-`sanitizeDisplayMessages` array, populated below right
* after the render-boundary `useMemo`. Read by `useChatDebugApi`'s `tail()`
* so DevTools sees exactly what the transcript renders without re-running
* the sanitize pipeline.
*/
Comment on lines +221 to +226
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete

sanitizedMessagesRef: MutableRefObject<DisplayMessage[]>;
activeConversationIdRef: MutableRefObject<string | null>;
assistantIdRef: MutableRefObject<string | null>;
streamContextRef: MutableRefObject<StreamContext | null>;
Expand Down Expand Up @@ -503,6 +510,7 @@ export function ChatRouteContent({
const {
inputRef,
messagesRef,
sanitizedMessagesRef,
activeConversationIdRef: _activeConversationIdRef,
assistantIdRef: _assistantIdRef,
streamContextRef,
Expand Down Expand Up @@ -780,15 +788,19 @@ export function ChatRouteContent({
// transcript renders (timestamp sort, blank/phantom row filter, duplicate
// trailing assistant drop). See `sanitize-display-messages.ts` for the
// rationale and removal triggers for each sub-step.
const sortedMessages = useMemo(
const sanitizedMessages = useMemo(
() => sanitizeDisplayMessages(messages),
[messages],
);

// Mirror into a ref so `useChatDebugApi`'s `tail()` can read what the
// UI is actually rendering without re-running the pipeline.
Comment on lines 795 to +797
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Mirror into a ref so `useChatDebugApi`'s `tail()` can read what the
// UI is actually rendering without re-running the pipeline.

sanitizedMessagesRef.current = sanitizedMessages;

const transcriptItems = useMemo(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

introduce another ref to track transcript items

() =>
buildTranscriptItems({
messages: sortedMessages,
messages: sanitizedMessages,
pendingSecret: pendingSecret
? { requestId: pendingSecret.requestId }
: null,
Expand All @@ -812,7 +824,7 @@ export function ChatRouteContent({
showOnboardingChoice,
}),
[
sortedMessages,
sanitizedMessages,
pendingSecret,
pendingConfirmation,
inlineConfirmationAttached,
Expand Down Expand Up @@ -1325,7 +1337,7 @@ export function ChatRouteContent({
<SlackChannelFooter
assistantId={assistantId ?? undefined}
conversation={activeConversation}
messages={sortedMessages}
messages={sanitizedMessages}
/>
);

Expand Down
3 changes: 0 additions & 3 deletions apps/web/src/domains/chat/transcript/build-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,6 @@ export interface BuildTranscriptItemsInput {
* d. `ErrorItem` when `errorNotice` is a non-empty string.
*
* Every returned item carries a non-empty, distinct `key`.
*
* `messages` must already be sanitized — phantom/blank row filtering and
* trailing-duplicate drops happen upstream in `sanitizeDisplayMessages`.
*/
export function buildTranscriptItems(
input: BuildTranscriptItemsInput,
Expand Down
50 changes: 39 additions & 11 deletions apps/web/src/domains/chat/utils/debug-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ function makeRefs(
};
return {
messagesRef: { current: [] } as MutableRefObject<DisplayMessage[]>,
sanitizedMessagesRef: { current: [] } as MutableRefObject<DisplayMessage[]>,
transcriptRef: { current: null as TranscriptHandle | null },
streamContextRef: { current: null } as MutableRefObject<{
assistantId: string;
Expand Down Expand Up @@ -125,18 +126,18 @@ function makeRefs(
// ---------------------------------------------------------------------------

describe("createChatDebugApi.tail", () => {
test("empty messagesRef → empty tail", () => {
test("empty sanitizedMessagesRef → empty tail", () => {
const api = createChatDebugApi(makeRefs());
const result = api.tail();
expect(result).toEqual([]);
});

test("returns the underlying DisplayMessage objects untouched", () => {
const message = fakeDisplayMessage({ content: "hello world" });
const messagesRef = {
const sanitizedMessagesRef = {
current: [message],
} as MutableRefObject<DisplayMessage[]>;
const api = createChatDebugApi(makeRefs({ messagesRef }));
const api = createChatDebugApi(makeRefs({ sanitizedMessagesRef }));
const result = api.tail();
expect(result).toHaveLength(1);
// Identity check — debug API must NOT project to a bespoke shape.
Expand All @@ -147,8 +148,10 @@ describe("createChatDebugApi.tail", () => {
const items: DisplayMessage[] = Array.from({ length: 30 }, (_, i) =>
fakeDisplayMessage({ stableId: `msg-${i}`, id: `id-${i}` }),
);
const messagesRef = { current: items } as MutableRefObject<DisplayMessage[]>;
const api = createChatDebugApi(makeRefs({ messagesRef }));
const sanitizedMessagesRef = {
current: items,
} as MutableRefObject<DisplayMessage[]>;
const api = createChatDebugApi(makeRefs({ sanitizedMessagesRef }));
const result = api.tail(5);
expect(result).toHaveLength(5);
expect(result[0]!.stableId).toBe("msg-25");
Expand All @@ -159,8 +162,10 @@ describe("createChatDebugApi.tail", () => {
const items: DisplayMessage[] = Array.from({ length: 30 }, (_, i) =>
fakeDisplayMessage({ stableId: `msg-${i}`, id: `id-${i}` }),
);
const messagesRef = { current: items } as MutableRefObject<DisplayMessage[]>;
const api = createChatDebugApi(makeRefs({ messagesRef }));
const sanitizedMessagesRef = {
current: items,
} as MutableRefObject<DisplayMessage[]>;
const api = createChatDebugApi(makeRefs({ sanitizedMessagesRef }));
const result = api.tail();
expect(result).toHaveLength(20);
expect(result[0]!.stableId).toBe("msg-10");
Expand All @@ -170,8 +175,10 @@ describe("createChatDebugApi.tail", () => {
const items: DisplayMessage[] = Array.from({ length: 5 }, (_, i) =>
fakeDisplayMessage({ stableId: `msg-${i}`, id: `id-${i}` }),
);
const messagesRef = { current: items } as MutableRefObject<DisplayMessage[]>;
const api = createChatDebugApi(makeRefs({ messagesRef }));
const sanitizedMessagesRef = {
current: items,
} as MutableRefObject<DisplayMessage[]>;
const api = createChatDebugApi(makeRefs({ sanitizedMessagesRef }));
const result = api.tail(20);
expect(result).toHaveLength(5);
expect(result[0]!.stableId).toBe("msg-0");
Expand All @@ -181,12 +188,33 @@ describe("createChatDebugApi.tail", () => {
const items: DisplayMessage[] = Array.from({ length: 30 }, (_, i) =>
fakeDisplayMessage({ stableId: `msg-${i}`, id: `id-${i}` }),
);
const messagesRef = { current: items } as MutableRefObject<DisplayMessage[]>;
const api = createChatDebugApi(makeRefs({ messagesRef }));
const sanitizedMessagesRef = {
current: items,
} as MutableRefObject<DisplayMessage[]>;
const api = createChatDebugApi(makeRefs({ sanitizedMessagesRef }));
expect(api.tail(-1)).toHaveLength(20);
expect(api.tail(NaN)).toHaveLength(20);
expect(api.tail(Infinity)).toHaveLength(20);
});

test("reads from sanitizedMessagesRef, NOT raw messagesRef", () => {
// tail() is logic-free — it surfaces whatever the render path
// already wrote to `sanitizedMessagesRef`. Raw `messagesRef` is
// intentionally ignored so DevTools always mirrors the UI.
const rawOnly = fakeDisplayMessage({ stableId: "raw-only" });
const sanitizedOnly = fakeDisplayMessage({ stableId: "sanitized-only" });
const api = createChatDebugApi(
makeRefs({
messagesRef: { current: [rawOnly] } as MutableRefObject<DisplayMessage[]>,
sanitizedMessagesRef: {
current: [sanitizedOnly],
} as MutableRefObject<DisplayMessage[]>,
}),
);
const result = api.tail();
expect(result).toHaveLength(1);
expect(result[0]!.stableId).toBe("sanitized-only");
});
});

// ---------------------------------------------------------------------------
Expand Down
21 changes: 12 additions & 9 deletions apps/web/src/domains/chat/utils/debug-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ import type {
} from "@/domains/chat/types/chat-ui-types.js";
import { recordChatDiagnostic } from "@/domains/chat/utils/diagnostics.js";
import type { DisplayMessage } from "@/domains/chat/utils/reconcile.js";
import { sanitizeDisplayMessages } from "@/domains/chat/utils/sanitize-display-messages.js";
import type { ReconcileActiveConversationResult } from "@/domains/chat/hooks/use-message-reconciliation.js";
import {
classifyScrollPosition,
Expand Down Expand Up @@ -301,6 +300,13 @@ const CHAT_NS = "chat";
*/
export interface ChatDebugRefs {
messagesRef: MutableRefObject<DisplayMessage[]>;
/**
* Post-`sanitizeDisplayMessages` snapshot — exactly what the transcript
* renders. Populated by `chat-route-content.tsx` right after the render
* boundary `useMemo`. `tail()` reads from this so DevTools mirrors the
* UI without re-running the sanitize pipeline.
*/
sanitizedMessagesRef: MutableRefObject<DisplayMessage[]>;
/**
* Ref to the mounted `<Transcript />` imperative handle. Used by
* {@link ChatDebugApi.getScrollState} to read scroll geometry directly
Expand Down Expand Up @@ -380,13 +386,9 @@ export function createChatDebugApi(refs: ChatDebugRefs): ChatDebugApi {
Number.isFinite(limit) && limit > 0
? Math.floor(limit)
: DEFAULT_TAIL_LIMIT;
// Sanitize so `tail()` returns the same array the UI ends up rendering
// (the chat-route render path also pipes `messages` through
// `sanitizeDisplayMessages`). Without this we'd surface trailing
// duplicates / blank rows that the UI is intentionally filtering out,
// which would be misleading when triaging "why does my chat look like X"
// reports.
const messages = sanitizeDisplayMessages(refs.messagesRef.current ?? []);
// Read straight from the post-sanitization snapshot the render path
// already wrote. `tail()` is now logic-free — same array the UI iterates.
const messages = refs.sanitizedMessagesRef.current ?? [];
const startIndex = Math.max(0, messages.length - safeLimit);
return messages.slice(startIndex);
}
Expand Down Expand Up @@ -618,7 +620,7 @@ export function createChatDebugApi(refs: ChatDebugRefs): ChatDebugApi {
const lines = [
"window._vellumDebug.chat — surgical chat debug API",
"",
" .tail(n?) last N DisplayMessage[] from messagesRef — raw UI shape",
" .tail(n?) last N DisplayMessage[] the UI is rendering",
" .thinkingIndicator() live evaluation of the `...` predicate + done signal",
" .visible / .failingConditions tell you why dots are or aren't showing",
" .done.terminal / .done.lastTerminalReason tell you if the turn is finished",
Expand Down Expand Up @@ -729,6 +731,7 @@ export function useChatDebugApi(refs: ChatDebugRefs): void {
useEffect(() => {
const stableRefs: ChatDebugRefs = {
messagesRef: refs.messagesRef,
sanitizedMessagesRef: refs.sanitizedMessagesRef,
transcriptRef: refs.transcriptRef,
streamContextRef: refs.streamContextRef,
streamRef: refs.streamRef,
Expand Down
34 changes: 11 additions & 23 deletions apps/web/src/domains/chat/utils/sanitize-display-messages.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// -----------------------------------------------------------------------------
// sanitizeDisplayMessages — single home for "this shouldn't be necessary,
// but is" frontend cleanup applied to DisplayMessage[] before the transcript
// renders (and before `window._vellumDebug.chat.tail()` returns).
// renders.
//
// Every sub-method below patches over an upstream issue. They are SHORT TERM
// and should be removed as the assistant backend stabilises the corresponding
Expand All @@ -13,25 +13,15 @@
import { sortedByTimestamp } from "@/domains/chat/utils/message-sorting.js";
import type { DisplayMessage } from "@/domains/chat/types/types.js";

/**
* Apply every render-boundary hack to `messages` in a fixed order and return
* a new array (never mutates the input).
*
* Pipeline:
* 1. `sortByTimestamp` (Hack #1)
* 2. `removeInvalidMessages` (Hack #2)
* 3. `removeDuplicateTrailingAssistant` (Hack #3)
*
* Each step is independent — removing any one when the corresponding upstream
* bug is fixed is a one-line edit.
*/
export function sanitizeDisplayMessages(
messages: DisplayMessage[],
): DisplayMessage[] {
let result = sortByTimestamp(messages);
result = removeInvalidMessages(result);
result = removeDuplicateTrailingAssistant(result);
return result;
const pipeline = [
sortedByTimestamp,
removeInvalidMessages,
removeDuplicateTrailingAssistant,
];
return pipeline.reduce((msgs, step) => step(msgs), messages);
}

// -----------------------------------------------------------------------------
Expand All @@ -53,9 +43,7 @@ export function sanitizeDisplayMessages(
// SHORT TERM until: the assistant backend merges multi-row clusters
// server-side so the client never sees the fragmented rows.
// -----------------------------------------------------------------------------
function sortByTimestamp(messages: DisplayMessage[]): DisplayMessage[] {
return sortedByTimestamp(messages);
}
// (Implementation: `sortedByTimestamp`, imported at the top.)
Comment on lines -56 to +46
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok we are using this in reconcileMessages, which we plan to cleanup, simplify, and remove in another convo. for now, let's add back the wrapper method we had here until we are ready to delete reconcileMessages


// -----------------------------------------------------------------------------
// Hack #2 — drop blank / phantom user rows
Expand All @@ -66,12 +54,12 @@ function sortByTimestamp(messages: DisplayMessage[]): DisplayMessage[] {
// user rows even when their parent `tool_use` lives on a previous page
// (to avoid permanent data loss). The daemon's renderer then drops the
// orphan `tool_result` block, leaving a blank user bubble on the wire.
// - The daemon synthesises tool calls with `toolName === "unknown"` when a
// - The assistant synthesises tool calls with `toolName === "unknown"` when a
// `tool_result` has no matching `tool_use`. Those arrive as empty user
// messages whose only payload is a list of "unknown" tools and would
// otherwise render as a confusing "Used unknown" chip (ATL-659).
// otherwise render as a confusing "Used unknown" chip.
//
// SHORT TERM until: the daemon stops emitting orphan `tool_result` rows and
// SHORT TERM until: the assistant stops emitting orphan `tool_result` rows and
// phantom unknown-tool placeholders at history boundaries.
// -----------------------------------------------------------------------------
function removeInvalidMessages(messages: DisplayMessage[]): DisplayMessage[] {
Expand Down