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
62 changes: 62 additions & 0 deletions desktop/renderer/src/components/chat/ChatMessageList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,68 @@ describe("chat waiting feedback", () => {
wrapper.unmount();
});

it("renders only visible bubbles while keeping reasoning and tools in their group panels", async () => {
const pinia = createPinia();
const store = useChatStore(pinia);
store.messages = [
{ role: "user", content: "Compare controller prices" },
...Array.from({ length: 50 }, (_, index) => ({
role: "assistant",
content: [{ type: "thinking", thinking: `Reasoning ${index}` }],
})),
{ role: "assistant", content: [{ type: "reasoning", reasoning: "Price analysis" }] },
{
role: "assistant",
content: [{ type: "toolCall", id: "search", name: "search", input: { query: "prices" } }],
},
{ role: "toolResult", content: [{ type: "text", text: "Search result" }] },
{ role: "assistant", content: [] },
{ role: "assistant", content: " " },
{ role: "assistant", content: "\u001b[0m" },
{ role: "assistant", content: "<!-- step:1:done -->" },
{ role: "assistant", content: [{ type: "tool_use", name: "search", input: {} }] },
{ role: "assistant", content: "Controller comparison" },
];
const wrapper = shallowMount(ChatMessageList, { global: { plugins: [pinia] } });

expect(wrapper.findAll(".chat-bubble")).toHaveLength(2);
expect(wrapper.get(".chat-group.user").text()).toContain("Compare controller prices");
expect(wrapper.get(".chat-bubble.assistant").text()).toBe("Controller comparison");
expect(wrapper.findAll(".chat-copy-btn")).toHaveLength(2);
expect(wrapper.get(".exec-panel__count").text()).toBe("1");
await wrapper.get(".context-panel__header").trigger("click");
expect(wrapper.findAll(".thinking-block")).toHaveLength(51);
expect(wrapper.text()).toContain("Price analysis");
wrapper.unmount();
});

it("preserves mixed text and reasoning and the original indexes used to edit user messages", async () => {
const pinia = createPinia();
const store = useChatStore(pinia);
store.messages = [
{ role: "user", content: "First question" },
{ role: "user", content: "Follow-up question" },
{ role: "assistant", content: [{ type: "thinking", thinking: "Hidden reasoning" }] },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "Mixed reasoning" },
{ type: "text", text: "Visible answer" },
{ type: "toolCall", id: "mixed", name: "search" },
],
},
];
const wrapper = shallowMount(ChatMessageList, { global: { plugins: [pinia] } });

expect(wrapper.findAll(".chat-bubble.assistant")).toHaveLength(1);
expect(wrapper.get(".chat-bubble.assistant").text()).toContain("Visible answer");
await wrapper.findAll(".chat-bubble.user")[1].trigger("dblclick");
expect((wrapper.get(".chat-edit-input").element as HTMLTextAreaElement).value).toBe(
"Follow-up question",
);
wrapper.unmount();
});

