mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-27 19:17:36 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
733e7852ef |
@@ -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,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>
|
||||
)
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user