From cf6b36126881cf9114573f76fe6019da21275c41 Mon Sep 17 00:00:00 2001 From: thdxr <826656+thdxr@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:35:25 +0000 Subject: [PATCH] feat(app): pick new session location from a project and worktree dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app/e2e/regression/new-session.spec.ts | 8 +- packages/app/e2e/utils/workspace.ts | 13 +- .../app/src/new-session/location/dialog.css | 39 ++ .../app/src/new-session/location/dialog.tsx | 505 +++++++++++++++ .../app/src/new-session/project/controller.ts | 10 +- .../src/new-session/project/selector.test.tsx | 4 +- .../app/src/new-session/project/selector.tsx | 579 +----------------- packages/app/src/new-session/screen.tsx | 28 +- packages/app/src/new-session/view.tsx | 141 ++++- .../src/new-session/workspace/controller.ts | 17 +- .../src/new-session/workspace/selector.tsx | 431 +++---------- packages/app/src/runtime/i18n/en.ts | 3 + packages/app/src/workspaces/paths.ts | 11 + 13 files changed, 823 insertions(+), 966 deletions(-) create mode 100644 packages/app/src/new-session/location/dialog.css create mode 100644 packages/app/src/new-session/location/dialog.tsx diff --git a/packages/app/e2e/regression/new-session.spec.ts b/packages/app/e2e/regression/new-session.spec.ts index c9a6b455123..c3c49296e80 100644 --- a/packages/app/e2e/regression/new-session.spec.ts +++ b/packages/app/e2e/regression/new-session.spec.ts @@ -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) diff --git a/packages/app/e2e/utils/workspace.ts b/packages/app/e2e/utils/workspace.ts index f0358a7b86c..f31f891654a 100644 --- a/packages/app/e2e/utils/workspace.ts +++ b/packages/app/e2e/utils/workspace.ts @@ -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 } diff --git a/packages/app/src/new-session/location/dialog.css b/packages/app/src/new-session/location/dialog.css new file mode 100644 index 00000000000..d36ab9b4358 --- /dev/null +++ b/packages/app/src/new-session/location/dialog.css @@ -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; +} diff --git a/packages/app/src/new-session/location/dialog.tsx b/packages/app/src/new-session/location/dialog.tsx new file mode 100644 index 00000000000..dfca1dd4d8b --- /dev/null +++ b/packages/app/src/new-session/location/dialog.tsx @@ -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( + () => ( + + ), + 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(() => { + 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(() => { + 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) { + 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 ( + + + + (resultsRef = el)}> +
+ 0} + fallback={ +
+ {store.drilled && listed.loading ? language.t("common.loading") : language.t("palette.empty")} +
+ } + > + + {(group) => ( +
+
{group.group}
+ + {(entry) => ( + + )} + +
+ )} +
+
+
+
+
+ + + {language.t("session.new.location.select")} + + + + {language.t("session.new.location.worktrees")} + + } + > + + + {language.t("session.new.location.back")} + + +
+
+
+ ) +} + +function ProjectRow(props: { project: PromptProject; path: string; onDrill?: () => void; drillLabel: string }) { + return ( + <> +
+ +
+ {displayName(props.project)} + + {props.path} + +
+
+ + {(onDrill) => ( + { + event.stopPropagation() + onDrill()() + }} + > + + + )} + + + ) +} + +function WorkspaceRow(props: { title: string; value: string; description: string }) { + return ( + <> +
+ +
+ {props.title} + + {props.description} + +
+
+ + ) +} + +function drillable(project: PromptProject) { + return !!project.id && project.vcs === "git" +} diff --git a/packages/app/src/new-session/project/controller.ts b/packages/app/src/new-session/project/controller.ts index 07e8234343e..44cd3fe593e 100644 --- a/packages/app/src/new-session/project/controller.ts +++ b/packages/app/src/new-session/project/controller.ts @@ -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, }) } diff --git a/packages/app/src/new-session/project/selector.test.tsx b/packages/app/src/new-session/project/selector.test.tsx index 219c6e037b5..a8886482ad8 100644 --- a/packages/app/src/new-session/project/selector.test.tsx +++ b/packages/app/src/new-session/project/selector.test.tsx @@ -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() }) diff --git a/packages/app/src/new-session/project/selector.tsx b/packages/app/src/new-session/project/selector.tsx index c4bbe6f5ab8..c8e6ee98394 100644 --- a/packages/app/src/new-session/project/selector.tsx +++ b/packages/app/src/new-session/project/selector.tsx @@ -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 - onDone: () => void -}) { +export function createPromptProjectController(input: { controls: Accessor }) { 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 => + !!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 -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(`[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( - '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 ( - { - if (open) dismiss.allowTriggerRestore() - props.controller.setOpen(open) - }} - > - - - event.preventDefault()} - onPointerDownOutside={dismiss.preventTriggerRestore} - onFocusOutside={dismiss.preventTriggerRestore} - onCloseAutoFocus={dismiss.onCloseAutoFocus} - > -
-
- - 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() - } - }} - /> - - - -
-
- 1} - fallback={ - - - {(project) => ( - - )} - - - } - > - - props.controller.projects().some((project) => project.server?.key === server!.key), - )} - > - {(server) => ( -
-
- {server!.name} -
- - project.server?.key === server!.key)} - > - {(project) => ( - - )} - - -
- )} -
-
-
-
-
-
- 1} - fallback={ - - } - > - - props.controller.setActive(props.controller.actionKey())} - > - - {props.controller.labels.add()} - - - - - {(server) => } - - - - - -
- - -
- ) -} - export function PromptProjectAddButton(props: { controller: PromptProjectController }) { return ( - ) -} - -function ProjectItem(props: { - project: PromptProject - controller: PromptProjectController - onSelect: (project: PromptProject) => void -}) { - const key = () => props.controller.projectKey(props.project) - - return ( - { - props.controller.setActive(key()) - props.controller.focusSearch() - }} - onSelect={() => props.onSelect(props.project)} - > - - {displayName(props.project)} - - ) -} - -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 ( - { - props.controller.setActive(key()) - props.controller.focusSearch() - }} - onSelect={() => props.onSelect(props.server)} - > - - {props.controller.labels.add()} - - ) -} - -function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) { - return ( - props.onSelect(props.server.key)}> - {props.server.name} - - ) -} diff --git a/packages/app/src/new-session/screen.tsx b/packages/app/src/new-session/screen.tsx index 30f28ad2830..12288c66e56 100644 --- a/packages/app/src/new-session/screen.tsx +++ b/packages/app/src/new-session/screen.tsx @@ -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 }) {
{suspendUntilPromptReady()}
- +
) diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index c2d85f5e23a..c8fd0e7169b 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -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 }) { const [onboarding, setOnboarding, , onboardingReady] = persisted( Persist.global("workspace-onboarding"), @@ -64,39 +72,46 @@ export function NewSessionView(props: {
-
+
-
- - - } - > - ( +
+ + props.location({ + onWorkspace: (value) => { + if (value !== "main") setOnboarding("used", true) + }, + }) + } /> - -
+ + {(branch) => ( + + )} + +
+ )}
@@ -243,3 +258,71 @@ function NewSessionTips(props: { ) } + +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 ( + +
+ + {language.t("workspace.onboarding.title")} +
+ {language.t("workspace.onboarding.description")} +
+ ) : ( + language.t("session.new.workspace.trigger.tooltip") + ) + } + contentClass={props.onboarding ? "max-w-[280px]" : undefined} + class="min-w-0" + > + + + ) +} diff --git a/packages/app/src/new-session/workspace/controller.ts b/packages/app/src/new-session/workspace/controller.ts index 8a3043587b0..89f2b0d4728 100644 --- a/packages/app/src/new-session/workspace/controller.ts +++ b/packages/app/src/new-session/workspace/controller.ts @@ -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, diff --git a/packages/app/src/new-session/workspace/selector.tsx b/packages/app/src/new-session/workspace/selector.tsx index 11935c47fb2..6ee542596e2 100644 --- a/packages/app/src/new-session/workspace/selector.tsx +++ b/packages/app/src/new-session/workspace/selector.tsx @@ -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 ( - <> - - - - -
- - {language.t("workspace.onboarding.title")} -
- {language.t("workspace.onboarding.description")} -
- ) : ( - language.t("session.new.workspace.trigger.tooltip") - ) - } - contentClass={props.onboarding ? "max-w-[280px]" : undefined} - class={summary() ? "min-w-0 w-full" : "min-w-0"} - > - - - - {label()} - - - - - - - - {language.t("session.new.workspace.runIn")} - select("main")}> - - - {language.t("session.new.workspace.local")} - - - - - - select("create")}> - - - {language.t("workspace.new")} - - - - - - - 0}> - - (pending = { type: "viewAll" })} - /> - - - - - - -
- - - {props.branch} - -
-
- ) : ( - - ) - } - > - - - - - - {language.t(summary() ? "session.summary.basedOn" : "session.new.workspace.fromBranch", { - branch: props.branch!, - })} - - - - - { - event.preventDefault() - // Kobalte defers its list autofocus until after the focus scope opens. - setTimeout(() => requestAnimationFrame(() => branchSearchInput?.focus({ preventScroll: true }))) - }} - > -
- - { - 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() - }} - /> - - - -
-
- - - {(branch) => ( - (pending = { type: "create", branch })} - > - {branch} - - )} - - -
-
-
-
-
- - - ) -} - -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 ( - - {(value) => ( - -
- - - {value()} - -
-
- )} -
+ + { + if (open) { + setSearch({ branches: "" }) + props.onSearch("") + + return + } + + const branch = pending + pending = undefined + + if (branch) props.onCreate(branch) + + props.onDone?.() + }} + > + + + + {language.t("session.new.workspace.fromBranch", { branch: props.branch })} + + + + + { + event.preventDefault() + // Kobalte defers its list autofocus until after the focus scope opens. + setTimeout(() => requestAnimationFrame(() => input?.focus({ preventScroll: true }))) + }} + > +
+ + { + setSearch("branches", event.currentTarget.value) + props.onSearch(event.currentTarget.value) + }} + onKeyDown={(event) => { + if (["Escape", "ArrowDown", "ArrowUp", "Enter"].includes(event.key)) return + event.stopPropagation() + }} + /> + + + +
+
+ + + {(branch) => ( + (pending = branch)} + > + {branch} + + )} + + +
+
+
+
+
) } diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 2902c501b34..a0122fe74b5 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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", diff --git a/packages/app/src/workspaces/paths.ts b/packages/app/src/workspaces/paths.ts index 7e68065e0cf..3022a359e05 100644 --- a/packages/app/src/workspaces/paths.ts +++ b/packages/app/src/workspaces/paths.ts @@ -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)