From 733e7852ef6888c1cda2c760c01f3392d4d7e1d1 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 17 Sep 2026 15:11:01 +0800 Subject: [PATCH] feat(app): add composer workspace footer --- .../src/new-session/workspace/selector.tsx | 101 ++-------------- packages/app/src/session/composer/region.tsx | 7 +- .../src/session/composer/workspace-footer.tsx | 73 ++++++++++++ packages/app/src/session/screen.tsx | 21 +++- packages/app/src/session/story-model.tsx | 8 +- .../timeline/session-workspace-menu.tsx | 27 ++--- packages/app/src/workspaces/submenu.tsx | 111 ++++++++++++++++++ 7 files changed, 231 insertions(+), 117 deletions(-) create mode 100644 packages/app/src/session/composer/workspace-footer.tsx create mode 100644 packages/app/src/workspaces/submenu.tsx diff --git a/packages/app/src/new-session/workspace/selector.tsx b/packages/app/src/new-session/workspace/selector.tsx index d0c3890bb300..e5ed27b795a3 100644 --- a/packages/app/src/new-session/workspace/selector.tsx +++ b/packages/app/src/new-session/workspace/selector.tsx @@ -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: { 0}> - { - if (!open) { - focusSearch = false - return - } - if (!focusSearch || props.workspaces.length < 10) return - focusSearch = false - focusWorktreeSearch() - }} - > - { - if ( - event.key === "ArrowRight" || - event.key === "ArrowLeft" || - event.key === "Enter" || - event.key === " " - ) - focusSearch = true - }} - > - - - {language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")} - - - - - = 10}> -
- - { - 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() - }} - /> -
-
- - {(workspace) => ( - select(workspace)}> - - {getFilename(workspace)} - - - - - )} - - -
- {language.t("session.new.workspace.search.empty")} -
-
- - (pending = { type: "viewAll" })}> - {language.t("common.viewAll")} - -
-
-
+ (pending = { type: "viewAll" })} + />
diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 016e3df9daaf..f96e42c9fc08 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -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 -export function ActiveSessionComposerRegion(props: { model: SessionComposerController }) { +export function ActiveSessionComposerRegion(props: { model: SessionComposerController; footer?: JSX.Element }) { return ( -
+
+ {props.footer}
} diff --git a/packages/app/src/session/composer/workspace-footer.tsx b/packages/app/src/session/composer/workspace-footer.tsx new file mode 100644 index 000000000000..daa6056ace92 --- /dev/null +++ b/packages/app/src/session/composer/workspace-footer.tsx @@ -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 = () => ( + <> + + + {props.local + ? language.t("session.new.workspace.triggerLocal") + : getFilename( + (props.move && + workspaceDirectories(props.move.project).find((directory) => containsDirectory(directory, props.directory))) ?? + props.directory, + )} + + + ) + + return ( +
+
+
+ {label()}
}> + {(move) => ( + + {label()} + + + )} + +
+ + {(branch) => ( +
+ + + {branch()} + +
+ )} +
+
+ + ) +} diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index f5886d94dc61..cd58828be26a 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -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 }) { - {(model) => } + {(model) => ( + + } + /> + )} ) diff --git a/packages/app/src/session/story-model.tsx b/packages/app/src/session/story-model.tsx index 1d2e968d10e6..db81a57d4773 100644 --- a/packages/app/src/session/story-model.tsx +++ b/packages/app/src/session/story-model.tsx @@ -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 } + composer={ +
+ + +
+ } /> diff --git a/packages/app/src/session/timeline/session-workspace-menu.tsx b/packages/app/src/session/timeline/session-workspace-menu.tsx index 94ae16a5f42d..3e810fab334d 100644 --- a/packages/app/src/session/timeline/session-workspace-menu.tsx +++ b/packages/app/src/session/timeline/session-workspace-menu.tsx @@ -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")} 0}> - - - - {language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")} - - - - - {(workspace) => ( - void move(workspace)}> - - {getFilename(workspace)} - - )} - - - - + void move(directory)} + /> diff --git a/packages/app/src/workspaces/submenu.tsx b/packages/app/src/workspaces/submenu.tsx new file mode 100644 index 000000000000..8bdd2d299bef --- /dev/null +++ b/packages/app/src/workspaces/submenu.tsx @@ -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 ( + { + if (!open) { + setStore({ search: "", focusSearch: false }) + return + } + if (store.focusSearch) focusSearch() + setStore("focusSearch", false) + }} + > + { + if (["ArrowRight", "ArrowLeft", "Enter", " "].includes(event.key)) setStore("focusSearch", true) + }} + > + + + {language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")} + + + + + +
+ + setStore("search", event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault() + event.stopPropagation() + const items = list?.querySelectorAll('[role="menuitem"]:not([data-disabled])') + items?.[event.key === "ArrowDown" ? 0 : items.length - 1]?.focus() + return + } + if (["Escape", "Enter"].includes(event.key)) return + event.stopPropagation() + }} + /> +
+
+
+ + {(directory) => ( + props.onSelect(directory)}> + + {getFilename(directory)} + + + + + )} + + +
+ {language.t("session.new.workspace.search.empty")} +
+
+
+ + + props.onViewAll?.()}> + {language.t("common.viewAll")} + + +
+
+
+ ) +}