feat(app): refine project and worktree picker (#59397)

This commit is contained in:
usrnk1
2026-10-27 15:27:58 +01:00
committed by GitHub
Unverified
parent 4a85158e27
commit 115113931c
10 changed files with 638 additions and 376 deletions
@@ -146,31 +146,39 @@ test("Search commands lists only commands and runs the chosen one", async ({ pag
await expect(page).toHaveURL("/settings")
})
test("Search projects opens a new session's project picker from a session and from Home", async ({ page }) => {
test("Search projects opens the full location picker from a session, draft, and Home", async ({ page }) => {
const editor = page.locator('[data-component="composer-editor"]')
const picker = page.getByRole("combobox", { name: "Search projects", exact: true })
const { dialog, input } = await openCommandPalette(page)
await input.fill("search projects")
await dialog.getByRole("option", { name: /^Search projects/ }).click()
await openPaletteWorkspace(page)
const shortcut = (await page.evaluate(() => navigator.platform.includes("Mac"))) ? "Meta+Shift+O" : "Control+Shift+O"
await page.keyboard.press(shortcut)
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
await expect(picker).toBeFocused()
// Just the projects: no "Add project", no worktrees and no key hints.
const projects = page.getByRole("dialog").getByRole("option")
await expect(projects).toHaveCount(1)
await expect(projects).toHaveText(/command-palette/)
await expect(projects.getByRole("button", { name: "Worktrees", exact: true })).toHaveCount(0)
await expect(page.locator(".new-session-location-hints")).toHaveCount(0)
await expect(projects.filter({ hasText: "Add project" })).toBeVisible()
await picker.fill("command-palette")
await expect(projects).toHaveCount(2)
await expect(projects.getByRole("button", { name: "Worktrees", exact: true })).toBeVisible()
await picker.press("ArrowRight")
await expect(page.getByRole("option", { name: /^New worktree/ })).toBeVisible()
await page.getByRole("combobox").press("Escape")
await picker.press("Escape")
await expect(picker).toHaveCount(0)
await expect(editor).toBeFocused()
await page.keyboard.press(shortcut)
await expect(picker).toBeFocused()
await expect(projects.filter({ hasText: "Add project" })).toBeVisible()
await picker.press("Escape")
await page.goto("/")
await expect(
page.getByRole("region", { name: "Recent sessions" }).getByRole("button", { name: /Palette fixture session/ }),
).toBeEnabled()
await page.keyboard.press("ControlOrMeta+Shift+O")
await page.keyboard.press(shortcut)
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
await expect(picker).toBeFocused()
await expect(projects.filter({ hasText: "Add project" })).toBeVisible()
})
declare global {
+4 -8
View File
@@ -179,7 +179,7 @@ test("Home lists the used server projects by recent activity, hides them, and li
await page.getByRole("menuitem", { name: "Close", exact: true }).click()
await expect(view.rows).toHaveText(["oldest"])
// The location dialog lists every server project once: used ones first, then the rest, each by recency.
// Closing a project moves it out of the added list, but it remains discoverable in the server inventory.
await page
.locator('[data-component="home-project-row"]')
.filter({ hasText: "oldest" })
@@ -188,13 +188,9 @@ test("Home lists the used server projects by recent activity, hides them, and li
.click()
await newSessionLocation(page).click()
const options = page.getByRole("dialog").getByRole("option")
await expect(options.locator(".command-palette-title")).toHaveText([
"Add project",
"oldest",
"fresh",
"unused",
"recent",
])
await expect(options.locator(".command-palette-title")).toHaveText(["oldest", "Other projects", "Add project"])
await options.filter({ hasText: "Other projects" }).click()
await expect(options.locator(".command-palette-title")).toHaveText(["fresh", "unused", "recent"])
await options.filter({ hasText: "C:/OpenCode/recent" }).click()
await expect(newSessionLocation(page)).toContainText("recent")
await page.getByRole("button", { name: "Home", exact: true }).click()
+121 -64
View File
@@ -51,6 +51,29 @@ test("the new session screen does not show session details", async ({ page }) =>
await expect(page.getByRole("button", { name: "Session details", exact: true })).toHaveCount(0)
})
for (const row of [
{
name: "POSIX",
directory: "/Users/alice/.local/share/opencode/worktree/123456/my-project",
pattern: /^\/Users\/.*…\/123456\/my-project$/,
},
{
name: "Windows",
directory: "C:\\Users\\alice\\Documents\\opencode\\worktree\\123456\\my-project",
pattern: /^C:\\.*…\\123456\\my-project$/,
},
]) {
test(`the location path keeps its start and final two segments on ${row.name}`, async ({ page }) => {
await page.setViewportSize({ width: 600, height: 700 })
await openDraft(page, { name: "A project with a long name", directory: row.directory })
const path = page.locator('[data-slot="new-session-location-path"]')
await expect(path).toHaveText(row.pattern)
await expect.poll(() => path.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true)
await path.hover()
await expect(page.getByRole("tooltip")).toHaveText(row.directory)
})
}
test("the combined location picker shows checkout branches and chooses the base for a new worktree", async ({
page,
}) => {
@@ -60,7 +83,7 @@ test("the combined location picker shows checkout branches and chooses the base
const view = await openWorktreeDraft(page, {
name: "BranchContext",
project: { name: "OpenCode" },
project: { name: "Custom project" },
vcs: (directory) => ({
current: directory === existing ? "model-picker-dialog" : "timeline-quotes",
default: "main",
@@ -126,7 +149,12 @@ test("the combined location picker shows checkout branches and chooses the base
await select(/^stellar-orchid /)
await expect(screen.getByText("model-picker-dialog", { exact: true })).toBeVisible()
await expect(screen.getByRole("button", { name: /from / })).toHaveCount(0)
await select(/^BranchContext /)
await newSessionLocation(page).click()
await page.getByRole("combobox", { name: "Search projects", exact: true }).press("ArrowRight")
await page.getByRole("combobox", { name: "Search worktrees", exact: true }).fill("Custom project")
await expect(page.getByRole("option")).toHaveCount(1)
await page.getByRole("option", { name: /Custom project C:\/OpenCode\/BranchContext/ }).click()
await expect(view.editor).toBeFocused()
await expect(screen.getByText("timeline-quotes", { exact: true })).toBeVisible()
await expect(screen.getByRole("button", { name: /from / })).toHaveCount(0)
await select(/^New worktree/)
@@ -649,9 +677,7 @@ test("executes a selected slash command after creating its worktree", async ({ p
expect(mock.prompts).toEqual([])
})
test("the location dialog lists every ready server's projects in one list and tags the remote ones", async ({
page,
}) => {
test("the location dialog separates added projects from server projects and tags the remote ones", async ({ page }) => {
const apollo = "http://apollo.test:4096"
const entry = (id: string, directory: string, active: number) =>
@@ -698,36 +724,33 @@ test("the location dialog lists every ready server's projects in one list and ta
await expect(search).toBeFocused()
const options = dialog.getByRole("option")
const add = options.filter({ hasText: "Add project" })
await expect(add.locator('[data-component="keybind-v2"]')).toContainText("O")
const rows = options.filter({ has: page.locator(".new-session-location-name") })
const names = options.locator(".new-session-location-name")
const paths = options.locator(".command-palette-description")
const chip = dialog.getByRole("button", { name: "Back to projects", exact: true })
const heading = dialog.getByText("Projects", { exact: true })
const addButton = dialog.getByRole("button", { name: "Add project", exact: true })
// Used projects first, then the rest, each by recent activity across servers; only remote ones carry a tag.
await expect(names).toHaveText(["opencodeapollo", "opencode", "console", "sstapollo"])
await expect(paths).toHaveText(["/home/dax/opencode", "C:/work/opencode", "C:/work/console", "/home/dax/sst"])
// "Add project" is the first row of the scrolling list, above the Projects heading, yet the first project is still
// the opening row, not the draft's current project (the second row).
await expect(dialog.locator("[data-scrollable]").getByRole("option").first()).toHaveText("Add project")
await expect(rows).toHaveCount(4)
await expect
.poll(async () => {
const row = await add.boundingBox()
const title = await heading.boundingBox()
return row && title ? row.y + row.height <= title.y : false
})
.toBe(true)
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
// The search field also ends with an "Add project" button, next in the Tab order.
await page.keyboard.press("Tab")
await expect(addButton).toBeFocused()
await search.focus()
const back = dialog.getByRole("button", { name: "Back to projects", exact: true })
// Only the projects added to this client open expanded; other ready-server projects remain discoverable.
await expect(names).toHaveText(["opencodeapollo", "opencode"])
await expect(paths).toHaveText(["/home/dax/opencode", "C:/work/opencode"])
const others = options.filter({ hasText: "Other projects" })
await expect(others).toBeVisible()
await expect(others.locator(".new-session-location-count")).toHaveText("2")
await expect(dialog.locator(".command-palette-group-title")).toHaveCount(0)
await expect(options.locator(".command-palette-title")).toHaveText([
"opencode",
"opencode",
"Other projects",
"Add project",
])
// It opens on the first row, not on the draft's current project (the second row).
await expect(options.nth(0)).toHaveAttribute("aria-selected", "true")
// The picker shares the palette's full-width search, compact rows, and single-line titles.
await expect(rows.nth(0)).toHaveCSS("height", "36px")
await expect(rows.nth(0)).toHaveCSS("display", "flex")
await expect(options.nth(0)).toHaveCSS("height", "28px")
await expect(dialog.locator(".palette-menu-search [data-component='text-input-v2']")).toHaveCSS("height", "28px")
await expect(dialog.locator(".new-session-location-hints")).toHaveCount(0)
await expect(dialog.locator("..")).toHaveCSS("width", "480px")
await expect(options.nth(0)).toHaveCSS("display", "flex")
await expect(names.nth(0).locator(".command-palette-title")).toHaveCSS("font-size", "13px")
await expect(names.nth(0).locator(".command-palette-title")).toHaveCSS("white-space", "nowrap")
await expect(rows.nth(0)).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
@@ -746,65 +769,99 @@ test("the location dialog lists every ready server's projects in one list and ta
})
.toBeLessThanOrEqual(1)
// Searching narrows the projects and keeps "Add project" first; with no match it is the active row.
await others.click()
await expect(names).toHaveText(["console", "sstapollo"])
await expect(back).toHaveText("Other projects")
await search.fill("no-project-matches")
await expect(options).toHaveCount(0)
await expect(back).toBeVisible()
await search.fill("")
await expect(names).toHaveText(["console", "sstapollo"])
await search.press("ArrowLeft")
await expect(names).toHaveText(["opencodeapollo", "opencode"])
await search.press("ArrowRight")
await expect(names).toHaveText(["console", "sstapollo"])
await search.press("Backspace")
await search.press("Enter")
await expect(names).toHaveText(["console", "sstapollo"])
await search.press("Escape")
await expect(names).toHaveText(["opencodeapollo", "opencode"])
await others.click()
await back.click()
await expect(names).toHaveText(["opencodeapollo", "opencode"])
await search.fill("apollo")
await expect(names).toHaveText(["opencodeapollo", "sstapollo"])
await expect(options.first()).toHaveText("Add project")
await search.fill("nothing matches")
await expect(options).toHaveText(["Add project"])
await expect(add).toHaveAttribute("aria-selected", "true")
await expect(heading).toHaveCount(0)
await expect(dialog.locator(".command-palette-group-title")).toHaveText("Other projects")
// The worktree step keeps the server's tag beside the project.
// The worktree step keeps the project's avatar and server tag in its main-checkout row.
await search.fill("opencode")
await expect(paths).toHaveText(["/home/dax/opencode", "C:/work/opencode"])
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
await page.keyboard.press("ArrowRight")
// The worktree step has no "Add project".
await expect(add).toHaveCount(0)
await expect(addButton).toHaveCount(0)
await expect(chip).toContainText("opencode")
await expect(chip.locator('[data-component="tag"]')).toHaveText("apollo")
await expect(back).toHaveText("Run session in")
await expect(options.nth(0).locator('[data-component="tag"]')).toHaveText("apollo")
await expect(options.locator(".command-palette-title")).toHaveText(["opencode", "New worktree", "opencode-beta"])
await expect(dialog.getByRole("separator")).toHaveCount(1)
await search.fill("opencode-beta")
await expect(options).toHaveCount(1)
await expect(dialog.getByRole("separator")).toHaveCount(0)
await search.fill("no-worktree-matches")
await expect(options).toHaveCount(0)
await expect(dialog.getByRole("separator")).toHaveCount(0)
await search.fill("")
await expect(options.nth(0)).toHaveAttribute("aria-selected", "true")
// The chip's avatar sits on the same column as the row icons below it.
await expect
.poll(async () => {
const avatar = await chip.locator('[data-component="project-avatar-v2"]').boundingBox()
const icon = await options.nth(0).locator(".command-palette-row-icon").boundingBox()
return avatar && icon ? Math.abs(avatar.x - icon.x) : Infinity
})
.toBeLessThanOrEqual(0.5)
await page.keyboard.press("Backspace")
await expect(search).toHaveValue("opencode")
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
// ArrowUp from the first project reaches "Add project" above it; adding a project picks one of the ready servers
// first, then browses that server.
// Adding a project picks one of the ready servers first, then browses that server.
await search.fill("")
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
await page.keyboard.press("ArrowUp")
await expect(add).toHaveAttribute("aria-selected", "true")
await page.keyboard.press("ArrowDown")
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
await page.keyboard.press("ArrowUp")
await page.keyboard.press("ArrowRight")
await expect(chip).toHaveText("Add project")
await expect(addButton).toHaveCount(0)
// The search field's button starts the same flow.
await page.keyboard.press("Backspace")
await expect(addButton).toBeVisible()
await page.keyboard.press("Tab")
await page.keyboard.press("Enter")
await expect(chip).toHaveText("Add project")
await expect(options.nth(3)).toHaveAttribute("aria-selected", "true")
await page.keyboard.press((await page.evaluate(() => navigator.platform.includes("Mac"))) ? "Meta+o" : "Control+o")
await expect(back).toHaveText("Servers")
// The server the app runs beside shows its hostname and "This computer" instead of its address.
await expect(options).toHaveText(["hailmaryThis computer", "apollo"])
await back.click()
await options.filter({ hasText: "Add project" }).click()
await expect(options).toHaveText(["hailmaryThis computer", "apollo"])
await options.filter({ hasText: "apollo" }).click()
await expect(page.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
await expect.poll(() => files.length).toBeGreaterThan(0)
})
for (const binding of ["mod+alt+p", "none"]) {
test(`the location dialog honors the Open project shortcut override: ${binding}`, async ({ page }) => {
await openDraft(page, {
name: "ProjectShortcut",
fileList: () => [],
seed: { settings: { keybinds: { "project.open": binding } } },
})
await newSessionLocation(page).click()
const search = page.getByRole("combobox", { name: "Search projects", exact: true })
await expect(search).toBeFocused()
const keys = page.getByRole("option", { name: /^Add project/ }).locator('[data-component="keybind-v2"]')
if (binding === "none") await expect(keys).toHaveCount(0)
if (binding !== "none") await expect(keys).toContainText("P")
const modifier = (await page.evaluate(() => navigator.platform.includes("Mac"))) ? "Meta" : "Control"
await search.press(`${modifier}+o`)
await search.fill("still searching")
await expect(search).toHaveValue("still searching")
if (binding === "none") return
await search.press(`${modifier}+Alt+p`)
await expect(page.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
})
}
const accentRoot = "C:/OpenCode/WorkspaceAccent"
const accentWorkspace = `${accentRoot}/.worktrees/feature`
+10 -7
View File
@@ -1,4 +1,5 @@
import { expect, type Page } from "@playwright/test"
import type { Schema } from "effect"
import {
NO_PROVIDER,
REMOTE_SERVER,
@@ -20,9 +21,9 @@ export type WorkspaceInput = Partial<Omit<MockServerConfig, "directory" | "proje
name: string
directory?: string
// Merged over the default project.
project?: Record<string, unknown>
project?: Partial<Parameters<typeof project>[0]>
// Defaults to one session titled `name`. Omitted fields come from `session()`.
sessions?: ({ id: string; title?: string } & Record<string, unknown>)[]
sessions?: (Partial<Parameters<typeof session>[0]> & { id: string })[]
// Merged over the default seed: the project used before, and a tab for every session.
seed?: SeedInput
}
@@ -31,7 +32,7 @@ export type WorkspaceInput = Partial<Omit<MockServerConfig, "directory" | "proje
export async function mockWorkspace(page: Page, input: WorkspaceInput) {
const slug = input.name.toLowerCase().replace(/[^a-z0-9]+/g, "_")
const directory = input.directory ?? `C:/OpenCode/${input.name}`
const projectID = typeof input.project?.id === "string" ? input.project.id : `proj_${slug}`
const projectID = input.project?.id ?? `proj_${slug}`
const sessions = (input.sessions ?? [{ id: `ses_${slug}`, title: input.name }]).map((item) =>
session({ directory, projectID, ...item }),
@@ -94,9 +95,9 @@ export function newSessionLocation(page: Page) {
export async function openWorktreeDraft(page: Page, input: WorkspaceInput & { draftID?: string }) {
const worktree = Promise.withResolvers<{ status: number; json: unknown }>()
const calls: string[] = []
const worktreeRequests: { url: URL; body: Record<string, unknown> }[] = []
const creates: Record<string, unknown>[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const worktreeRequests: { url: URL; body: Schema.JsonObject }[] = []
const creates: Schema.JsonObject[] = []
const prompts: { sessionID: string; body: Schema.JsonObject }[] = []
page.on("request", (request) => {
if (request.method() !== "POST") return
const url = new URL(request.url())
@@ -140,7 +141,9 @@ export async function openWorktreeDraft(page: Page, input: WorkspaceInput & { dr
await expect(editor).toBeVisible({ timeout: APP_READY_TIMEOUT })
await newSessionLocation(page).click()
// The dialog opens on projects; → lists the current project's worktrees.
await page.keyboard.press("ArrowRight")
const search = page.getByRole("combobox", { name: "Search projects", exact: true })
await expect(search).toBeFocused()
await search.press("ArrowRight")
await page.getByRole("option", { name: /^New worktree/ }).click()
await expect(newSessionLocation(page)).toContainText("New worktree")
await expect(editor).toBeEditable()
+154 -67
View File
@@ -1,7 +1,150 @@
[data-component="dialog-v2"]:has(.new-session-location) {
align-items: flex-start;
}
[data-component="dialog-v2"]:has(.new-session-location) [data-slot="dialog-container"] {
width: min(calc(100vw - 24px), 480px);
height: auto;
max-height: min(calc(100vh - 96px), 480px);
min-height: 0;
margin-top: max(48px, calc((100vh - 480px) / 2));
background: var(--v2-background-bg-layer-01);
box-shadow: var(--v2-elevation-overlay);
}
.new-session-location .palette-menu-search {
padding: 6px 6px 0;
}
.new-session-location .palette-menu-search [data-component="text-input-v2"] {
height: 28px;
padding-inline-end: 6px;
background: transparent;
}
.new-session-location .palette-menu-search [data-slot="text-input-v2-value"] {
gap: 6px;
}
.new-session-location .palette-menu-search [data-slot="text-input-v2-leading-icon"] {
padding-inline-start: 6px;
}
.new-session-location .palette-menu-search [data-slot="text-input-v2-input"]::placeholder {
color: var(--v2-text-text-base);
opacity: 0.5;
}
.new-session-location-divider {
position: relative;
height: 9px;
flex-shrink: 0;
margin-block: 4px;
}
.new-session-location-divider::after {
position: absolute;
inset-inline: 0;
top: 4px;
border-top: 0.5px solid var(--v2-border-border-base);
content: "";
}
.new-session-location .command-palette-results {
gap: 4px;
padding: 0 6px 6px;
}
.new-session-location .command-palette-group {
gap: 4px;
}
.new-session-location-action-divider {
height: 1px;
flex-shrink: 0;
margin-block: 2px;
margin-inline: -6px;
background: var(--v2-border-border-muted);
transform: scaleY(0.5);
}
.new-session-location .command-palette-group-title {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
margin: 0;
padding: 0 6px;
color: var(--v2-text-text-muted);
line-height: var(--line-height-compact);
}
.new-session-location-back {
border: 0;
border-radius: 2px;
background: transparent;
font: inherit;
font-size: 13px;
text-align: start;
cursor: pointer;
}
.new-session-location-back:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
.new-session-location .palette-menu-row[data-size="compact"] {
padding-inline: 6px;
}
.new-session-location .palette-menu-row:is([data-active], :focus-visible) {
background: var(--v2-overlay-simple-overlay-hover);
}
.new-session-location .command-palette-row-text {
flex: 1;
}
.new-session-location .command-palette-description {
flex: 1;
color: var(--v2-text-text-faint);
}
.new-session-location .new-session-location-workspace .command-palette-description {
font-size: 11px;
letter-spacing: 0.05px;
}
.new-session-location-workspace .command-palette-row-icon {
color: var(--v2-icon-icon-base);
}
.new-session-location-workspace .command-palette-row-text > .command-palette-title {
max-width: 60%;
flex-shrink: 0;
}
.new-session-location .new-session-location-option .command-palette-title {
color: var(--v2-text-text-muted);
}
.new-session-location-other {
gap: 8px;
}
.new-session-location-count {
color: var(--v2-text-text-faint);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
font-variant-numeric: tabular-nums;
}
.new-session-location-drill {
display: flex;
width: 20px;
height: 20px;
width: 16px;
height: 16px;
flex-shrink: 0;
align-items: center;
justify-content: center;
@@ -9,40 +152,12 @@
color: var(--v2-icon-icon-faint);
}
.command-palette-row[data-active] .new-session-location-drill {
color: var(--v2-icon-icon-base);
}
.new-session-location-drill:hover {
background: var(--v2-overlay-simple-overlay-hover);
}
.new-session-location-hints {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 16px;
padding: 8px 14px;
border-top: 0.5px solid var(--v2-border-border-muted);
color: var(--v2-text-text-muted);
font-size: 12px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
user-select: none;
}
.new-session-location-hints > span {
display: flex;
align-items: center;
gap: 6px;
}
/* The project name truncates before its server tag. */
/* Keep the name legible while the path gives up space first. */
.new-session-location-name {
display: flex;
min-width: 0;
max-width: 100%;
max-width: 55%;
flex-shrink: 0;
align-items: center;
gap: 6px;
}
@@ -62,45 +177,17 @@
.new-session-location-tag > span {
min-width: 0;
overflow: hidden;
line-height: 16px;
line-height: var(--line-height-compact);
text-overflow: ellipsis;
white-space: nowrap;
}
.new-session-location-add {
display: flex;
height: 24px;
align-items: center;
gap: 4px;
padding: 0 8px 0 6px;
border-radius: 4px;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
white-space: nowrap;
outline: none;
}
.new-session-location-add:is(:hover, :focus-visible) {
background: var(--v2-overlay-simple-overlay-hover);
color: var(--v2-text-text-base);
}
.new-session-location-add:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: -2px;
}
/* Narrow screens keep only the icon; the tooltip names it. */
@media (max-width: 480px) {
.new-session-location-add {
width: 24px;
justify-content: center;
padding: 0;
@media (max-width: 640px) {
.new-session-location .palette-menu-row:has(.command-palette-description) {
min-height: 40px;
}
.new-session-location-add > span {
display: none;
.new-session-location-name {
max-width: 100%;
}
}
+254 -205
View File
@@ -11,10 +11,10 @@ import {
ProjectAvatar,
} from "@opencode/ui/project-avatar"
import { TextInput } from "@opencode/ui/text-input"
import { Tooltip } from "@opencode/ui/tooltip"
import { createTypeahead } from "@opencode/ui/typeahead"
import { createEventListener } from "@solid-primitives/event-listener"
import { getFilename } from "@opencode/util/path"
import { createEffect, createMemo, createResource, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
import { createEffect, createMemo, createResource, For, Match, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import type { PromptProject, PromptProjectController, PromptServer } from "@/new-session/project/selector"
import type { NewSessionWorkspaceController } from "@/new-session/workspace/controller"
@@ -22,6 +22,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useGlobal } from "@/runtime/server/runtime"
import { useCommand } from "@/shell/commands/command"
import { workspaceSelectionDirectories } from "@/workspaces/paths"
import "@/shell/commands/dialog.css"
import "@/shell/palette-menu.css"
@@ -30,13 +31,13 @@ import "./dialog.css"
type LocationEntry =
| { id: string; type: "project"; project: PromptProject }
| { id: string; type: "add" }
| { id: string; type: "other" }
| { id: string; type: "server"; server: PromptServer }
| { id: string; type: "workspace"; value: string; directory: string }
/**
* Opens the new-session location dialog: one project list across every ready server, → on a Git project lists where
* the session can run, and → on "Add project" picks the server when there are several. Search projects passes
* `projectsOnly` for just the project list: no "Add project", no worktrees and no key hints, like the other palettes.
* Opens the new-session location dialog: added projects across ready servers, a separate list of other server projects,
* and the worktree destinations for each Git project. Adding a project picks the server when there are several.
*/
export function useNewSessionLocationDialog(input: {
project: PromptProjectController
@@ -45,14 +46,13 @@ export function useNewSessionLocationDialog(input: {
}) {
const dialog = useDialog()
return (options?: { onWorkspace?: (value: string) => void; projectsOnly?: boolean }) => {
return (options?: { onWorkspace?: (value: string) => void }) => {
void dialog.show(
() => (
<NewSessionLocationDialog
project={input.project}
workspace={input.workspace}
onWorkspace={options?.onWorkspace}
projectsOnly={options?.projectsOnly}
/>
),
input.onDone,
@@ -64,10 +64,10 @@ function NewSessionLocationDialog(props: {
project: PromptProjectController
workspace: NewSessionWorkspaceController
onWorkspace?: (value: string) => void
projectsOnly?: boolean
}) {
const dialog = useDialog()
const language = useLanguage()
const command = useCommand()
const global = useGlobal()
const servers = useServers()
const serverSDK = useServerSDK()
@@ -76,10 +76,12 @@ function NewSessionLocationDialog(props: {
drilled?: PromptProject
/** Picking the server to add a project to. */
picking?: boolean
/** Browsing server projects this client has not added. */
others: boolean
query: string
/** The projects view to restore when stepping back out of a project or the server step. */
back?: { query: string; active: string }
}>({ query: "" })
back?: { query: string; active: string; others: boolean }
}>({ query: "", others: false })
const connection = (project: PromptProject) =>
project.server ? servers.list.find((item) => ServerConnection.key(item) === project.server!.key) : serverSDK.server
@@ -124,18 +126,28 @@ function NewSessionLocationDialog(props: {
return !query || values.some((value) => value?.toLowerCase().includes(query))
}
const addEntry: LocationEntry = { id: "add", type: "add" }
const otherCount = createMemo(() => props.project.projects().filter((project) => !project.used).length)
// Remote projects also match their server's name, which their tag shows. "Add project" is shown first, above the
// heading, but navigates last, so the first project stays the opening row and the arrow keys' wrap-around still
// follows the rows on screen.
const projectEntries = createMemo<LocationEntry[]>(() => [
...props.project
.projects()
.filter((project) => matches(displayName(project), project.worktree, tagged(project.server)?.name))
.map((project) => ({ id: projectEntryID(project), type: "project" as const, project })),
...(props.projectsOnly ? [] : [addEntry]),
])
const projectEntries = createMemo<LocationEntry[]>(() => {
return [
...props.project
.projects()
.filter((project) => (store.others ? !project.used : project.used || !!store.query.trim()))
// Remote projects also match their server's name, which their tag shows.
.filter((project) => matches(displayName(project), project.worktree, tagged(project.server)?.name))
.map((project) => ({ id: projectEntryID(project), type: "project" as const, project })),
...(!store.others
? [
...(!store.query.trim() && otherCount() ? [{ id: "other", type: "other" as const }] : []),
{ id: "add", type: "add" as const },
]
: []),
]
})
const firstOther = createMemo(
() => projectEntries().find((entry) => entry.type === "project" && !entry.project.used)?.id,
)
const serverEntries = createMemo<LocationEntry[]>(() =>
props.project
@@ -170,16 +182,16 @@ function NewSessionLocationDialog(props: {
return projectEntries()
}
const drillable = (project: PromptProject) => !props.projectsOnly && !!project.id && project.vcs === "git"
const grouped = () => entries().filter((entry) => entry !== addEntry)
const drillable = (project: PromptProject) => !!project.id && project.vcs === "git"
const title = () => {
if (store.drilled) return language.t("session.new.workspace.runIn")
if (store.picking) return language.t("session.new.location.servers")
return language.t("home.projects")
if (store.others) return language.t("session.new.location.otherProjects")
return undefined
}
const placeholder = () => {
@@ -191,6 +203,21 @@ function NewSessionLocationDialog(props: {
}
const choosesServer = () => props.project.servers().length > 1
command.register("new-session-location", () => [
{
id: "project.open",
title: language.t("command.project.open"),
category: language.t("command.category.project"),
keybind: "mod+o",
onSelect: () => choose({ id: "add", type: "add" }),
},
])
createEventListener(document, "keydown", (event) => {
if (event.isComposing || !command.matches("project.open", event)) return
event.preventDefault()
event.stopPropagation()
command.trigger("project.open", "keybind")
})
// Every step opens on its first row; the user's choice holds while it stays in the results.
const typeahead = createTypeahead<LocationEntry>({
@@ -215,7 +242,7 @@ function NewSessionLocationDialog(props: {
})
function workspaceTitle(project: PromptProject, value: string) {
if (value === "main") return getFilename(project.worktree)
if (value === "main") return displayName(project)
if (value === "create") return language.t("workspace.new")
@@ -223,18 +250,33 @@ function NewSessionLocationDialog(props: {
}
function drill(project: PromptProject) {
setStore({ drilled: project, query: "", back: { query: store.query, active: projectEntryID(project) } })
setStore({
drilled: project,
query: "",
back: { query: store.query, active: projectEntryID(project), others: store.others },
})
inputRef?.focus()
}
function pick() {
setStore({ picking: true, query: "", back: { query: store.query, active: "add" } })
setStore({
drilled: undefined,
picking: true,
query: "",
back: { query: store.query, active: "add", others: store.others },
})
inputRef?.focus()
}
function back() {
const active = store.back?.active
setStore({ drilled: undefined, picking: undefined, query: store.back?.query ?? "", back: undefined })
const active = store.drilled || store.picking ? store.back?.active : "other"
setStore({
drilled: undefined,
picking: undefined,
others: store.drilled || store.picking ? (store.back?.others ?? false) : false,
query: store.drilled || store.picking ? (store.back?.query ?? "") : "",
back: undefined,
})
typeahead.setActive(active)
inputRef?.focus()
}
@@ -242,6 +284,13 @@ function NewSessionLocationDialog(props: {
function choose(entry: LocationEntry | undefined) {
if (!entry) return
if (entry.type === "other") {
setStore({ others: true, query: "" })
inputRef?.focus()
return
}
if (entry.type === "add" && choosesServer()) {
pick()
@@ -286,26 +335,36 @@ function NewSessionLocationDialog(props: {
const atEnd = target.selectionStart === target.value.length && target.selectionEnd === target.value.length
const atStart = target.selectionStart === 0 && target.selectionEnd === 0
const active = activeEntry()
const forward = language.direction() === "rtl" ? "ArrowLeft" : "ArrowRight"
const backward = language.direction() === "rtl" ? "ArrowRight" : "ArrowLeft"
if (event.key === "ArrowRight" && atEnd && active?.type === "project" && drillable(active.project)) {
if (event.key === forward && atEnd && active?.type === "project" && drillable(active.project)) {
event.preventDefault()
drill(active.project)
return
}
if (event.key === "ArrowRight" && atEnd && active?.type === "add" && choosesServer()) {
if (event.key === forward && atEnd && active?.type === "add" && choosesServer()) {
event.preventDefault()
pick()
return
}
if (active?.type === "other" && atEnd && event.key === forward) {
event.preventDefault()
choose(active)
return
}
if (
(store.drilled || store.picking) &&
((event.key === "ArrowLeft" && atStart) || (event.key === "Backspace" && !target.value))
(store.drilled || store.picking || store.others) &&
((event.key === backward && atStart) || (event.key === "Backspace" && !target.value) || event.key === "Escape")
) {
event.preventDefault()
event.stopPropagation()
back()
return
@@ -314,74 +373,8 @@ function NewSessionLocationDialog(props: {
typeahead.onKeyDown(event)
}
const option = (entry: LocationEntry) => (
<button
type="button"
id={typeahead.optionID(entry)}
class="command-palette-row palette-menu-row"
role="option"
aria-selected={activeEntry()?.id === entry.id}
data-active={activeEntry()?.id === entry.id ? "" : undefined}
onMouseMove={(event) => {
// Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
if (event.movementX === 0 && event.movementY === 0) return
typeahead.setActive(entry.id)
}}
onMouseDown={(event) => event.preventDefault()}
onClick={() => choose(entry)}
>
<Switch>
<Match when={entry.type === "project" && entry}>
{(item) => (
<ProjectRow
project={item().project}
path={item().project.worktree}
onDrill={drillable(item().project) ? () => drill(item().project) : undefined}
drillLabel={language.t("session.new.location.worktrees")}
/>
)}
</Match>
<Match when={entry.type === "add"}>
<div class="command-palette-row-main">
<Icon name="plus" class="command-palette-row-icon" />
<div class="command-palette-row-text">
<span class="command-palette-title palette-menu-title">{language.t("session.new.project.add")}</span>
</div>
</div>
<Show when={choosesServer()}>
<DrillButton label={language.t("session.new.location.servers")} onDrill={pick} />
</Show>
</Match>
<Match when={entry.type === "server" && entry}>
{(item) => (
<div class="command-palette-row-main">
<Icon name={serverIcon(item().server)} class="command-palette-row-icon" />
<div class="command-palette-row-text">
<span class="command-palette-title palette-menu-title">{item().server.name}</span>
<Show when={item().server.hint}>
{(hint) => <span class="command-palette-description">{hint()}</span>}
</Show>
</div>
</div>
)}
</Match>
<Match when={entry.type === "workspace" && store.drilled && entry}>
{(item) => (
<WorkspaceRow
title={workspaceTitle(store.drilled!, item().value)}
value={item().value}
description={
item().value === "create" ? language.t("session.new.workspace.new.tooltip") : item().directory
}
/>
)}
</Match>
</Switch>
</button>
)
return (
<Dialog class="command-palette" size="large">
<Dialog class="command-palette new-session-location" size="large">
<DialogBody class="command-palette-body">
<div class="palette-menu-search">
<TextInput
@@ -393,100 +386,176 @@ function NewSessionLocationDialog(props: {
appearance="large"
aria-label={placeholder()}
placeholder={placeholder()}
leadingIcon={
<Switch fallback={<Icon name="magnifying-glass" />}>
<Match when={store.drilled}>
{(project) => (
<BackChip label={language.t("session.new.location.back")} onBack={back}>
<ProjectAvatar
class="shrink-0"
fallback={displayName(project())}
src={getProjectAvatarSource(project().id, project().icon)}
variant={getProjectAvatarVariant(project().icon?.color)}
generated={getProjectAvatarGenerated(project())}
/>
<span class="min-w-0 truncate">{displayName(project())}</span>
<ServerTag server={project().server} />
</BackChip>
)}
</Match>
<Match when={store.picking}>
<BackChip label={language.t("session.new.location.back")} onBack={back}>
<Icon name="plus" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{language.t("session.new.project.add")}</span>
</BackChip>
</Match>
</Switch>
}
leadingIcon={<Icon name="magnifying-glass" />}
role="combobox"
aria-expanded="true"
data-owns-escape={store.drilled || store.picking || store.others ? "" : undefined}
aria-controls={typeahead.listID}
aria-activedescendant={typeahead.activeID()}
onInput={(event) => setStore("query", event.currentTarget.value)}
onKeyDown={handleKeyDown}
trailing={
<Show when={!props.projectsOnly && !store.drilled && !store.picking}>
<Tooltip placement="top" value={language.t("session.new.project.add")}>
<button
type="button"
class="new-session-location-add"
aria-label={language.t("session.new.project.add")}
onClick={() => choose(addEntry)}
>
<Icon name="plus" size="small" />
<span>{language.t("session.new.project.add")}</span>
</button>
</Tooltip>
</Show>
}
/>
</div>
<div class="new-session-location-divider" aria-hidden="true" />
<div class="command-palette-scroll" data-scrollable ref={(el) => (resultsRef = el)}>
<div id={typeahead.listID} class="command-palette-results" role="listbox">
<Show
when={entries().length > 0}
fallback={
// The worktree list may still be loading; hold the row's height without flashing a label.
<div class="command-palette-state" classList={{ invisible: !!store.drilled && listed.loading }}>
{language.t("palette.empty")}
</div>
}
>
{/* One group, so the heading follows "Add project" at the row rhythm rather than the gap between groups. */}
<div class="command-palette-group">
<Show when={entries().includes(addEntry)}>{option(addEntry)}</Show>
<Show when={grouped().length > 0}>
<div class="command-palette-group-title">{title()}</div>
<For each={grouped()}>{option}</For>
</Show>
</div>
</Show>
<div class="command-palette-group">
<Show when={title()}>
{(value) => (
<button
type="button"
class="command-palette-group-title new-session-location-back"
aria-label={language.t("session.new.location.back")}
onClick={back}
>
{value()}
</button>
)}
</Show>
<Show
when={entries().length > 0}
fallback={
// The worktree list may still be loading; hold the row's height without flashing a label.
<div class="command-palette-state" classList={{ invisible: !!store.drilled && listed.loading }}>
{language.t("palette.empty")}
</div>
}
>
<For each={entries()}>
{(entry, index) => (
<>
<Show
when={
store.query.trim() &&
!store.drilled &&
!store.picking &&
!store.others &&
entry.id === firstOther()
}
>
<div class="command-palette-group-title">
{language.t("session.new.location.otherProjects")}
</div>
</Show>
<Show
when={
index() > 0 &&
(entry.type === "add" ||
(entry.type === "workspace" &&
entry.value !== "main" &&
entry.value !== "create" &&
["main", "create"].includes(entries()[index() - 1]!.id)))
}
>
<div class="new-session-location-action-divider" role="separator" />
</Show>
<button
type="button"
id={typeahead.optionID(entry)}
class="command-palette-row palette-menu-row"
data-size="compact"
classList={{
"new-session-location-option": entry.type === "other" || entry.type === "add",
"new-session-location-workspace": entry.type === "workspace",
}}
role="option"
aria-selected={activeEntry()?.id === entry.id}
data-active={activeEntry()?.id === entry.id ? "" : undefined}
onMouseMove={(event) => {
// Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
if (event.movementX === 0 && event.movementY === 0) return
typeahead.setActive(entry.id)
}}
onMouseDown={(event) => event.preventDefault()}
onFocus={() => typeahead.setActive(entry.id)}
onClick={() => choose(entry)}
>
<Switch>
<Match when={entry.type === "project" && entry}>
{(item) => (
<ProjectRow
project={item().project}
path={item().project.worktree}
onDrill={drillable(item().project) ? () => drill(item().project) : undefined}
drillLabel={language.t("session.new.location.worktrees")}
/>
)}
</Match>
<Match when={entry.type === "other"}>
<div class="command-palette-row-main new-session-location-other">
<Icon name="menu" class="command-palette-row-icon" />
<span class="command-palette-title palette-menu-title">
{language.t("session.new.location.otherProjects")}
</span>
<span class="new-session-location-count">{otherCount()}</span>
</div>
<DrillButton
label={language.t("session.new.location.otherProjects")}
onDrill={() => choose(entry)}
/>
</Match>
<Match when={entry.type === "add"}>
<div class="command-palette-row-main">
<Icon name="plus" class="command-palette-row-icon" />
<div class="command-palette-row-text">
<span class="command-palette-title palette-menu-title">
{language.t("session.new.project.add")}
</span>
</div>
</div>
<Show when={command.keybindParts("project.open").length}>
<Keybind keys={command.keybindParts("project.open")} variant="neutral" class="shrink-0" />
</Show>
<Show when={choosesServer()}>
<DrillButton label={language.t("session.new.location.servers")} onDrill={pick} />
</Show>
</Match>
<Match when={entry.type === "server" && entry}>
{(item) => (
<div class="command-palette-row-main">
<Icon name={serverIcon(item().server)} class="command-palette-row-icon" />
<div class="command-palette-row-text">
<span class="command-palette-title palette-menu-title">{item().server.name}</span>
<Show when={item().server.hint}>
{(hint) => <span class="command-palette-description">{hint()}</span>}
</Show>
</div>
</div>
)}
</Match>
<Match when={entry.type === "workspace" && store.drilled && entry}>
{(item) => (
<Show
when={item().value === "main"}
fallback={
<WorkspaceRow
title={workspaceTitle(store.drilled!, item().value)}
value={item().value}
description={
item().value === "create"
? language.t("session.new.workspace.new.tooltip")
: item().directory
}
/>
}
>
<ProjectRow
project={store.drilled!}
path={item().directory}
drillLabel={language.t("session.new.location.worktrees")}
/>
</Show>
)}
</Match>
</Switch>
</button>
</>
)}
</For>
</Show>
</div>
</div>
</div>
<Show when={!props.projectsOnly}>
<div class="new-session-location-hints">
<span>
<Keybind keys={["↵"]} variant="neutral" />
{language.t("session.new.location.select")}
</span>
<Show
when={store.drilled || store.picking}
fallback={
<span>
<Keybind keys={["→"]} variant="neutral" />
{activeEntry()?.type === "add" && choosesServer()
? language.t("session.new.location.servers")
: language.t("session.new.location.worktrees")}
</span>
}
>
<span>
<Keybind keys={["←"]} variant="neutral" />
{language.t("session.new.location.back")}
</span>
</Show>
</div>
</Show>
</DialogBody>
</Dialog>
)
@@ -529,27 +598,11 @@ function DrillButton(props: { label: string; onDrill: () => void }) {
props.onDrill()
}}
>
<Icon name="chevron-right" size="small" />
<Icon name="chevron-right" />
</span>
)
}
// The negative start margin cancels the chip's padding, so its icon sits where the search icon would and lines up with
// the row icons, leaving the chip the same 6px inset from the field on every side.
function BackChip(props: { label: string; onBack: () => void; children: JSX.Element }) {
return (
<button
type="button"
class="-ms-1.5 flex h-6 max-w-[280px] items-center gap-1.5 rounded-sm bg-v2-background-bg-base px-1.5 text-13-medium text-v2-text-text-base shadow-[var(--v2-elevation-raised)]"
aria-label={props.label}
onMouseDown={(event) => event.preventDefault()}
onClick={props.onBack}
>
{props.children}
</button>
)
}
/** The server a project lives on; projects on the current server have none. */
function ServerTag(props: { server?: PromptServer }) {
return (
@@ -576,11 +629,7 @@ function WorkspaceRow(props: { title: string; value: string; description: string
return (
<>
<div class="command-palette-row-main">
<Icon
name={workspaceIcon(props.value)}
class="command-palette-row-icon"
classList={{ "!text-v2-icon-icon-accent": props.value !== "main" && props.value !== "create" }}
/>
<Icon name={workspaceIcon(props.value)} class="command-palette-row-icon" />
<div class="command-palette-row-text">
<span class="command-palette-title palette-menu-title">{props.title}</span>
<span class="command-palette-description" dir="ltr">
+2 -2
View File
@@ -53,7 +53,7 @@ export default function NewSessionPage(props: { draftId: string }) {
restoreFocus: model.restoreFocus,
project: {
empty: project.empty,
open: () => location({ projectsOnly: true }),
open: () => location(),
},
workspace: {
enabled: workspace.bar.visible,
@@ -67,7 +67,7 @@ export default function NewSessionPage(props: { draftId: string }) {
if (!ready) return
// Search projects opened this draft; the picker returns focus to the composer when it closes.
if (layout.draft.projectSearch.take()) location({ projectsOnly: true })
if (layout.draft.projectSearch.take()) location()
else model.restoreFocus()
}),
)
+72 -12
View File
@@ -2,6 +2,8 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { Tooltip } from "@opencode/ui/tooltip"
import { Icon } from "@opencode/ui/icon"
import { Slash } from "@opencode/ui/slash"
import { Keybind } from "@opencode/ui/keybind"
import { useCommand } from "@/shell/commands/command"
import { Show, createMemo, createSignal, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
@@ -221,9 +223,7 @@ function NewSessionTips(props: { selection: ComposerModel["model"]["selection"];
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
onClick={open}
>
<span class="truncate">
{language.t("home.providerTip")}
</span>
<span class="truncate">{language.t("home.providerTip")}</span>
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
<Icon name="chevron-down" size="small" class="-rotate-90" />
</span>
@@ -249,6 +249,29 @@ function NewSessionTips(props: { selection: ComposerModel["model"]["selection"];
)
}
// Keeps as many leading segments as fit, then "…/", then the last two segments. The cut always lands on a slash.
function fitPath(value: string, width: number, font: string) {
const separator = value.includes("/") ? "/" : "\\"
const parts = value.split(separator)
const context = document.createElement("canvas").getContext("2d")
if (!context) return { text: value, minimum: 0 }
context.font = font
const fits = (text: string) => context.measureText(text).width <= width
const first = parts[0] === "" ? 2 : 1
const candidates = Array.from({ length: Math.max(0, parts.length - 2 - first) }, (_, index) =>
[...parts.slice(0, parts.length - 3 - index), "…", ...parts.slice(-2)].join(separator),
)
const minimum = candidates.at(-1) ?? value
return {
text: !width || fits(value) ? value : (candidates.find(fits) ?? minimum),
minimum: context.measureText(minimum).width,
}
}
/** The composer tray: combined location picker, Git context, and the session's directory. */
function NewSessionLocation(props: {
project: PromptProject
@@ -260,7 +283,8 @@ function NewSessionLocation(props: {
onClick: () => void
}) {
const language = useLanguage()
const [truncated, setTruncated] = createSignal(false)
const command = useCommand()
const [layout, setLayout] = createStore({ width: 0, font: "" })
const worktree = () => {
if (props.workspace === "main") return
@@ -276,6 +300,8 @@ function NewSessionLocation(props: {
return props.workspace === "main" ? props.root : props.workspace
}
const fitted = createMemo(() => fitPath(path() ?? "", layout.width, layout.font))
return (
<div class="relative flex min-h-9 min-w-0 items-center gap-3 ps-2 pe-1.5 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px]">
<div class="flex min-w-0 max-w-full flex-wrap items-center gap-1 py-1.5">
@@ -337,22 +363,56 @@ function NewSessionLocation(props: {
</div>
<Show when={path()}>
{(directory) => (
<div class="flex min-w-0 flex-1 justify-end pe-1.5 @max-[560px]:hidden">
<div
class="flex min-w-0 flex-1 justify-end pe-1.5 @max-[560px]:hidden"
style={{ "min-width": `min(calc(100% - 12px), ${Math.ceil(fitted().minimum) + 26}px)` }}
>
<Tooltip
placement="top"
value={directory()}
disabled={!truncated()}
class="flex min-w-0"
disabled={fitted().text === directory() && fitted().minimum <= layout.width}
class="flex min-w-0 flex-1"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
ref={(element) =>
createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth))
ref={(row) =>
createResizeObserver(row, () => {
const icon = row.querySelector("[data-slot=new-session-location-icon]")
const style = getComputedStyle(row)
setLayout({
width: row.clientWidth - (icon?.clientWidth ?? 0) - parseFloat(style.columnGap),
font: style.font,
})
})
}
data-slot="new-session-location-path"
class="block min-w-0 truncate text-v2-text-text-faint [direction:rtl] [text-align:end]"
class="flex min-w-0 flex-1 items-center justify-end gap-1.5"
dir="ltr"
>
<bdi dir="ltr">{directory()}</bdi>
<Tooltip
placement="top"
openDelay={800}
value={
<span class="flex items-center gap-2">
{language.t("session.location.toggle")}
<Keybind keys={command.keybindParts("session.location.cycle")} variant="neutral" />
</span>
}
>
<span class="flex shrink-0" data-slot="new-session-location-icon">
<Icon
name={worktree() ? "outline-worktree" : "monitor"}
size="small"
class="text-v2-icon-icon-muted"
/>
</span>
</Tooltip>
<span
data-slot="new-session-location-path"
class="min-w-0 overflow-hidden whitespace-nowrap text-v2-text-text-faint [direction:rtl] [text-align:end]"
>
<bdi dir="ltr">{fitted().text}</bdi>
</span>
</span>
</Tooltip>
</div>
@@ -80,7 +80,7 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
when={creating()}
fallback={
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 ps-1.5 pe-2 text-v2-text-text-faint">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-faint" />
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={observe} dir="auto" class="min-w-0 truncate">
{label()}
</span>
+2
View File
@@ -60,6 +60,7 @@ export const dict = {
"command.usage.view": "View usage",
"command.usage.view.description": "See your OpenCode Go allowance and Console budgets",
"command.session.location.cycle": "Cycle session location",
"session.location.toggle": "Toggle session location",
"command.palette": "Search everything",
"command.commands.search": "Search commands",
@@ -1128,6 +1129,7 @@ export const dict = {
"session.new.workspace.search.placeholder": "Search worktrees",
"session.new.workspace.search.empty": "No matching worktrees",
"session.new.location.select": "Select",
"session.new.location.otherProjects": "Other projects",
"session.new.location.worktrees": "Worktrees",
"session.new.location.back": "Back to projects",
"session.new.location.servers": "Servers",