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}
-