Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 9 additions & 92 deletions packages/app/src/new-session/workspace/selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -51,7 +43,7 @@ export function PromptWorkspaceSelector(props: {
}
const onOpenChange = (open: boolean) => {
if (open) {
setSearch({ workspaces: "", branches: "" })
setSearch({ branches: "" })
props.onSearch("")
return
}
Expand Down Expand Up @@ -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>
Expand Down
7 changes: 4 additions & 3 deletions packages/app/src/session/composer/region.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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>
}
Expand Down
73 changes: 73 additions & 0 deletions packages/app/src/session/composer/workspace-footer.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
21 changes: 20 additions & 1 deletion packages/app/src/session/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 }))
Expand Down Expand Up @@ -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>
</>
)
Expand Down
8 changes: 7 additions & 1 deletion packages/app/src/session/story-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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}>
Expand Down
27 changes: 7 additions & 20 deletions packages/app/src/session/timeline/session-workspace-menu.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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>
Expand Down
Loading
Loading