Skip to content

Commit ab60ee2

Browse files
authored
feat(app): split new session project and worktree triggers (#60486)
1 parent 7b67adf commit ab60ee2

7 files changed

Lines changed: 183 additions & 150 deletions

File tree

‎packages/app/e2e/regression/new-session.spec.ts‎

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@ import { expect, test, type Locator, type Page } from "@playwright/test"
22
import type { OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise"
33
import { draftHref, expectPath, NO_PROVIDER, project, provider, seed, sessionHref, T0 } from "../utils/app"
44
import { mockOpenCodeServer } from "../utils/mock-server"
5-
import { newSessionLocation, openDraft, openSession, openWorktreeDraft, type WorkspaceInput } from "../utils/workspace"
5+
import {
6+
newSessionLocation,
7+
newSessionWorktree,
8+
openDraft,
9+
openSession,
10+
openWorktreeDraft,
11+
type WorkspaceInput,
12+
} from "../utils/workspace"
613

714
const directory = "C:/OpenCode/WorkspacePending"
815

@@ -52,19 +59,32 @@ test("the new session screen does not show session details", async ({ page }) =>
5259
})
5360

5461
test("the location shortcut keeps cycling after hovering its icon", async ({ page }) => {
55-
const view = await openDraft(page, { name: "LocationShortcut" })
62+
const existing = "C:/OpenCode/LocationShortcut/.worktrees/quiet-river"
63+
64+
const view = await openDraft(page, {
65+
name: "LocationShortcut",
66+
worktrees: () => [{ directory: "C:/OpenCode/LocationShortcut" }, { directory: existing, strategy: "git" }],
67+
})
68+
5669
const modifier = await page.evaluate(() => (/Mac/i.test(navigator.platform) ? "Meta" : "Control"))
70+
const path = page.locator('[data-slot="new-session-location-path"]')
71+
const icon = page.locator('[data-slot="new-session-location-icon"]')
72+
73+
// The worktree trigger already says Local, so the local path has no icon of its own.
74+
await expect(path).toHaveText(view.directory)
75+
await expect(icon).toHaveCount(0)
76+
await newSessionWorktree(page).click()
77+
await page.getByRole("option", { name: /^quiet-river / }).click()
5778
await expect(view.editor).toBeFocused()
79+
await expect(path).toHaveText(existing)
80+
await icon.hover()
81+
await expect(page.getByRole("tooltip")).toContainText("Toggle session location")
5882
await view.editor.press(`${modifier}+Alt+KeyL`)
59-
await expect(newSessionLocation(page)).toContainText("New worktree")
83+
await expect(newSessionWorktree(page)).toHaveText("Local")
84+
await expect(icon).toHaveCount(0)
6085
await expect(view.editor).toBeFocused()
6186
await view.editor.press(`${modifier}+Alt+KeyL`)
62-
await expect(newSessionLocation(page)).not.toContainText("New worktree")
63-
await expect(page.locator('[data-slot="new-session-location-path"]')).toHaveText(view.directory)
64-
await page.locator('[data-slot="new-session-location-icon"]').hover()
65-
await expect(page.getByRole("tooltip")).toContainText("Toggle session location")
66-
await view.editor.press(`${modifier}+Alt+KeyL`)
67-
await expect(newSessionLocation(page)).toContainText("New worktree")
87+
await expect(newSessionWorktree(page)).toContainText("New worktree")
6888
await expect(view.editor).toBeFocused()
6989
})
7090

@@ -172,7 +192,10 @@ test("the combined location picker shows checkout branches and chooses the base
172192
await expect(page.getByRole("option")).toHaveCount(1)
173193
await page.getByRole("option", { name: /Custom project C:\/OpenCode\/BranchContext/ }).click()
174194
await expect(view.editor).toBeFocused()
195+
await expect(screen.getByRole("button", { name: "Local", exact: true })).toBeVisible()
196+
// The local checkout's branch is context, not a control: the app cannot switch it.
175197
await expect(screen.getByText("timeline-quotes", { exact: true })).toBeVisible()
198+
await expect(screen.getByRole("button", { name: "timeline-quotes", exact: true })).toHaveCount(0)
176199
await expect(screen.getByRole("button", { name: /from / })).toHaveCount(0)
177200
await select(/^New worktree/)
178201
await expect(base).toBeVisible()
@@ -191,7 +214,7 @@ test("the combined location picker shows checkout branches and chooses the base
191214
.toEqual([expect.objectContaining({ body: expect.objectContaining({ text: "Use the selected base" }) })])
192215
})
193216

194-
test("the checkout branch opens the current project's worktree step", async ({ page }) => {
217+
test("the worktree trigger opens the current project's worktree step", async ({ page }) => {
195218
const existing = "C:/OpenCode/BranchWorktrees/.worktrees/quiet-river"
196219

197220
const view = await openDraft(page, {
@@ -201,13 +224,14 @@ test("the checkout branch opens the current project's worktree step", async ({ p
201224
})
202225

203226
const screen = page.locator('[data-component="new-session"]')
204-
const branch = screen.getByRole("button", { name: "dev", exact: true })
227+
// The app cannot switch the checkout branch, so the local checkout names the mode instead of "dev".
228+
const local = newSessionWorktree(page)
205229
const dialog = page.getByRole("dialog")
206230
const search = dialog.getByRole("combobox", { name: "Search worktrees", exact: true })
207231
const options = dialog.getByRole("option")
208232
const back = dialog.getByRole("button", { name: "Back to projects", exact: true })
209233

210-
await branch.click()
234+
await local.click()
211235
await expect(search).toBeFocused()
212236
await expect(back).toHaveText("Run session in")
213237
await expect(options.locator(".command-palette-title")).toHaveText([
@@ -228,19 +252,19 @@ test("the checkout branch opens the current project's worktree step", async ({ p
228252
await expect(view.editor).toBeFocused()
229253

230254
// Escape also steps back first, then closes.
231-
await branch.focus()
255+
await local.focus()
232256
await page.keyboard.press("Enter")
233257
await expect(search).toBeFocused()
234258
await search.press("Escape")
235259
await expect(projects).toBeFocused()
236260
await projects.press("Escape")
237261
await expect(dialog).toHaveCount(0)
238262

239-
await branch.click()
263+
await local.click()
240264
await options.filter({ hasText: "quiet-river" }).click()
241265
await expect(dialog).toHaveCount(0)
242-
await expect(newSessionLocation(page)).toContainText("quiet-river")
243-
await expect(screen.getByRole("button", { name: "quiet-river-branch", exact: true })).toBeVisible()
266+
await expect(newSessionWorktree(page)).toContainText("quiet-river")
267+
await expect(screen.getByText("quiet-river-branch", { exact: true })).toBeVisible()
244268
await expect(view.editor).toBeFocused()
245269
})
246270

@@ -619,7 +643,7 @@ for (const row of [
619643

620644
if (row.followUp) return
621645
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
622-
await expect(newSessionLocation(page)).toContainText("New worktree")
646+
await expect(newSessionWorktree(page)).toContainText("New worktree")
623647
await expect(pending.message).toHaveCount(0)
624648
await expect(tabLink(page, pending.sessionID)).toHaveCount(0)
625649
expect(mock.creates).toEqual([])
@@ -636,7 +660,7 @@ test("retains the draft and reuses the created workspace after session creation
636660
await expect(page.getByText("Failed to create session", { exact: true })).toBeVisible()
637661
await expect(editor(page)).toHaveText(text)
638662
await expect(submit(page)).toBeEnabled()
639-
await expect(newSessionLocation(page)).toContainText("pending-workspace")
663+
await expect(newSessionWorktree(page)).toContainText("pending-workspace")
640664
await expect(pending.shimmer).toHaveCount(0)
641665
await expect(pending.message).toHaveCount(0)
642666
expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })])
@@ -684,7 +708,7 @@ test("restores the draft after closing and revisiting a pending session that fai
684708
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
685709
await expect(editor(page)).toHaveText(`${text}\n\n${followUp}`)
686710
await expect(submit(page)).toBeEnabled()
687-
await expect(newSessionLocation(page)).toContainText("New worktree")
711+
await expect(newSessionWorktree(page)).toContainText("New worktree")
688712
await expect(page.locator(`[data-titlebar-tab-link][href="${draftHref(draftID)}"]`)).toHaveCount(1)
689713
await expect(tab).toHaveCount(0)
690714
await expect(pending.message).toHaveCount(0)

‎packages/app/e2e/utils/workspace.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,16 @@ export async function openDraft(page: Page, input: WorkspaceInput & { draftID?:
8787
// Opens a draft on the local project with "New worktree" selected. Worktree creation stays pending until the test
8888
// resolves `worktree`; POST order (`worktree`, `session`, `prompt`), worktree requests, session creates and prompts are
8989
// recorded. The draft tab comes first, then a tab per session.
90-
// The new-session trigger that shows the draft's project and worktree and opens the location dialog.
90+
// The new-session trigger that shows the draft's project and opens the location dialog on its projects.
9191
export function newSessionLocation(page: Page) {
9292
return page.locator('[data-component="new-session"] [data-action="prompt-project"]')
9393
}
9494

95+
// The new-session trigger that shows the draft's worktree, or Local, and opens the project's worktree step.
96+
export function newSessionWorktree(page: Page) {
97+
return page.locator('[data-component="new-session"] [data-action="prompt-worktree"]')
98+
}
99+
95100
export async function openWorktreeDraft(page: Page, input: WorkspaceInput & { draftID?: string }) {
96101
const worktree = Promise.withResolvers<{ status: number; json: unknown }>()
97102
const calls: string[] = []
@@ -139,13 +144,10 @@ export async function openWorktreeDraft(page: Page, input: WorkspaceInput & { dr
139144
await page.goto(draftHref(draftID))
140145
const editor = page.locator('[data-component="composer-editor"]')
141146
await expect(editor).toBeVisible({ timeout: APP_READY_TIMEOUT })
142-
await newSessionLocation(page).click()
143-
// The dialog opens on projects; → lists the current project's worktrees.
144-
const search = page.getByRole("combobox", { name: "Search projects", exact: true })
145-
await expect(search).toBeFocused()
146-
await search.press("ArrowRight")
147+
await newSessionWorktree(page).click()
148+
await expect(page.getByRole("combobox", { name: "Search worktrees", exact: true })).toBeFocused()
147149
await page.getByRole("option", { name: /^New worktree/ }).click()
148-
await expect(newSessionLocation(page)).toContainText("New worktree")
150+
await expect(newSessionWorktree(page)).toContainText("New worktree")
149151
await expect(editor).toBeEditable()
150152

151153
return { ...workspace, draftID, editor, worktree, calls, worktreeRequests, creates, prompts }

‎packages/app/src/new-session/location/dialog.css‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,6 @@
111111
color: var(--v2-text-text-faint);
112112
}
113113

114-
.new-session-location .new-session-location-workspace .command-palette-description {
115-
font-size: 11px;
116-
letter-spacing: 0.05px;
117-
}
118-
119114
.new-session-location-workspace .command-palette-row-icon {
120115
color: var(--v2-icon-icon-base);
121116
}
@@ -141,17 +136,25 @@
141136
font-variant-numeric: tabular-nums;
142137
}
143138

139+
/* Its own hover, layered over the row's, shows that it drills in rather than choosing the row. The negative margin
140+
keeps the glyph where the 16px slot put it. */
144141
.new-session-location-drill {
145142
display: flex;
146-
width: 16px;
147-
height: 16px;
143+
width: 20px;
144+
height: 20px;
148145
flex-shrink: 0;
149146
align-items: center;
150147
justify-content: center;
148+
margin-inline: -2px;
151149
border-radius: 4px;
152150
color: var(--v2-icon-icon-faint);
153151
}
154152

153+
.new-session-location-drill:hover {
154+
background: var(--v2-overlay-simple-overlay-hover);
155+
color: var(--v2-icon-icon-base);
156+
}
157+
155158
/* Keep the name legible while the path gives up space first. */
156159
.new-session-location-name {
157160
display: flex;

0 commit comments

Comments
 (0)