From fbdcffb35272601505e8b0de97b9f9eb68323c95 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 27 Jul 2026 16:13:28 +0800 Subject: [PATCH 1/4] refactor(app): thin new session composition --- packages/app/src/pages/new-session.tsx | 305 ++---------------- .../new-session-command-controller.tsx | 64 ++++ .../new-session-draft-controller.ts | 64 ++++ .../pages/new-session/new-session-view.tsx | 165 ++++++++++ .../new-session-workspace-controller.test.ts | 43 +++ .../new-session-workspace-controller.ts | 78 +++++ .../app/src/pages/new-session/new-session.tsx | 47 +++ 7 files changed, 482 insertions(+), 284 deletions(-) create mode 100644 packages/app/src/pages/new-session/new-session-command-controller.tsx create mode 100644 packages/app/src/pages/new-session/new-session-draft-controller.ts create mode 100644 packages/app/src/pages/new-session/new-session-view.tsx create mode 100644 packages/app/src/pages/new-session/new-session-workspace-controller.test.ts create mode 100644 packages/app/src/pages/new-session/new-session-workspace-controller.ts create mode 100644 packages/app/src/pages/new-session/new-session.tsx diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 570f04463e70..e96f58ae7611 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -1,290 +1,27 @@ -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 { 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 { createPromptProjectController } from "@/components/prompt-project-selector" +import { createNewSessionCommandController } from "./new-session/new-session-command-controller" +import { createNewSessionDraftController } from "./new-session/new-session-draft-controller" +import { NewSession } from "./new-session/new-session" +import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller" -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 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(() => ) - }, + 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, + }) + const commands = createNewSessionCommandController({ + restoreFocus: draft.input.restoreFocus, + project: { + empty: project.empty, + open: () => project.setOpen(true), }, - { - 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 }) - }) - }) - - createEffect(() => { - if (!prompt.ready()) return - promptInputV2Controller.restoreFocus() - }) - - const ready = Promise.resolve() - const [suspendUntilPromptReady] = createResource( - () => prompt.ready.promise ?? 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 ( - -
-
- - - - -
-
-
- ) + return } diff --git a/packages/app/src/pages/new-session/new-session-command-controller.tsx b/packages/app/src/pages/new-session/new-session-command-controller.tsx new file mode 100644 index 000000000000..e1c88898b063 --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-command-controller.tsx @@ -0,0 +1,64 @@ +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useSettingsCommand } from "@/components/settings-dialog" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { useSDK } from "@/context/sdk" +import { useServerSync } from "@/context/server-sync" +import { useProviders } from "@/hooks/use-providers" + +export function createNewSessionCommandController(input: { + restoreFocus: () => void + project: { + empty: () => boolean + open: () => void + } +}) { + const command = useCommand() + const dialog = useDialog() + const language = useLanguage() + const sdk = useSDK() + const serverSync = useServerSync() + const providers = useProviders(() => sdk().directory) + + 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, + }, + ]) + + return { + provider: { + ready: () => serverSync().child(sdk().directory)[0].provider_ready, + connected: () => providers.paid().length > 0, + open: () => { + void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => { + void dialog.show(() => sdk().directory} />) + }) + }, + }, + } +} + +export type NewSessionCommandController = ReturnType 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 000000000000..bf22834e48d9 --- /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 000000000000..aba05e6acd49 --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-view.tsx @@ -0,0 +1,165 @@ +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 { Show, createEffect, createMemo, createResource, createSignal, type Accessor, type JSX } from "solid-js" +import { createStore } from "solid-js/store" +import { Portal } from "solid-js/web" +import createPresence from "solid-presence" +import { NewSessionDesignView } from "@/components/session" +import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" +import type { PromptProject } from "@/components/prompt-project-selector" +import { StatusPopoverV2 } from "@/components/status-popover" +import { useLanguage } from "@/context/language" +import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" +import { Persist, persisted } from "@/utils/persist" + +const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000 + +export function NewSessionView(props: { + rightMount: Accessor + statusVisible: Accessor + statusLabel: Accessor + promptReady: Accessor + promptReadyPromise: Accessor | undefined> + restoreFocus: () => void + composer: () => JSX.Element + projectEmpty: Accessor + projectSelected: Accessor + projectAdd: () => JSX.Element + projectSelector: () => JSX.Element + workspaceVisible: Accessor + workspaceValue: Accessor + workspaceRoot: Accessor + workspaces: Accessor + branch: Accessor + noGit: Accessor + onWorkspaceChange: (value: string) => void + providerReady: Accessor + providerConnected: Accessor + onOpenProviders: () => void +}) { + createEffect(() => { + if (!props.promptReady()) return + props.restoreFocus() + }) + + const ready = Promise.resolve() + const [suspendUntilPromptReady] = createResource( + () => props.promptReadyPromise() ?? ready, + (promise) => promise.then(() => true), + ) + + return ( +
+ {suspendUntilPromptReady()} + + {(mount) => ( + + + + + + + + )} + +
+
+
+ +
+
+ {props.composer()} + {props.projectAdd()} + +
+ {props.projectSelector()} + } + > + + +
+
+
+
+
+ +
+
+
+
+ ) +} + +function ProviderTip(props: { ready: Accessor; connected: Accessor; 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, + ) + const [ref, setRef] = createSignal() + const presence = createPresence({ + show: visible, + element: () => ref() ?? null, + }) + + 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 000000000000..2a79ae77fa7a --- /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 000000000000..39c4cf8492fb --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-workspace-controller.ts @@ -0,0 +1,78 @@ +import { createMemo } from "solid-js" +import { createStore } from "solid-js/store" +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 [store, setStore] = createStore<{ worktree?: string }>({}) + const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git") + const value = createMemo(() => + resolveNewSessionWorktree({ + enabled: visible(), + selected: store.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: () => setStore("worktree", undefined), + set: (worktree: string) => + setStore("worktree", 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/new-session.tsx b/packages/app/src/pages/new-session/new-session.tsx new file mode 100644 index 000000000000..e14e9189bee0 --- /dev/null +++ b/packages/app/src/pages/new-session/new-session.tsx @@ -0,0 +1,47 @@ +import type { PromptProjectController } from "@/components/prompt-project-selector" +import { PromptProjectAddButton, PromptProjectSelector } from "@/components/prompt-project-selector" +import { PromptInputV2Composer } from "@/components/prompt-input-v2" +import { useLanguage } from "@/context/language" +import { useSettings } from "@/context/settings" +import { useTitlebarRightMount } from "@/components/titlebar" +import type { NewSessionCommandController } from "./new-session-command-controller" +import type { NewSessionDraftController } from "./new-session-draft-controller" +import { NewSessionView } from "./new-session-view" +import type { NewSessionWorkspaceController } from "./new-session-workspace-controller" + +export function NewSession(props: { + draft: NewSessionDraftController + commands: NewSessionCommandController + workspace: NewSessionWorkspaceController + project: PromptProjectController +}) { + const language = useLanguage() + const settings = useSettings() + const rightMount = useTitlebarRightMount() + + return ( + language.t("status.popover.trigger")} + promptReady={props.draft.prompt.ready} + promptReadyPromise={props.draft.prompt.readyPromise} + restoreFocus={props.draft.input.restoreFocus} + composer={() => } + projectEmpty={props.project.empty} + projectSelected={props.project.selected} + projectAdd={() => } + projectSelector={() => } + workspaceVisible={props.workspace.bar.visible} + workspaceValue={props.workspace.selection.value} + workspaceRoot={props.workspace.project.root} + workspaces={props.workspace.project.workspaces} + branch={props.workspace.bar.branch} + noGit={() => !props.workspace.project.git()} + onWorkspaceChange={props.workspace.selection.set} + providerReady={props.commands.provider.ready} + providerConnected={props.commands.provider.connected} + onOpenProviders={props.commands.provider.open} + /> + ) +} From 5e2e4a1da7c08f2c423c36452df20c742ffc3cf4 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 28 Jul 2026 14:41:23 +0800 Subject: [PATCH 2/4] refactor(app): simplify new session view --- packages/app/src/components/session/index.ts | 1 - .../session/session-new-design-view.tsx | 16 -- packages/app/src/pages/new-session.tsx | 31 +++- .../pages/new-session/new-session-view.tsx | 153 +++++++++--------- .../app/src/pages/new-session/new-session.tsx | 47 ------ 5 files changed, 102 insertions(+), 146 deletions(-) delete mode 100644 packages/app/src/components/session/session-new-design-view.tsx delete mode 100644 packages/app/src/pages/new-session/new-session.tsx diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts index 9e44cea17be6..c4fdb2ff3834 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 a324c64fa6df..000000000000 --- 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 ( -
-
-
- -
{props.children}
-
-
-
- ) -} diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index e96f58ae7611..9fca3d77ddc4 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -1,11 +1,16 @@ import { createPromptProjectController } from "@/components/prompt-project-selector" +import { useTitlebarRightMount } from "@/components/titlebar" +import { useSettings } from "@/context/settings" +import { createEffect, createResource } from "solid-js" import { createNewSessionCommandController } from "./new-session/new-session-command-controller" import { createNewSessionDraftController } from "./new-session/new-session-draft-controller" -import { NewSession } from "./new-session/new-session" +import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view" import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller" /** The draft-only V2 session page. Submitting promotes the draft into a real session. */ export default function NewSessionPage() { + const settings = useSettings() + const rightMount = useTitlebarRightMount() const workspace = createNewSessionWorkspaceController() const draft = createNewSessionDraftController({ worktree: workspace.selection.value, @@ -22,6 +27,28 @@ export default function NewSessionPage() { open: () => project.setOpen(true), }, }) + createEffect(() => { + if (!draft.prompt.ready()) return + draft.input.restoreFocus() + }) + const ready = Promise.resolve() + const [suspendUntilPromptReady] = createResource( + () => draft.prompt.readyPromise() ?? ready, + (promise) => promise.then(() => true), + ) - return + return ( +
+ {suspendUntilPromptReady()} + +
+ +
+
+ ) } diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx index aba05e6acd49..688c2edf0fdf 100644 --- a/packages/app/src/pages/new-session/new-session-view.tsx +++ b/packages/app/src/pages/new-session/new-session-view.tsx @@ -1,109 +1,102 @@ 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 { Show, createEffect, createMemo, createResource, createSignal, type Accessor, type JSX } from "solid-js" +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 { NewSessionDesignView } from "@/components/session" +import { PromptInputV2Composer } from "@/components/prompt-input-v2" import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" -import type { PromptProject } from "@/components/prompt-project-selector" +import { + PromptProjectAddButton, + PromptProjectSelector, + type PromptProjectController, +} from "@/components/prompt-project-selector" import { StatusPopoverV2 } from "@/components/status-popover" import { useLanguage } from "@/context/language" import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" import { Persist, persisted } from "@/utils/persist" +import type { NewSessionCommandController } from "./new-session-command-controller" +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: { - rightMount: Accessor - statusVisible: Accessor - statusLabel: Accessor - promptReady: Accessor - promptReadyPromise: Accessor | undefined> - restoreFocus: () => void - composer: () => JSX.Element - projectEmpty: Accessor - projectSelected: Accessor - projectAdd: () => JSX.Element - projectSelector: () => JSX.Element - workspaceVisible: Accessor - workspaceValue: Accessor - workspaceRoot: Accessor - workspaces: Accessor - branch: Accessor - noGit: Accessor - onWorkspaceChange: (value: string) => void - providerReady: Accessor - providerConnected: Accessor - onOpenProviders: () => void + input: NewSessionDraftController["input"] + project: PromptProjectController + workspace: NewSessionWorkspaceController + provider: NewSessionCommandController["provider"] }) { - createEffect(() => { - if (!props.promptReady()) return - props.restoreFocus() - }) - - const ready = Promise.resolve() - const [suspendUntilPromptReady] = createResource( - () => props.promptReadyPromise() ?? ready, - (promise) => promise.then(() => true), - ) - return ( -
- {suspendUntilPromptReady()} - - {(mount) => ( - - - - - - - - )} - -
-
-
- -
-
- {props.composer()} - {props.projectAdd()} - -
- {props.projectSelector()} - } - > - - -
+
+
+
+
+ +
+ + + + + +
+ + + } + > +
-
- - + +
+
) } +export function NewSessionStatus(props: { mount: Accessor; visible: Accessor }) { + const language = useLanguage() + + return ( + + {(mount) => ( + + + + + + + + )} + + ) +} + function ProviderTip(props: { ready: Accessor; connected: Accessor; openProviders: () => void }) { const language = useLanguage() const [persistedState, setPersistedState, , persistedReady] = persisted( diff --git a/packages/app/src/pages/new-session/new-session.tsx b/packages/app/src/pages/new-session/new-session.tsx deleted file mode 100644 index e14e9189bee0..000000000000 --- a/packages/app/src/pages/new-session/new-session.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { PromptProjectController } from "@/components/prompt-project-selector" -import { PromptProjectAddButton, PromptProjectSelector } from "@/components/prompt-project-selector" -import { PromptInputV2Composer } from "@/components/prompt-input-v2" -import { useLanguage } from "@/context/language" -import { useSettings } from "@/context/settings" -import { useTitlebarRightMount } from "@/components/titlebar" -import type { NewSessionCommandController } from "./new-session-command-controller" -import type { NewSessionDraftController } from "./new-session-draft-controller" -import { NewSessionView } from "./new-session-view" -import type { NewSessionWorkspaceController } from "./new-session-workspace-controller" - -export function NewSession(props: { - draft: NewSessionDraftController - commands: NewSessionCommandController - workspace: NewSessionWorkspaceController - project: PromptProjectController -}) { - const language = useLanguage() - const settings = useSettings() - const rightMount = useTitlebarRightMount() - - return ( - language.t("status.popover.trigger")} - promptReady={props.draft.prompt.ready} - promptReadyPromise={props.draft.prompt.readyPromise} - restoreFocus={props.draft.input.restoreFocus} - composer={() => } - projectEmpty={props.project.empty} - projectSelected={props.project.selected} - projectAdd={() => } - projectSelector={() => } - workspaceVisible={props.workspace.bar.visible} - workspaceValue={props.workspace.selection.value} - workspaceRoot={props.workspace.project.root} - workspaces={props.workspace.project.workspaces} - branch={props.workspace.bar.branch} - noGit={() => !props.workspace.project.git()} - onWorkspaceChange={props.workspace.selection.set} - providerReady={props.commands.provider.ready} - providerConnected={props.commands.provider.connected} - onOpenProviders={props.commands.provider.open} - /> - ) -} From 6c2c3c92ab0b0d8192f456d961055dad74ef3039 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 28 Jul 2026 14:46:11 +0800 Subject: [PATCH 3/4] refactor(app): separate new session commands --- packages/app/src/pages/new-session.tsx | 11 ++----- .../pages/new-session/new-session-view.tsx | 29 ++++++++++++------- ...oller.tsx => use-new-session-commands.tsx} | 22 +------------- 3 files changed, 22 insertions(+), 40 deletions(-) rename packages/app/src/pages/new-session/{new-session-command-controller.tsx => use-new-session-commands.tsx} (60%) diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 9fca3d77ddc4..7c7b89f58927 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -2,10 +2,10 @@ import { createPromptProjectController } from "@/components/prompt-project-selec import { useTitlebarRightMount } from "@/components/titlebar" import { useSettings } from "@/context/settings" import { createEffect, createResource } from "solid-js" -import { createNewSessionCommandController } from "./new-session/new-session-command-controller" 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 draft-only V2 session page. Submitting promotes the draft into a real session. */ export default function NewSessionPage() { @@ -20,7 +20,7 @@ export default function NewSessionPage() { controls: draft.project.controls, onDone: draft.input.restoreFocus, }) - const commands = createNewSessionCommandController({ + useNewSessionCommands({ restoreFocus: draft.input.restoreFocus, project: { empty: project.empty, @@ -42,12 +42,7 @@ export default function NewSessionPage() { {suspendUntilPromptReady()}
- +
) diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx index 688c2edf0fdf..3117bf800ad6 100644 --- a/packages/app/src/pages/new-session/new-session-view.tsx +++ b/packages/app/src/pages/new-session/new-session-view.tsx @@ -1,3 +1,4 @@ +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" @@ -15,9 +16,11 @@ import { } 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 { NewSessionCommandController } from "./new-session-command-controller" import type { NewSessionDraftController } from "./new-session-draft-controller" import type { NewSessionWorkspaceController } from "./new-session-workspace-controller" @@ -27,7 +30,6 @@ export function NewSessionView(props: { input: NewSessionDraftController["input"] project: PromptProjectController workspace: NewSessionWorkspaceController - provider: NewSessionCommandController["provider"] }) { return (
@@ -69,11 +71,7 @@ export function NewSessionView(props: {
- +
) @@ -97,17 +95,21 @@ export function NewSessionStatus(props: { mount: Accessor; v ) } -function ProviderTip(props: { ready: Accessor; connected: Accessor; openProviders: () => void }) { +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( () => - props.ready() && + serverSync().child(sdk().directory)[0].provider_ready && persistedReady() && - !props.connected() && + providers.paid().length === 0 && Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration, ) const [ref, setRef] = createSignal() @@ -115,6 +117,11 @@ function ProviderTip(props: { ready: Accessor; connected: Accessor ref() ?? null, }) + const openProviders = () => { + void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => { + void dialog.show(() => sdk().directory} />) + }) + } return ( @@ -129,7 +136,7 @@ function ProviderTip(props: { ready: Accessor; connected: Accessor {language.t("home.providerTip")}