it("keeps truthful activity visible after tools complete and live text arrives", async () => {
const pinia = createPinia();
const store = useChatStore(pinia);
Expand Down
28 changes: 15 additions & 13 deletions desktop/renderer/src/components/chat/ChatMessageList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -171,14 +171,13 @@
</div>

<div
v-for="(msg, idx) in group.messages"
v-for="{ msg, idx, segments } in getVisibleMessages(group)"
:key="group.key + '-' + idx"
class="chat-bubble"
:data-perf-run-id="performanceEnabled ? msg.performanceRunId : undefined"
:class="[
group.normalizedRole,
{ editable: group.normalizedRole === 'user' && !isEditingMessage(group, idx) },
{ 'hidden-msg': group.normalizedRole === 'assistant' && isToolMessage(msg) },
]"
@dblclick.prevent="
group.normalizedRole === 'user' &&
Expand All @@ -194,10 +193,7 @@
<!-- Thinking/reasoning content — skip per-message rendering; merged at group level below -->
<!-- Normal text content -->
<div class="chat-content-wrapper">
<template
v-for="(seg, si) in getMessageSegments(msg, group.key + '-' + idx)"
:key="si"
>
<template v-for="(seg, si) in segments" :key="si">
<div
v-if="seg.type === 'intermediate'"
class="context-panel"
Expand Down Expand Up @@ -535,7 +531,7 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch } from "vue";
import { useRouter } from "vue-router";
import { useChatStore } from "@/stores/chat";
import { useChatStore, type ChatMessage } from "@/stores/chat";
import { useAgentStore } from "@/stores/agents";
import {
renderMarkdown,
Expand Down Expand Up @@ -918,7 +914,18 @@ function isHistoryPanelOpen(key: string): boolean {
return openHistoryPanels.value.has(key);
}

function getMessageSegments(msg: unknown, _key: string): MessageSegment[] {
function getVisibleMessages(
group: MessageGroup,
): { msg: ChatMessage; idx: number; segments: MessageSegment[] }[] {
return group.messages.flatMap((msg, idx) => {
if (group.normalizedRole === "user") return [{ msg, idx, segments: [] }];
if (isToolMessage(msg)) return [];
const segments = getMessageSegments(msg).filter((segment) => segment.content.trim());
return segments.length > 0 ? [{ msg, idx, segments }] : [];
});
}

function getMessageSegments(msg: unknown): MessageSegment[] {
const m = msg as Record<string, unknown>;

if (Array.isArray(m.content)) {
Expand Down Expand Up @@ -1190,11 +1197,6 @@ function handleEditKeydown(e: KeyboardEvent) {
padding-left: 0;
}

/* Hide tool-related messages (rendered in group-level exec panel) */
.chat-bubble.hidden-msg {
display: none;
}

/* ── Copy button ── */
.chat-content-wrapper {
position: relative;
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/chat-run-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ export type ChatSessionRuntime = ChatSessionRuntimeBase &

export interface ChatHistoryResult {
sessionKey?: string;
sessionId?: string;
messages?: unknown[];
olderCursor?: string;
hasMore?: boolean;
nextOffset?: number;
thinkingLevel?: string;
sessionInfo?: ChatSessionRuntime;
}
Expand Down
108 changes: 108 additions & 0 deletions desktop/src/gateway-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,114 @@ describe("GatewayClient.sendChat", () => {
});

describe("GatewayClient.loadHistory", () => {
it("loads older pages so a tool-heavy turn does not lose its user message", async () => {
const client = Object.create(GatewayClient.prototype) as GatewayClient;
Object.assign(client, { _mainSessionKey: null, _connectionGeneration: 1 });
const tools = Array.from({ length: 200 }, (_, index) => ({
role: "assistant",
content: [{ type: "thinking", thinking: `Step ${index}` }],
}));
const user = { role: "user", content: "Compare controller prices" };
const latest = {
sessionKey: "main",
sessionId: "controller-session",
messages: tools,
olderCursor: "older-tools",
hasMore: true,
nextOffset: 200,
sessionInfo: {
key: "agent:main:main",
isMain: true,
hasActiveRun: false,
activeRunIds: [],
},
};
const request = vi
.spyOn(client, "request")
.mockResolvedValueOnce(latest)
.mockResolvedValueOnce({
...latest,
sessionKey: "agent:main:main",
messages: [{ role: "assistant", content: "Starting comparison" }],
olderCursor: "oldest-turn",
})
.mockResolvedValueOnce({
...latest,
sessionKey: "agent:main:main",
messages: [user],
olderCursor: undefined,
hasMore: false,
sessionInfo: { ...latest.sessionInfo, hasActiveRun: true, activeRunIds: ["old-run"] },
});

const history = await client.loadHistory("main");

expect(history.messages).toEqual([
user,
{ role: "assistant", content: "Starting comparison" },
...tools,
]);
expect(history.sessionInfo).toEqual(latest.sessionInfo);
expect(history.sessionKey).toBe("agent:main:main");
expect(request).toHaveBeenNthCalledWith(2, "chat.history", {
sessionKey: "agent:main:main",
limit: 200,
cursor: "older-tools",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.history", {
sessionKey: "agent:main:main",
limit: 200,
cursor: "oldest-turn",
});
});

it("follows the gateway's nextOffset when history has no page cursor", async () => {
const client = Object.create(GatewayClient.prototype) as GatewayClient;
const request = vi
.spyOn(client, "request")
.mockResolvedValueOnce({
messages: [{ role: "assistant", content: "Latest reply" }],
hasMore: true,
nextOffset: 17,
})
.mockResolvedValueOnce({
messages: [{ role: "user", content: "Original question" }],
hasMore: false,
});

await expect(client.loadHistory("agent:main:main")).resolves.toMatchObject({
messages: [
{ role: "user", content: "Original question" },
{ role: "assistant", content: "Latest reply" },
],
});
expect(request).toHaveBeenNthCalledWith(2, "chat.history", {
sessionKey: "agent:main:main",
limit: 200,
offset: 17,
});
});

it("rejects repeated pagination selectors rather than looping or hiding missing history", async () => {
const client = Object.create(GatewayClient.prototype) as GatewayClient;
const request = vi.spyOn(client, "request").mockResolvedValue({
messages: [],
olderCursor: "same-page",
});

await expect(client.loadHistory("agent:main:main")).rejects.toThrow(/pagination/i);
expect(request).toHaveBeenCalledTimes(2);
});

it("does not combine history from different transcripts after a reset", async () => {
const client = Object.create(GatewayClient.prototype) as GatewayClient;
vi.spyOn(client, "request")
.mockResolvedValueOnce({ sessionId: "before-reset", messages: [], olderCursor: "older" })
.mockResolvedValueOnce({ sessionId: "after-reset", messages: [] });

await expect(client.loadHistory("agent:main:main")).rejects.toThrow(/session/i);
});

it("canonicalizes an echoed alias and learns main identity without hello defaults", async () => {
const client = Object.create(GatewayClient.prototype) as GatewayClient;
Object.assign(client, { _mainSessionKey: null, _connectionGeneration: 1 });
Expand Down
44 changes: 43 additions & 1 deletion desktop/src/gateway-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,14 +382,56 @@ export class GatewayClient {
limit: 200,
});
const canonicalKey = resolveChatHistorySessionKey(sessionKey, history);
const pages = [history.messages ?? []];
const visited = new Set<string>();
let page = history;
let offset = 0;
while (page.olderCursor || page.hasMore) {
const cursor = page.olderCursor;
const nextOffset = page.nextOffset;
if (!cursor && (!Number.isSafeInteger(nextOffset) || nextOffset! <= offset)) {
throw new Error("Invalid chat history pagination offset");
}
const selector = cursor ? { cursor } : { offset: nextOffset! };
const selectorKey = JSON.stringify(selector);
if (visited.has(selectorKey)) {
throw new Error("Chat history pagination did not advance");
}
visited.add(selectorKey);
page = await this.request<ChatHistoryResult>("chat.history", {
sessionKey: canonicalKey,
limit: 200,
...selector,
});
if (
generation !== this.connectionGeneration ||
resolveChatHistorySessionKey(canonicalKey, page) !== canonicalKey ||
(history.sessionId && page.sessionId !== history.sessionId)
) {
throw new Error("Chat history session changed while loading older messages");
}
if (!cursor) offset = nextOffset!;
pages.push(page.messages ?? []);
}
if (
generation === this.connectionGeneration &&
sessionKey === "main" &&
history.sessionInfo?.isMain === true
) {
this._mainSessionKey = canonicalKey;
}
return { ...history, sessionKey: canonicalKey };
return {
...history,
...(pages.length > 1
? {
messages: pages.reverse().flat(),
olderCursor: undefined,
hasMore: false,
nextOffset: undefined,
}
: {}),
sessionKey: canonicalKey,
};
}

/** Abort the current chat run. */
Expand Down
Loading