Skip to content
Open
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
96 changes: 96 additions & 0 deletions packages/app/e2e/regression/tab-navigate-mousedown.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/home/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions packages/app/src/settings/general/general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<SettingsRow
title={language.t("settings.general.row.newTabProject.title")}
description={language.t("settings.general.row.newTabProject.description")}
>
<Select
data-action="settings-new-tab-project"
options={options()}
current={options().find((option) => option.value === settings.general.newTabProject())}
value={(option) => option.value}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && settings.general.setNewTabProject(option.value)}
/>
</SettingsRow>
)
}

const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
const language = useLanguage()
return (
Expand Down Expand Up @@ -348,6 +376,7 @@ export const SettingsGeneral: Component = () => {
<SettingsList>
<LanguageSetting />
<TabLayoutSetting />
<NewTabProjectSetting />

<WorkspaceDestinationSetting />
<AutoApprovePermissionsSetting />
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/settings/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ describe("settings schema", () => {
mobileDiffWrap: true,
terminalPlacement: "side",
followUpBehavior: "steer",
newTabProject: "current-tab",
},
sessionSummary: { projectExpanded: true, serverExpanded: true },
appearance: {
Expand All @@ -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: {
Expand All @@ -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 },
Expand All @@ -122,6 +130,7 @@ describe("settings schema", () => {
releaseNotes: true,
timelineDetail: timelinePresets[2].value,
followUpBehavior: "steer",
newTabProject: "current-tab",
})
expect(settings.appearance).toEqual({
fontSize: 14,
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/settings/model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/settings/search-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
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",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/settings/search-results.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
70 changes: 40 additions & 30 deletions packages/app/src/shell/titlebar/titlebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComposerState>(pending.draft, "prompt")?.model.current()
void tabs.newDraft({ server: route.server, directory: pending.draft.directory }, "", model)
const sourceModel = tabs.stateValue<ComposerState>(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<ComposerState>(sessionTab, "prompt")?.model.current()
void tabs.newDraft(
{ server: sessionTab.server, directory: activeSession.location.directory },
"",
model,
)
if (!activeSession && !lastSelected) return

const sourceModel = activeSession
? tabs
.stateValue<ComposerState>(
{ 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<ComposerState>(activeTab, "prompt")?.model.current()
void tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
const sourceModel = tabs.stateValue<ComposerState>(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, "")
}
}
}
Expand Down
Loading