From aabeb33f41dac7cc975861227920745c720191fe Mon Sep 17 00:00:00 2001 From: yuta Date: Wed, 7 Oct 2026 22:44:00 +0900 Subject: [PATCH] feat: make Research tabs closable and preserve workspace navigation --- README.md | 5 +- e2e/integrations/research.test.ts | 25 ++- e2e/integrations/workspace.test.ts | 9 +- src/components/app-shell.tsx | 148 +++++++++--------- src/components/ui/button.tsx | 12 +- src/features/navigation/search.test.ts | 10 ++ src/features/navigation/search.ts | 1 + src/features/research/research-panel.test.tsx | 73 ++++++++- src/features/research/research-panel.tsx | 109 ++++++++++--- 9 files changed, 287 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 93a73e1..d551f91 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,7 @@ See [execution support](docs/execution-support.md) and the Reader search and article selection, Messages views and conversations, Home previews, account management, Journal review/correction views, and Vitals -navigation are represented in search parameters. Research uses deck, run, and -citation IDs. Reloading or browser Back/Forward restores the view while its +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 underlying data remains available. Login preserves the requested workspace URL. 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 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** 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. 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 22765d0..3fc7f19 100644 --- a/e2e/integrations/research.test.ts +++ b/e2e/integrations/research.test.ts @@ -348,9 +348,17 @@ test("keeps browser conversation selection independent and restores it on reload 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" }); + const firstTab = history.getByRole("button", { name: "Browser one conversation", exact: true }); + const secondTab = history.getByRole("button", { + name: "Browser two conversation", + exact: true, + }); 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 secondTab.click(); 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 page.goBack(); 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}`, { waitUntil: "domcontentloaded", }); diff --git a/e2e/integrations/workspace.test.ts b/e2e/integrations/workspace.test.ts index b32f315..b211d73 100644 --- a/e2e/integrations/workspace.test.ts +++ b/e2e/integrations/workspace.test.ts @@ -18,14 +18,19 @@ test("keeps the sidebar, composer and deck reachable at compact widths", async ( 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); + 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 = width < 768 ? await page .getByRole("dialog", { name: "Workspace" }) .evaluate((element) => element.clientWidth) : width; - expect(historyBounds.width).toBe(availableWidth); + expect(historyBounds.width).toBe(availableWidth - chatBounds.x); expect(historyBounds.y + historyBounds.height).toBeLessThanOrEqual(chatBounds.y); const composer = researchPage.composer; await expect(composer).toBeVisible(); diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index c1916e7..ce620c0 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -23,7 +23,7 @@ type ShellProps = { export function AppShell(props: ShellProps) { return ( - + @@ -51,82 +51,80 @@ function Workspace({ return ( <> - {(!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" - > - - {item.title} - - - ))} - - -
- {researchChat(() => setOpenMobile(false))} -
-
{history}
+ )} + +
+ + (isMobile ? setOpenMobile(false) : setOpen(false))} + className="size-10 justify-center p-0" > -

Decks

-
{sidebar}
- - -
-
-
- -
- - Research - Research workspace -
-
+ + + } > - {children} -
-
-
+ {navigation.map((item) => ( + + { + setPanel(item.id); + if (isMobile) setOpenMobile(true); + else setOpen(true); + }} + className="size-10 justify-center p-0" + > + + {item.title} + + + ))} + + + + + +