feat: show research chat history as full-width tabs

This commit is contained in:
2026-10-07 22:28:58 +09:00
parent a538628670
commit c46e787738
8 changed files with 192 additions and 108 deletions
+1 -1
View File
@@ -201,7 +201,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** reopens 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, with a **+** button for a new chat. 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
+11
View File
@@ -347,6 +347,17 @@ test("keeps browser conversation selection independent and restores it on reload
const secondWorkspace = new WorkspacePage(secondPage); const secondWorkspace = new WorkspacePage(secondPage);
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 firstTab = history.getByRole("button", { name: "Browser one conversation" });
const secondTab = history.getByRole("button", { name: "Browser two conversation" });
await expect(firstTab).toHaveAttribute("aria-current", "page");
await workspacePage.showDeckManagement();
await secondTab.click();
await expect(page).toHaveURL(new RegExp(`run=${second}`));
await expect(secondTab).toHaveAttribute("aria-current", "page");
await expect(researchPage.conversation).toContainText("Browser two conversation");
await page.goBack();
await expect(firstTab).toHaveAttribute("aria-current", "page");
await secondResearch.goTo(`?run=${second}`, { await secondResearch.goTo(`?run=${second}`, {
waitUntil: "domcontentloaded", waitUntil: "domcontentloaded",
}); });
+14
View File
@@ -13,6 +13,20 @@ test("keeps the sidebar, composer and deck reachable at compact widths", async (
await workspacePage.showResearch(); await workspacePage.showResearch();
const chat = researchPage.panel; const chat = researchPage.panel;
await expect(chat).toBeVisible(); await expect(chat).toBeVisible();
const history = page.getByRole("navigation", { name: "Chat history" });
await expect(history).toBeVisible();
const historyBounds = await history.boundingBox();
const chatBounds = await chat.boundingBox();
if (!historyBounds || !chatBounds) throw new Error("Chat and history must have visible bounds");
expect(historyBounds.x).toBe(0);
const availableWidth =
width < 768
? await page
.getByRole("dialog", { name: "Workspace" })
.evaluate((element) => element.clientWidth)
: width;
expect(historyBounds.width).toBe(availableWidth);
expect(historyBounds.y + historyBounds.height).toBeLessThanOrEqual(chatBounds.y);
const composer = researchPage.composer; const composer = researchPage.composer;
await expect(composer).toBeVisible(); await expect(composer).toBeVisible();
const bounds = await composer.boundingBox(); const bounds = await composer.boundingBox();
+87 -65
View File
@@ -15,6 +15,7 @@ import { WorkspaceNavigation } from "#/components/workspace-navigation";
type ShellProps = { type ShellProps = {
hasTemporaryDecks: boolean; hasTemporaryDecks: boolean;
sidebar: ReactNode; sidebar: ReactNode;
researchHistory: (onSelect: () => void) => ReactNode;
researchChat: (closeMobileSidebar: () => void) => ReactNode; researchChat: (closeMobileSidebar: () => void) => ReactNode;
children: ReactNode; children: ReactNode;
}; };
@@ -22,16 +23,27 @@ 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 overflow-hidden [--sidebar-width:25rem] [--sidebar-width-icon:4rem]"> <SidebarProvider className="h-dvh min-h-0 flex-col overflow-hidden [--sidebar-width:25rem] [--sidebar-width-icon:4rem]">
<Workspace {...props} /> <Workspace {...props} />
</SidebarProvider> </SidebarProvider>
</TooltipProvider> </TooltipProvider>
); );
} }
function Workspace({ sidebar, researchChat, children, hasTemporaryDecks }: ShellProps) { function Workspace({
sidebar,
researchChat,
researchHistory,
children,
hasTemporaryDecks,
}: ShellProps) {
const [panel, setPanel] = useState<"chat" | "decks">("chat"); const [panel, setPanel] = useState<"chat" | "decks">("chat");
const { setOpen, setOpenMobile, isMobile } = useSidebar(); const { setOpen, setOpenMobile, isMobile, openMobile } = useSidebar();
const history = researchHistory(() => {
setPanel("chat");
if (isMobile) setOpenMobile(true);
else setOpen(true);
});
const navigation = [ const navigation = [
{ id: "chat", title: "Research chat", icon: MessageSquare }, { id: "chat", title: "Research chat", icon: MessageSquare },
{ id: "decks", title: "Deck manager", icon: Columns3 }, { id: "decks", title: "Deck manager", icon: Columns3 },
@@ -39,72 +51,82 @@ function Workspace({ sidebar, researchChat, children, hasTemporaryDecks }: Shell
return ( return (
<> <>
<Sidebar {(!isMobile || !openMobile) && history}
collapsible="icon" <div className="relative flex min-h-0 min-w-0 flex-1">
className="overflow-hidden transition-none *:data-[sidebar=sidebar]:flex-row" <Sidebar collapsible="icon" className="absolute! h-full overflow-hidden transition-none">
> <div className="flex h-full w-full min-w-0 flex-col">
<WorkspaceNavigation {isMobile && openMobile && history}
hasTemporaryDecks={hasTemporaryDecks} <div className="flex min-h-0 flex-1">
footer={ <WorkspaceNavigation
<SidebarMenuItem> hasTemporaryDecks={hasTemporaryDecks}
<SidebarMenuButton footer={
aria-label="Close sidebar" <SidebarMenuItem>
tooltip={{ children: "Close sidebar", hidden: false }} <SidebarMenuButton
onClick={() => (isMobile ? setOpenMobile(false) : setOpen(false))} aria-label="Close sidebar"
className="size-10 justify-center p-0" tooltip={{ children: "Close sidebar", hidden: false }}
onClick={() => (isMobile ? setOpenMobile(false) : setOpen(false))}
className="size-10 justify-center p-0"
>
<PanelLeftClose />
</SidebarMenuButton>
</SidebarMenuItem>
}
> >
<PanelLeftClose /> {navigation.map((item) => (
</SidebarMenuButton> <SidebarMenuItem key={item.id}>
</SidebarMenuItem> <SidebarMenuButton
} aria-label={item.title}
> tooltip={{ children: item.title, hidden: false }}
{navigation.map((item) => ( isActive={panel === item.id}
<SidebarMenuItem key={item.id}> onClick={() => {
<SidebarMenuButton setPanel(item.id);
aria-label={item.title} if (isMobile) setOpenMobile(true);
tooltip={{ children: item.title, hidden: false }} else setOpen(true);
isActive={panel === item.id} }}
onClick={() => { className="size-10 justify-center p-0"
setPanel(item.id); >
if (isMobile) setOpenMobile(true); <item.icon />
else setOpen(true); <span className="sr-only">{item.title}</span>
}} </SidebarMenuButton>
className="size-10 justify-center p-0" </SidebarMenuItem>
))}
</WorkspaceNavigation>
<Sidebar
collapsible="none"
className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden"
> >
<item.icon /> <div className={panel === "chat" ? "flex min-h-0 flex-1 flex-col" : "hidden"}>
<span className="sr-only">{item.title}</span> {researchChat(() => setOpenMobile(false))}
</SidebarMenuButton> </div>
</SidebarMenuItem> <section
))} aria-label="Deck manager"
</WorkspaceNavigation> className={
<Sidebar collapsible="none" className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden"> panel === "decks"
<div className={panel === "chat" ? "flex min-h-0 flex-1 flex-col" : "hidden"}> ? "flex min-h-0 flex-1 flex-col overflow-y-auto p-4"
{researchChat(() => setOpenMobile(false))} : "hidden"
}
>
<h2 className="mb-4 text-base font-semibold">Decks</h2>
<div className="flex flex-col gap-4">{sidebar}</div>
</section>
</Sidebar>
</div>
</div> </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> </Sidebar>
</Sidebar> <SidebarInset className="min-w-0 overflow-hidden">
<SidebarInset className="min-w-0 overflow-hidden"> <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> <span className="ml-auto text-xs text-muted-foreground">Research workspace</span>
<span className="ml-auto text-xs text-muted-foreground">Research workspace</span> </header>
</header> <section
<section className="overflow-hidden flex min-h-0 min-w-0 flex-1 flex-col"
className="overflow-hidden flex min-h-0 min-w-0 flex-1 flex-col" aria-label="Decks"
aria-label="Decks" >
> {children}
{children} </section>
</section> </SidebarInset>
</SidebarInset> </div>
</> </>
); );
} }
+2
View File
@@ -14,6 +14,7 @@ const buttonVariants = cva(
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
tab: "rounded-b-none border-b-2 text-muted-foreground hover:bg-muted hover:text-foreground aria-[current=page]:border-b-primary aria-[current=page]:bg-background aria-[current=page]:text-foreground",
destructive: destructive:
"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",
@@ -24,6 +25,7 @@ const buttonVariants = cva(
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",
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",
+2 -1
View File
@@ -153,10 +153,11 @@ export function DeckPage() {
if (activate) clearEditors(); if (activate) clearEditors();
}} }}
> >
{(renderResearchPanel) => ( {(renderResearchPanel, researchHistory) => (
<AppShell <AppShell
hasTemporaryDecks={workspace.decks.some((deck) => !deck.persisted)} hasTemporaryDecks={workspace.decks.some((deck) => !deck.persisted)}
researchChat={renderResearchPanel} researchChat={renderResearchPanel}
researchHistory={researchHistory}
sidebar={ sidebar={
<> <>
<DeckSwitcher <DeckSwitcher
+13 -8
View File
@@ -117,7 +117,12 @@ function show(connections = [connection], ready = true) {
onOpenDeck={onOpenDeck} onOpenDeck={onOpenDeck}
onNavigateCitation={onNavigateCitation} onNavigateCitation={onNavigateCitation}
> >
{(renderPanel) => <div key={presentation}>{renderPanel()}</div>} {(renderPanel, renderHistory) => (
<div key={presentation}>
{renderHistory()}
{renderPanel()}
</div>
)}
</ResearchPanel> </ResearchPanel>
</QueryClientProvider> </QueryClientProvider>
); );
@@ -592,9 +597,7 @@ it("opens a saved chat with its context deck and selected accounts, preserving l
fireEvent.change(screen.getByLabelText("Message"), { fireEvent.change(screen.getByLabelText("Message"), {
target: { value: "別の下書き" }, target: { value: "別の下書き" },
}); });
fireEvent.change(screen.getByLabelText("Chat history"), { fireEvent.click(screen.getByRole("button", { name: "以前の調査" }));
target: { value: saved.id },
});
expect(await screen.findByText("保存した会話")).toBeVisible(); expect(await screen.findByText("保存した会話")).toBeVisible();
fireEvent.click(screen.getByRole("link", { name: "前回の引用" })); fireEvent.click(screen.getByRole("link", { name: "前回の引用" }));
expect(view.onNavigateCitation).toHaveBeenCalledWith( expect(view.onNavigateCitation).toHaveBeenCalledWith(
@@ -659,7 +662,11 @@ it("restores a context-only chat on reload and permits browsing history during a
}; };
const view = show(); const view = show();
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledWith(contextDeck, true)); await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledWith(contextDeck, true));
expect(screen.getByLabelText("Chat history")).toBeEnabled(); expect(screen.getByRole("button", { name: running.topic })).toBeEnabled();
expect(screen.getByRole("button", { name: running.topic })).toHaveAttribute(
"aria-current",
"page",
);
}); });
it("keeps newer scoped stream state when the selected chat fetch arrives late", async () => { it("keeps newer scoped stream state when the selected chat fetch arrives late", async () => {
@@ -682,9 +689,7 @@ it("keeps newer scoped stream state when the selected chat fetch arrives late",
: Promise.resolve(current), : Promise.resolve(current),
); );
const view = show(); const view = show();
fireEvent.change(await screen.findByLabelText("Chat history"), { fireEvent.click(await screen.findByRole("button", { name: "Saved chat" }));
target: { value: saved.id },
});
await waitFor(() => expect(MockEventSource.instances).toHaveLength(2)); await waitFor(() => expect(MockEventSource.instances).toHaveLength(2));
expect(MockEventSource.instances[0]?.close).toHaveBeenCalledOnce(); expect(MockEventSource.instances[0]?.close).toHaveBeenCalledOnce();
expect(MockEventSource.instances[1]?.url).toBe("/api/research/events?run=saved"); expect(MockEventSource.instances[1]?.url).toBe("/api/research/events?run=saved");
+62 -33
View File
@@ -1,6 +1,6 @@
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, SquareIcon } from "lucide-react"; import { ArrowUpIcon, PlusIcon, SquareIcon } 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";
@@ -23,7 +23,6 @@ import {
MessageScrollerProvider, MessageScrollerProvider,
MessageScrollerViewport, MessageScrollerViewport,
} from "#/components/ui/message-scroller"; } from "#/components/ui/message-scroller";
import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
import type { Connection } from "../connections/model"; import type { Connection } from "../connections/model";
import type { Deck } from "../decks/model"; import type { Deck } from "../decks/model";
import { useUrlState } from "../navigation/use-url-state"; import { useUrlState } from "../navigation/use-url-state";
@@ -51,6 +50,7 @@ type ResearchDraft = {
selected: string[] | null; selected: string[] | null;
}; };
type RenderResearchPanel = (onCitationNavigated?: () => void) => ReactNode; type RenderResearchPanel = (onCitationNavigated?: () => void) => ReactNode;
type RenderResearchHistory = (onSelect?: () => void) => ReactNode;
export function ResearchPanel({ export function ResearchPanel({
ready, ready,
@@ -65,14 +65,13 @@ export function ResearchPanel({
connections: Connection[]; connections: Connection[];
onOpenDeck: (deck: Deck, activate?: boolean) => unknown; onOpenDeck: (deck: Deck, activate?: boolean) => unknown;
onNavigateCitation?: (url: string, citations: ResearchCitation[], restore?: boolean) => boolean; onNavigateCitation?: (url: string, citations: ResearchCitation[], restore?: boolean) => boolean;
children?: (renderPanel: RenderResearchPanel) => ReactNode; children?: (renderPanel: RenderResearchPanel, renderHistory: RenderResearchHistory) => ReactNode;
}) { }) {
const [search, setSearch] = useUrlState(); const [search, setSearch] = useUrlState();
const fetchStatus = useServerFn(loadResearchStatus); const fetchStatus = useServerFn(loadResearchStatus);
const start = useServerFn(startResearch); const start = useServerFn(startResearch);
const cancel = useServerFn(cancelResearch); const cancel = useServerFn(cancelResearch);
const client = useQueryClient(); const client = useQueryClient();
const historyId = useId();
const selectedId = search.run === "new" ? undefined : search.run; const selectedId = search.run === "new" ? undefined : search.run;
const selection = useRef(selectedId); const selection = useRef(selectedId);
useEffect(() => { useEffect(() => {
@@ -292,6 +291,57 @@ export function ResearchPanel({
} }
} }
const renderHistory: RenderResearchHistory = (onSelect) => (
<nav
aria-label="Chat history"
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>
))}
{!selectedId && (
<span
className="flex h-10 w-48 shrink-0 items-center rounded-t-lg border-b-2 border-primary bg-background px-3 text-sm font-medium"
aria-current="page"
>
New chat
</span>
)}
</div>
<Button
variant="ghost"
size="icon"
type="button"
className="mx-2 self-center"
aria-label="New chat"
title="New chat"
disabled={busy || !run}
onClick={() => {
newChat();
onSelect?.();
}}
>
<PlusIcon />
</Button>
</nav>
);
const renderPanel: RenderResearchPanel = (onCitationNavigated) => ( const renderPanel: RenderResearchPanel = (onCitationNavigated) => (
<aside <aside
data-testid="research-panel" data-testid="research-panel"
@@ -303,34 +353,6 @@ export function ResearchPanel({
className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b p-4" className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b p-4"
> >
<h2 className="m-0 text-base">Research chat</h2> <h2 className="m-0 text-base">Research chat</h2>
<Button variant="ghost" size="sm" type="button" disabled={busy || !run} onClick={newChat}>
New chat
</Button>
{(status.data?.history?.length ?? 0) > 0 && (
<label
data-testid="research-history"
className="flex w-full min-w-0 items-center gap-2 text-research-label text-muted-foreground [&>[data-slot=native-select-wrapper]]:min-w-0 [&>[data-slot=native-select-wrapper]]:flex-1"
htmlFor={historyId}
>
<span className="shrink-0">Chat history</span>
<NativeSelect
id={historyId}
size="sm"
value={run?.id ?? ""}
disabled={busy}
onChange={(event) => selectChat(event.target.value)}
>
<NativeSelectOption value="" disabled>
New chat
</NativeSelectOption>
{status.data?.history?.map((entry) => (
<NativeSelectOption key={entry.id} value={entry.id}>
{entry.title}
</NativeSelectOption>
))}
</NativeSelect>
</label>
)}
</header> </header>
{reconnecting && ( {reconnecting && (
<p <p
@@ -410,7 +432,14 @@ export function ResearchPanel({
/> />
</aside> </aside>
); );
return children ? children(renderPanel) : renderPanel(); return children ? (
children(renderPanel, renderHistory)
) : (
<>
{renderHistory()}
{renderPanel()}
</>
);
} }
function ResearchConversation({ function ResearchConversation({