From c46e78773898c136cb203e79e4ee766f27e95e88 Mon Sep 17 00:00:00 2001 From: yuta Date: Wed, 7 Oct 2026 22:28:58 +0900 Subject: [PATCH] feat: show research chat history as full-width tabs --- README.md | 2 +- e2e/integrations/research.test.ts | 11 ++ e2e/integrations/workspace.test.ts | 14 ++ src/components/app-shell.tsx | 152 ++++++++++-------- src/components/ui/button.tsx | 2 + src/features/decks/deck-page.tsx | 3 +- src/features/research/research-panel.test.tsx | 21 ++- src/features/research/research-panel.tsx | 95 +++++++---- 8 files changed, 192 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index a35c541..e43ca0c 100644 --- a/README.md +++ b/README.md @@ -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 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. -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. SQLite stores conversation snapshots. Active selection is not stored globally. Each turn is limited to 6 columns, 12 upstream requests shared between list diff --git a/e2e/integrations/research.test.ts b/e2e/integrations/research.test.ts index ec71331..22765d0 100644 --- a/e2e/integrations/research.test.ts +++ b/e2e/integrations/research.test.ts @@ -347,6 +347,17 @@ test("keeps browser conversation selection independent and restores it on reload const secondWorkspace = new WorkspacePage(secondPage); await researchPage.goTo(`?run=${first}`, { waitUntil: "domcontentloaded" }); 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}`, { waitUntil: "domcontentloaded", }); diff --git a/e2e/integrations/workspace.test.ts b/e2e/integrations/workspace.test.ts index 55c4d11..b32f315 100644 --- a/e2e/integrations/workspace.test.ts +++ b/e2e/integrations/workspace.test.ts @@ -13,6 +13,20 @@ test("keeps the sidebar, composer and deck reachable at compact widths", async ( await workspacePage.showResearch(); const chat = researchPage.panel; 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; await expect(composer).toBeVisible(); const bounds = await composer.boundingBox(); diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 5e2e787..c1916e7 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -15,6 +15,7 @@ import { WorkspaceNavigation } from "#/components/workspace-navigation"; type ShellProps = { hasTemporaryDecks: boolean; sidebar: ReactNode; + researchHistory: (onSelect: () => void) => ReactNode; researchChat: (closeMobileSidebar: () => void) => ReactNode; children: ReactNode; }; @@ -22,16 +23,27 @@ type ShellProps = { export function AppShell(props: ShellProps) { return ( - + ); } -function Workspace({ sidebar, researchChat, children, hasTemporaryDecks }: ShellProps) { +function Workspace({ + sidebar, + researchChat, + researchHistory, + children, + hasTemporaryDecks, +}: ShellProps) { 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 = [ { id: "chat", title: "Research chat", icon: MessageSquare }, { id: "decks", title: "Deck manager", icon: Columns3 }, @@ -39,72 +51,82 @@ function Workspace({ sidebar, researchChat, children, hasTemporaryDecks }: Shell return ( <> - - - (isMobile ? setOpenMobile(false) : setOpen(false))} - className="size-10 justify-center p-0" + {(!isMobile || !openMobile) && history} +
+ +
+ {isMobile && openMobile && history} +
+ + (isMobile ? setOpenMobile(false) : setOpen(false))} + className="size-10 justify-center p-0" + > + + + + } > - - - - } - > - {navigation.map((item) => ( - - { - setPanel(item.id); - if (isMobile) setOpenMobile(true); - else setOpen(true); - }} - className="size-10 justify-center p-0" + {navigation.map((item) => ( + + { + setPanel(item.id); + if (isMobile) setOpenMobile(true); + else setOpen(true); + }} + className="size-10 justify-center p-0" + > + + {item.title} + + + ))} + + - - {item.title} - - - ))} - - -
- {researchChat(() => setOpenMobile(false))} +
+ {researchChat(() => setOpenMobile(false))} +
+
+

Decks

+
{sidebar}
+
+ +
-
-

Decks

