Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "./buildConversationItems";
import { GitActionMessage } from "./GitActionMessage";
import { GitActionResult } from "./GitActionResult";
import { mergeConversationItems } from "./mergeConversationItems";
import { SessionFooter } from "./SessionFooter";
import { QueuedMessageView } from "./session-update/QueuedMessageView";
import {
Expand Down Expand Up @@ -117,13 +118,18 @@ export function ConversationView({
[queuedMessages],
);

const items = useMemo<ConversationItem[]>(() => {
const result: ConversationItem[] = [
...conversationItems,
...optimisticItems,
];
return queuedItems.length > 0 ? [...result, ...queuedItems] : result;
}, [conversationItems, optimisticItems, queuedItems]);
const isCloud = session?.isCloud ?? false;

const items = useMemo<ConversationItem[]>(
() =>
mergeConversationItems({
conversationItems,
optimisticItems,
queuedItems,
isCloud,
}),
[conversationItems, optimisticItems, queuedItems, isCloud],
);

// Keep MCP App tool call items mounted so their iframes and bridges
// survive scrolling out of the virtualized viewport.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { QueuedMessage } from "@features/sessions/stores/sessionStore";
import { describe, expect, it } from "vitest";
import type { ConversationItem } from "./buildConversationItems";
import { mergeConversationItems } from "./mergeConversationItems";

function userMessage(
id: string,
content: string,
): Extract<ConversationItem, { type: "user_message" }> {
return { type: "user_message", id, content, timestamp: 0 };
}

function queuedItem(
id: string,
content: string,
): Extract<ConversationItem, { type: "queued" }> {
const message: QueuedMessage = {
id,
content,
rawPrompt: [{ type: "text", text: content }],
queuedAt: 0,
};
return { type: "queued", id, message };
}

describe("mergeConversationItems", () => {
it("local: appends optimistic at the chronological end", () => {
const result = mergeConversationItems({
conversationItems: [userMessage("a", "first")],
optimisticItems: [userMessage("opt", "draft")],
queuedItems: [],
isCloud: false,
});
expect(result.map((i) => i.id)).toEqual(["a", "opt"]);
});

it("local: queued items always come last", () => {
const result = mergeConversationItems({
conversationItems: [userMessage("a", "first")],
optimisticItems: [userMessage("opt", "draft")],
queuedItems: [queuedItem("q1", "later")],
isCloud: false,
});
expect(result.map((i) => i.id)).toEqual(["a", "opt", "q1"]);
});

it("local: does NOT dedupe — duplicate echoes are intentionally retained", () => {
const result = mergeConversationItems({
conversationItems: [userMessage("echo", "hello")],
optimisticItems: [userMessage("opt", "hello")],
queuedItems: [],
isCloud: false,
});
expect(result.map((i) => i.id)).toEqual(["echo", "opt"]);
});

it("cloud: pins optimistic at the top", () => {
const result = mergeConversationItems({
conversationItems: [userMessage("setup", "setup info")],
optimisticItems: [userMessage("opt", "draft")],
queuedItems: [],
isCloud: true,
});
expect(result.map((i) => i.id)).toEqual(["opt", "setup"]);
});

it("cloud: filters echoed user_message that matches optimistic content", () => {
const result = mergeConversationItems({
conversationItems: [
userMessage("echo", "hello"),
userMessage("other", "different"),
],
optimisticItems: [userMessage("opt", "hello")],
queuedItems: [],
isCloud: true,
});
expect(result.map((i) => i.id)).toEqual(["opt", "other"]);
});

it("cloud: dedupe is no-op when there are no optimistic items", () => {
const result = mergeConversationItems({
conversationItems: [
userMessage("a", "first"),
userMessage("b", "second"),
],
optimisticItems: [],
queuedItems: [],
isCloud: true,
});
expect(result.map((i) => i.id)).toEqual(["a", "b"]);
});

it("cloud: queued items always come last", () => {
const result = mergeConversationItems({
conversationItems: [userMessage("setup", "setup")],
optimisticItems: [userMessage("opt", "draft")],
queuedItems: [queuedItem("q1", "later")],
isCloud: true,
});
expect(result.map((i) => i.id)).toEqual(["opt", "setup", "q1"]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { ConversationItem } from "./buildConversationItems";

type QueuedItem = Extract<ConversationItem, { type: "queued" }>;

interface MergeConversationItemsArgs {
conversationItems: ConversationItem[];
optimisticItems: ConversationItem[];
queuedItems: QueuedItem[];
isCloud: boolean;
}

// Cloud's initial optimistic is pinned to the top so the user's prompt stays
// visible above setup progress. When the agent echoes it back via
// `session/prompt`, the duplicate `user_message` is filtered out by content
// match so the bubble doesn't disappear-then-reappear when the echo lands.
//
// Local sessions keep optimistic at the chronological end — they rely on
// `replaceOptimisticWithEvent` to swap optimistic↔real in place.
export function mergeConversationItems({
conversationItems,
optimisticItems,
queuedItems,
isCloud,
}: MergeConversationItemsArgs): ConversationItem[] {
if (!isCloud) {
const result: ConversationItem[] = [
...conversationItems,
...optimisticItems,
];
return queuedItems.length > 0 ? [...result, ...queuedItems] : result;
}

const optimisticUserContents = new Set(
optimisticItems
.filter(
(item): item is Extract<typeof item, { type: "user_message" }> =>
item.type === "user_message",
)
.map((item) => item.content),
);
const dedupedConversation =
optimisticUserContents.size === 0
? conversationItems
: conversationItems.filter((item) => {
if (item.type !== "user_message") return true;
return !optimisticUserContents.has(item.content);
});
const result: ConversationItem[] = [
...optimisticItems,
...dedupedConversation,
];
return queuedItems.length > 0 ? [...result, ...queuedItems] : result;
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export function useSessionConnection({
initialMode,
adapter,
initialModel,
task.description ?? undefined,
);
return cleanup;
}, [
Expand All @@ -106,6 +107,7 @@ export function useSessionConnection({
task.latest_run?.model,
task.latest_run?.runtime_adapter,
task.latest_run?.state?.initial_permission_mode,
task.description,
]);

useEffect(() => {
Expand Down
Loading
Loading