Przeglądaj źródła

feat(app): add new session workspace controls (#34085)

Brendan Allan 2 miesięcy temu
rodzic
commit
4a8fee3b2d

+ 7 - 4
packages/app/src/components/prompt-project-selector.tsx

@@ -167,7 +167,10 @@ export function createPromptProjectController(input: {
 
 export type PromptProjectController = ReturnType<typeof createPromptProjectController>
 
-export function PromptProjectSelector(props: { controller: PromptProjectController }) {
+export function PromptProjectSelector(props: {
+  controller: PromptProjectController
+  placement?: "bottom" | "bottom-start"
+}) {
   let contentRef: HTMLDivElement | undefined
   let restoreTrigger = true
 
@@ -229,9 +232,8 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
   return (
     <DropdownMenu
       open={props.controller.open()}
-      placement="bottom-start"
+      placement={props.placement ?? "bottom"}
       gutter={4}
-      shift={-6}
       modal={false}
       onOpenChange={(open) => props.controller.setOpen(open)}
     >
@@ -376,11 +378,12 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
       {...rest}
       data-action="prompt-project"
       type="button"
-      class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
+      class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
       classList={{
         ...local.classList,
         "hover:bg-v2-overlay-simple-overlay-hover": !local.controller.open(),
         "bg-v2-overlay-simple-overlay-pressed": local.controller.open(),
+        "text-v2-text-text-muted": local.controller.open(),
       }}
       onClick={local.onClick ?? (() => local.controller.setOpen(true))}
       onKeyDown={(event) => {

+ 102 - 0
packages/app/src/components/prompt-workspace-selector.tsx

@@ -0,0 +1,102 @@
+import { For, Show } from "solid-js"
+import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { getFilename } from "@opencode-ai/core/util/path"
+import { useLanguage } from "@/context/language"
+
+export function PromptWorkspaceSelector(props: {
+  value: string
+  projectRoot: string
+  workspaces: string[]
+  branch?: string
+  onChange: (value: string) => void
+  onDone: () => void
+}) {
+  const language = useLanguage()
+  let pending: string | undefined
+  const selected = () => (props.value === props.projectRoot ? "main" : props.value)
+  const icon = () => {
+    if (selected() === "main") return "monitor"
+    if (selected() === "create") return "workspace-new"
+    return "workspace"
+  }
+  const select = (value: string) => {
+    pending = value
+  }
+  const onOpenChange = (open: boolean) => {
+    if (open) return
+    const value = pending
+    pending = undefined
+    if (value) props.onChange(value)
+    props.onDone()
+  }
+  const label = () => {
+    if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
+    if (props.value === "create") return language.t("workspace.new")
+    return getFilename(props.value)
+  }
+
+  return (
+    <>
+      <span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
+      <MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
+        <MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 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 data-[expanded]:text-v2-text-text-muted">
+          <IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
+          <span class="min-w-0 truncate">{label()}</span>
+          <Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
+        </MenuV2.Trigger>
+        <MenuV2.Portal>
+          <MenuV2.Content class="w-[180px]">
+            <MenuV2.Group>
+              <MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
+              <MenuV2.Item onSelect={() => select("main")}>
+                <IconV2 name="monitor" />
+                <span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
+                <Show when={selected() === "main"}>
+                  <Icon name="check" size="small" class="shrink-0" />
+                </Show>
+              </MenuV2.Item>
+              <MenuV2.Item onSelect={() => select("create")}>
+                <IconV2 name="workspace-new" />
+                <span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
+                <Show when={selected() === "create"}>
+                  <Icon name="check" size="small" class="shrink-0" />
+                </Show>
+              </MenuV2.Item>
+            </MenuV2.Group>
+            <Show when={props.workspaces.length > 0}>
+              <MenuV2.Separator />
+              <MenuV2.Sub gutter={0} overlap overflowPadding={8}>
+                <MenuV2.SubTrigger>
+                  <IconV2 name="workspace" />
+                  {language.t("session.new.workspace.existing")}
+                </MenuV2.SubTrigger>
+                <MenuV2.Portal>
+                  <MenuV2.SubContent class="max-w-[200px]">
+                    <For each={props.workspaces}>
+                      {(workspace) => (
+                        <MenuV2.Item onSelect={() => select(workspace)}>
+                          <IconV2 name="workspace-isolated" />
+                          <span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
+                          <Show when={selected() === workspace}>
+                            <Icon name="check" size="small" class="shrink-0" />
+                          </Show>
+                        </MenuV2.Item>
+                      )}
+                    </For>
+                  </MenuV2.SubContent>
+                </MenuV2.Portal>
+              </MenuV2.Sub>
+            </Show>
+          </MenuV2.Content>
+        </MenuV2.Portal>
+      </MenuV2>
+      <span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
+      <div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
+        <Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
+        <span class="min-w-0 truncate">{props.branch || "main"}</span>
+      </div>
+    </>
+  )
+}

+ 4 - 0
packages/app/src/i18n/en.ts

@@ -656,6 +656,10 @@ export const dict = {
   "session.new.worktree.main": "Main branch",
   "session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
   "session.new.worktree.create": "Create new worktree",
+  "session.new.workspace.runIn": "Run session in",
+  "session.new.workspace.triggerLocal": "Local",
+  "session.new.workspace.local": "Local repository",
+  "session.new.workspace.existing": "Workspace…",
   "session.new.lastModified": "Last modified",
 
   "session.header.search.placeholder": "Search {{project}}",

+ 42 - 8
packages/app/src/pages/new-session.tsx

@@ -19,6 +19,9 @@ import { createPromptInputController, createPromptProjectControls } from "@/page
 import { useSessionKey } from "@/pages/session/session-layout"
 import { useComposerCommands } from "@/pages/session/use-composer-commands"
 import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
+import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
+
+const showWorkspaceBar = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
 
 /**
  * The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
@@ -51,16 +54,21 @@ export default function NewSessionPage() {
     onDone: () => inputRef?.focus(),
   })
 
-  const [store, setStore] = createStore({
-    worktree: "main",
-  })
+  const [store, setStore] = createStore<{ worktree?: string }>({})
 
   const newSessionWorktree = createMemo(() => {
-    if (store.worktree === "create") return "create"
+    if (store.worktree) return store.worktree
     const project = sync().project
     if (project && sdk().directory !== project.worktree) return sdk().directory
     return "main"
   })
+  const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
+  const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
+  const selectedBranch = createMemo(() => {
+    const worktree = newSessionWorktree()
+    if (worktree === "main" || worktree === "create") return localBranch()
+    return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
+  })
 
   createEffect(() => {
     if (!prompt.ready()) return
@@ -97,7 +105,7 @@ export default function NewSessionPage() {
                     </div>
                   }
                 >
-                  <div class="flex flex-col gap-3">
+                  <div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar, "gap-3": !showWorkspaceBar }}>
                     <PromptInput
                       controls={inputController()}
                       variant="new-session"
@@ -105,7 +113,7 @@ export default function NewSessionPage() {
                         inputRef = el
                       }}
                       newSessionWorktree={newSessionWorktree()}
-                      onNewSessionWorktreeReset={() => setStore("worktree", "main")}
+                      onNewSessionWorktreeReset={() => setStore("worktree", undefined)}
                       onSubmit={() => comments.clear()}
                       toolbar={
                         <Show when={!projectController.selected()}>
@@ -114,8 +122,34 @@ export default function NewSessionPage() {
                       }
                     />
                     <Show when={projectController.selected()}>
-                      <div class="flex h-7 min-w-0 items-center gap-0 px-2">
-                        <PromptProjectSelector controller={projectController} />
+                      <div
+                        class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
+                        classList={{
+                          "flex-col justify-center sm:flex-row": showWorkspaceBar,
+                          "justify-start": !showWorkspaceBar,
+                        }}
+                      >
+                        <PromptProjectSelector
+                          controller={projectController}
+                          placement={showWorkspaceBar ? "bottom" : "bottom-start"}
+                        />
+                        <Show when={showWorkspaceBar}>
+                          <PromptWorkspaceSelector
+                            value={newSessionWorktree()}
+                            projectRoot={projectRoot()}
+                            workspaces={sync().project?.sandboxes ?? []}
+                            branch={selectedBranch()}
+                            onChange={(value) =>
+                              setStore(
+                                "worktree",
+                                value === "main" && sync().project?.worktree !== sdk().directory
+                                  ? sync().project?.worktree
+                                  : value,
+                              )
+                            }
+                            onDone={() => inputRef?.focus()}
+                          />
+                        </Show>
                       </div>
                     </Show>
                   </div>

+ 16 - 0
packages/ui/src/v2/components/icon.tsx

@@ -53,6 +53,22 @@ const icons = {
     viewBox: "0 0 16 16",
     body: `<path d="M3.53613 8.17857L6.39328 11.75L12.4647 4.25" stroke="currentColor"/>`,
   },
+  monitor: {
+    viewBox: "0 0 16 16",
+    body: `<path d="M4.05559 9.38889H0.500007C0.500007 9.38889 0.500017 8.59298 0.500017 7.61112V2.27778C0.500017 1.29594 0.500102 0.5 0.500102 0.5H13.3889C13.3889 0.5 13.3889 1.29594 13.3889 2.27778V7.61112C13.3889 8.59298 13.3889 9.38889 13.3889 9.38889H9.83336M4.05559 9.38889V11.6111H6.94448H9.83336V9.38889M4.05559 9.38889H9.83336" transform="translate(1.05556 1.94444)" stroke="currentColor"/>`,
+  },
+  "workspace-new": {
+    viewBox: "0 0 16 16",
+    body: `<path d="M2 10.7578V14.0011H5.24324M13.9991 5.24324V2H10.7559M13.9991 10.7578V14.0011H10.7559M2 5.24324V2H5.24324" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/><path d="M8 4.5V11.5M4.5 8H11.5" stroke="currentColor" stroke-linejoin="round"/>`,
+  },
+  "workspace-isolated": {
+    viewBox: "0 0 16 16",
+    body: `<path d="M10.5 10.5V5.5H5.5V10.5H10.5Z" fill="currentColor"/><rect x="2.5" y="2.5" width="11" height="11" stroke="currentColor"/>`,
+  },
+  workspace: {
+    viewBox: "0 0 16 16",
+    body: `<path d="M2 10.668V14.0013H10.6667M13.9974 10.6667V2H2.66406M13.9974 10.668V14.0013H10.6641M2 10V2H5.33333" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/><path d="M10.6693 10.6654V5.33203H5.33594V10.6654H10.6693Z" fill="currentColor"/>`,
+  },
   close: {
     viewBox: "0 0 20 20",
     body: `<path d="M14.4446 5.55566L5.55566 14.4446M5.55566 5.55566L14.4446 14.4446" stroke="currentColor" stroke-linejoin="round"/>`,