feat: make Research tabs closable and preserve workspace navigation

This commit is contained in:
2026-10-07 22:44:00 +09:00
parent 71df9fb421
commit aabeb33f41
9 changed files with 287 additions and 105 deletions
+2 -3
View File
@@ -40,8 +40,7 @@ See [execution support](docs/execution-support.md) and the
Reader search and article selection, Messages views and conversations, Home Reader search and article selection, Messages views and conversations, Home
previews, account management, Journal review/correction views, and Vitals previews, account management, Journal review/correction views, and Vitals
navigation are represented in search parameters. Research uses deck, run, and navigation are represented in search parameters. Research uses deck, run, citation IDs, and closed-tab IDs. Reloading or browser Back/Forward restores the view while its
citation IDs. Reloading or browser Back/Forward restores the view while its
underlying data remains available. Login preserves the requested workspace URL. underlying data remains available. Login preserves the requested workspace URL.
Explicit selections add browser history; search input replaces it after a Explicit selections add browser history; search input replaces it after a
@@ -201,7 +200,7 @@ available. The backend handles tools even when the browser is closed.
Use **New chat** to leave the current conversation and start a fresh Codex Use **New chat** to leave the current conversation and start a fresh Codex
thread with your next message. The open deck and host reports remain available. thread with your next message. The open deck and host reports remain available.
A running turn continues in the background when you open another chat. A running turn continues in the background when you open another chat.
Selection belongs to the current URL; another browser does not change it. **Chat history** appears as a horizontally scrollable tab strip above the chat and deck, with a **+** button for a new chat. Tabs reopen saved conversations with their account Selection belongs to the current URL; another browser does not change it. **Chat history** appears as a horizontally scrollable tab strip above the chat and deck, to the right of the workspace navigation. Use **+** for a new chat or **×** to close a tab. Closing a tab keeps its conversation and running research; the history menu reopens it. Tab clicks have no animation. Tabs reopen saved conversations with their account
selection, deck and citations; the next message resumes the same Codex thread. selection, deck and citations; the next message resumes the same Codex thread.
SQLite stores conversation snapshots. Active selection is not stored globally. SQLite stores conversation snapshots. Active selection is not stored globally.
Each turn is limited to 6 columns, 12 upstream requests shared between list Each turn is limited to 6 columns, 12 upstream requests shared between list
+23 -2
View File
@@ -348,9 +348,17 @@ test("keeps browser conversation selection independent and restores it on reload
await researchPage.goTo(`?run=${first}`, { waitUntil: "domcontentloaded" }); await researchPage.goTo(`?run=${first}`, { waitUntil: "domcontentloaded" });
await workspacePage.showResearch(); await workspacePage.showResearch();
const history = page.getByRole("navigation", { name: "Chat history" }); const history = page.getByRole("navigation", { name: "Chat history" });
const firstTab = history.getByRole("button", { name: "Browser one conversation" }); const firstTab = history.getByRole("button", { name: "Browser one conversation", exact: true });
const secondTab = history.getByRole("button", { name: "Browser two conversation" }); const secondTab = history.getByRole("button", {
name: "Browser two conversation",
exact: true,
});
await expect(firstTab).toHaveAttribute("aria-current", "page"); await expect(firstTab).toHaveAttribute("aria-current", "page");
await expect(firstTab).toHaveCSS("transition-duration", "0s");
await firstTab.hover();
await page.mouse.down();
await expect(firstTab).toHaveCSS("translate", "none");
await page.mouse.up();
await workspacePage.showDeckManagement(); await workspacePage.showDeckManagement();
await secondTab.click(); await secondTab.click();
await expect(page).toHaveURL(new RegExp(`run=${second}`)); await expect(page).toHaveURL(new RegExp(`run=${second}`));
@@ -358,6 +366,19 @@ test("keeps browser conversation selection independent and restores it on reload
await expect(researchPage.conversation).toContainText("Browser two conversation"); await expect(researchPage.conversation).toContainText("Browser two conversation");
await page.goBack(); await page.goBack();
await expect(firstTab).toHaveAttribute("aria-current", "page"); await expect(firstTab).toHaveAttribute("aria-current", "page");
await history
.getByRole("button", { name: "Close Browser two conversation", exact: true })
.click();
await expect(secondTab).toHaveCount(0);
await expect(firstTab).toHaveAttribute("aria-current", "page");
await page.reload({ waitUntil: "domcontentloaded" });
await workspacePage.showResearch();
await expect(secondTab).toHaveCount(0);
await history.getByRole("button", { name: "Open chat history" }).click();
await page.getByRole("menuitem", { name: "Browser two conversation", exact: true }).click();
await expect(secondTab).toHaveAttribute("aria-current", "page");
await expect(researchPage.conversation).toContainText("Browser two conversation");
await firstTab.click();
await secondResearch.goTo(`?run=${second}`, { await secondResearch.goTo(`?run=${second}`, {
waitUntil: "domcontentloaded", waitUntil: "domcontentloaded",
}); });
+7 -2
View File
@@ -18,14 +18,19 @@ test("keeps the sidebar, composer and deck reachable at compact widths", async (
const historyBounds = await history.boundingBox(); const historyBounds = await history.boundingBox();
const chatBounds = await chat.boundingBox(); const chatBounds = await chat.boundingBox();
if (!historyBounds || !chatBounds) throw new Error("Chat and history must have visible bounds"); if (!historyBounds || !chatBounds) throw new Error("Chat and history must have visible bounds");
expect(historyBounds.x).toBe(0); expect(historyBounds.x).toBe(chatBounds.x);
const navigationMark = await page
.getByRole("img", { name: "Workspace", exact: true })
.boundingBox();
if (!navigationMark) throw new Error("Workspace navigation must be visible");
expect(navigationMark.y).toBeLessThan(historyBounds.y + historyBounds.height);
const availableWidth = const availableWidth =
width < 768 width < 768
? await page ? await page
.getByRole("dialog", { name: "Workspace" }) .getByRole("dialog", { name: "Workspace" })
.evaluate((element) => element.clientWidth) .evaluate((element) => element.clientWidth)
: width; : width;
expect(historyBounds.width).toBe(availableWidth); expect(historyBounds.width).toBe(availableWidth - chatBounds.x);
expect(historyBounds.y + historyBounds.height).toBeLessThanOrEqual(chatBounds.y); expect(historyBounds.y + historyBounds.height).toBeLessThanOrEqual(chatBounds.y);
const composer = researchPage.composer; const composer = researchPage.composer;
await expect(composer).toBeVisible(); await expect(composer).toBeVisible();
+10 -12
View File
@@ -23,7 +23,7 @@ type ShellProps = {
export function AppShell(props: ShellProps) { export function AppShell(props: ShellProps) {
return ( return (
<TooltipProvider delay={800}> <TooltipProvider delay={800}>
<SidebarProvider className="h-dvh min-h-0 flex-col overflow-hidden [--sidebar-width:25rem] [--sidebar-width-icon:4rem]"> <SidebarProvider className="relative h-dvh min-h-0 overflow-hidden [--sidebar-width:25rem] [--sidebar-width-icon:4rem]">
<Workspace {...props} /> <Workspace {...props} />
</SidebarProvider> </SidebarProvider>
</TooltipProvider> </TooltipProvider>
@@ -51,12 +51,11 @@ function Workspace({
return ( return (
<> <>
{(!isMobile || !openMobile) && history} {(!isMobile || !openMobile) && (
<div className="relative flex min-h-0 min-w-0 flex-1"> <div className="absolute inset-x-0 top-0 z-20 md:left-16">{history}</div>
<Sidebar collapsible="icon" className="absolute! h-full overflow-hidden transition-none"> )}
<div className="flex h-full w-full min-w-0 flex-col"> <Sidebar collapsible="icon" className="overflow-hidden transition-none">
{isMobile && openMobile && history} <div className="flex h-full w-full min-w-0">
<div className="flex min-h-0 flex-1">
<WorkspaceNavigation <WorkspaceNavigation
hasTemporaryDecks={hasTemporaryDecks} hasTemporaryDecks={hasTemporaryDecks}
footer={ footer={
@@ -95,15 +94,15 @@ function Workspace({
collapsible="none" collapsible="none"
className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden" className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden"
> >
<div aria-hidden="true" className="hidden h-12 shrink-0 md:block" />
{isMobile && openMobile && history}
<div className={panel === "chat" ? "flex min-h-0 flex-1 flex-col" : "hidden"}> <div className={panel === "chat" ? "flex min-h-0 flex-1 flex-col" : "hidden"}>
{researchChat(() => setOpenMobile(false))} {researchChat(() => setOpenMobile(false))}
</div> </div>
<section <section
aria-label="Deck manager" aria-label="Deck manager"
className={ className={
panel === "decks" panel === "decks" ? "flex min-h-0 flex-1 flex-col overflow-y-auto p-4" : "hidden"
? "flex min-h-0 flex-1 flex-col overflow-y-auto p-4"
: "hidden"
} }
> >
<h2 className="mb-4 text-base font-semibold">Decks</h2> <h2 className="mb-4 text-base font-semibold">Decks</h2>
@@ -111,9 +110,9 @@ function Workspace({
</section> </section>
</Sidebar> </Sidebar>
</div> </div>
</div>
</Sidebar> </Sidebar>
<SidebarInset className="min-w-0 overflow-hidden"> <SidebarInset className="min-w-0 overflow-hidden">
<div aria-hidden="true" className="h-12 shrink-0" />
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-3"> <header className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<SidebarTrigger aria-label="Toggle sidebar" /> <SidebarTrigger aria-label="Toggle sidebar" />
<span className="text-sm font-medium">Research</span> <span className="text-sm font-medium">Research</span>
@@ -126,7 +125,6 @@ function Workspace({
{children} {children}
</section> </section>
</SidebarInset> </SidebarInset>
</div>
</> </>
); );
} }
+9 -3
View File
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn"; import { cn } from "cn";
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
@@ -19,13 +19,17 @@ const buttonVariants = cva(
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
motion: {
default: "transition-all active:not-aria-[haspopup]:translate-y-px",
none: "transition-none",
},
size: { size: {
default: default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
tab: "h-10 w-48 min-w-24 justify-start px-3", tab: "h-10 w-48 min-w-24 justify-start pr-9 pl-3",
icon: "size-8", icon: "size-8",
"icon-xs": "icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
@@ -37,6 +41,7 @@ const buttonVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
size: "default", size: "default",
motion: "default",
}, },
}, },
); );
@@ -45,12 +50,13 @@ function Button({
className, className,
variant = "default", variant = "default",
size = "default", size = "default",
motion = "default",
...props ...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) { }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return ( return (
<ButtonPrimitive <ButtonPrimitive
data-slot="button" data-slot="button"
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, motion, className }))}
{...props} {...props}
/> />
); );
+10
View File
@@ -9,6 +9,7 @@ it("discards malformed navigation state rather than breaking the page", () => {
run: "../private", run: "../private",
article: { id: 1 }, article: { id: 1 },
password: "secret", password: "secret",
closedRuns: ["../private"],
}); });
expect(state).not.toHaveProperty("password"); expect(state).not.toHaveProperty("password");
expect(state.q).toBeUndefined(); expect(state.q).toBeUndefined();
@@ -16,4 +17,13 @@ it("discards malformed navigation state rather than breaking the page", () => {
expect(state.view).toBeUndefined(); expect(state.view).toBeUndefined();
expect(state.run).toBeUndefined(); expect(state.run).toBeUndefined();
expect(state.article).toBeUndefined(); expect(state.article).toBeUndefined();
expect(state.closedRuns).toBeUndefined();
});
it("preserves closed chat tabs when restoring a workspace URL", () => {
const id = "d3d65e3f-84e0-44de-ae3f-662c97919ee7";
expect(validateWorkspaceSearch({ run: "new", closedRuns: [id] })).toMatchObject({
run: "new",
closedRuns: [id],
});
}); });
+1
View File
@@ -17,6 +17,7 @@ const workspaceSearch = z.object({
.optional() .optional()
.catch(undefined), .catch(undefined),
citation: text, citation: text,
closedRuns: z.array(z.uuid()).optional().catch(undefined),
vitalSection: z.enum(["Body", "Environment"]).optional().catch(undefined), vitalSection: z.enum(["Body", "Environment"]).optional().catch(undefined),
vitalTab: text, vitalTab: text,
vitalHistory: flag, vitalHistory: flag,
+72 -1
View File
@@ -6,7 +6,7 @@ import type { ResearchHistoryEntry, ResearchRun } from "./model";
import { ResearchPanel } from "./research-panel"; import { ResearchPanel } from "./research-panel";
const navigation = vi.hoisted(() => ({ const navigation = vi.hoisted(() => ({
search: {} as { run?: string; deck?: string; citation?: string }, search: {} as { run?: string; deck?: string; citation?: string; closedRuns?: string[] },
set: vi.fn<(patch: { run?: string; deck?: string; citation?: string }) => void>(), set: vi.fn<(patch: { run?: string; deck?: string; citation?: string }) => void>(),
})); }));
vi.mock("../navigation/use-url-state", async () => { vi.mock("../navigation/use-url-state", async () => {
@@ -835,3 +835,74 @@ it("submits with Enter while preserving Shift+Enter and IME composition", async
data: expect.objectContaining({ topic: "Compare browser automation tools" }), data: expect.objectContaining({ topic: "Compare browser automation tools" }),
}); });
}); });
it("closes an inactive tab without changing the current conversation", async () => {
navigation.search = { run: running.id, citation: "twitter:123" };
current = {
configured: true,
run: running,
history: [
{ id: running.id, title: "Current chat", status: "running", updatedAt: 1000 },
{ id: "other", title: "Other chat", status: "complete", updatedAt: 500 },
],
};
show();
fireEvent.click(await screen.findByRole("button", { name: "Close Other chat" }));
expect(screen.queryByRole("button", { name: "Other chat" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Current chat" })).toHaveAttribute(
"aria-current",
"page",
);
expect(navigation.set).toHaveBeenLastCalledWith({ closedRuns: ["other"] });
expect(actions.cancel).not.toHaveBeenCalled();
});
it("selects the adjacent chat when closing the active tab without stopping its running turn", async () => {
navigation.search = { run: running.id };
current = {
configured: true,
run: running,
history: [
{ id: running.id, title: "Current chat", status: "running", updatedAt: 1000 },
{ id: "next", title: "Next chat", status: "complete", updatedAt: 500 },
],
};
show();
fireEvent.click(await screen.findByRole("button", { name: "Close Current chat" }));
expect(navigation.set).toHaveBeenLastCalledWith({
closedRuns: [running.id],
run: "next",
citation: undefined,
});
await waitFor(() =>
expect(screen.getByRole("button", { name: "Next chat" })).toHaveAttribute(
"aria-current",
"page",
),
);
expect(screen.queryByRole("button", { name: "Current chat" })).not.toBeInTheDocument();
expect(actions.cancel).not.toHaveBeenCalled();
});
it("shows a new chat after closing the last tab and keeps the saved chat in history", async () => {
navigation.search = { run: running.id };
current = {
configured: true,
run: running,
history: [{ id: running.id, title: "Last chat", status: "running", updatedAt: 1000 }],
};
show();
fireEvent.click(await screen.findByRole("button", { name: "Close Last chat" }));
expect(navigation.set).toHaveBeenLastCalledWith({
closedRuns: [running.id],
run: "new",
citation: undefined,
});
await waitFor(() =>
expect(screen.getByRole("button", { name: "Open chat history" })).toBeEnabled(),
);
expect(screen.queryByRole("button", { name: "Last chat" })).not.toBeInTheDocument();
expect(screen.getByText("New chat")).toBeVisible();
expect(actions.start).not.toHaveBeenCalled();
expect(actions.cancel).not.toHaveBeenCalled();
});
+76 -5
View File
@@ -1,9 +1,15 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useServerFn } from "@tanstack/react-start"; import { useServerFn } from "@tanstack/react-start";
import { ArrowUpIcon, PlusIcon, SquareIcon } from "lucide-react"; import { ArrowUpIcon, HistoryIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react"; import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
import { Bubble, BubbleContent } from "#/components/ui/bubble"; import { Bubble, BubbleContent } from "#/components/ui/bubble";
import { Button } from "#/components/ui/button"; import { Button } from "#/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "#/components/ui/dropdown-menu";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "#/components/ui/empty"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "#/components/ui/empty";
import { Checkbox } from "#/components/ui/checkbox"; import { Checkbox } from "#/components/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "#/components/ui/collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "#/components/ui/collapsible";
@@ -85,6 +91,11 @@ export function ResearchPanel({
}); });
const run = status.data?.run ?? null; const run = status.data?.run ?? null;
const runId = run?.id ?? null; const runId = run?.id ?? null;
const closedRuns = search.closedRuns ?? [];
const history = status.data?.history ?? [];
const openChats = history.filter(
(entry) => entry.id === selectedId || !closedRuns.includes(entry.id),
);
const [draft, setDraft] = useState<ResearchDraft>({ const [draft, setDraft] = useState<ResearchDraft>({
runId, runId,
topic: "", topic: "",
@@ -276,7 +287,21 @@ export function ResearchPanel({
function selectChat(id: string) { function selectChat(id: string) {
if (!id || id === run?.id) return; if (!id || id === run?.id) return;
setSearch({ run: id, citation: undefined }); const remaining = closedRuns.filter((closed) => closed !== id);
setSearch({
run: id,
citation: undefined,
closedRuns: remaining.length ? remaining : undefined,
});
}
function closeChat(id: string) {
const index = openChats.findIndex((entry) => entry.id === id);
const next = openChats[index + 1] ?? openChats[index - 1];
setSearch({
closedRuns: [...closedRuns.filter((closed) => closed !== id), id],
...(id === selectedId ? { run: next?.id ?? "new", citation: undefined } : {}),
});
} }
async function openDeck(deck: Deck) { async function openDeck(deck: Deck) {
@@ -297,12 +322,13 @@ export function ResearchPanel({
data-testid="research-history" data-testid="research-history"
className="flex h-12 min-w-0 shrink-0 items-stretch border-b bg-sidebar" className="flex h-12 min-w-0 shrink-0 items-stretch border-b bg-sidebar"
> >
<div className="flex min-w-0 flex-1 items-end gap-1 overflow-x-auto px-2 pt-2"> <div className="flex min-w-0 flex-1 items-end gap-1 overflow-x-auto px-2 pt-1">
{status.data?.history?.map((entry) => ( {openChats.map((entry) => (
<div key={entry.id} className="relative shrink-0">
<Button <Button
key={entry.id}
type="button" type="button"
variant="tab" variant="tab"
motion="none"
size="tab" size="tab"
aria-current={entry.id === selectedId ? "page" : undefined} aria-current={entry.id === selectedId ? "page" : undefined}
title={entry.title} title={entry.title}
@@ -314,6 +340,20 @@ export function ResearchPanel({
> >
<span className="truncate">{entry.title}</span> <span className="truncate">{entry.title}</span>
</Button> </Button>
<Button
type="button"
variant="ghost"
motion="none"
size="icon-xs"
className="absolute top-2 right-1"
aria-label={`Close ${entry.title}`}
title="Close tab"
disabled={busy}
onClick={() => closeChat(entry.id)}
>
<XIcon />
</Button>
</div>
))} ))}
{!selectedId && ( {!selectedId && (
<span <span
@@ -329,6 +369,7 @@ export function ResearchPanel({
size="icon" size="icon"
type="button" type="button"
className="mx-2 self-center" className="mx-2 self-center"
motion="none"
aria-label="New chat" aria-label="New chat"
title="New chat" title="New chat"
disabled={busy || !run} disabled={busy || !run}
@@ -339,6 +380,36 @@ export function ResearchPanel({
> >
<PlusIcon /> <PlusIcon />
</Button> </Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
motion="none"
size="icon"
className="mr-2 self-center"
aria-label="Open chat history"
title="Chat history"
disabled={busy || history.length === 0}
/>
}
>
<HistoryIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{history.map((entry) => (
<DropdownMenuItem
key={entry.id}
onClick={() => {
selectChat(entry.id);
onSelect?.();
}}
>
<span className="truncate">{entry.title}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</nav> </nav>
); );