Skip to content
Draft
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
143 changes: 142 additions & 1 deletion frontend/src/__tests__/chat-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,28 @@ import type { UIMessage } from "ai";
import { describe, expect, it } from "vitest";
import { Maps } from "@/utils/maps";
import { replaceMessagesInChat } from "../core/ai/chat-utils";
import type { Chat, ChatId, ChatState } from "../core/ai/state";
import {
closeChatTab,
type Chat,
type ChatId,
type ChatState,
MAX_STORED_CHATS,
openChatTab,
pruneChats,
} from "../core/ai/state";

const CHAT_1 = "chat-1" as ChatId;
const CHAT_2 = "chat-2" as ChatId;

function makeChat(id: number): Chat {
return {
id: `chat-${id}` as ChatId,
title: `Chat ${id}`,
messages: [{ id: `m-${id}`, role: "user", parts: [] }],
createdAt: id,
updatedAt: id,
};
}

function asMap(list: Iterable<Chat>) {
return Maps.keyBy(list, (c) => c.id);
Expand All @@ -30,6 +49,7 @@ describe("replaceMessagesInChat", () => {
},
]),
activeChatId: CHAT_1,
openChatIds: [CHAT_1],
};

it("replaces messages in a chat", () => {
Expand Down Expand Up @@ -61,3 +81,124 @@ describe("replaceMessagesInChat", () => {
expect(result).toEqual(mockChatState);
});
});

describe("openChatTab", () => {
const baseState: ChatState = {
chats: asMap([
{
id: CHAT_1,
title: "Chat 1",
messages: [],
createdAt: 1000,
updatedAt: 1000,
},
{
id: CHAT_2,
title: "Chat 2",
messages: [],
createdAt: 2000,
updatedAt: 2000,
},
]),
activeChatId: null,
openChatIds: [],
};

it("opens a chat tab and sets it active", () => {
const result = openChatTab(baseState, CHAT_1);
expect(result.activeChatId).toBe(CHAT_1);
expect(result.openChatIds).toEqual([CHAT_1]);
});

it("does not duplicate open tabs", () => {
const state = { ...baseState, openChatIds: [CHAT_1], activeChatId: CHAT_2 };
const result = openChatTab(state, CHAT_1);
expect(result.openChatIds).toEqual([CHAT_1]);
expect(result.activeChatId).toBe(CHAT_1);
});
});

describe("closeChatTab", () => {
const baseState: ChatState = {
chats: asMap([
{
id: CHAT_1,
title: "Chat 1",
messages: [{ id: "m1", role: "user", parts: [] }],
createdAt: 1000,
updatedAt: 1000,
},
{
id: CHAT_2,
title: "Chat 2",
messages: [{ id: "m2", role: "user", parts: [] }],
createdAt: 2000,
updatedAt: 2000,
},
]),
activeChatId: CHAT_2,
openChatIds: [CHAT_1, CHAT_2],
};

it("hides a tab without deleting the chat", () => {
const result = closeChatTab(baseState, CHAT_1);
expect(result.openChatIds).toEqual([CHAT_2]);
expect(result.activeChatId).toBe(CHAT_2);
expect(result.chats.has(CHAT_1)).toBe(true);
});

it("activates a neighbor when closing the active tab", () => {
const result = closeChatTab(baseState, CHAT_2);
expect(result.openChatIds).toEqual([CHAT_1]);
expect(result.activeChatId).toBe(CHAT_1);
expect(result.chats.has(CHAT_2)).toBe(true);
});

it("clears active chat when closing the last tab", () => {
const state = { ...baseState, openChatIds: [CHAT_2], activeChatId: CHAT_2 };
const result = closeChatTab(state, CHAT_2);
expect(result.openChatIds).toEqual([]);
expect(result.activeChatId).toBeNull();
expect(result.chats.has(CHAT_2)).toBe(true);
});

it("returns unchanged state when the tab is not open", () => {
const state = { ...baseState, openChatIds: [CHAT_2], activeChatId: CHAT_2 };
const result = closeChatTab(state, CHAT_1);
expect(result).toBe(state);
});
});

