Compare commits

...
Author SHA1 Message Date
Brendan Allan 733e7852ef feat(app): add composer workspace footer 2026-09-17 15:11:01 +08:00
7 changed files with 231 additions and 117 deletions
@@ -7,6 +7,7 @@ import { Icon } from "@opencode/ui/icon"
import { getFilename } from "@opencode/util/path"
import { useLanguage } from "@/runtime/i18n/language"
import { sameDirectory } from "@/workspaces/paths"
import { WorkspaceSubmenu } from "@/workspaces/submenu"
export function PromptWorkspaceSelector(props: {
value: string
@@ -27,20 +28,11 @@ export function PromptWorkspaceSelector(props: {
const placement = createMemo(() =>
summary() ? (language.direction() === "rtl" ? "right-start" : "left-start") : "bottom",
)
const [search, setSearch] = createStore({ workspaces: "", branches: "" })
let searchInput: HTMLInputElement | undefined
const [search, setSearch] = createStore({ branches: "" })
let branchSearchInput: HTMLInputElement | undefined
let focusSearch = false
const branchTruncation = createTruncatedText()
const focusWorktreeSearch = () =>
requestAnimationFrame(() => requestAnimationFrame(() => searchInput?.focus({ preventScroll: true })))
let pending: { type: "select"; value: string } | { type: "create"; branch: string } | { type: "viewAll" } | undefined
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
const workspaces = createMemo(() => {
const query = search.workspaces.trim().toLowerCase()
if (!query) return props.workspaces
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
})
const icon = () => {
if (selected() === "main") return "monitor"
if (selected() === "create") return "plus"
@@ -51,7 +43,7 @@ export function PromptWorkspaceSelector(props: {
}
const onOpenChange = (open: boolean) => {
if (open) {
setSearch({ workspaces: "", branches: "" })
setSearch({ branches: "" })
props.onSearch("")
return
}
@@ -167,87 +159,12 @@ export function PromptWorkspaceSelector(props: {
</Menu.Group>
<Show when={props.workspaces.length > 0}>
<Menu.Separator class="h-[0.5px]" />
<Menu.Sub
gutter={0}
overlap
overflowPadding={24}
onOpenChange={(open) => {
if (!open) {
focusSearch = false
return
}
if (!focusSearch || props.workspaces.length < 10) return
focusSearch = false
focusWorktreeSearch()
}}
>
<Menu.SubTrigger
onClick={focusWorktreeSearch}
onKeyDown={(event) => {
if (
event.key === "ArrowRight" ||
event.key === "ArrowLeft" ||
event.key === "Enter" ||
event.key === " "
)
focusSearch = true
}}
>
<Icon name="outline-worktree" />
<span class="min-w-0 flex-1 truncate">
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</span>
</Menu.SubTrigger>
<Menu.Portal>
<Menu.SubContent class="max-h-[66.667dvh] w-[200px] overflow-y-auto !pb-0 [&>[data-component=menu-v2-item]:last-child]:mb-0.5 [@media(max-height:600px)]:max-h-[calc(100dvh-48px)]">
<Show when={props.workspaces.length >= 10}>
<div class="flex h-7 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(element) => {
searchInput = element
}}
value={search.workspaces}
placeholder={language.t("session.new.workspace.search.placeholder")}
aria-label={language.t("session.new.workspace.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("workspaces", event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key === "Escape" ||
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter"
)
return
event.stopPropagation()
}}
/>
</div>
</Show>
<For each={workspaces()}>
{(workspace) => (
<Menu.Item onSelect={() => select(workspace)}>
<Icon name="outline-worktree" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</Menu.Item>
)}
</For>
<Show when={search.workspaces.trim() && workspaces().length === 0}>
<div class="px-3 py-4 text-center text-[13px] font-[440] leading-5 text-v2-text-text-muted">
{language.t("session.new.workspace.search.empty")}
</div>
</Show>
<Menu.Separator class="h-[0.5px]" />
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</Menu.Item>
</Menu.SubContent>
</Menu.Portal>
</Menu.Sub>
<WorkspaceSubmenu
directories={props.workspaces}
selected={selected()}
onSelect={select}
onViewAll={() => (pending = { type: "viewAll" })}
/>
</Show>
</Menu.Content>
</Menu.Portal>
+4 -3
View File
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode/ui/scroll-view"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, on, onMount, type Accessor } from "solid-js"
import { createEffect, createMemo, on, onMount, type Accessor, type JSX } from "solid-js"
import { Composer } from "@/composer/composer"
import { useComposerState } from "@/composer/persistence"
import { createComposerControls } from "@/composer/selection"
@@ -213,15 +213,16 @@ export function createActiveSessionRegion(input: {
export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegion>
export function ActiveSessionComposerRegion(props: { model: SessionComposerController }) {
export function ActiveSessionComposerRegion(props: { model: SessionComposerController; footer?: JSX.Element }) {
return (
<SessionComposerRegion
controller={props.model.region}
composer={
<div class="relative">
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<div class="relative z-10 rounded-xl border border-v2-border-border-base bg-v2-background-bg-deep">
<Composer model={props.model.composer} borderUnderlay />
{props.footer}
</div>
</div>
}
@@ -0,0 +1,73 @@
import { Icon } from "@opencode/ui/icon"
import { getFilename } from "@opencode/util/path"
import { Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import type { Project } from "@/runtime/server/types"
import { containsDirectory, workspaceDirectories } from "@/workspaces/paths"
import { SessionWorkspaceMenu } from "../timeline/session-workspace-menu"
export function SessionWorkspaceFooter(props: {
directory: string
local: boolean
branch?: string
move?: { project: Project; sessionID: string; eligible: boolean }
}) {
const language = useLanguage()
const label = () => (
<>
<Icon
name={props.local ? "monitor" : "outline-worktree"}
size="small"
class={props.local ? "shrink-0 text-v2-icon-icon-muted" : "shrink-0 text-v2-icon-icon-accent"}
/>
<span dir="auto" class="min-w-0 truncate">
{props.local
? language.t("session.new.workspace.triggerLocal")
: getFilename(
(props.move &&
workspaceDirectories(props.move.project).find((directory) => containsDirectory(directory, props.directory))) ??
props.directory,
)}
</span>
</>
)
return (
<div data-component="session-workspace-footer" class="w-full shrink-0 rounded-b-xl bg-v2-background-bg-deep">
<div
class="flex h-9 w-full min-w-0 items-center gap-2 px-2.5 text-[12px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
>
<div class="min-w-0 max-w-[203px]" title={props.directory}>
<Show when={props.move} fallback={<div class="flex h-6 min-w-0 items-center gap-1 px-1.5">{label()}</div>}>
{(move) => (
<SessionWorkspaceMenu
project={move().project}
sessionID={move().sessionID}
eligible={move().eligible}
directory={props.directory}
placement="top-start"
class="flex h-6 min-w-0 max-w-full items-center gap-1 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 disabled:opacity-50"
>
{label()}
<Icon name="chevron-down" size="small" class="size-3 shrink-0 text-v2-icon-icon-muted" />
</SessionWorkspaceMenu>
)}
</Show>
</div>
<Show when={props.branch}>
{(branch) => (
<div
class="flex h-5 min-w-0 max-w-[220px] items-center gap-1 rounded-full bg-v2-background-bg-layer-02 px-2"
title={branch()}
>
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span dir="auto" class="min-w-0 truncate">
{branch()}
</span>
</div>
)}
</Show>
</div>
</div>
)
}
+20 -1
View File
@@ -33,6 +33,7 @@ import { createSessionTimelineInteraction } from "./timeline/interaction"
import { createTimelineSearchController } from "./timeline/search-controller"
import { TimelineSearchBar } from "./timeline/search-bar"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionWorkspaceFooter } from "./composer/workspace-footer"
import { SessionIdentityHeader } from "./session-identity-header"
import { SessionReviewToggle } from "./header/session-header-actions"
import { createAnimatedPresence } from "@/runtime/animated-presence"
@@ -182,6 +183,12 @@ export function SessionScreen(props: { session: SessionModel }) {
visible: conversationVisible,
})
useUsageExceededDialogs()
const workspaceMove = createMemo(() => {
const project = detailsProject()
const sessionID = session.identity.sessionID()
if (!project || !sessionID) return
return { project, sessionID, eligible: composer.workspaceMoveEligible() }
})
const sessionErrorFallback = (error: unknown, reset: () => void) => {
createEffect(on(session.identity.sessionKey, reset, { defer: true }))
@@ -325,7 +332,19 @@ export function SessionScreen(props: { session: SessionModel }) {
</div>
<Show when={composer.active()} keyed>
{(model) => <ActiveSessionComposerRegion model={model} />}
{(model) => (
<ActiveSessionComposerRegion
model={model}
footer={
<SessionWorkspaceFooter
move={workspaceMove()}
directory={session.workspace.directory()}
local={!session.workspace.current()}
branch={session.shared.data.location.vcs.info({ directory: session.workspace.directory() })?.branch.current}
/>
}
/>
)}
</Show>
</>
)
+7 -1
View File
@@ -7,6 +7,7 @@ import {
type SessionComposerRegionViewController,
} from "@/session/composer/session-composer-region"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import { SessionWorkspaceFooter } from "@/session/composer/workspace-footer"
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode/client/promise"
import type { SessionDocument } from "@opencode/session-ui/document"
import { CurrentSessionProviders, STORY_MODEL } from "@opencode/session-ui/storybook"
@@ -266,7 +267,12 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
</div>
<SessionComposerRegion
controller={region}
composer={<Composer model={prompt.controller} borderUnderlay />}
composer={
<div class="rounded-xl border border-v2-border-border-base bg-v2-background-bg-deep">
<Composer model={prompt.controller} borderUnderlay />
<SessionWorkspaceFooter directory="/workspace/opencode" local branch="modular-session-ui" />
</div>
}
/>
</section>
<Show when={state.reviewOpened}>
@@ -1,8 +1,7 @@
import { Menu } from "@opencode/ui/menu"
import { Icon } from "@opencode/ui/icon"
import { getFilename } from "@opencode/util/path"
import { createStore } from "solid-js/store"
import { createSignal, For, onCleanup, Show, type ComponentProps, type JSX } from "solid-js"
import { createSignal, onCleanup, Show, type ComponentProps, type JSX } from "solid-js"
import type { Project } from "@/runtime/server/types"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
@@ -11,6 +10,7 @@ import { pathKey } from "@/workspaces/path-key"
import { showToast } from "@/shell/notifications/toast"
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/workspaces/paths"
import { createWorktree } from "@/workspaces/create"
import { WorkspaceSubmenu } from "@/workspaces/submenu"
export function SessionWorkspaceMenu(props: {
eligible?: boolean
@@ -112,24 +112,11 @@ export function SessionWorkspaceMenu(props: {
{language.t("workspace.new")}
</Menu.Item>
<Show when={workspaces().length > 0}>
<Menu.Sub gutter={0} overlap overflowPadding={24}>
<Menu.SubTrigger>
<Icon name="outline-worktree" />
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</Menu.SubTrigger>
<Menu.Portal>
<Menu.SubContent class="max-h-[66.667dvh] w-[200px] overflow-y-auto !pb-0 [&>[data-component=menu-v2-item]:last-child]:mb-0.5 [@media(max-height:600px)]:max-h-[calc(100dvh-48px)]">
<For each={workspaces()}>
{(workspace) => (
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
<Icon name="outline-worktree" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
</Menu.Item>
)}
</For>
</Menu.SubContent>
</Menu.Portal>
</Menu.Sub>
<WorkspaceSubmenu
directories={workspaces()}
disabled={!!store.selected || blocked()}
onSelect={(directory) => void move(directory)}
/>
</Show>
</Menu.Group>
</Menu.Content>
+111
View File
@@ -0,0 +1,111 @@
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { getFilename } from "@opencode/util/path"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
export function WorkspaceSubmenu(props: {
directories: string[]
selected?: string
disabled?: boolean
onSelect: (directory: string) => void
onViewAll?: () => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ search: "", focusSearch: false })
let input: HTMLInputElement | undefined
let list: HTMLDivElement | undefined
const searchable = () => props.directories.length >= 10
const directories = createMemo(() => {
const query = store.search.trim().toLowerCase()
return props.directories.filter((directory) => getFilename(directory).toLowerCase().includes(query))
})
const focusSearch = () => {
if (!searchable()) return
requestAnimationFrame(() => requestAnimationFrame(() => input?.focus({ preventScroll: true })))
}
return (
<Menu.Sub
gutter={0}
overlap
overflowPadding={24}
onOpenChange={(open) => {
if (!open) {
setStore({ search: "", focusSearch: false })
return
}
if (store.focusSearch) focusSearch()
setStore("focusSearch", false)
}}
>
<Menu.SubTrigger
onClick={focusSearch}
onKeyDown={(event) => {
if (["ArrowRight", "ArrowLeft", "Enter", " "].includes(event.key)) setStore("focusSearch", true)
}}
>
<Icon name="outline-worktree" />
<span class="min-w-0 flex-1 truncate">
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</span>
</Menu.SubTrigger>
<Menu.Portal>
<Menu.SubContent
data-slot="workspace-submenu"
class="max-h-[min(320px,calc(100dvh-48px))] w-[200px] overflow-hidden"
>
<Show when={searchable()}>
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={input}
value={store.search}
placeholder={language.t("session.new.workspace.search.placeholder")}
aria-label={language.t("session.new.workspace.search.placeholder")}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => setStore("search", event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault()
event.stopPropagation()
const items = list?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([data-disabled])')
items?.[event.key === "ArrowDown" ? 0 : items.length - 1]?.focus()
return
}
if (["Escape", "Enter"].includes(event.key)) return
event.stopPropagation()
}}
/>
</div>
</Show>
<div ref={list} data-slot="workspace-submenu-list" class="min-h-0 overflow-y-auto overscroll-contain">
<For each={directories()}>
{(directory) => (
<Menu.Item disabled={props.disabled} onSelect={() => props.onSelect(directory)}>
<Icon name="outline-worktree" />
<span class="min-w-0 flex-1 truncate">{getFilename(directory)}</span>
<Show when={props.selected === directory}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</Menu.Item>
)}
</For>
<Show when={store.search.trim() && directories().length === 0}>
<div class="px-3 py-4 text-center text-[13px] font-[440] leading-5 text-v2-text-text-muted">
{language.t("session.new.workspace.search.empty")}
</div>
</Show>
</div>
<Show when={props.onViewAll}>
<Menu.Separator class="h-[0.5px] shrink-0" />
<Menu.Item onSelect={() => props.onViewAll?.()}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</Menu.Item>
</Show>
</Menu.SubContent>
</Menu.Portal>
</Menu.Sub>
)
}