feat(app): pick new session location from a project and worktree dialog

Replace the new-session project menu and nested worktree submenu with one
palette-style dialog: projects first, → lists a Git project's worktrees.
The location row under the composer shows only the project and its path,
left-aligned so changing it does not shift the layout.
This commit is contained in:
thdxr
2026-10-18 20:35:25 +00:00
committed by Hona
Unverified
parent bd36dcc172
commit cf6b361268
13 changed files with 823 additions and 966 deletions
@@ -2,7 +2,7 @@ import { expect, test, type Locator, type Page } from "@playwright/test"
import type { OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise"
import { draftHref, expectPath, provider, sessionHref } from "../utils/app"
import type { MockAnswer } from "../utils/mock-server"
import { openDraft, openSession, openWorktreeDraft, type WorkspaceInput } from "../utils/workspace"
import { newSessionLocation, openDraft, openSession, openWorktreeDraft, type WorkspaceInput } from "../utils/workspace"
const directory = "C:/OpenCode/WorkspacePending"
@@ -281,7 +281,7 @@ for (const row of [
if (row.followUp) return
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
await expect(newSessionLocation(page)).toContainText("New worktree")
await expect(pending.message).toHaveCount(0)
await expect(tabLink(page, pending.sessionID)).toHaveCount(0)
expect(mock.creates).toEqual([])
@@ -298,7 +298,7 @@ test("retains the draft and reuses the created workspace after session creation
await expect(page.getByText("Failed to create session", { exact: true })).toBeVisible()
await expect(editor(page)).toHaveText(text)
await expect(submit(page)).toBeEnabled()
await expect(page.getByRole("button", { name: "pending-workspace", exact: true })).toBeVisible()
await expect(newSessionLocation(page)).toContainText("pending-workspace")
await expect(pending.shimmer).toHaveCount(0)
await expect(pending.message).toHaveCount(0)
expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })])
@@ -346,7 +346,7 @@ test("restores the draft after closing and revisiting a pending session that fai
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
await expect(editor(page)).toHaveText(`${text}\n\n${followUp}`)
await expect(submit(page)).toBeEnabled()
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
await expect(newSessionLocation(page)).toContainText("New worktree")
await expect(page.locator(`[data-titlebar-tab-link][href="${draftHref(draftID)}"]`)).toHaveCount(1)
await expect(tab).toHaveCount(0)
await expect(pending.message).toHaveCount(0)
+10 -3
View File
@@ -87,6 +87,11 @@ export async function openDraft(page: Page, input: WorkspaceInput & { draftID?:
// Opens a draft on the local project with "New worktree" selected. Worktree creation stays pending until the test
// resolves `worktree`; POST order (`worktree`, `session`, `prompt`), worktree requests, session creates and prompts are
// recorded. The draft tab comes first, then a tab per session.
// The new-session trigger that shows the draft's project and worktree and opens the location dialog.
export function newSessionLocation(page: Page) {
return page.locator('[data-component="new-session"] [data-action="prompt-project"]')
}
export async function openWorktreeDraft(page: Page, input: WorkspaceInput & { draftID?: string }) {
const worktree = Promise.withResolvers<{ status: number; json: unknown }>()
const calls: string[] = []
@@ -134,9 +139,11 @@ export async function openWorktreeDraft(page: Page, input: WorkspaceInput & { dr
await page.goto(draftHref(draftID))
const editor = page.locator('[data-component="composer-editor"]')
await expect(editor).toBeVisible({ timeout: APP_READY_TIMEOUT })
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
await newSessionLocation(page).click()
// The dialog opens on projects; → lists the current project's worktrees.
await page.keyboard.press("ArrowRight")
await page.getByRole("option", { name: /^New worktree/ }).click()
await expect(newSessionLocation(page)).toContainText("New worktree")
await expect(editor).toBeEditable()
return { ...workspace, draftID, editor, worktree, calls, worktreeRequests, creates, prompts }
@@ -0,0 +1,39 @@
.new-session-location-drill {
display: flex;
width: 20px;
height: 20px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 4px;
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;
}
@@ -0,0 +1,505 @@
import { Dialog, DialogBody } from "@opencode/ui/dialog"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
import { Keybind } from "@opencode/ui/keybind"
import {
displayName,
getProjectAvatarSource,
getProjectAvatarVariant,
ProjectAvatar,
} from "@opencode/ui/project-avatar"
import { ScrollView } from "@opencode/ui/scroll-view"
import { TextInput } from "@opencode/ui/text-input"
import { getFilename, sameDirectory } from "@opencode/util/path"
import { createEffect, createMemo, createResource, For, Match, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import type { PromptProject, PromptProjectController } from "@/new-session/project/selector"
import type { NewSessionWorkspaceController } from "@/new-session/workspace/controller"
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 { workspaceSelectionDirectories } from "@/workspaces/paths"
import "@/shell/commands/dialog.css"
import "./dialog.css"
type LocationEntry =
| { id: string; type: "project"; group: string; project: PromptProject }
| { id: string; type: "add"; group: string; server?: string }
| { id: string; type: "workspace"; group: string; value: string; directory: string }
/** Opens the new-session location dialog: projects first, and → on a Git project lists where the session can run. */
export function useNewSessionLocationDialog(input: {
project: PromptProjectController
workspace: NewSessionWorkspaceController
onDone: () => void
}) {
const dialog = useDialog()
return (options?: { onWorkspace?: (value: string) => void }) => {
void dialog.show(
() => (
<NewSessionLocationDialog
project={input.project}
workspace={input.workspace}
onWorkspace={options?.onWorkspace}
/>
),
input.onDone,
)
}
}
function NewSessionLocationDialog(props: {
project: PromptProjectController
workspace: NewSessionWorkspaceController
onWorkspace?: (value: string) => void
}) {
const dialog = useDialog()
const language = useLanguage()
const global = useGlobal()
const servers = useServers()
const serverSDK = useServerSDK()
const [store, setStore] = createStore<{
drilled?: PromptProject
query: string
active?: string
/** The projects view to restore when stepping back out of a project. */
back?: { query: string; active: string }
}>({ query: "" })
const connection = (project: PromptProject) =>
project.server ? servers.list.find((item) => ServerConnection.key(item) === project.server!.key) : serverSDK.server
const context = (project: PromptProject) => {
const target = connection(project)
return target ? global.ensureServerCtx(target) : undefined
}
const [listed, listedActions] = createResource(
() => store.drilled,
(project) =>
context(project)
?.sdk.api.worktree.list({ projectID: project.id! })
.catch(() => project.worktrees ?? []) ?? Promise.resolve(project.worktrees ?? []),
)
onCleanup(
serverSDK.event.listen((event) => {
if (event.type === "worktree.updated" && event.data.projectID === store.drilled?.id) void listedActions.refetch()
}),
)
const matches = (...values: (string | undefined)[]) => {
const query = store.query.trim().toLowerCase()
return !query || values.some((value) => value?.toLowerCase().includes(query))
}
const projectEntries = createMemo<LocationEntry[]>(() => {
const grouped = props.project.servers().length > 1
const projects = props.project
.projects()
.filter((project) => matches(displayName(project), project.worktree))
.map((project) => ({
id: `project:${project.server?.key ?? ""}:${project.worktree}`,
type: "project" as const,
group: grouped ? project.server!.name : language.t("home.projects"),
project,
}))
if (!grouped) return [...projects, { id: "add", type: "add" as const, group: language.t("home.projects") }]
// Projects keep their server grouping; adding a project stays one row per server at the end.
return [
...props.project
.servers()
.flatMap((server) => projects.filter((entry) => entry.project.server?.key === server.key)),
...props.project.servers().map((server) => ({
id: `add:${server.key}`,
type: "add" as const,
group: language.t("session.new.project.add"),
server: server.key,
})),
]
})
const workspaceEntries = createMemo<LocationEntry[]>(() => {
const project = store.drilled
if (!project) return []
const group = language.t("session.new.workspace.runIn")
const directories = workspaceSelectionDirectories(project, listed.latest ?? project.worktrees ?? [])
return [
{ id: "main", type: "workspace" as const, group, value: "main", directory: project.worktree },
...directories.map((directory) => ({
id: `worktree:${directory}`,
type: "workspace" as const,
group,
value: directory,
directory,
})),
{ id: "create", type: "workspace" as const, group, value: "create", directory: project.worktree },
].filter((entry) => matches(workspaceTitle(project, entry.value), entry.directory))
})
const entries = () => (store.drilled ? workspaceEntries() : projectEntries())
const groups = createMemo(() =>
entries().reduce<{ group: string; entries: LocationEntry[] }[]>((all, entry) => {
const last = all.at(-1)
if (last?.group === entry.group) last.entries.push(entry)
if (last?.group !== entry.group) all.push({ group: entry.group, entries: [entry] })
return all
}, []),
)
const currentEntry = () =>
entries().find((entry) => {
if (entry.type === "project") return props.project.isSelected(entry.project)
if (entry.type === "workspace") return isCurrentWorkspace(entry)
return false
})
// Follow the current location, then the first result, until the user explicitly chooses a row.
const activeEntry = createMemo(
() =>
entries().find((entry) => entry.id === store.active) ??
(store.query.trim() ? undefined : currentEntry()) ??
entries()[0],
)
let resultsRef: HTMLDivElement | undefined
let inputRef: HTMLInputElement | undefined
createEffect(() => {
if (!activeEntry()) return
const frame = requestAnimationFrame(() =>
resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" }),
)
onCleanup(() => cancelAnimationFrame(frame))
})
function workspaceTitle(project: PromptProject, value: string) {
if (value === "main") return getFilename(project.worktree)
if (value === "create") return language.t("workspace.new")
return getFilename(value)
}
function isCurrentWorkspace(entry: Extract<LocationEntry, { type: "workspace" }>) {
if (!store.drilled || !props.project.isSelected(store.drilled)) return false
const current = props.workspace.selection.value()
if (entry.value === "main" || entry.value === "create") return current === entry.value
return current !== "main" && current !== "create" && sameDirectory(current, entry.value)
}
function drill(project: PromptProject) {
setStore({
drilled: project,
query: "",
active: undefined,
back: { query: store.query, active: `project:${project.server?.key ?? ""}:${project.worktree}` },
})
inputRef?.focus()
}
function back() {
const project = store.drilled
setStore({
drilled: undefined,
query: store.back?.query ?? "",
active: store.back?.active ?? (project ? `project:${project.server?.key ?? ""}:${project.worktree}` : undefined),
back: undefined,
})
inputRef?.focus()
}
function choose(entry: LocationEntry | undefined) {
if (!entry) return
if (entry.type === "add") {
dialog.close()
props.project.add(entry.server)
return
}
if (entry.type === "project") {
props.project.select(entry.project)
dialog.close()
return
}
const project = store.drilled
if (!project) return
if (props.project.isSelected(project)) props.workspace.selection.set(entry.value)
if (!props.project.isSelected(project)) props.project.select(project, entry.value)
props.onWorkspace?.(entry.value)
dialog.close()
}
const move = (delta: -1 | 1) => {
const list = entries()
if (list.length === 0) return
const index = list.findIndex((entry) => entry.id === activeEntry()?.id)
setStore("active", list[(index + delta + list.length) % list.length].id)
}
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.currentTarget as HTMLInputElement
const atEnd = target.selectionStart === target.value.length && target.selectionEnd === target.value.length
const atStart = target.selectionStart === 0 && target.selectionEnd === 0
const active = activeEntry()
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault()
move(event.key === "ArrowDown" ? 1 : -1)
return
}
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
choose(active)
return
}
if (event.key === "ArrowRight" && atEnd && active?.type === "project" && drillable(active.project)) {
event.preventDefault()
drill(active.project)
return
}
if (store.drilled && ((event.key === "ArrowLeft" && atStart) || (event.key === "Backspace" && !target.value))) {
event.preventDefault()
back()
}
}
return (
<Dialog class="command-palette" size="large">
<DialogBody class="command-palette-body">
<div class="command-palette-search">
<TextInput
ref={inputRef}
value={store.query}
autofocus
autocomplete="off"
spellcheck={false}
appearance="large"
aria-label={
store.drilled
? language.t("session.new.workspace.search.placeholder")
: language.t("session.new.project.search")
}
placeholder={
store.drilled
? language.t("session.new.workspace.search.placeholder")
: language.t("session.new.project.search")
}
leadingIcon={
<Show when={store.drilled} fallback={<Icon name="magnifying-glass" />}>
{(project) => (
<button
type="button"
class="flex h-6 max-w-[200px] 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={language.t("session.new.location.back")}
onMouseDown={(event) => event.preventDefault()}
onClick={back}
>
<ProjectAvatar
class="shrink-0"
fallback={displayName(project())}
src={getProjectAvatarSource(project().id, project().icon)}
variant={getProjectAvatarVariant(project().icon?.color)}
/>
<span class="min-w-0 truncate">{displayName(project())}</span>
</button>
)}
</Show>
}
onInput={(event) => setStore({ query: event.currentTarget.value, active: undefined })}
onKeyDown={handleKeyDown}
/>
</div>
<ScrollView class="command-palette-scroll" viewportRef={(el) => (resultsRef = el)}>
<div class="command-palette-results" role="listbox">
<Show
when={entries().length > 0}
fallback={
<div class="command-palette-state">
{store.drilled && listed.loading ? language.t("common.loading") : language.t("palette.empty")}
</div>
}
>
<For each={groups()}>
{(group) => (
<div class="command-palette-group">
<div class="command-palette-group-title">{group.group}</div>
<For each={group.entries}>
{(entry) => (
<button
type="button"
class="command-palette-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
setStore("active", 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" && entry}>
{(item) => (
<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">{language.t("session.new.project.add")}</span>
<Show when={item().server && props.project.servers().length > 1}>
<span class="command-palette-description">
{props.project.servers().find((server) => server.key === item().server)?.name}
</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>
)}
</For>
</div>
)}
</For>
</Show>
</div>
</ScrollView>
<div class="new-session-location-hints">
<span>
<Keybind keys={["↵"]} variant="neutral" />
{language.t("session.new.location.select")}
</span>
<Show
when={store.drilled}
fallback={
<span>
<Keybind keys={["→"]} variant="neutral" />
{language.t("session.new.location.worktrees")}
</span>
}
>
<span>
<Keybind keys={["←"]} variant="neutral" />
{language.t("session.new.location.back")}
</span>
</Show>
</div>
</DialogBody>
</Dialog>
)
}
function ProjectRow(props: { project: PromptProject; path: string; onDrill?: () => void; drillLabel: string }) {
return (
<>
<div class="command-palette-row-main">
<ProjectAvatar
class="shrink-0"
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
<div class="command-palette-row-text">
<span class="command-palette-title">{displayName(props.project)}</span>
<span class="command-palette-description" dir="ltr">
{props.path}
</span>
</div>
</div>
<Show when={props.onDrill}>
{(onDrill) => (
<span
role="button"
aria-label={props.drillLabel}
class="new-session-location-drill"
onClick={(event) => {
event.stopPropagation()
onDrill()()
}}
>
<Icon name="chevron-right" size="small" />
</span>
)}
</Show>
</>
)
}
function WorkspaceRow(props: { title: string; value: string; description: string }) {
return (
<>
<div class="command-palette-row-main">
<Icon
name={props.value === "main" ? "monitor" : props.value === "create" ? "plus" : "outline-worktree"}
class="command-palette-row-icon"
classList={{ "!text-v2-icon-icon-accent": props.value !== "main" && props.value !== "create" }}
/>
<div class="command-palette-row-text">
<span class="command-palette-title">{props.title}</span>
<span class="command-palette-description" dir="ltr">
{props.description}
</span>
</div>
</div>
</>
)
}
function drillable(project: PromptProject) {
return !!project.id && project.vcs === "git"
}
@@ -31,7 +31,7 @@ export function createComposerProjectControls(props: { draftId: string; worktree
})
})
const selectProject = (worktree: string, serverKey?: string) => {
const selectProject = (directory: string, serverKey?: string, workspace?: string) => {
const connection = serverKey
? servers.list.find((connection) => ServerConnection.key(connection) === serverKey)
: projectServer()
@@ -39,12 +39,12 @@ export function createComposerProjectControls(props: { draftId: string; worktree
if (!connection) return
const target = global.ensureServerCtx(connection)
target.projects.open(worktree)
target.projects.touch(worktree)
target.projects.open(directory)
target.projects.touch(directory)
tabs.updateDraft(props.draftId, {
server: ServerConnection.key(connection),
directory: worktree,
worktree: workspaceSelectionDestination(props.worktree(), location().directory),
directory,
worktree: workspace ?? workspaceSelectionDestination(props.worktree(), location().directory),
branch: undefined,
})
}
@@ -26,7 +26,7 @@ describe("new session project selection", () => {
}
createRoot((dispose) => {
const controller = createPromptProjectController({ controls: () => controls, onDone() {} })
const controller = createPromptProjectController({ controls: () => controls })
expect(controller.selected()).toEqual(current)
dispose()
})
@@ -46,7 +46,7 @@ describe("new session project selection", () => {
}
createRoot((dispose) => {
const controller = createPromptProjectController({ controls: () => controls, onDone() {} })
const controller = createPromptProjectController({ controls: () => controls })
expect(controller.selected()).toEqual(local)
dispose()
})
+23 -556
View File
@@ -1,32 +1,15 @@
import {
createEffect,
createSignal,
For,
onCleanup,
Show,
splitProps,
type Accessor,
type ComponentProps,
} from "solid-js"
import { createStore } from "solid-js/store"
import { Menu } from "@opencode/ui/menu"
import { type Accessor } from "solid-js"
import { Icon } from "@opencode/ui/icon"
import {
displayName,
getProjectAvatarSource,
getProjectAvatarVariant,
ProjectAvatar,
} from "@opencode/ui/project-avatar"
import { useLanguage } from "@/runtime/i18n/language"
import { pathKey } from "@/workspaces/path-key"
import { handleDocumentSearchKeydown } from "@/shell/commands/search-keydown"
import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
export type PromptProject = {
name?: string
id?: string
worktree: string
vcs?: string
sandboxes?: string[]
worktrees?: { directory: string; strategy?: string }[]
icon?: { color?: string; url?: string; override?: string }
server?: { key: string; name: string }
}
@@ -36,29 +19,13 @@ export type PromptProjectControls = {
directory: string
projectID?: string
server?: string
select: (worktree: string, server?: string) => void
/** Moves the draft to a project; `workspace` is "main", "create", or a worktree directory. */
select: (directory: string, server?: string, workspace?: string) => void
add: (title: string, server?: string) => void
}
const actionPrefix = "action:"
const projectPrefix = "project:"
function projectKey(project: PromptProject) {
return `${projectPrefix}${encodeURIComponent(project.server?.key ?? "")}:${encodeURIComponent(project.worktree)}`
}
function actionKey(server?: string) {
return `${actionPrefix}${encodeURIComponent(server ?? "")}`
}
export function createPromptProjectController(input: {
controls: Accessor<PromptProjectControls>
onDone: () => void
}) {
export function createPromptProjectController(input: { controls: Accessor<PromptProjectControls> }) {
const language = useLanguage()
const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined
const current = () => {
const controls = input.controls()
@@ -75,423 +42,38 @@ export function createPromptProjectController(input: {
const selected = () => current() ?? input.controls().available[0]
const projects = () => {
const search = store.search.trim().toLowerCase()
if (!search) return input.controls().available
return input.controls().available.filter((project) => displayName(project).toLowerCase().includes(search))
}
const servers = () =>
input
.controls()
.available.map((project) => project.server)
.filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index)
const keys = () => {
if (servers().length <= 1) {
return [...projects().map(projectKey), actionKey(servers()[0]?.key)]
}
return [
...servers().flatMap((server) =>
projects()
.filter((project) => project.server?.key === server!.key)
.map(projectKey),
),
actionKey(),
]
}
const initialActive = () => {
const selectedKey = selected() ? projectKey(selected()!) : undefined
const options = keys()
if (selectedKey && options.includes(selectedKey)) return selectedKey
return options[0] ?? ""
}
const close = () => {
setStore({ open: false, search: "", active: "" })
input.onDone()
}
const select = (project: PromptProject) => {
if (
pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") ||
project.server?.key !== current()?.server?.key
) {
input.controls().select(project.worktree, project.server?.key)
}
close()
}
const add = (server?: string) => {
setStore({ open: false, search: "", active: "" })
input.controls().add(language.t("command.project.open"), server)
}
const setSearch = (value: string) => {
const search = value.trim().toLowerCase()
const first = input
.controls()
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
setStore({
search: value,
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
})
}
const matches = (project: PromptProject, other?: PromptProject) =>
!!other && pathKey(other.worktree) === pathKey(project.worktree) && other.server?.key === project.server?.key
return {
selected,
isSelected: (project: PromptProject) => matches(project, selected()),
empty: () => input.controls().available.length === 0,
projects,
servers,
projectKey,
actionKey,
open: () => store.open,
search: () => store.search,
active: () => store.active,
projects: () => input.controls().available,
servers: () =>
input
.controls()
.available.map((project) => project.server)
.filter(
(server, index, all): server is NonNullable<typeof server> =>
!!server && all.findIndex((item) => item?.key === server.key) === index,
),
labels: {
add: () => language.t("session.new.project.add"),
clear: () => language.t("common.clear"),
new: () => language.t("session.new.project.new"),
search: () => language.t("session.new.project.search"),
},
add,
select,
setOpen(open: boolean) {
if (open) {
setStore({ open: true, active: initialActive() })
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
return
}
setStore({ open: false, search: "", active: "" })
add(server?: string) {
input.controls().add(language.t("command.project.open"), server)
},
setSearch,
clearSearch() {
setStore({ search: "", active: initialActive() })
setTimeout(() => searchRef?.focus())
},
setActive(key: string) {
setStore("active", key)
},
moveActive(delta: number) {
const options = keys()
if (options.length === 0) return
const index = options.indexOf(store.active)
const start = index === -1 ? 0 : index
setStore("active", options[(start + delta + options.length) % options.length])
},
activeProject() {
return store.active.startsWith(projectPrefix)
? projects().find((project) => projectKey(project) === store.active)
: undefined
},
activeServer() {
return store.active.startsWith(actionPrefix)
? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined
: undefined
},
activeAction() {
return store.active.startsWith(actionPrefix)
},
setSearchRef(el: HTMLInputElement) {
searchRef = el
},
focusSearch() {
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
},
handleSearchKeydown(event: KeyboardEvent) {
return handleDocumentSearchKeydown(searchRef, event, store.search, setSearch)
select(project: PromptProject, workspace?: string) {
if (matches(project, current())) return
input.controls().select(project.worktree, project.server?.key, workspace)
},
}
}
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
export function PromptProjectSelector(props: {
controller: PromptProjectController
placement?: "bottom" | "bottom-start"
}) {
const [triggerReady, setTriggerReady] = createSignal(false)
let contentRef: HTMLDivElement | undefined
const dismiss = createMenuDismissController(() => contentRef)
let triggerFrame: number | undefined
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
const setTriggerRef = (element: HTMLButtonElement) => {
const ready = () => {
if (!element.isConnected) {
triggerFrame = requestAnimationFrame(ready)
return
}
triggerFrame = undefined
setTriggerReady(true)
}
ready()
}
onCleanup(() => {
if (triggerFrame !== undefined) cancelAnimationFrame(triggerFrame)
})
const activeItem = () =>
props.controller.active()
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
: undefined
const selectProject = (project: PromptProject) => {
dismiss.preventTriggerRestore()
props.controller.setOpen(false)
dismiss.afterClose(() => props.controller.select(project))
}
const selectAction = (server?: string) => {
dismiss.preventTriggerRestore()
props.controller.setOpen(false)
dismiss.afterClose(() => props.controller.add(server))
}
const selectActive = () => {
const project = props.controller.activeProject()
if (project) {
selectProject(project)
return
}
if (props.controller.activeAction() && props.controller.servers().length > 1) {
const item = activeItem()
item?.focus()
item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }))
return
}
selectAction(props.controller.activeServer())
}
const moveActive = (delta: number) => {
props.controller.moveActive(delta)
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
}
const focusPreviousControl = () => {
const target = Array.from(
document.querySelectorAll<HTMLElement>(
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
)
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
.findLast((element) => element.offsetParent !== null)
dismiss.preventTriggerRestore()
target?.focus()
queueMicrotask(() => {
if (props.controller.open()) props.controller.setOpen(false)
})
}
const selectedValue = () => {
const project = props.controller.selected()
return project ? props.controller.projectKey(project) : undefined
}
createEffect(() => {
if (!props.controller.open()) return
const handler = (event: KeyboardEvent) => props.controller.handleSearchKeydown(event)
document.addEventListener("keydown", handler, true)
onCleanup(() => document.removeEventListener("keydown", handler, true))
})
return (
<Menu
open={triggerReady() && props.controller.open()}
placement={props.placement ?? "bottom"}
gutter={4}
modal={false}
onOpenChange={(open) => {
if (open) dismiss.allowTriggerRestore()
props.controller.setOpen(open)
}}
>
<Menu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
<Menu.Portal>
<Menu.Content
ref={contentRef}
id="prompt-project-menu"
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
onOpenAutoFocus={(event) => event.preventDefault()}
onPointerDownOutside={dismiss.preventTriggerRestore}
onFocusOutside={dismiss.preventTriggerRestore}
onCloseAutoFocus={dismiss.onCloseAutoFocus}
>
<div class="flex flex-col">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-1 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(el) => props.controller.setSearchRef(el)}
value={props.controller.search()}
placeholder={props.controller.labels.search()}
aria-autocomplete="list"
aria-controls="prompt-project-menu"
aria-activedescendant={props.controller.active() || undefined}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => props.controller.setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Tab") {
event.preventDefault()
event.stopPropagation()
if (event.shiftKey) {
focusPreviousControl()
return
}
activeItem()?.focus()
return
}
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
props.controller.setOpen(false)
return
}
if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
moveActive(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
moveActive(-1)
return
}
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
selectActive()
}
}}
/>
<Show when={props.controller.search().trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm bg-transparent text-v2-icon-icon-faint transition-colors hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base active:text-v2-icon-icon-base"
onPointerDown={(event) => event.preventDefault()}
onClick={() => props.controller.clearSearch()}
aria-label={props.controller.labels.clear()}
>
<Icon name="circle-xmark" />
</button>
</Show>
</div>
<div class="my-0.5 max-h-[224px] overflow-y-auto">
<Show
when={props.controller.servers().length > 1}
fallback={
<Menu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects()}>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</Menu.RadioGroup>
}
>
<For
each={props.controller
.servers()
.filter((server) =>
props.controller.projects().some((project) => project.server?.key === server!.key),
)}
>
{(server) => (
<div>
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
{server!.name}
</div>
<Menu.RadioGroup value={selectedValue()}>
<For
each={props.controller.projects().filter((project) => project.server?.key === server!.key)}
>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</Menu.RadioGroup>
</div>
)}
</For>
</Show>
</div>
</div>
<div class="-mx-0.5 h-px bg-v2-border-border-muted" />
<div class="flex flex-col pt-0.5">
<Show
when={props.controller.servers().length > 1}
fallback={
<ProjectAction
server={props.controller.servers()[0]?.key}
controller={props.controller}
onSelect={selectAction}
/>
}
>
<Menu.Sub>
<Menu.SubTrigger
id={props.controller.actionKey()}
data-option-key={props.controller.actionKey()}
class={projectActionClass}
classList={{
"!bg-v2-overlay-simple-overlay-hover": props.controller.active() === props.controller.actionKey(),
}}
onMouseEnter={() => props.controller.setActive(props.controller.actionKey())}
>
<Icon name="plus" size="small" />
<span class="min-w-0 flex-1 truncate leading-5">{props.controller.labels.add()}</span>
</Menu.SubTrigger>
<Menu.Portal>
<Menu.SubContent class="max-h-[224px] min-w-[180px] overflow-y-auto rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<For each={props.controller.servers()}>
{(server) => <ServerAction server={server!} onSelect={selectAction} />}
</For>
</Menu.SubContent>
</Menu.Portal>
</Menu.Sub>
</Show>
</div>
</Menu.Content>
</Menu.Portal>
</Menu>
)
}
export function PromptProjectAddButton(props: { controller: PromptProjectController }) {
return (
<button
@@ -506,118 +88,3 @@ export function PromptProjectAddButton(props: { controller: PromptProjectControl
</button>
)
}
function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) {
const [local, rest] = splitProps(props, ["controller", "class", "classList", "onClick", "onKeyDown"])
const project = () => local.controller.selected()
return (
<button
{...rest}
data-action="prompt-project"
type="button"
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
classList={{
...local.classList,
"hover:bg-v2-overlay-simple-overlay-hover": !local.controller.open(),
"bg-v2-overlay-simple-overlay-pressed": local.controller.open(),
"text-v2-text-text-muted": local.controller.open(),
}}
onClick={local.onClick ?? (() => local.controller.setOpen(true))}
onKeyDown={(event) => {
if (!local.controller.open() && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault()
event.stopPropagation()
return
}
if (typeof local.onKeyDown === "function") local.onKeyDown(event)
}}
>
<Show
when={project()}
fallback={<Icon name="folder-add-left" size="small" class="shrink-0 text-v2-icon-icon-muted" />}
>
{(item) => (
<ProjectAvatar
fallback={displayName(item())}
src={getProjectAvatarSource(item().id, item().icon)}
variant={getProjectAvatarVariant(item().icon?.color)}
/>
)}
</Show>
<span class="min-w-0 truncate leading-5">
{project() ? displayName(project()!) : local.controller.labels.new()}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</button>
)
}
function ProjectItem(props: {
project: PromptProject
controller: PromptProjectController
onSelect: (project: PromptProject) => void
}) {
const key = () => props.controller.projectKey(props.project)
return (
<Menu.RadioItem
id={key()}
value={key()}
data-option-key={key()}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
closeOnSelect
onMouseEnter={() => {
props.controller.setActive(key())
props.controller.focusSearch()
}}
onSelect={() => props.onSelect(props.project)}
>
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
<span class="min-w-0 truncate leading-5">{displayName(props.project)}</span>
</Menu.RadioItem>
)
}
const projectActionClass =
"h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
function ProjectAction(props: {
server?: string
controller: PromptProjectController
onSelect: (server?: string) => void
}) {
const key = () => props.controller.actionKey(props.server)
return (
<Menu.Item
id={key()}
data-option-key={key()}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
onMouseEnter={() => {
props.controller.setActive(key())
props.controller.focusSearch()
}}
onSelect={() => props.onSelect(props.server)}
>
<Icon name="plus" size="small" />
<span class="min-w-0 truncate leading-5">{props.controller.labels.add()}</span>
</Menu.Item>
)
}
function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) {
return (
<Menu.Item class={projectActionClass} onSelect={() => props.onSelect(props.server.key)}>
<span class="min-w-0 flex-1 truncate leading-5">{props.server.name}</span>
</Menu.Item>
)
}
+5 -23
View File
@@ -1,7 +1,6 @@
import { createPromptProjectController } from "@/new-session/project/selector"
import { useSettingsSurface } from "@/settings/surface"
import { useNewSessionLocationDialog } from "@/new-session/location/dialog"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { useSettingsServers } from "@/settings/servers/inventory"
import { useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, on, untrack } from "solid-js"
import { createComposerModel } from "@/composer/model"
@@ -16,25 +15,11 @@ import { createDraftMcpControls } from "./mcp"
export default function NewSessionPage(props: { draftId: string }) {
const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>()
const tabs = useTabs()
const servers = useSettingsServers()
const settingsSurface = useSettingsSurface()
const draftTab = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
)
const openWorkspaces = () => {
const draft = draftTab()
if (servers().length > 1 && draft) {
settingsSurface.openServer(draft.server, "workspaces")
return
}
settingsSurface.open("workspaces")
}
const workspace = createNewSessionWorkspaceController({
selectedWorktree: () => draftTab()?.worktree,
selectedBranch: () => draftTab()?.branch,
@@ -44,7 +29,6 @@ export default function NewSessionPage(props: { draftId: string }) {
setSelectedBranch: (branch) => {
if (search.draftId) tabs.updateDraft(search.draftId, { branch })
},
onViewAll: openWorkspaces,
})
const mcp = createDraftMcpControls({ draftID: props.draftId, worktree: workspace.selection.value })
@@ -60,16 +44,14 @@ export default function NewSessionPage(props: { draftId: string }) {
const model = createComposerModel(composer.adapter)
useComposerCommands({ model: composer.model })
const project = createPromptProjectController({
controls: composer.project,
onDone: model.restoreFocus,
})
const project = createPromptProjectController({ controls: composer.project })
const location = useNewSessionLocationDialog({ project, workspace, onDone: model.restoreFocus })
useNewSessionCommands({
restoreFocus: model.restoreFocus,
project: {
empty: project.empty,
open: () => project.setOpen(true),
open: () => location(),
},
workspace: {
enabled: workspace.bar.visible,
@@ -104,7 +86,7 @@ export default function NewSessionPage(props: { draftId: string }) {
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<div class="flex-1 min-h-0 flex flex-col gap-2 px-[var(--shell-inline-inset,8px)] pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<NewSessionView composer={model} project={project} workspace={workspace} />
<NewSessionView composer={model} project={project} workspace={workspace} location={location} />
</div>
</div>
)
+112 -29
View File
@@ -2,17 +2,24 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { Tooltip } from "@opencode/ui/tooltip"
import { Icon } from "@opencode/ui/icon"
import { Show, createMemo, createSignal } from "solid-js"
import {
displayName,
getProjectAvatarSource,
getProjectAvatarVariant,
ProjectAvatar,
} from "@opencode/ui/project-avatar"
import { Schema } from "effect"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
import { ComposerDropzone } from "@/composer/dropzone"
import type { ComposerModel } from "@/composer/model"
import { PromptGitStatus, PromptWorkspaceSelector } from "@/new-session/workspace/selector"
import { PromptBranchSelector } from "@/new-session/workspace/selector"
import {
PromptProjectAddButton,
PromptProjectSelector,
type PromptProject,
type PromptProjectController,
} from "@/new-session/project/selector"
import type { useNewSessionLocationDialog } from "@/new-session/location/dialog"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
@@ -38,6 +45,7 @@ export function NewSessionView(props: {
composer: ComposerModel
project: PromptProjectController
workspace: NewSessionWorkspaceController
location: ReturnType<typeof useNewSessionLocationDialog>
}) {
const [onboarding, setOnboarding, , onboardingReady] = persisted(
Persist.global("workspace-onboarding"),
@@ -64,39 +72,46 @@ export function NewSessionView(props: {
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<NewSessionWordmark />
<div class="mt-8 flex flex-col gap-8">
<div class="mt-8 flex flex-col gap-3">
<Composer model={props.composer} />
<Show when={props.project.empty()}>
<PromptProjectAddButton controller={props.project} />
</Show>
<Show when={props.project.selected()}>
<div class="flex min-h-7 min-w-0 flex-row flex-wrap items-center justify-center gap-0 text-v2-text-text-faint">
<PromptProjectSelector controller={props.project} placement="bottom" />
<Show
when={props.workspace.bar.visible()}
fallback={
<PromptGitStatus
branch={props.workspace.bar.branch()}
noGit={!props.workspace.project.git()}
class="ms-1"
/>
}
>
<PromptWorkspaceSelector
value={props.workspace.selection.value()}
projectRoot={props.workspace.project.root()}
workspaces={props.workspace.project.workspaces()}
branches={props.workspace.project.branches()}
branch={props.workspace.bar.branch()}
onboarding={onboardingReady() && !onboarding.used}
onChange={select}
onCreate={props.workspace.selection.create}
onSearch={props.workspace.project.searchBranches}
onDone={props.composer.restoreFocus}
onViewAll={props.workspace.project.openAll}
{(project) => (
<div class="flex min-h-7 min-w-0 flex-row flex-wrap items-center gap-0 ps-2.5 text-v2-text-text-faint">
<NewSessionLocationTrigger
project={project()}
workspace={props.workspace.bar.visible() ? props.workspace.selection.value() : "main"}
root={props.workspace.project.root()}
onboarding={onboardingReady() && !onboarding.used && props.workspace.bar.visible()}
onClick={() =>
props.location({
onWorkspace: (value) => {
if (value !== "main") setOnboarding("used", true)
},
})
}
/>
</Show>
</div>
<Show
when={
props.workspace.bar.visible() &&
props.workspace.selection.value() === "create" &&
props.workspace.bar.branch()
}
>
{(branch) => (
<PromptBranchSelector
branch={branch()}
branches={props.workspace.project.branches()}
onCreate={props.workspace.selection.create}
onSearch={props.workspace.project.searchBranches}
onDone={props.composer.restoreFocus}
/>
)}
</Show>
</div>
)}
</Show>
</div>
</div>
@@ -243,3 +258,71 @@ function NewSessionTips(props: {
</Show>
)
}
function NewSessionLocationTrigger(props: {
project: PromptProject
/** "main", "create", or a worktree directory. */
workspace: string
root: string
onboarding: boolean
onClick: () => void
}) {
const language = useLanguage()
const path = () => {
if (props.workspace === "main") return props.root
if (props.workspace === "create") return language.t("workspace.new")
return props.workspace
}
return (
<Tooltip
appearance={props.onboarding ? "large" : undefined}
placement="top"
openDelay={800}
value={
props.onboarding ? (
<div class="flex flex-col gap-1 text-start">
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
<Icon name="outline-worktree" size="small" class="shrink-0 text-v2-text-text-accent" />
<span>{language.t("workspace.onboarding.title")}</span>
</div>
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
</div>
) : (
language.t("session.new.workspace.trigger.tooltip")
)
}
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
class="min-w-0"
>
<button
type="button"
data-action="prompt-project"
aria-haspopup="dialog"
aria-description={language.t("session.new.workspace.trigger.tooltip")}
class="flex h-7 min-w-0 max-w-full items-center gap-2 rounded-sm px-1.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={props.onClick}
>
<ProjectAvatar
class="shrink-0"
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
<span class="max-w-[200px] shrink-0 truncate text-v2-text-text-muted">{displayName(props.project)}</span>
<span dir="ltr" class="min-w-0 truncate text-v2-text-text-faint">
{path()}
</span>
<Show when={props.onboarding}>
<span
data-slot="workspace-onboarding-dot"
aria-hidden="true"
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
/>
</Show>
</button>
</Tooltip>
)
}
@@ -14,6 +14,7 @@ import {
isWorkspaceSelection,
workspaceDefaultSelection,
workspaceSelectionDestination,
workspaceSelectionDirectories,
} from "@/workspaces/paths"
export function resolveNewSessionWorktree(input: { enabled: boolean; selected?: string; fallback?: string }) {
@@ -53,7 +54,6 @@ export function createNewSessionWorkspaceController(input: {
selectedBranch: () => string | undefined
setSelectedWorktree: (worktree: string | undefined) => void
setSelectedBranch: (branch: string | undefined) => void
onViewAll: () => void
}) {
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
@@ -119,15 +119,7 @@ export function createNewSessionWorkspaceController(input: {
if (!project) return []
const directories = [
...worktreeItems().map((item) => item.directory),
...project.worktrees.map((item) => item.directory),
...(project.sandboxes ?? []),
]
return directories
.filter((directory) => !sameDirectory(project.worktree, directory))
.filter((directory, index, items) => items.findIndex((item) => sameDirectory(item, directory)) === index)
return workspaceSelectionDirectories(project, worktreeItems())
})
const managedWorktrees = createMemo(() => {
@@ -186,8 +178,8 @@ export function createNewSessionWorkspaceController(input: {
const [branches] = createResource(
() => (visible() ? { directory: projectRoot(), search: state.search } : undefined),
({ directory, search }) =>
serverSDK.api.vcs
.branch.list({ location: { directory }, search, limit: 50 })
serverSDK.api.vcs.branch
.list({ location: { directory }, search, limit: 50 })
.then((response) => ({ directory, search, data: response.data }))
.catch(() => ({ directory, search, data: [] })),
)
@@ -295,7 +287,6 @@ export function createNewSessionWorkspaceController(input: {
].slice(0, 50)
},
searchBranches,
openAll: input.onViewAll,
},
bar: {
visible,
@@ -1,351 +1,120 @@
import { createMemo, createSignal, For, Show } from "solid-js"
import { createSignal, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Menu } from "@opencode/ui/menu"
import { Tooltip } from "@opencode/ui/tooltip"
import { Icon } from "@opencode/ui/icon"
import { getFilename, sameDirectory } from "@opencode/util/path"
import { useLanguage } from "@/runtime/i18n/language"
import { WorkspaceSubmenu } from "@/workspaces/submenu"
export function PromptWorkspaceSelector(props: {
value: string
projectRoot: string
workspaces: string[]
/** The base branch for a new worktree. */
export function PromptBranchSelector(props: {
branch: string
branches: string[]
branch?: string
onboarding?: boolean
variant?: "inline" | "summary"
onChange: (value: string) => void
onCreate: (branch: string) => void
onSearch: (search: string) => void
onDone?: () => void
onViewAll: () => void
}) {
const language = useLanguage()
const summary = () => props.variant === "summary"
const placement = createMemo(() =>
summary() ? (language.direction() === "rtl" ? "right-start" : "left-start") : "bottom",
)
const [search, setSearch] = createStore({ branches: "" })
let branchSearchInput: HTMLInputElement | undefined
const branchTruncation = createTruncatedText()
let pending: { type: "select"; value: string } | { type: "create"; branch: string } | { type: "viewAll" } | undefined
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
const icon = () => {
if (selected() === "main") return "monitor"
if (selected() === "create") return "plus"
return "outline-worktree"
}
const select = (value: string) => {
pending = { type: "select", value }
}
const onOpenChange = (open: boolean) => {
if (open) {
setSearch({ branches: "" })
props.onSearch("")
return
}
const action = pending
pending = undefined
if (action?.type === "select") props.onChange(action.value)
if (action?.type === "create") props.onCreate(action.branch)
if (action?.type === "viewAll") {
props.onViewAll()
return
}
props.onDone?.()
}
const label = () => {
if (selected() === "main")
return language.t(summary() ? "session.new.workspace.local" : "session.new.workspace.triggerLocal")
if (props.value === "create") return language.t("workspace.new")
return getFilename(props.value)
}
return (
<>
<Show when={!summary()}>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
</Show>
<Tooltip
appearance={props.onboarding ? "large" : undefined}
placement="top"
openDelay={800}
value={
props.onboarding ? (
<div class="flex flex-col gap-1 text-start">
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
<Icon name="outline-worktree" size="small" class="shrink-0 text-v2-text-text-accent" />
<span>{language.t("workspace.onboarding.title")}</span>
</div>
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
</div>
) : (
language.t("session.new.workspace.trigger.tooltip")
)
}
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
class={summary() ? "min-w-0 w-full" : "min-w-0"}
>
<Menu
placement={placement()}
gutter={4}
overflowPadding={24}
modal={summary() ? false : undefined}
onOpenChange={onOpenChange}
>
<Menu.Trigger
aria-description={language.t("session.new.workspace.trigger.tooltip")}
class={
summary()
? "session-summary-row"
: "flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
}
>
<Icon
name={icon()}
class={`shrink-0 ${summary() || selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
/>
<span class={summary() ? "session-summary-label" : "min-w-0 truncate"}>{label()}</span>
<Show when={props.onboarding}>
<span
data-slot="workspace-onboarding-dot"
aria-hidden="true"
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
/>
</Show>
<Icon
name={summary() ? "fill-triangle-down" : "chevron-down"}
size={summary() ? "normal" : "small"}
class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted"
/>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="w-[200px]">
<Menu.Group>
<Menu.GroupLabel>{language.t("session.new.workspace.runIn")}</Menu.GroupLabel>
<Menu.Item onSelect={() => select("main")}>
<Icon name="monitor" />
<Tooltip
placement="right"
openDelay={800}
value={language.t("session.new.workspace.local.tooltip")}
contentClass="max-w-[140px]"
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
</Tooltip>
<Show when={selected() === "main"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</Menu.Item>
<Menu.Item onSelect={() => select("create")}>
<Icon name="plus" />
<Tooltip
placement="right"
openDelay={800}
value={language.t("session.new.workspace.new.tooltip")}
contentClass="max-w-[140px]"
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</Tooltip>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</Menu.Item>
</Menu.Group>
<Show when={props.workspaces.length > 0}>
<Menu.Separator class="h-[0.5px]" />
<WorkspaceSubmenu
directories={props.workspaces}
selected={selected()}
onSelect={select}
onViewAll={() => (pending = { type: "viewAll" })}
/>
</Show>
</Menu.Content>
</Menu.Portal>
</Menu>
</Tooltip>
<Show
when={selected() === "create" && props.branch}
fallback={
summary() ? (
<Show when={props.branch}>
<div class="session-summary-row">
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<span dir="auto" class="session-summary-label">
{props.branch}
</span>
</div>
</Show>
) : (
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
)
}
>
<Tooltip
placement="top"
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
disabled={!branchTruncation.truncated()}
class={summary() ? "min-w-0 w-full" : "ms-1 min-w-0 max-w-[220px]"}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<Menu placement={placement()} gutter={4} modal={summary() ? false : undefined} onOpenChange={onOpenChange}>
<Menu.Trigger
class={
summary()
? "session-summary-row"
: "flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted"
}
>
<Icon name="branch-out" size={summary() ? "normal" : "small"} class="shrink-0 text-v2-icon-icon-muted" />
<span ref={branchTruncation.observe} class={summary() ? "session-summary-label" : "min-w-0 truncate"}>
{language.t(summary() ? "session.summary.basedOn" : "session.new.workspace.fromBranch", {
branch: props.branch!,
})}
</span>
<Icon
name={summary() ? "fill-triangle-down" : "chevron-down"}
size={summary() ? "normal" : "small"}
class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted"
/>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onOpenAutoFocus={(event) => {
event.preventDefault()
// Kobalte defers its list autofocus until after the focus scope opens.
setTimeout(() => requestAnimationFrame(() => branchSearchInput?.focus({ preventScroll: true })))
}}
>
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-1 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(element) => {
branchSearchInput = element
}}
value={search.branches}
placeholder={language.t("session.new.workspace.branch.search.placeholder")}
aria-label={language.t("session.new.workspace.branch.search.placeholder")}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => {
setSearch("branches", event.currentTarget.value)
props.onSearch(event.currentTarget.value)
}}
onKeyDown={(event) => {
if (
event.key === "Escape" ||
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter"
)
return
event.stopPropagation()
}}
/>
<Show when={search.branches.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm bg-transparent text-v2-icon-icon-faint transition-colors hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base active:text-v2-icon-icon-base"
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
setSearch("branches", "")
props.onSearch("")
}}
aria-label={language.t("common.clear")}
>
<Icon name="circle-xmark" />
</button>
</Show>
</div>
<div class="max-h-[224px] overflow-y-auto">
<Menu.RadioGroup value={props.branch}>
<For each={props.branches}>
{(branch) => (
<Menu.RadioItem
value={branch}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
closeOnSelect
onSelect={() => (pending = { type: "create", branch })}
>
<span class="min-w-0 truncate leading-5">{branch}</span>
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</div>
</Menu.Content>
</Menu.Portal>
</Menu>
</Tooltip>
</Show>
</>
)
}
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
const language = useLanguage()
const truncation = createTruncatedText()
const label = () => {
if (props.noGit) return language.t("session.new.git.none")
if (!props.branch) return undefined
if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch })
return props.branch
}
const icon = () => {
if (props.noGit) return "monitor"
if (props.from) return "branch-out"
return "branch"
}
let input: HTMLInputElement | undefined
let pending: string | undefined
return (
<Show when={label()}>
{(value) => (
<Tooltip
placement="top"
value={value()}
disabled={!truncation.truncated()}
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={truncation.observe} class="min-w-0 truncate">
{value()}
</span>
</div>
</Tooltip>
)}
</Show>
<Tooltip
placement="top"
value={language.t("session.new.workspace.fromBranch", { branch: props.branch })}
disabled={!truncation.truncated()}
class="ms-1 min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<Menu
placement="bottom-start"
gutter={4}
onOpenChange={(open) => {
if (open) {
setSearch({ branches: "" })
props.onSearch("")
return
}
const branch = pending
pending = undefined
if (branch) props.onCreate(branch)
props.onDone?.()
}}
>
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={truncation.observe} class="min-w-0 truncate">
{language.t("session.new.workspace.fromBranch", { branch: props.branch })}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onOpenAutoFocus={(event) => {
event.preventDefault()
// Kobalte defers its list autofocus until after the focus scope opens.
setTimeout(() => requestAnimationFrame(() => input?.focus({ preventScroll: true })))
}}
>
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-1 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={input}
value={search.branches}
placeholder={language.t("session.new.workspace.branch.search.placeholder")}
aria-label={language.t("session.new.workspace.branch.search.placeholder")}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => {
setSearch("branches", event.currentTarget.value)
props.onSearch(event.currentTarget.value)
}}
onKeyDown={(event) => {
if (["Escape", "ArrowDown", "ArrowUp", "Enter"].includes(event.key)) return
event.stopPropagation()
}}
/>
<Show when={search.branches.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm bg-transparent text-v2-icon-icon-faint transition-colors hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base active:text-v2-icon-icon-base"
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
setSearch("branches", "")
props.onSearch("")
}}
aria-label={language.t("common.clear")}
>
<Icon name="circle-xmark" />
</button>
</Show>
</div>
<div class="max-h-[224px] overflow-y-auto">
<Menu.RadioGroup value={props.branch}>
<For each={props.branches}>
{(branch) => (
<Menu.RadioItem
value={branch}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
closeOnSelect
onSelect={() => (pending = branch)}
>
<span class="min-w-0 truncate leading-5">{branch}</span>
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</div>
</Menu.Content>
</Menu.Portal>
</Menu>
</Tooltip>
)
}
+3
View File
@@ -978,6 +978,9 @@ export const dict = {
"session.new.workspace.trigger.tooltip": "Select where to run session",
"session.new.workspace.search.placeholder": "Search worktrees",
"session.new.workspace.search.empty": "No matching worktrees",
"session.new.location.select": "Select",
"session.new.location.worktrees": "Worktrees",
"session.new.location.back": "Back to projects",
"settings.tab.workspaces": "Worktrees",
"settings.workspaces.description": "Review worktrees and manage disk usage",
"settings.workspaces.filter.all": "All projects",
+11
View File
@@ -12,6 +12,17 @@ export function workspaceDirectories(project: WorkspaceProject) {
return (project.sandboxes ?? []).filter((directory) => !sameDirectory(project.worktree, directory))
}
/** Every non-root directory a new session can run in, from the server inventory and the project's cached lists. */
export function workspaceSelectionDirectories(project: WorkspaceProject, listed: readonly WorktreeDirectory[]) {
return [
...listed.map((item) => item.directory),
...(project.worktrees ?? []).map((item) => item.directory),
...(project.sandboxes ?? []),
]
.filter((directory) => !sameDirectory(project.worktree, directory))
.filter((directory, index, items) => items.findIndex((item) => sameDirectory(item, directory)) === index)
}
export function managedWorkspaceDirectories(project: WorkspaceProject) {
return (project.worktrees ?? [])
.filter((worktree) => worktree.strategy !== undefined)