feat: make Research tabs closable and preserve workspace navigation
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user