diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts
index 9e44cea17be..c4fdb2ff383 100644
--- a/packages/app/src/components/session/index.ts
+++ b/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"
diff --git a/packages/app/src/components/session/session-new-design-view.tsx b/packages/app/src/components/session/session-new-design-view.tsx
deleted file mode 100644
index a324c64fa6d..00000000000
--- a/packages/app/src/components/session/session-new-design-view.tsx
+++ /dev/null
@@ -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 (
-
- )
-}
diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx
index 570f04463e7..7c7b89f5892 100644
--- a/packages/app/src/pages/new-session.tsx
+++ b/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"
+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"
-const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
-const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
-const providerTipExitDuration = 250
-
-/**
- * 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(() => 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 workspace = createNewSessionWorkspaceController()
+ const draft = createNewSessionDraftController({
+ worktree: workspace.selection.value,
+ resetWorktree: workspace.selection.reset,
})
- 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 project = createPromptProjectController({
+ controls: draft.project.controls,
+ onDone: draft.input.restoreFocus,
})
- const promptInputV2Controller = usePromptInputV2Controller({
- get controls() {
- return inputController()
+ useNewSessionCommands({
+ restoreFocus: draft.input.restoreFocus,
+ project: {
+ empty: project.empty,
+ open: () => project.setOpen(true),
},
- 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(() => )
- },
- },
- {
- id: "input.focus",
- title: language.t("command.input.focus"),
- category: language.t("command.category.view"),
- keybind: "ctrl+l",
- onSelect: () => promptInputV2Controller.restoreFocus(),
- },
- {
- 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 })
- })
+ if (!draft.prompt.ready()) return
+ draft.input.restoreFocus()
})
-
- createEffect(() => {
- if (!prompt.ready()) return
- promptInputV2Controller.restoreFocus()
- })
-
const ready = Promise.resolve()
const [suspendUntilPromptReady] = createResource(
- () => prompt.ready.promise ?? ready,
+ () => draft.prompt.readyPromise() ?? ready,
(promise) => promise.then(() => true),
)
return (
{suspendUntilPromptReady()}
-
- {(mount) => (
-
-
-
-
-
-
-
- )}
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
}
- >
-
- setStore(
- "worktree",
- value === "main" && sync().project?.worktree !== sdk().directory
- ? sync().project?.worktree
- : value,
- )
- }
- onDone={promptInputV2Controller.restoreFocus}
- />
-
-
-
-
- {/**/}
-
-
-
serverSync().child(sdk().directory)[0].provider_ready}
- connected={() => providers.paid().length > 0}
- openProviders={openProviders}
- />
-
-
+
)
}
-
-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()
- const presence = createPresence({
- show: () => visible(),
- element: () => ref() ?? null,
- })
-
- return (
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/packages/app/src/pages/new-session/new-session-draft-controller.ts b/packages/app/src/pages/new-session/new-session-draft-controller.ts
new file mode 100644
index 00000000000..bf22834e48d
--- /dev/null
+++ b/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
diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx
new file mode 100644
index 00000000000..3117bf800ad
--- /dev/null
+++ b/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 (
+
+ )
+}
+
+export function NewSessionStatus(props: { mount: Accessor; visible: Accessor }) {
+ const language = useLanguage()
+
+ return (
+
+ {(mount) => (
+
+
+
+
+
+
+
+ )}
+
+ )
+}
+
+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()
+ const presence = createPresence({
+ show: visible,
+ element: () => ref() ?? null,
+ })
+ const openProviders = () => {
+ void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
+ void dialog.show(() => sdk().directory} />)
+ })
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts b/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts
new file mode 100644
index 00000000000..2a79ae77fa7
--- /dev/null
+++ b/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")
+ })
+})
diff --git a/packages/app/src/pages/new-session/new-session-workspace-controller.ts b/packages/app/src/pages/new-session/new-session-workspace-controller.ts
new file mode 100644
index 00000000000..f3fc9b2708d
--- /dev/null
+++ b/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()
+ 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
diff --git a/packages/app/src/pages/new-session/use-new-session-commands.tsx b/packages/app/src/pages/new-session/use-new-session-commands.tsx
new file mode 100644
index 00000000000..a0d835f97f5
--- /dev/null
+++ b/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(() => )
+ },
+ },
+ {
+ 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,
+ },
+ ])
+}