Sfoglia il codice sorgente

refactor(app): thin new session composition (#39227)

Brendan Allan 2 settimane fa
parent
commit
d46be565aa

+ 0 - 1
packages/app/src/components/session/index.ts

@@ -4,4 +4,3 @@ export { SortableTab, FileVisual } from "./session-sortable-tab"
 export { SortableTabV2 } from "./session-sortable-tab-v2"
 export { SortableTerminalTab } from "./session-sortable-terminal-tab"
 export { NewSessionView } from "./session-new-view"
-export { NewSessionDesignView } from "./session-new-design-view"

+ 0 - 16
packages/app/src/components/session/session-new-design-view.tsx

@@ -1,16 +0,0 @@
-import type { JSX } from "solid-js"
-import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
-import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
-
-export function NewSessionDesignView(props: { children: JSX.Element }) {
-  return (
-    <div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
-      <div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
-        <div class={NEW_SESSION_CONTENT_WIDTH}>
-          <WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
-          <div class="mt-8">{props.children}</div>
-        </div>
-      </div>
-    </div>
-  )
-}

+ 27 - 268
packages/app/src/pages/new-session.tsx

@@ -1,290 +1,49 @@
-import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, untrack } from "solid-js"
-import { createStore } from "solid-js/store"
-import { Portal } from "solid-js/web"
-import { useSearchParams } from "@solidjs/router"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
-import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
-import { NewSessionDesignView } from "@/components/session"
-import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
-import { StatusPopoverV2 } from "@/components/status-popover"
-import {
-  PromptProjectAddButton,
-  PromptProjectSelector,
-  createPromptProjectController,
-} from "@/components/prompt-project-selector"
-import { useComments } from "@/context/comments"
-import { usePrompt } from "@/context/prompt"
-import { useSDK } from "@/context/sdk"
-import { useSync } from "@/context/sync"
-import { useServerSync } from "@/context/server-sync"
-import { useLanguage } from "@/context/language"
-import { useSettings } from "@/context/settings"
-import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
-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 { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
+import { createPromptProjectController } from "@/components/prompt-project-selector"
 import { useTitlebarRightMount } from "@/components/titlebar"
-import { useCommand } from "@/context/command"
-import { useProviders } from "@/hooks/use-providers"
-import { useSettingsCommand } from "@/components/settings-dialog"
-import { Persist, persisted } from "@/utils/persist"
-import createPresence from "solid-presence"
-import { useLocal } from "@/context/local"
-import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
-
-const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
-const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
-const providerTipExitDuration = 250
+import { useSettings } from "@/context/settings"
+import { createEffect, createResource } from "solid-js"
+import { createNewSessionDraftController } from "./new-session/new-session-draft-controller"
+import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view"
+import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
+import { useNewSessionCommands } from "./new-session/use-new-session-commands"
 
-/**
- * The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
- * composer for a brand-new session — no terminal, review pane, file tree, or message
- * timeline. Submitting promotes the draft into a real session (see prompt-input/submit).
- */
+/** The draft-only V2 session page. Submitting promotes the draft into a real session. */
 export default function NewSessionPage() {
-  const prompt = usePrompt()
-  const sdk = useSDK()
-  const sync = useSync()
-  const serverSync = useServerSync()
-  const comments = useComments()
-  const language = useLanguage()
   const settings = useSettings()
-  const dialog = useDialog()
-  const command = useCommand()
-  const providers = useProviders(() => sdk().directory)
-  const openProviders = () => {
-    void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
-      void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
-    })
-  }
-  useSettingsCommand()
-  const route = useSessionKey()
-  const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
-  const local = useLocal()
-  const model = createPromptModelSelection({ agent: local.agent.current })
-
-  useComposerCommands({ model })
-
-  const inputController = createPromptInputController({
-    sessionKey: route.sessionKey,
-    sessionID: () => route.params.id,
-    queryOptions: serverSync().queryOptions,
-    model,
-  })
-  const projectControls = createPromptProjectControls()
-
-  const [store, setStore] = createStore<{ worktree?: string }>({})
   const rightMount = useTitlebarRightMount()
