From 17f7990cf086d5d4f124a8f05496bc2895a5bd75 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 11 Aug 2026 17:48:07 +0700 Subject: [PATCH 1/4] feat(app): send New session to the draft already open for that project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draft tabs persist until they are promoted into a session or closed, so every click on New session pushed another identical empty draft into the tab bar. Focus the one already open for that server and directory instead, and only create a draft when there is none to go to. Matched on server and directory because a draft belongs to a project; jumping to one pointing elsewhere would be a worse answer than opening a new draft. LaunchNewSession already reused a draft at boot for the same reason — this extends it to every deliberate New session. Covers the three labelled affordances, all of which route through createHomeModel: the sidebar row, the recent-sessions screen button and a project menu's New session. The tab bar's + and mod+t are left alone — they are `tab.new`, and always producing a new tab is the point. Both e2e assertions were run against the unfixed code and fail there, stacking two and three drafts respectively. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 11 ++++ .../new-session-reuses-draft.spec.ts | 64 +++++++++++++++++++ .../app/src/pages/home-draft-reuse.test.ts | 48 ++++++++++++++ packages/app/src/pages/home-draft-reuse.ts | 15 +++++ packages/app/src/pages/home-model.tsx | 12 +++- 5 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 packages/app/e2e/regression/new-session-reuses-draft.spec.ts create mode 100644 packages/app/src/pages/home-draft-reuse.test.ts create mode 100644 packages/app/src/pages/home-draft-reuse.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 080b92faaa..f554d3a2b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ file is the human-readable summary of what changed and why. ## Unreleased +### Desktop / Web UI + +- **New session goes to the draft you already have open** — clicking **New + session** used to add another empty draft tab every time, so a few clicks + left a row of identical ones. It now jumps to the draft already open for that + project, and only starts one when there isn't a draft to go to. This covers + the sidebar's **New session** row, the button on the recent-sessions screen + and the **New session** item in a project's menu. The **+** at the end of the + tab bar and `mod+t` are unchanged: those are new-_tab_ actions, and always + giving you a new one is the point. + ## 1.18.4-8 ### Desktop / Web UI diff --git a/packages/app/e2e/regression/new-session-reuses-draft.spec.ts b/packages/app/e2e/regression/new-session-reuses-draft.spec.ts new file mode 100644 index 0000000000..1c2330d8f8 --- /dev/null +++ b/packages/app/e2e/regression/new-session-reuses-draft.spec.ts @@ -0,0 +1,64 @@ +import { expect, test, type Page } from "@playwright/test" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" + +const sidebar = (page: Page) => page.locator('[data-component="app-sidebar"]') +const newSession = (page: Page) => sidebar(page).locator('[data-action="sidebar-new-session"]') +const tabs = (page: Page) => page.locator("[data-titlebar-tab-slot]") +const draftTabs = (page: Page) => tabs(page).filter({ has: page.locator('a[href^="/new-session"]') }) + +async function boot(page: Page) { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + }, fixture.directory) + await page.goto("/") + await expect(newSession(page)).toBeVisible({ timeout: 30_000 }) +} + +test("New session focuses the draft already open instead of stacking another", async ({ page }) => { + await boot(page) + expect(await draftTabs(page).count()).toBe(0) + + await newSession(page).click() + await expect(page).toHaveURL(/\/new-session/) + await expect(draftTabs(page)).toHaveCount(1) + const href = await draftTabs(page).locator("a").first().getAttribute("href") + expect(href).toBeTruthy() + + // Navigate away so the second click is a real navigation rather than a no-op + // on the page we are already sitting on. + await sidebar(page).locator('[data-action="sidebar-view-all"]').click() + await expect(page).not.toHaveURL(/\/new-session/) + + // The draft persists until it is promoted or closed, so a second New session + // used to push an identical empty draft beside the first. + await newSession(page).click() + await expect(page).toHaveURL(/\/new-session/) + await expect(draftTabs(page)).toHaveCount(1) + // And it is the same draft, not a replacement carrying a fresh id. + expect(await draftTabs(page).locator("a").first().getAttribute("href")).toBe(href) +}) + +test("a third click still lands on the one draft", async ({ page }) => { + await boot(page) + for (let click = 0; click < 3; click++) { + await newSession(page).click() + await expect(page).toHaveURL(/\/new-session/) + await sidebar(page).locator('[data-action="sidebar-view-all"]').click() + await expect(page).not.toHaveURL(/\/new-session/) + } + await expect(draftTabs(page)).toHaveCount(1) +}) diff --git a/packages/app/src/pages/home-draft-reuse.test.ts b/packages/app/src/pages/home-draft-reuse.test.ts new file mode 100644 index 0000000000..f25362e435 --- /dev/null +++ b/packages/app/src/pages/home-draft-reuse.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" +import { findOpenDraft } from "./home-draft-reuse" + +const server = "http://127.0.0.1:4096" +const other = "http://127.0.0.1:4097" +const directory = "C:/project" + +const draft = (overrides: { draftID?: string; server?: string; directory?: string } = {}) => ({ + type: "draft", + draftID: "draft-1", + server, + directory, + ...overrides, +}) +// Session tabs carry no directory at all, which is why the helper treats it as +// optional rather than requiring it of every tab. +const session = (sessionId = "ses_1") => ({ type: "session", server, sessionId }) + +describe("findOpenDraft", () => { + test("finds the draft already open for this project", () => { + const open = draft() + expect(findOpenDraft([session(), open], { server, directory })).toBe(open) + }) + + test("ignores session tabs", () => { + expect(findOpenDraft([session("a"), session("b")], { server, directory })).toBeUndefined() + }) + + test("will not reuse a draft pointing at another project", () => { + // Jumping to a draft for the wrong directory is worse than opening a new + // one, so this has to miss. + expect(findOpenDraft([draft({ directory: "C:/elsewhere" })], { server, directory })).toBeUndefined() + }) + + test("will not reuse a draft belonging to another server", () => { + expect(findOpenDraft([draft({ server: other })], { server, directory })).toBeUndefined() + }) + + test("takes the first match when several drafts exist for one project", () => { + const first = draft({ draftID: "draft-1" }) + const second = draft({ draftID: "draft-2" }) + expect(findOpenDraft([first, second], { server, directory })).toBe(first) + }) + + test("is undefined with no tabs at all", () => { + expect(findOpenDraft([], { server, directory })).toBeUndefined() + }) +}) diff --git a/packages/app/src/pages/home-draft-reuse.ts b/packages/app/src/pages/home-draft-reuse.ts new file mode 100644 index 0000000000..f1d8544a9c --- /dev/null +++ b/packages/app/src/pages/home-draft-reuse.ts @@ -0,0 +1,15 @@ +// Draft tabs persist until they are promoted into a session or closed, so +// "New session" used to stack an identical empty draft beside the one already +// open. Find that one instead of making another. +// +// Matched on server *and* directory: a draft belongs to a project, and jumping +// to one pointing somewhere else would be a worse answer than opening a new +// draft. Typed structurally — `directory` is optional because session tabs +// carry none — so this needs nothing from the tabs context, at build time or +// run time. +export function findOpenDraft( + tabs: readonly T[], + target: { server: string; directory: string }, +) { + return tabs.find((tab) => tab.type === "draft" && tab.server === target.server && tab.directory === target.directory) +} diff --git a/packages/app/src/pages/home-model.tsx b/packages/app/src/pages/home-model.tsx index aead0695ac..6a47a3d310 100644 --- a/packages/app/src/pages/home-model.tsx +++ b/packages/app/src/pages/home-model.tsx @@ -31,6 +31,7 @@ import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { pathKey } from "@/utils/path-key" import { showToast } from "@/utils/toast" import { archiveHomeSession } from "./home-session-archive" +import { findOpenDraft } from "./home-draft-reuse" import { shouldOpenSessionInBackground } from "./home-session-open" export const HOME_SESSION_LIMIT = 64 @@ -257,7 +258,16 @@ export function createHomeModel() { const ctx = global.ensureServerCtx(conn) ctx.projects.open(directory) ctx.projects.touch(directory) - tabs.newDraft({ server: ServerConnection.key(conn), directory }) + const server = ServerConnection.key(conn) + // Focus the draft this project already has open rather than stacking a + // second empty one beside it. LaunchNewSession does the same at boot; this + // extends it to every deliberate "New session". + const existing = findOpenDraft(tabs.store, { server, directory }) + if (existing) { + tabs.select(existing) + return + } + tabs.newDraft({ server, directory }) } function editProject(conn: ServerConnection.Any, project: LocalProject) { From 5a24ba15fabb9e6d61a8d415178f599397aa583e Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 11 Aug 2026 17:58:08 +0700 Subject: [PATCH 2/4] feat(app): extend draft reuse to the tab bar + and mod+t MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both share `openNewTab`, which resolved a project from whatever was on screen and then opened a draft unconditionally. All five of its resolution branches now go through a local `openDraftFor`, which selects the draft already open for that server and directory and only creates one when there is none. They are labelled "New session" and reached for with the same intent as the sidebar row, so splitting the behaviour on which control you happened to use was the wrong line to draw. Home (mod+b) already reused a draft; this makes the three consistent. The + gains data-action="titlebar-new-tab". Without it the e2e selector matched by accessible name, which the sidebar row also carries — the first version of that test silently re-tested the sidebar path instead. DIVERGENCES row 120 said Home's reuse was deliberately unlike the New session button, "which is expected to always make a new one". That is no longer true, so the rationale is corrected rather than left contradicting the shipped behaviour. Both new e2e assertions fail against the unfixed titlebar. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 15 +++---- DIVERGENCES.md | 2 +- .../new-session-reuses-draft.spec.ts | 41 +++++++++++++++++++ packages/app/src/components/titlebar.tsx | 28 ++++++++++--- 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f554d3a2b5..ec12e279bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,15 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI -- **New session goes to the draft you already have open** — clicking **New - session** used to add another empty draft tab every time, so a few clicks - left a row of identical ones. It now jumps to the draft already open for that +- **New session goes to the draft you already have open** — starting a new + session used to add another empty draft tab every time, so a few clicks left + a row of identical ones. It now jumps to the draft already open for that project, and only starts one when there isn't a draft to go to. This covers - the sidebar's **New session** row, the button on the recent-sessions screen - and the **New session** item in a project's menu. The **+** at the end of the - tab bar and `mod+t` are unchanged: those are new-_tab_ actions, and always - giving you a new one is the point. + every way in: the sidebar's **New session** row, the button on the + recent-sessions screen, the **New session** item in a project's menu, the + **+** at the end of the tab bar, and `mod+t`. A draft belongs to a project, + so starting a session in one project never lands you in another's draft — you + get a new draft there. Home (`mod+b`) already worked this way. ## 1.18.4-8 diff --git a/DIVERGENCES.md b/DIVERGENCES.md index ff4382af0a..36363f164d 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -117,7 +117,7 @@ from the squash message first, or use a plain merge. | `packages/ui/src/theme/themes/oc-2.json` | One character: the light variant's `"icon-weak-base": "C7C7C7"` → `"#C7C7C7"`. Not a brand change — an upstream typo, reported as [anomalyco/opencode#41503](https://github.com/anomalyco/opencode/issues/41503). Without the `#` it is still a valid *custom-property* declaration, so it parses cleanly and wins the cascade over the static `--icon-weak-base` in `packages/ui/src/styles/theme.css` (resolved tokens are injected into a `