-
{sidebar}
-
- - -
- - Research - Research workspace -
-
- {children} -
-
+ +
+ + Research + Research workspace +
+
+ {children} +
+
+
); } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 4212cbb..881117f 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -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", ghost: "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: "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", @@ -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", 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", 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", diff --git a/src/features/decks/deck-page.tsx b/src/features/decks/deck-page.tsx index 292f455..91c2635 100644 --- a/src/features/decks/deck-page.tsx +++ b/src/features/decks/deck-page.tsx @@ -153,10 +153,11 @@ export function DeckPage() { if (activate) clearEditors(); }} > - {(renderResearchPanel) => ( + {(renderResearchPanel, researchHistory) => ( !deck.persisted)} researchChat={renderResearchPanel} + researchHistory={researchHistory} sidebar={ <> - {(renderPanel) =>
{renderPanel()}
} + {(renderPanel, renderHistory) => ( +
+ {renderHistory()} + {renderPanel()} +
+ )} ); @@ -592,9 +597,7 @@ it("opens a saved chat with its context deck and selected accounts, preserving l fireEvent.change(screen.getByLabelText("Message"), { target: { value: "別の下書き" }, }); - fireEvent.change(screen.getByLabelText("Chat history"), { - target: { value: saved.id }, - }); + fireEvent.click(screen.getByRole("button", { name: "以前の調査" })); expect(await screen.findByText("保存した会話")).toBeVisible(); fireEvent.click(screen.getByRole("link", { name: "前回の引用" })); 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(); 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 () => { @@ -682,9 +689,7 @@ it("keeps newer scoped stream state when the selected chat fetch arrives late", : Promise.resolve(current), ); const view = show(); - fireEvent.change(await screen.findByLabelText("Chat history"), { - target: { value: saved.id }, - }); + fireEvent.click(await screen.findByRole("button", { name: "Saved chat" })); await waitFor(() => expect(MockEventSource.instances).toHaveLength(2)); expect(MockEventSource.instances[0]?.close).toHaveBeenCalledOnce(); expect(MockEventSource.instances[1]?.url).toBe("/api/research/events?run=saved"); diff --git a/src/features/research/research-panel.tsx b/src/features/research/research-panel.tsx index b5297bb..da96511 100644 --- a/src/features/research/research-panel.tsx +++ b/src/features/research/research-panel.tsx @@ -1,6 +1,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; 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 { Bubble, BubbleContent } from "#/components/ui/bubble"; import { Button } from "#/components/ui/button"; @@ -23,7 +23,6 @@ import { MessageScrollerProvider, MessageScrollerViewport, } from "#/components/ui/message-scroller"; -import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select"; import type { Connection } from "../connections/model"; import type { Deck } from "../decks/model"; import { useUrlState } from "../navigation/use-url-state"; @@ -51,6 +50,7 @@ type ResearchDraft = { selected: string[] | null; }; type RenderResearchPanel = (onCitationNavigated?: () => void) => ReactNode; +type RenderResearchHistory = (onSelect?: () => void) => ReactNode; export function ResearchPanel({ ready, @@ -65,14 +65,13 @@ export function ResearchPanel({ connections: Connection[]; onOpenDeck: (deck: Deck, activate?: boolean) => unknown; onNavigateCitation?: (url: string, citations: ResearchCitation[], restore?: boolean) => boolean; - children?: (renderPanel: RenderResearchPanel) => ReactNode; + children?: (renderPanel: RenderResearchPanel, renderHistory: RenderResearchHistory) => ReactNode; }) { const [search, setSearch] = useUrlState(); const fetchStatus = useServerFn(loadResearchStatus); const start = useServerFn(startResearch); const cancel = useServerFn(cancelResearch); const client = useQueryClient(); - const historyId = useId(); const selectedId = search.run === "new" ? undefined : search.run; const selection = useRef(selectedId); useEffect(() => { @@ -292,6 +291,57 @@ export function ResearchPanel({ } } + const renderHistory: RenderResearchHistory = (onSelect) => ( + + ); + const renderPanel: RenderResearchPanel = (onCitationNavigated) => ( ); - return children ? children(renderPanel) : renderPanel(); + return children ? ( + children(renderPanel, renderHistory) + ) : ( + <> + {renderHistory()} + {renderPanel()} + + ); } function ResearchConversation({