Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion DIVERGENCES.md

Large diffs are not rendered by default.

145 changes: 145 additions & 0 deletions packages/app/e2e/regression/new-session-reuses-draft.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
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)
})

// The tab bar's + and mod+t share `openNewTab`, which resolves the project from
// whatever is on screen and then opened an unconditional draft. Both are
// labelled "New session", so they follow the same rule.
// By hook, not by accessible name: the sidebar row is also a button named
// "New session", so a role query would have matched it and quietly re-tested
// the path the tests above already cover.
const plusButton = (page: Page) => page.locator('[data-action="titlebar-new-tab"]')

test("the tab bar + goes to the draft already open", async ({ page }) => {
await boot(page)
await newSession(page).click()
await expect(draftTabs(page)).toHaveCount(1)
const href = await draftTabs(page).locator("a").first().getAttribute("href")

await sidebar(page).locator('[data-action="sidebar-view-all"]').click()
await expect(page).not.toHaveURL(/\/new-session/)

await plusButton(page).click()
await expect(page).toHaveURL(/\/new-session/)
await expect(draftTabs(page)).toHaveCount(1)
expect(await draftTabs(page).locator("a").first().getAttribute("href")).toBe(href)
})

test("mod+t goes to the draft already open", async ({ page }) => {
await boot(page)
await newSession(page).click()
await expect(draftTabs(page)).toHaveCount(1)
const href = await draftTabs(page).locator("a").first().getAttribute("href")

await sidebar(page).locator('[data-action="sidebar-view-all"]').click()
await expect(page).not.toHaveURL(/\/new-session/)

// Modifier read from the page, not the host: the Desktop Chrome profile
// always reports Win32, so `mod` resolves to Control here (see #190).
const mac = await page.evaluate(() => /(Mac|iPod|iPhone|iPad)/.test(navigator.platform))
await page.keyboard.press(mac ? "Meta+t" : "Control+t")
await expect(page).toHaveURL(/\/new-session/)
await expect(draftTabs(page)).toHaveCount(1)
expect(await draftTabs(page).locator("a").first().getAttribute("href")).toBe(href)
})

test("a new draft opens at the head of the tab strip, not after the sessions", async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.addInitScript(
({ directory, seeded }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(seeded))
},
{
directory: fixture.directory,
seeded: [
{ type: "session", server: "http://127.0.0.1:4096", sessionId: fixture.sourceID },
{ type: "session", server: "http://127.0.0.1:4096", sessionId: fixture.targetID },
],
},
)
await page.goto("/")
await expect(tabs(page)).toHaveCount(2)

await newSession(page).click()
await expect(page).toHaveURL(/\/new-session/)
await expect(tabs(page)).toHaveCount(3)

// A draft is where you start, so it leads the strip rather than trailing
// every session already open. Session tabs still append.
await expect(tabs(page).first()).toContainText("New session")
await expect(tabs(page).nth(1)).toContainText("Uncommitted changes inquiry")
})
28 changes: 23 additions & 5 deletions packages/app/src/components/titlebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
import { useGlobal } from "@/context/global"
import { findOpenDraft } from "@/pages/home-draft-reuse"
import { ServerConnection, useServer } from "@/context/server"
import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt"
import type { PromptModel } from "@/context/prompt-state"
import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind"

Expand Down Expand Up @@ -325,6 +327,21 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
tabsStoreActions.removeSessions(detail)
})

// Same rule the sidebar's New session follows: land on the draft
// this project already has open rather than stacking an identical
// empty one beside it. Scoped to server + directory, so a draft for
// another project is never reused. The model carried over from the
// active tab only seeds a new draft — an existing one keeps the
// model it already has, exactly as selecting its tab by hand would.
const openDraftFor = (draft: { server: ServerConnection.Key; directory: string }, model?: PromptModel) => {
const existing = findOpenDraft(tabsStore, draft)
if (existing) {
tabs.select(existing)
return
}
tabs.newDraft(draft, "", model)
}

const openNewTab = () => {
const route = layout.route()
const activeSession = session()
Expand All @@ -335,14 +352,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
sessionId: activeSession.id,
}
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
openDraftFor({ server: sessionTab.server, directory: activeSession.directory }, model)
return
}

const activeTab = currentTab()
if (activeTab?.type === "draft") {
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
openDraftFor({ server: activeTab.server, directory: activeTab.directory }, model)
return
}

Expand All @@ -356,14 +373,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
.find((item) => item.worktree === selection.directory)
: undefined
if (conn && project) {
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
openDraftFor({ server: ServerConnection.key(conn), directory: project.worktree })
return
}
}

const current = layout.projects.list()[0]
if (current) {
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
openDraftFor({ server: server.key, directory: current.worktree })
return
}

Expand All @@ -373,7 +390,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
})[0]
if (!fallback) return

tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "")
openDraftFor({ server: fallback.server, directory: fallback.project.worktree })
}
// The home screen is a new session, not the recent-sessions list, so
// this goes there rather than toggling between "/" and the last tab.
Expand Down Expand Up @@ -573,6 +590,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewTab}
data-action="titlebar-new-tab"
aria-label={language.t("command.session.new")}
/>
</TooltipV2>
Expand Down
5 changes: 4 additions & 1 deletion packages/app/src/context/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
await startTransition(() => {
setStore(
produce((tabs) => {
tabs.push(tab)
// Front, not back: a draft is where you start, so it belongs at
// the head of the strip rather than after every session you have
// open. Session tabs still append.
tabs.unshift(tab)
}),
)
navigate(draftHref(draftID))
Expand Down
48 changes: 48 additions & 0 deletions packages/app/src/pages/home-draft-reuse.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
15 changes: 15 additions & 0 deletions packages/app/src/pages/home-draft-reuse.ts
Original file line number Diff line number Diff line change
@@ -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<T extends { type: string; server: string; directory?: string }>(
tabs: readonly T[],
target: { server: string; directory: string },
) {
return tabs.find((tab) => tab.type === "draft" && tab.server === target.server && tab.directory === target.directory)
}
12 changes: 11 additions & 1 deletion packages/app/src/pages/home-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Loading