diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts
index 2ff07837f045..0913e1e4ad21 100644
--- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts
+++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts
@@ -46,6 +46,102 @@ test("new session tab matches neighboring session widths", async ({ page }, test
}
})
+for (const preference of ["current-tab", "last-selected"] as const) {
+ test(`new session tab uses ${preference} project preference`, async ({ page }) => {
+ const selectedDirectory = "C:/selected-project"
+ const lastDirectory = "C:/last-project"
+ await mockServer(page)
+ await page.addInitScript(
+ ({ server, sessionID, directory, selectedDirectory, lastDirectory, preference }) => {
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newTabProject: preference } }))
+ localStorage.setItem(
+ "opencode.global.dat:server",
+ JSON.stringify({
+ projects: {
+ local: [{ worktree: directory }, { worktree: selectedDirectory }, { worktree: lastDirectory }],
+ },
+ lastProject: { local: lastDirectory },
+ }),
+ )
+ localStorage.setItem(
+ "opencode.global.dat:layout",
+ JSON.stringify({ home: { selection: { server, directory: selectedDirectory } } }),
+ )
+ localStorage.setItem(
+ "opencode.window.browser.dat:tabs",
+ JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
+ )
+ },
+ { server, sessionID: sessionA.id, directory: sessionA.directory, selectedDirectory, lastDirectory, preference },
+ )
+
+ await page.goto(`/server/${base64Encode(server)}/session/${sessionA.id}`)
+ await expect(page.locator('[data-slot="titlebar-tabs"]').getByText(sessionA.title, { exact: true })).toBeVisible()
+ await expect(page.getByRole("textbox", { name: "Prompt" })).toBeVisible()
+ await page.keyboard.press("Control+n")
+ await expect(page.locator('[data-titlebar-tab-link][href^="/new-session?draftId="]')).toBeVisible()
+ await expect(
+ page.getByRole("button", { name: preference === "current-tab" ? "tab-project" : "last-project" }),
+ ).toBeVisible()
+ })
+}
+
+test("new tab project preference is editable and persists", async ({ page }) => {
+ await mockServer(page)
+ await page.goto("/settings")
+ const setting = page.locator('[data-action="settings-new-tab-project"]')
+ await expect(setting).toContainText("Current tab's directory")
+ await setting.click()
+ await page.getByRole("option", { name: "Last selected project" }).click()
+ await expect(setting).toContainText("Last selected project")
+ await page.reload()
+ await expect(setting).toContainText("Last selected project")
+})
+
+test("selecting a project on Home updates the last-selected new tab destination", async ({ page }) => {
+ const selectedDirectory = "C:/selected-project"
+ await mockServer(page)
+ await page.addInitScript(
+ ({ server, sessionID, directory, selectedDirectory }) => {
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newTabProject: "last-selected" } }))
+ localStorage.setItem(
+ "opencode.global.dat:server",
+ JSON.stringify({
+ projects: { local: [{ worktree: directory }, { worktree: selectedDirectory }] },
+ lastProject: { local: directory },
+ }),
+ )
+ localStorage.setItem(
+ "opencode.window.browser.dat:tabs",
+ JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
+ )
+ },
+ { server, sessionID: sessionA.id, directory: sessionA.directory, selectedDirectory },
+ )
+
+ await page.goto("/")
+ const project = page.locator('[data-component="home-project-row"]').filter({ hasText: "selected-project" })
+ await expect(project).toBeVisible()
+ await project.click()
+ await expect(project).toHaveAttribute("aria-current", "page")
+ await expect
+ .poll(() =>
+ page.evaluate(() => {
+ const state = JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}") as {
+ lastProject?: { local?: string }
+ }
+ return state.lastProject?.local
+ }),
+ )
+ .toBe(selectedDirectory)
+
+ await page.locator(`[data-titlebar-tab-link][href="/server/${base64Encode(server)}/session/${sessionA.id}"]`).click()
+ await expect(page).toHaveURL(new RegExp(`/session/${sessionA.id}$`))
+ await page.keyboard.press("Control+t")
+ await expect(page).toHaveURL(/\/new-session\?draftId=/)
+ await expect(page.getByRole("button", { name: "selected-project" })).toBeVisible()
+})
+
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
diff --git a/packages/app/src/home/model.ts b/packages/app/src/home/model.ts
index f93e4193c3db..946a1004f16c 100644
--- a/packages/app/src/home/model.ts
+++ b/packages/app/src/home/model.ts
@@ -116,7 +116,9 @@ export function createHomeController() {
.some((project) => project.worktree === directory)
)
return
- setSelection(toggleHomeProjectSelection(selection(), key, directory))
+ const next = toggleHomeProjectSelection(selection(), key, directory)
+ if (next.directory) global.ensureServerCtx(conn).projects.touch(directory)
+ setSelection(next)
},
add: (conn: ServerConnection.Any, directories: string[]) => {
const directory = addProjects(global.ensureServerCtx(conn), directories)
diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts
index 3a0618536992..d8e522421494 100644
--- a/packages/app/src/runtime/i18n/en.ts
+++ b/packages/app/src/runtime/i18n/en.ts
@@ -1316,6 +1316,10 @@ export const dict = {
"Choose whether to queue follow-ups or steer the current turn. Use {{keybind}} to switch.",
"settings.general.row.followUpBehavior.queue": "Queue",
"settings.general.row.followUpBehavior.steer": "Steer",
+ "settings.general.row.newTabProject.title": "New tab project",
+ "settings.general.row.newTabProject.description": "Choose where new session tabs start",
+ "settings.general.row.newTabProject.currentTab": "Current tab's directory",
+ "settings.general.row.newTabProject.lastSelected": "Last selected project",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.reasoningMode.title": "Model reasoning",
diff --git a/packages/app/src/settings/general/general.tsx b/packages/app/src/settings/general/general.tsx
index 9a63b508db45..14a7fd64e8cf 100644
--- a/packages/app/src/settings/general/general.tsx
+++ b/packages/app/src/settings/general/general.tsx
@@ -10,6 +10,7 @@ import { usePlatform } from "@/runtime/platform/platform"
import { useUpdaterAction } from "@/shell/updates/action"
import {
type FollowUpBehavior,
+ type NewTabProject,
type TerminalPlacement,
type WorkspaceDefaultDestination,
useSettings,
@@ -184,6 +185,33 @@ const FollowUpBehaviorSetting: Component = () => {
)
}
+const NewTabProjectSetting: Component = () => {
+ const language = useLanguage()
+ const settings = useSettings()
+ const options = createMemo((): { value: NewTabProject; label: string }[] => [
+ { value: "current-tab", label: language.t("settings.general.row.newTabProject.currentTab") },
+ { value: "last-selected", label: language.t("settings.general.row.newTabProject.lastSelected") },
+ ])
+
+ return (
+
+
+ )
+}
+
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
const language = useLanguage()
return (
@@ -348,6 +376,7 @@ export const SettingsGeneral: Component = () => {
+
diff --git a/packages/app/src/settings/model.test.ts b/packages/app/src/settings/model.test.ts
index e73e829c7a58..28596c726078 100644
--- a/packages/app/src/settings/model.test.ts
+++ b/packages/app/src/settings/model.test.ts
@@ -76,6 +76,7 @@ describe("settings schema", () => {
mobileDiffWrap: true,
terminalPlacement: "side",
followUpBehavior: "steer",
+ newTabProject: "current-tab",
},
sessionSummary: { projectExpanded: true, serverExpanded: true },
appearance: {
@@ -100,6 +101,12 @@ describe("settings schema", () => {
})
})
+ test("round trips the new tab project choice and retains the previous default", () => {
+ expect(decode({ general: {} }).general.newTabProject).toBe("current-tab")
+ const settings = decode({ general: { newTabProject: "last-selected" } })
+ expect(decode(encode(settings)).general.newTabProject).toBe("last-selected")
+ })
+
test("defaults invalid preferences locally while retaining valid siblings", () => {
const settings = decode({
general: {
@@ -108,6 +115,7 @@ describe("settings schema", () => {
releaseNotes: undefined,
reasoningMode: 3,
followUpBehavior: "invalid",
+ newTabProject: "invalid",
},
appearance: { fontSize: "large", mono: "Custom Mono", tabLayout: "vertical", showProjectName: true },
permissions: { autoApprove: true },
@@ -122,6 +130,7 @@ describe("settings schema", () => {
releaseNotes: true,
timelineDetail: timelinePresets[2].value,
followUpBehavior: "steer",
+ newTabProject: "current-tab",
})
expect(settings.appearance).toEqual({
fontSize: 14,
diff --git a/packages/app/src/settings/model.tsx b/packages/app/src/settings/model.tsx
index 8a01d9342728..54b28bc06d62 100644
--- a/packages/app/src/settings/model.tsx
+++ b/packages/app/src/settings/model.tsx
@@ -12,6 +12,7 @@ export type WorkspaceDefaultDestination = Settings["workspaces"]["defaultDestina
export type WorkspaceLastUsed = Settings["workspaces"]["lastUsed"][string]
export type TerminalPlacement = Settings["general"]["terminalPlacement"]
export type FollowUpBehavior = Settings["general"]["followUpBehavior"]
+export type NewTabProject = Settings["general"]["newTabProject"]
export type TabLayout = Settings["appearance"]["tabLayout"]
export type NotificationSettings = Settings["notifications"]
export type SoundSettings = Settings["sounds"]
@@ -93,6 +94,7 @@ const generalSchema = Persistence.struct({
mobileDiffWrap: Schema.Boolean,
terminalPlacement: Schema.Literals(["side", "bottom"]),
followUpBehavior: Schema.Literals(["queue", "steer"]),
+ newTabProject: Schema.Literals(["current-tab", "last-selected"]),
})
const appearanceSchema = Persistence.struct({
@@ -247,6 +249,7 @@ export const defaultSettings: Settings = {
mobileDiffWrap: true,
terminalPlacement: "side",
followUpBehavior: "steer",
+ newTabProject: "current-tab",
},
sessionSummary: { projectExpanded: true, serverExpanded: true },
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal" },
@@ -350,6 +353,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setFollowUpBehavior(value: FollowUpBehavior) {
setStore("general", "followUpBehavior", value)
},
+ newTabProject: withFallback(() => store.general?.newTabProject, defaultSettings.general.newTabProject),
+ setNewTabProject(value: NewTabProject) {
+ setStore("general", "newTabProject", value)
+ },
},
sessionSummary: {
projectExpanded: withFallback(
diff --git a/packages/app/src/settings/search-catalog.ts b/packages/app/src/settings/search-catalog.ts
index 0cf4f6b69222..5d82aae5eb3f 100644
--- a/packages/app/src/settings/search-catalog.ts
+++ b/packages/app/src/settings/search-catalog.ts
@@ -42,6 +42,13 @@ export const clientSettings: Entry[] = [
target: "settings-tab-layout",
keywords: "vertical horizontal tabs",
},
+ {
+ tab: "general",
+ label: "settings.general.row.newTabProject.title",
+ target: "settings-new-tab-project",
+ description: "settings.general.row.newTabProject.description",
+ keywords: "new tab session directory project last selected",
+ },
{
tab: "general",
label: "settings.workspaces.default.title",
diff --git a/packages/app/src/settings/search-results.test.ts b/packages/app/src/settings/search-results.test.ts
index 3324728eeb30..7ee37fca4251 100644
--- a/packages/app/src/settings/search-results.test.ts
+++ b/packages/app/src/settings/search-results.test.ts
@@ -54,6 +54,7 @@ describe("settings search index", () => {
expect(targets({})).not.toContain("settings-experimental-browser")
expect(targets({})).not.toContain("settings-show-project-icon")
expect(targets({})).toContain("settings-tab-layout")
+ expect(targets({})).toContain("settings-new-tab-project")
expect(targets({ desktop: true })).toContain("settings-pinch-zoom")
expect(targets({ browser: true })).not.toContain("settings-experimental-browser")
expect(targets({})).not.toContain("settings-show-project-icon")
diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx
index b90fa873e591..f078f38e3fc2 100644
--- a/packages/app/src/shell/titlebar/titlebar.tsx
+++ b/packages/app/src/shell/titlebar/titlebar.tsx
@@ -287,56 +287,66 @@ export function Titlebar(props: {
tabsStoreActions.removeSessions(detail)
})
+ const homeProject = (lastSelected: boolean) => {
+ const selection = layout.home.selection()
+ const conn =
+ global.servers.list().find((item) => ServerConnection.key(item) === selection.server) ??
+ global.servers.list()[0]
+ const projects = conn ? global.ensureServerCtx(conn).projects : undefined
+ const selected = projects?.list().find((item) => item.worktree === selection.directory)
+ const last = projects?.list().find((item) => item.worktree === projects.last())
+ const project = (lastSelected ? (last ?? selected) : (selected ?? last)) ?? projects?.list()[0]
+ if (conn && project) return { server: ServerConnection.key(conn), directory: project.worktree }
+ }
+
const openNewTab = () => {
const route = layout.route()
+ const lastSelected = settings.general.newTabProject() === "last-selected"
switch (route.type) {
case "session": {
const pending = tabs.pendingSession(route.server, route.sessionId)
if (pending) {
- const model = tabs.stateValue(pending.draft, "prompt")?.model.current()
- void tabs.newDraft({ server: route.server, directory: pending.draft.directory }, "", model)
+ const sourceModel = tabs.stateValue(pending.draft, "prompt")?.model.current()
+ const target = lastSelected
+ ? (homeProject(lastSelected) ?? { server: route.server, directory: pending.draft.directory })
+ : { server: route.server, directory: pending.draft.directory }
+ void tabs.newDraft(target, "", target.server === route.server ? sourceModel : undefined)
return
}
const activeSession = session()
- if (!activeSession) return
-
- const sessionTab = {
- type: "session" as const,
- server: route.server,
- sessionId: activeSession.id,
- }
- const model = tabs.stateValue(sessionTab, "prompt")?.model.current()
- void tabs.newDraft(
- { server: sessionTab.server, directory: activeSession.location.directory },
- "",
- model,
- )
+ if (!activeSession && !lastSelected) return
+
+ const sourceModel = activeSession
+ ? tabs
+ .stateValue(
+ { type: "session", server: route.server, sessionId: activeSession.id },
+ "prompt",
+ )
+ ?.model.current()
+ : undefined
+ const target = lastSelected
+ ? (homeProject(lastSelected) ??
+ (activeSession && { server: route.server, directory: activeSession.location.directory }))
+ : activeSession && { server: route.server, directory: activeSession.location.directory }
+ if (target) void tabs.newDraft(target, "", target.server === route.server ? sourceModel : undefined)
return
}
case "draft": {
const activeTab = currentTab()
if (activeTab?.type !== "draft") return
- const model = tabs.stateValue(activeTab, "prompt")?.model.current()
- void tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
+ const sourceModel = tabs.stateValue(activeTab, "prompt")?.model.current()
+ const target = lastSelected
+ ? (homeProject(lastSelected) ?? { server: activeTab.server, directory: activeTab.directory })
+ : { server: activeTab.server, directory: activeTab.directory }
+ void tabs.newDraft(target, "", target.server === activeTab.server ? sourceModel : undefined)
return
}
case "settings":
case "connect":
case "home": {
- const selection = layout.home.selection()
- const conn =
- global.servers.list().find((item) => ServerConnection.key(item) === selection.server) ??
- global.servers.list()[0]
- const projects = conn ? global.ensureServerCtx(conn).projects : undefined
- const project =
- projects?.list().find((item) => item.worktree === selection.directory) ??
- projects?.list().find((item) => item.worktree === projects.last()) ??
- projects?.list()[0]
- if (conn && project) {
- void tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
- return
- }
+ const target = homeProject(lastSelected)
+ if (target) void tabs.newDraft(target, "")
}
}
}