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
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,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 @@ -102,13 +103,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