describe("pruneChats", () => {
it("returns the same map when under the cap", () => {
const chats = Maps.keyBy(
Array.from({ length: 5 }, (_, i) => makeChat(i)),
(c) => c.id,
);
expect(pruneChats(chats, [])).toBe(chats);
});

it("keeps the most recently updated chats up to the cap", () => {
const chats = Maps.keyBy(
Array.from({ length: MAX_STORED_CHATS + 5 }, (_, i) => makeChat(i)),
(c) => c.id,
);
const result = pruneChats(chats, []);
expect(result.size).toBe(MAX_STORED_CHATS);
// The 5 oldest (updatedAt 0..4) should be evicted.
expect(result.has("chat-0" as ChatId)).toBe(false);
expect(result.has("chat-4" as ChatId)).toBe(false);
expect(result.has("chat-5" as ChatId)).toBe(true);
});

it("never evicts protected (open) chats, even when old", () => {
const chats = Maps.keyBy(
Array.from({ length: MAX_STORED_CHATS + 5 }, (_, i) => makeChat(i)),
(c) => c.id,
);
const oldId = "chat-0" as ChatId;
const result = pruneChats(chats, [oldId]);
expect(result.has(oldId)).toBe(true);
expect(result.size).toBe(MAX_STORED_CHATS + 1);
});
});
2 changes: 1 addition & 1 deletion frontend/src/components/chat/chat-history-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { groupChatsByDate } from "./chat-history-utils";

interface ChatHistoryPopoverProps {
activeChatId: ChatId | undefined;
setActiveChat: (id: ChatId | null) => void;
setActiveChat: (chatId: ChatId | null) => void;
}