-
-  const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
-  const newSessionWorktree = createMemo(() => {
-    if (!showWorkspaceBar()) return "main"
-    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()
-  })
-  const promptInputV2Controller = usePromptInputV2Controller({
-    get controls() {
-      return inputController()
-    },
-    get newSessionWorktree() {
-      return newSessionWorktree()
-    },
-    onNewSessionWorktreeReset: () => setStore("worktree", undefined),
-    onSubmit: () => comments.clear(),
-  })
-  const projectController = createPromptProjectController({
-    controls: projectControls,
-    onDone: promptInputV2Controller.restoreFocus,
-  })
-
-  command.register("new-session", () => [
-    {
-      id: "command.palette",
-      title: language.t("command.palette"),
-      hidden: true,
-      onSelect: async () => {
-        const { DialogSelectFile } = await import("@/components/dialog-select-file")
-        void dialog.show(() => <DialogSelectFile />)
-      },
-    },
-    {
-      id: "input.focus",
-      title: language.t("command.input.focus"),
-      category: language.t("command.category.view"),
-      keybind: "ctrl+l",
-      onSelect: () => promptInputV2Controller.restoreFocus(),
+  const workspace = createNewSessionWorkspaceController()
+  const draft = createNewSessionDraftController({
+    worktree: workspace.selection.value,
+    resetWorktree: workspace.selection.reset,
+  })
+  const project = createPromptProjectController({
+    controls: draft.project.controls,
+    onDone: draft.input.restoreFocus,
+  })
+  useNewSessionCommands({
+    restoreFocus: draft.input.restoreFocus,
+    project: {
+      empty: project.empty,
+      open: () => project.setOpen(true),
     },
-    {
-      id: "project.select",
-      title: language.t("session.new.project.search"),
-      category: language.t("command.category.project"),
-      keybind: "mod+shift+o",
-      disabled: projectController.empty(),
-      onSelect: () => projectController.setOpen(true),
-    },
-  ])
-
-  createEffect(() => {
-    if (!prompt.ready()) return
-    untrack(() => {
-      const text = searchParams.prompt
-      if (!text) return
-      prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
-      setSearchParams({ ...searchParams, prompt: undefined })
-    })
   })
-
   createEffect(() => {
-    if (!prompt.ready()) return
-    promptInputV2Controller.restoreFocus()
+    if (!draft.prompt.ready()) return
+    draft.input.restoreFocus()
   })
-
   const ready = Promise.resolve()
   const [suspendUntilPromptReady] = createResource(
-    () => prompt.ready.promise ?? ready,
+    () => draft.prompt.readyPromise() ?? ready,
     (promise) => promise.then(() => true),
   )
 
   return (
     <div class="relative size-full overflow-hidden flex flex-col">
       {suspendUntilPromptReady()}
-      <Show when={rightMount()}>
-        {(mount) => (
-          <Portal mount={mount()}>
-            <Show when={settings.visibility.status()}>
-              <Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
-                <StatusPopoverV2 />
-              </Tooltip>
-            </Show>
-          </Portal>
-        )}
-      </Show>
+      <NewSessionStatus mount={rightMount} visible={settings.visibility.status} />
       <div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
-        <div class="@container relative flex flex-col min-h-0 h-full flex-1">
-          <div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
-            <NewSessionDesignView>
-              <div class={NEW_SESSION_CONTENT_WIDTH}>
-                <div class="flex flex-col gap-8">
-                  <PromptInputV2Composer controller={promptInputV2Controller} />
-                  <Show when={projectController.empty()}>
-                    <PromptProjectAddButton controller={projectController} />
-                  </Show>
-                  <Show when={projectController.selected()}>
-                    <div class="flex min-h-7 min-w-0 flex-col items-center justify-center gap-0 text-v2-text-text-faint sm:flex-row">
-                      <PromptProjectSelector controller={projectController} placement="bottom" />
-                      <Show
-                        when={showWorkspaceBar()}
-                        fallback={<PromptGitStatus branch={selectedBranch()} noGit={sync().project?.vcs !== "git"} />}
-                      >
-                        <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={promptInputV2Controller.restoreFocus}
-                        />
-                      </Show>
-                    </div>
-                  </Show>
-                </div>
-                {/*</Show>*/}
-              </div>
-            </NewSessionDesignView>
-            <ProviderTip
-              ready={() => serverSync().child(sdk().directory)[0].provider_ready}
-              connected={() => providers.paid().length > 0}
-              openProviders={openProviders}
-            />
-          </div>
-        </div>
+        <NewSessionView input={draft.input} project={project} workspace={workspace} />
       </div>
     </div>
   )
 }
-
-function ProviderTip(props: { ready: () => boolean; connected: () => boolean; openProviders: () => void }) {
-  const language = useLanguage()
-  const [persistedState, setPersistedState, , persistedReady] = persisted(
-    Persist.global("new-session.provider-tip"),
-    createStore({ dismissedAt: 0 }),
-  )
-  const visible = createMemo(
-    () =>
-      props.ready() &&
-      persistedReady() &&
-      !props.connected() &&
-      Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
-  )
-
-  function dismiss() {
-    setPersistedState("dismissedAt", Date.now())
-  }
-
-  const [ref, setRef] = createSignal<HTMLDivElement>()
-  const presence = createPresence({
-    show: () => visible(),
-    element: () => ref() ?? null,
-  })
-
-  return (
-    <Show when={presence.present()}>
-      <div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
-        <div
-          ref={setRef}
-          data-component="provider-tip"
-          data-visible={visible()}
-          class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
-          classList={{
-            "data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true,
-          }}
-        >
-          <button
-            type="button"
-            class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
-            onClick={props.openProviders}
-          >
-            <span class="truncate">{language.t("home.providerTip")}</span>
-            <span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
-              <IconV2 name="chevron-down" size="small" class="-rotate-90" />
-            </span>
-          </button>
-          <TooltipV2
-            class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
-            placement="top"
-            openDelay={1000}
-            value={language.t("common.dismiss")}
-          >
-            <button
-              type="button"
-              class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
-              aria-label={language.t("common.dismiss")}
-              onClick={dismiss}
-            >
-              <IconV2 name="xmark-small" />
-            </button>
-          </TooltipV2>
-        </div>
-      </div>
-    </Show>
-  )
-}

+ 64 - 0
packages/app/src/pages/new-session/new-session-draft-controller.ts

@@ -0,0 +1,64 @@
+import { useSearchParams } from "@solidjs/router"
+import { createEffect, untrack } from "solid-js"
+import { usePromptInputV2Controller } from "@/components/prompt-input-v2"
+import { useComments } from "@/context/comments"
+import { useLocal } from "@/context/local"
+import { usePrompt } from "@/context/prompt"
+import { useServerSync } from "@/context/server-sync"
+import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
+import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
+import { useSessionKey } from "@/pages/session/session-layout"
+import { useComposerCommands } from "@/pages/session/use-composer-commands"
+
+export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) {
+  const prompt = usePrompt()
+  const serverSync = useServerSync()
+  const comments = useComments()
+  const local = useLocal()
+  const route = useSessionKey()
+  const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
+  const model = createPromptModelSelection({ agent: () => local.agent.current() })
+
+  useComposerCommands({ model })
+
+  const controls = createPromptInputController({
+    sessionKey: route.sessionKey,
+    sessionID: () => route.params.id,
+    queryOptions: serverSync().queryOptions,
+    model,
+  })
+  const projectControls = createPromptProjectControls()
+  const input = usePromptInputV2Controller({
+    get controls() {
+      return controls()
+    },
+    get newSessionWorktree() {
+      return workspace.worktree()
+    },
+    onNewSessionWorktreeReset: workspace.resetWorktree,
+    onSubmit: comments.clear,
+  })
+
+  createEffect(() => {
+    if (!prompt.ready()) return
+    untrack(() => {
+      const text = searchParams.prompt
+      if (!text) return
+      prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
+      setSearchParams({ ...searchParams, prompt: undefined })
+    })
+  })
+
+  return {
+    input,
+    prompt: {
+      ready: prompt.ready,
+      readyPromise: () => prompt.ready.promise,
+    },
+    project: {
+      controls: projectControls,
+    },
+  }
+}
+
+export type NewSessionDraftController = ReturnType<typeof createNewSessionDraftController>

+ 165 - 0
packages/app/src/pages/new-session/new-session-view.tsx

@@ -0,0 +1,165 @@
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
+import { Show, createMemo, createSignal, type Accessor } from "solid-js"
+import { createStore } from "solid-js/store"
+import { Portal } from "solid-js/web"
+import createPresence from "solid-presence"
+import { PromptInputV2Composer } from "@/components/prompt-input-v2"
+import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
+import {
+  PromptProjectAddButton,
+  PromptProjectSelector,
+  type PromptProjectController,
+} from "@/components/prompt-project-selector"
+import { StatusPopoverV2 } from "@/components/status-popover"
+import { useLanguage } from "@/context/language"
+import { useSDK } from "@/context/sdk"
+import { useServerSync } from "@/context/server-sync"
+import { useProviders } from "@/hooks/use-providers"
+import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
+import { Persist, persisted } from "@/utils/persist"
+import type { NewSessionDraftController } from "./new-session-draft-controller"
+import type { NewSessionWorkspaceController } from "./new-session-workspace-controller"
+
+const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
+
+export function NewSessionView(props: {
+  input: NewSessionDraftController["input"]
+  project: PromptProjectController
+  workspace: NewSessionWorkspaceController
+}) {
+  return (
+    <div class="@container relative flex flex-col min-h-0 h-full flex-1">
+      <div
+        data-component="session-new-design"
+        class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-deep"
+      >
+        <div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
+          <div class={NEW_SESSION_CONTENT_WIDTH}>
+            <WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
+            <div class="mt-8 flex flex-col gap-8">
+              <PromptInputV2Composer controller={props.input} />
+              <Show when={props.project.empty()}>
+                <PromptProjectAddButton controller={props.project} />
+              </Show>
+              <Show when={props.project.selected()}>
+                <div class="flex min-h-7 min-w-0 flex-col items-center justify-center gap-0 text-v2-text-text-faint sm:flex-row">
+                  <PromptProjectSelector controller={props.project} placement="bottom" />
+                  <Show
+                    when={props.workspace.bar.visible()}
+                    fallback={
+                      <PromptGitStatus
+                        branch={props.workspace.bar.branch()}
+                        noGit={!props.workspace.project.git()}
+                      />
+                    }
+                  >
+                    <PromptWorkspaceSelector
+                      value={props.workspace.selection.value()}
+                      projectRoot={props.workspace.project.root()}
+                      workspaces={props.workspace.project.workspaces()}
+                      branch={props.workspace.bar.branch()}
+                      onChange={props.workspace.selection.set}
+                      onDone={props.input.restoreFocus}
+                    />
+                  </Show>
+                </div>
+              </Show>
+            </div>
+          </div>
+        </div>
+        <ProviderTip />
+      </div>
+    </div>
+  )
+}
+
+export function NewSessionStatus(props: { mount: Accessor<HTMLElement | null>; visible: Accessor<boolean> }) {
+  const language = useLanguage()
+
+  return (
+    <Show when={props.mount()}>
+      {(mount) => (
+        <Portal mount={mount()}>
+          <Show when={props.visible()}>
+            <Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
+              <StatusPopoverV2 />
+            </Tooltip>
+          </Show>
+        </Portal>
+      )}
+    </Show>
+  )
+}
+
+function ProviderTip() {
+  const language = useLanguage()
+  const dialog = useDialog()
+  const sdk = useSDK()
+  const serverSync = useServerSync()
+  const providers = useProviders(() => sdk().directory)
+  const [persistedState, setPersistedState, , persistedReady] = persisted(
+    Persist.global("new-session.provider-tip"),
+    createStore({ dismissedAt: 0 }),
+  )
+  const visible = createMemo(
+    () =>
+      serverSync().child(sdk().directory)[0].provider_ready &&
+      persistedReady() &&
+      providers.paid().length === 0 &&
+      Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
+  )
+  const [ref, setRef] = createSignal<HTMLDivElement>()
+  const presence = createPresence({
+    show: visible,
+    element: () => ref() ?? null,
+  })
+  const openProviders = () => {
+    void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
+      void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
+    })
+  }
+
+  return (
+    <Show when={presence.present()}>
+      <div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
+        <div
+          ref={setRef}
+          data-component="provider-tip"
+          data-visible={visible()}
+          class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
+          classList={{ "data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true }}
+        >
+          <button
+            type="button"
+            class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
+            onClick={openProviders}
+          >
+            <span class="truncate">{language.t("home.providerTip")}</span>
+            <span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
+              <IconV2 name="chevron-down" size="small" class="-rotate-90" />
+            </span>
+          </button>
+          <TooltipV2
+            class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
+            placement="top"
+            openDelay={1000}
+            value={language.t("common.dismiss")}
+          >
+            <button
+              type="button"
+              class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
+              aria-label={language.t("common.dismiss")}
+              onClick={() => setPersistedState("dismissedAt", Date.now())}
+            >
+              <IconV2 name="xmark-small" />
+            </button>
+          </TooltipV2>
+        </div>
+      </div>
+    </Show>
+  )
+}

+ 43 - 0
packages/app/src/pages/new-session/new-session-workspace-controller.test.ts

@@ -0,0 +1,43 @@
+import { describe, expect, test } from "bun:test"
+import {
+  normalizeNewSessionWorktree,
+  resolveNewSessionBranch,
+  resolveNewSessionWorktree,
+} from "./new-session-workspace-controller"
+
+describe("new session workspace selection", () => {
+  test("uses main when the workspace bar is unavailable", () => {
+    expect(
+      resolveNewSessionWorktree({
+        enabled: false,
+        selected: "/project/feature",
+        directory: "/project/feature",
+        projectWorktree: "/project",
+      }),
+    ).toBe("main")
+  })
+
+  test("derives an existing worktree from the current directory", () => {
+    expect(
+      resolveNewSessionWorktree({ enabled: true, directory: "/project/feature", projectWorktree: "/project" }),
+    ).toBe("/project/feature")
+    expect(resolveNewSessionWorktree({ enabled: true, directory: "/project", projectWorktree: "/project" })).toBe(
+      "main",
+    )
+  })
+
+  test("normalizes main to the project root outside the main worktree", () => {
+    expect(normalizeNewSessionWorktree("main", "/project/feature", "/project")).toBe("/project")
+    expect(normalizeNewSessionWorktree("main", "/project", "/project")).toBe("main")
+  })
+
+  test("falls back to the local branch for main, create, and unknown worktrees", () => {
+    const branch = (worktree: string) => (worktree === "/project/feature" ? "feature" : undefined)
+    expect(resolveNewSessionBranch({ worktree: "main", local: "dev", worktreeBranch: branch })).toBe("dev")
+    expect(resolveNewSessionBranch({ worktree: "create", local: "dev", worktreeBranch: branch })).toBe("dev")
+    expect(resolveNewSessionBranch({ worktree: "/project/feature", local: "dev", worktreeBranch: branch })).toBe(
+      "feature",
+    )
+    expect(resolveNewSessionBranch({ worktree: "/missing", local: "dev", worktreeBranch: branch })).toBe("dev")
+  })
+})

+ 77 - 0
packages/app/src/pages/new-session/new-session-workspace-controller.ts

@@ -0,0 +1,77 @@
+import { createMemo, createSignal } from "solid-js"
+import { useSDK } from "@/context/sdk"
+import { useServerSync } from "@/context/server-sync"
+import { useSync } from "@/context/sync"
+
+const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
+
+export function resolveNewSessionWorktree(input: {
+  enabled: boolean
+  selected?: string
+  directory: string
+  projectWorktree?: string
+}) {
+  if (!input.enabled) return "main"
+  if (input.selected) return input.selected
+  if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory
+  return "main"
+}
+
+export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
+  if (value === "main" && projectWorktree !== directory) return projectWorktree
+  return value
+}
+
+export function resolveNewSessionBranch(input: {
+  worktree: string
+  local?: string
+  worktreeBranch: (worktree: string) => string | undefined
+}) {
+  if (input.worktree === "main" || input.worktree === "create") return input.local
+  return input.worktreeBranch(input.worktree) ?? input.local
+}
+
+export function createNewSessionWorkspaceController() {
+  const sdk = useSDK()
+  const sync = useSync()
+  const serverSync = useServerSync()
+  const [worktree, setWorktree] = createSignal<string>()
+  const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
+  const value = createMemo(() =>
+    resolveNewSessionWorktree({
+      enabled: visible(),
+      selected: worktree(),
+      directory: sdk().directory,
+      projectWorktree: sync().project?.worktree,
+    }),
+  )
+  const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
+  const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
+  const branch = createMemo(() =>
+    resolveNewSessionBranch({
+      worktree: value(),
+      local: localBranch(),
+      worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch,
+    }),
+  )
+
+  return {
+    selection: {
+      value,
+      reset: () => setWorktree(),
+      set: (worktree: string) =>
+        setWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)),
+    },
+    project: {
+      root: projectRoot,
+      workspaces: () => sync().project?.sandboxes ?? [],
+      git: () => sync().project?.vcs === "git",
+    },
+    bar: {
+      visible,
+      branch,
+    },
+  }
+}
+
+export type NewSessionWorkspaceController = ReturnType<typeof createNewSessionWorkspaceController>

