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
+73 -75
View File
@@ -23,7 +23,7 @@ type ShellProps = {
export function AppShell(props: ShellProps) {
return (
<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} />
</SidebarProvider>
</TooltipProvider>
@@ -51,82 +51,80 @@ function Workspace({
return (
<>
{(!isMobile || !openMobile) && history}
<div className="relative flex min-h-0 min-w-0 flex-1">
<Sidebar collapsible="icon" className="absolute! h-full overflow-hidden transition-none">
<div className="flex h-full w-full min-w-0 flex-col">
{isMobile && openMobile && history}
<div className="flex min-h-0 flex-1">
<WorkspaceNavigation
hasTemporaryDecks={hasTemporaryDecks}
footer={
<SidebarMenuItem>
<SidebarMenuButton
aria-label="Close sidebar"
tooltip={{ children: "Close sidebar", hidden: false }}
onClick={() => (isMobile ? setOpenMobile(false) : setOpen(false))}
className="size-10 justify-center p-0"
>
<PanelLeftClose />
</SidebarMenuButton>
</SidebarMenuItem>
}
>
{navigation.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
aria-label={item.title}
tooltip={{ children: item.title, hidden: false }}
isActive={panel === item.id}
onClick={() => {
setPanel(item.id);
if (isMobile) setOpenMobile(true);
else setOpen(true);
}}
className="size-10 justify-center p-0"
>
<item.icon />
<span className="sr-only">{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</WorkspaceNavigation>
<Sidebar
collapsible="none"
className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden"
>
<div className={panel === "chat" ? "flex min-h-0 flex-1 flex-col" : "hidden"}>
{researchChat(() => setOpenMobile(false))}
</div>
<section
aria-label="Deck manager"
className={
panel === "decks"
? "flex min-h-0 flex-1 flex-col overflow-y-auto p-4"
: "hidden"
}
{(!isMobile || !openMobile) && (
<div className="absolute inset-x-0 top-0 z-20 md:left-16">{history}</div>
)}
<Sidebar collapsible="icon" className="overflow-hidden transition-none">
<div className="flex h-full w-full min-w-0">
<WorkspaceNavigation
hasTemporaryDecks={hasTemporaryDecks}
footer={
<SidebarMenuItem>
<SidebarMenuButton
aria-label="Close sidebar"
tooltip={{ children: "Close sidebar", hidden: false }}
onClick={() => (isMobile ? setOpenMobile(false) : setOpen(false))}
className="size-10 justify-center p-0"
>
<h2 className="mb-4 text-base font-semibold">Decks</h2>
<div className="flex flex-col gap-4">{sidebar}</div>
</section>
</Sidebar>
</div>
</div>
</Sidebar>
<SidebarInset className="min-w-0 overflow-hidden">
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<SidebarTrigger aria-label="Toggle sidebar" />
<span className="text-sm font-medium">Research</span>
<span className="ml-auto text-xs text-muted-foreground">Research workspace</span>
</header>
<section
className="overflow-hidden flex min-h-0 min-w-0 flex-1 flex-col"
aria-label="Decks"
<PanelLeftClose />
</SidebarMenuButton>
</SidebarMenuItem>
}
>
{children}
</section>
</SidebarInset>
</div>
{navigation.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
aria-label={item.title}
tooltip={{ children: item.title, hidden: false }}
isActive={panel === item.id}
onClick={() => {
setPanel(item.id);
if (isMobile) setOpenMobile(true);
else setOpen(true);
}}
className="size-10 justify-center p-0"
>
<item.icon />
<span className="sr-only">{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</WorkspaceNavigation>
<Sidebar
collapsible="none"
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"}>
{researchChat(() => setOpenMobile(false))}
</div>
<section
aria-label="Deck manager"
className={
panel === "decks" ? "flex min-h-0 flex-1 flex-col overflow-y-auto p-4" : "hidden"
}
>
<h2 className="mb-4 text-base font-semibold">Decks</h2>
<div className="flex flex-col gap-4">{sidebar}</div>
</section>
</Sidebar>
</div>
</Sidebar>
<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">
<SidebarTrigger aria-label="Toggle sidebar" />
<span className="text-sm font-medium">Research</span>
<span className="ml-auto text-xs text-muted-foreground">Research workspace</span>
</header>
<section
className="overflow-hidden flex min-h-0 min-w-0 flex-1 flex-col"
aria-label="Decks"
>
{children}
</section>
</SidebarInset>
</>
);
}
+9 -3
View File
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
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: {
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",
link: "text-primary underline-offset-4 hover:underline",
},
motion: {
default: "transition-all active:not-aria-[haspopup]:translate-y-px",
none: "transition-none",
},
size: {
default:
"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",
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",
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-xs":
"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: {
variant: "default",
size: "default",
motion: "default",
},
},
);
@@ -45,12 +50,13 @@ function Button({
className,
variant = "default",
size = "default",
motion = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
className={cn(buttonVariants({ variant, size, motion, className }))}
{...props}
/>
);
+10
View File
@@ -9,6 +9,7 @@ it("discards malformed navigation state rather than breaking the page", () => {
run: "../private",
article: { id: 1 },
password: "secret",
closedRuns: ["../private"],
});
expect(state).not.toHaveProperty("password");
expect(state.q).toBeUndefined();
@@ -16,4 +17,13 @@ it("discards malformed navigation state rather than breaking the page", () => {
expect(state.view).toBeUndefined();
expect(state.run).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()
.catch(undefined),
citation: text,
closedRuns: z.array(z.uuid()).optional().catch(undefined),
vitalSection: z.enum(["Body", "Environment"]).optional().catch(undefined),
vitalTab: text,
vitalHistory: flag,
+72 -1
View File
@@ -6,7 +6,7 @@ import type { ResearchHistoryEntry, ResearchRun } from "./model";
import { ResearchPanel } from "./research-panel";
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>(),
}));
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" }),
});
});
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();
});
+90 -19
View File
@@ -1,9 +1,15 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
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 { Bubble, BubbleContent } from "#/components/ui/bubble";
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 { Checkbox } from "#/components/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "#/components/ui/collapsible";
@@ -85,6 +91,11 @@ export function ResearchPanel({
});
const run = status.data?.run ?? 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>({
runId,
topic: "",
@@ -276,7 +287,21 @@ export function ResearchPanel({
function selectChat(id: string) {
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) {
@@ -297,23 +322,38 @@ export function ResearchPanel({
data-testid="research-history"
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">
{status.data?.history?.map((entry) => (
<Button
key={entry.id}
type="button"
variant="tab"
size="tab"
aria-current={entry.id === selectedId ? "page" : undefined}
title={entry.title}
disabled={busy}
onClick={() => {
selectChat(entry.id);
onSelect?.();
}}
>
<span className="truncate">{entry.title}</span>
</Button>
<div className="flex min-w-0 flex-1 items-end gap-1 overflow-x-auto px-2 pt-1">
{openChats.map((entry) => (
<div key={entry.id} className="relative shrink-0">
<Button
type="button"
variant="tab"
motion="none"
size="tab"
aria-current={entry.id === selectedId ? "page" : undefined}
title={entry.title}
disabled={busy}
onClick={() => {
selectChat(entry.id);
onSelect?.();
}}
>
<span className="truncate">{entry.title}</span>
</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 && (
<span
@@ -329,6 +369,7 @@ export function ResearchPanel({
size="icon"
type="button"
className="mx-2 self-center"
motion="none"
aria-label="New chat"
title="New chat"
disabled={busy || !run}
@@ -339,6 +380,36 @@ export function ResearchPanel({
>
<PlusIcon />
</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>
);