export const ChatHistoryPopover: React.FC<ChatHistoryPopoverProps> = ({
Expand Down
18 changes: 6 additions & 12 deletions frontend/src/components/chat/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { AiModelId } from "@/core/ai/ids/ids";
import { useStagedAICellsActions } from "@/core/ai/staged-cells";
import {
activeChatAtom,
addChatAndOpenTab,
type Chat,
type ChatId,
chatStateAtom,
Expand Down Expand Up @@ -84,6 +85,7 @@ import {
} from "./chat-components";
import { renderUIMessage } from "./chat-display";
import { ChatHistoryPopover } from "./chat-history-popover";
import { ChatTabs } from "./chat-tabs";
import {
convertToFileUIPart,
generateChatTitle,
Expand All @@ -101,7 +103,7 @@ const DEFAULT_MODE = "manual";
interface ChatHeaderProps {
onNewChat: () => void;
activeChatId: ChatId | undefined;
setActiveChat: (id: ChatId | null) => void;
setActiveChat: (chatId: ChatId | null) => void;
}

const ChatHeader: React.FC<ChatHeaderProps> = ({
Expand Down Expand Up @@ -613,17 +615,8 @@ const ChatPanelBody = () => {
updatedAt: now,
};

// Create new chat and set as active
setChatState((prev) => {
const newChats = new Map(prev.chats);
newChats.set(newChat.id, newChat);
const newState = {
...prev,
chats: newChats,
activeChatId: newChat.id,
};
return newState;
});
// Create new chat, open it as a tab, and set as active
setChatState((prev) => addChatAndOpenTab(prev, newChat));

const fileParts =
initialAttachments && initialAttachments.length > 0
Expand Down Expand Up @@ -783,6 +776,7 @@ const ChatPanelBody = () => {
activeChatId={activeChat?.id}
setActiveChat={setActiveChat}
/>
<ChatTabs />

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.

P1: Streaming responses can be persisted to the wrong chat after tab switches. onFinish writes by current activeChatId, and new tabs make mid-stream chat switches easy.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At frontend/src/components/chat/chat-panel.tsx, line 779:

<comment>Streaming responses can be persisted to the wrong chat after tab switches. `onFinish` writes by current `activeChatId`, and new tabs make mid-stream chat switches easy.</comment>

<file context>
@@ -783,6 +776,7 @@ const ChatPanelBody = () => {
           activeChatId={activeChat?.id}
           setActiveChat={setActiveChat}
         />
+        <ChatTabs />
       </TooltipProvider>
 
</file context>

</TooltipProvider>

<div
Expand Down
95 changes: 95 additions & 0 deletions frontend/src/components/chat/chat-tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { useAtom, useSetAtom } from "jotai";
import { XIcon } from "lucide-react";
import { memo, useMemo } from "react";
import useEvent from "react-use-event-hook";
import { Button } from "@/components/ui/button";
import {
activeChatAtom,
closeChatTab,
type Chat,
type ChatId,
chatStateAtom,
} from "@/core/ai/state";
import { cn } from "@/utils/cn";

interface ChatTabProps {
chat: Chat;
isActive: boolean;
onSelect: (chatId: ChatId) => void;
onClose: (chatId: ChatId) => void;
}

const ChatTab = memo<ChatTabProps>(({ chat, isActive, onSelect, onClose }) => {
return (
<div
className={cn(
"group flex items-center gap-1 px-2 py-1 text-sm border-r border-border cursor-pointer min-w-0 max-w-[160px] transition-colors",
isActive
? "bg-background text-foreground relative z-1"
: "bg-muted/30 text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}
onClick={() => onSelect(chat.id)}

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.

P2: Tab selection is mouse-only on a non-interactive div. Add keyboard semantics so tabs are operable via Enter/Space.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At frontend/src/components/chat/chat-tabs.tsx, line 33:

<comment>Tab selection is mouse-only on a non-interactive div. Add keyboard semantics so tabs are operable via Enter/Space.</comment>

<file context>
@@ -0,0 +1,95 @@
+          ? "bg-background text-foreground relative z-1"
+          : "bg-muted/30 text-muted-foreground hover:bg-muted/50 hover:text-foreground",
+      )}
+      onClick={() => onSelect(chat.id)}
+    >
+      <span className="truncate flex-1 min-w-0" title={chat.title}>
</file context>

>
<span className="truncate flex-1 min-w-0" title={chat.title}>
{chat.title}
</span>
<Button
variant="ghost"
size="sm"

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.

P2: Icon-only close button is missing an accessible label. Add aria-label so assistive tech can identify the action.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At frontend/src/components/chat/chat-tabs.tsx, line 40:

<comment>Icon-only close button is missing an accessible label. Add `aria-label` so assistive tech can identify the action.</comment>

<file context>
@@ -0,0 +1,95 @@
+      </span>
+      <Button
+        variant="ghost"
+        size="sm"
+        className={cn(
+          "h-4 w-4 p-0 shrink-0 opacity-0 group-hover:opacity-100 hover:bg-destructive/20 hover:text-destructive",
</file context>
Suggested change
size="sm"
size="sm"
aria-label={`Close ${chat.title}`}

className={cn(
"h-4 w-4 p-0 shrink-0 opacity-0 group-hover:opacity-100 hover:bg-destructive/20 hover:text-destructive",
isActive && "opacity-100",
)}
onClick={(e) => {
e.stopPropagation();
onClose(chat.id);
}}
>
<XIcon className="h-3 w-3" />
</Button>
</div>
);
});
ChatTab.displayName = "ChatTab";

export const ChatTabs = memo(() => {
const [chatState, setChatState] = useAtom(chatStateAtom);
const setActiveChat = useSetAtom(activeChatAtom);

const openChats = useMemo(() => {
return chatState.openChatIds
.map((id) => chatState.chats.get(id))
.filter((chat): chat is Chat => chat !== undefined);
}, [chatState.chats, chatState.openChatIds]);

const handleSelectChat = useEvent((chatId: ChatId) => {
setActiveChat(chatId);
});

const handleCloseChat = useEvent((chatId: ChatId) => {
setChatState((prev) => closeChatTab(prev, chatId));
});

if (openChats.length === 0) {
return null;
}

return (
<div className="flex items-center border-b bg-muted/20 overflow-hidden shrink-0">
<div className="flex min-w-0 flex-1 overflow-x-auto">
{openChats.map((chat) => (
<ChatTab
key={chat.id}
chat={chat}
isActive={chat.id === chatState.activeChatId}
onSelect={handleSelectChat}
onClose={handleCloseChat}
/>
))}
</div>
</div>
);
});
ChatTabs.displayName = "ChatTabs";
Loading
Loading