feat(app): simplify new session location row

Show one clickable project/worktree pill aligned to the composer grid, with the session path right-aligned. Remove the base-branch selector; new worktrees start from the current branch.
This commit is contained in:
Dax Raad
2026-10-19 00:05:33 -04:00
Unverified
parent 2ce42cac3d
commit 5ef2337a96
3 changed files with 108 additions and 238 deletions
+108 -78
View File
@@ -2,6 +2,8 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { Tooltip } from "@opencode/ui/tooltip"
import { Icon } from "@opencode/ui/icon"
import { Show, createMemo, createSignal } from "solid-js"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { getFilename } from "@opencode/util/path"
import {
displayName,
getProjectAvatarSource,
@@ -13,7 +15,6 @@ import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
import { ComposerDropzone } from "@/composer/dropzone"
import type { ComposerModel } from "@/composer/model"
import { PromptBranchSelector } from "@/new-session/workspace/selector"
import {
PromptProjectAddButton,
type PromptProject,
@@ -78,14 +79,17 @@ export function NewSessionView(props: {
<div class={NEW_SESSION_CONTENT_WIDTH}>
<NewSessionWordmark ref={setWordmark} />
<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()}>
{(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
<div
data-slot="new-session-composer"
classList={{
"rounded-[14px] bg-v2-background-bg-layer-01 shadow-[inset_0_0_0_0.5px_var(--v2-border-border-muted)]":
!!props.project.selected(),
}}
>
<Composer model={props.composer} />
<Show when={props.project.selected()}>
{(project) => (
<NewSessionLocation
project={project()}
workspace={props.workspace.bar.visible() ? props.workspace.selection.value() : "main"}
root={props.workspace.project.root()}
@@ -98,25 +102,11 @@ export function NewSessionView(props: {
})
}
/>
<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>
<Show when={props.project.empty()}>
<PromptProjectAddButton controller={props.project} />
</Show>
</div>
</div>
@@ -267,7 +257,8 @@ function NewSessionTips(props: {
)
}
function NewSessionLocationTrigger(props: {
/** The tray under the Composer: the location picker on the left and the session's directory on the right. */
function NewSessionLocation(props: {
project: PromptProject
/** "main", "create", or a worktree directory. */
workspace: string
@@ -276,61 +267,100 @@ function NewSessionLocationTrigger(props: {
onClick: () => void
}) {
const language = useLanguage()
const [truncated, setTruncated] = createSignal(false)
const path = () => {
if (props.workspace === "main") return props.root
const worktree = () => {
if (props.workspace === "main") return
if (props.workspace === "create") return language.t("workspace.new")
return props.workspace
return getFilename(props.workspace)
}
const path = () => {
if (props.workspace === "create") return
return props.workspace === "main" ? props.root : 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 class="flex h-9 min-w-0 items-center gap-3 ps-2 pe-2 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px]">
<div class="flex min-w-0 max-w-[60%] shrink-0 @max-[560px]:max-w-full">
<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="flex min-w-0 max-w-full"
>
<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-1.5 rounded-full text-start bg-v2-background-bg-layer-02 ps-1.5 pe-2.5 text-v2-text-text-muted transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-base focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-base 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="min-w-0 truncate">{displayName(props.project)}</span>
<Show when={worktree()}>
{(name) => (
<>
<Icon name="chevron-right" class="-mx-1 shrink-0 text-v2-icon-icon-faint" />
<span class="min-w-12 shrink-[1000] truncate">{name()}</span>
</>
)}
</Show>
<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>
</div>
<Show when={path()}>
{(directory) => (
<div class="flex min-w-0 flex-1 justify-end @max-[560px]:hidden">
<Tooltip
placement="top"
value={directory()}
disabled={!truncated()}
class="flex min-w-0"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
ref={(element) =>
createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth))
}
data-slot="new-session-location-path"
class="block min-w-0 truncate text-v2-text-text-faint [direction:rtl] [text-align:end]"
>
<bdi dir="ltr">{directory()}</bdi>
</span>
</Tooltip>
</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>
)}
</Show>
</div>
)
}
@@ -1,4 +1,3 @@
import { debounce } from "@solid-primitives/scheduled"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -62,12 +61,9 @@ export function createNewSessionWorkspaceController(input: {
const tabs = useTabs()
const [state, setState] = createStore({
search: "",
existing: undefined as { projectID: string; directory: string } | undefined,
})
const searchBranches = debounce((search: string) => setState("search", search.trim()), 100)
const currentProject = createMemo(() => {
const projectID = data.location.info({ directory: sdk().directory })?.project.id
const current = projectID ? data.project.get(projectID) : undefined
@@ -175,15 +171,6 @@ export function createNewSessionWorkspaceController(input: {
const projectRoot = createMemo(() => currentProject()?.worktree ?? sdk().directory)
const [branches] = createResource(
() => (visible() ? { directory: projectRoot(), search: state.search } : undefined),
({ directory, search }) =>
serverSDK.api.vcs.branch
.list({ location: { directory }, search, limit: 50 })
.then((response) => ({ directory, search, data: response.data }))
.catch(() => ({ directory, search, data: [] })),
)
createEffect(() => {
void Promise.all([
data.location.syncInfo({ directory: sdk().directory }),
@@ -263,30 +250,12 @@ export function createNewSessionWorkspaceController(input: {
remember,
set: select,
cycle: () => select(cycleNewSessionWorktree({ current: value(), existing: existing() })),
create: (branch: string) => {
input.setSelectedBranch(branch)
input.setSelectedWorktree("create")
remember("create")
},
},
project: {
root: projectRoot,
workspaces: worktreeDirectories,
managed: managedWorktrees,
git: visible,
branches: () => {
const current = data.location.vcs.info({ directory: sdk().directory })?.branch.current
const loaded = branches.latest
const list = loaded?.directory === projectRoot() ? loaded.data : []
return [
...new Set([
...list,
...(current && current.toLowerCase().includes(state.search.toLowerCase()) ? [current] : []),
]),
].slice(0, 50)
},
searchBranches,
},
bar: {
visible,
@@ -1,129 +0,0 @@
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 { useLanguage } from "@/runtime/i18n/language"
/** The base branch for a new worktree. */
export function PromptBranchSelector(props: {
branch: string
branches: string[]
onCreate: (branch: string) => void
onSearch: (search: string) => void
onDone?: () => void
}) {
const language = useLanguage()
const [search, setSearch] = createStore({ branches: "" })
const truncation = createTruncatedText()
let input: HTMLInputElement | undefined
let pending: string | undefined
return (
<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>
)
}
function createTruncatedText() {
const [truncated, setTruncated] = createSignal(false)
return {
truncated,
observe: (element: HTMLSpanElement) =>
createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth)),
}
}