+ 44 - 0
packages/app/src/pages/new-session/use-new-session-commands.tsx

@@ -0,0 +1,44 @@
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useSettingsCommand } from "@/components/settings-dialog"
+import { useCommand } from "@/context/command"
+import { useLanguage } from "@/context/language"
+
+export function useNewSessionCommands(input: {
+  restoreFocus: () => void
+  project: {
+    empty: () => boolean
+    open: () => void
+  }
+}) {
+  const command = useCommand()
+  const dialog = useDialog()
+  const language = useLanguage()
+
+  useSettingsCommand()
+  command.register("new-session", () => [
+    {
+      id: "command.palette",
+      title: language.t("command.palette"),
+      hidden: true,
+      onSelect: async () => {
+        const { DialogSelectFile } = await import("@/components/dialog-select-file")
+        void dialog.show(() => <DialogSelectFile />)
+      },
+    },
+    {
+      id: "input.focus",
+      title: language.t("command.input.focus"),
+      category: language.t("command.category.view"),
+      keybind: "ctrl+l",
+      onSelect: input.restoreFocus,
+    },
+    {
+      id: "project.select",
+      title: language.t("session.new.project.search"),
+      category: language.t("command.category.project"),
+      keybind: "mod+shift+o",
+      disabled: input.project.empty(),
+      onSelect: input.project.open,
+    },
+  ])
+}