mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-27 13:16:17 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a8259bc15 |
@@ -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(() => <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 workspace = createNewSessionWorkspaceController()
|
||||
const draft = createNewSessionDraftController({
|
||||
worktree: workspace.selection.value,
|
||||
resetWorktree: workspace.selection.reset,
|
||||
})
|
||||
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 project = createPromptProjectController({
|
||||
controls: draft.project.controls,
|
||||
onDone: draft.input.restoreFocus,
|
||||
})
|
||||
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()
|
||||
const commands = createNewSessionCommandController({
|
||||
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(() => <DialogSelectFile />)
|
||||
},
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
)
|
||||
return <NewSession draft={draft} commands={commands} workspace={workspace} project={project} />
|
||||
}
|
||||
|
||||
@@ -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(() => <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,
|
||||
},
|
||||
])
|
||||
|
||||
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(() => <DialogConnectProvider directory={() => sdk().directory} />)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type NewSessionCommandController = ReturnType<typeof createNewSessionCommandController>
|
||||
@@ -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>
|
||||
@@ -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<HTMLElement | null>
|
||||
statusVisible: Accessor<boolean>
|
||||
statusLabel: Accessor<string>
|
||||
promptReady: Accessor<boolean>
|
||||
promptReadyPromise: Accessor<Promise<unknown> | undefined>
|
||||
restoreFocus: () => void
|
||||
composer: () => JSX.Element
|
||||
projectEmpty: Accessor<boolean>
|
||||
projectSelected: Accessor<PromptProject | undefined>
|
||||
projectAdd: () => JSX.Element
|
||||
projectSelector: () => JSX.Element
|
||||
workspaceVisible: Accessor<boolean>
|
||||
workspaceValue: Accessor<string>
|
||||
workspaceRoot: Accessor<string>
|
||||
workspaces: Accessor<string[]>
|
||||
branch: Accessor<string | undefined>
|
||||
noGit: Accessor<boolean>
|
||||
onWorkspaceChange: (value: string) => void
|
||||
providerReady: Accessor<boolean>
|
||||
providerConnected: Accessor<boolean>
|
||||
onOpenProviders: () => void
|
||||
}) {
|
||||
createEffect(() => {
|
||||
if (!props.promptReady()) return
|
||||
props.restoreFocus()
|
||||
})
|
||||
|
||||
const ready = Promise.resolve()
|
||||
const [suspendUntilPromptReady] = createResource(
|
||||
() => props.promptReadyPromise() ?? ready,
|
||||
(promise) => promise.then(() => true),
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<Show when={props.rightMount()}>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<Show when={props.statusVisible()}>
|
||||
<Tooltip placement="bottom" value={props.statusLabel()}>
|
||||
<StatusPopoverV2 />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
<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">
|
||||
{props.composer()}
|
||||
<Show when={props.projectEmpty()}>{props.projectAdd()}</Show>
|
||||
<Show when={props.projectSelected()}>
|
||||
<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">
|
||||
{props.projectSelector()}
|
||||
<Show
|
||||
when={props.workspaceVisible()}
|
||||
fallback={<PromptGitStatus branch={props.branch()} noGit={props.noGit()} />}
|
||||
>
|
||||
<PromptWorkspaceSelector
|
||||
value={props.workspaceValue()}
|
||||
projectRoot={props.workspaceRoot()}
|
||||
workspaces={props.workspaces()}
|
||||
branch={props.branch()}
|
||||
onChange={props.onWorkspaceChange}
|
||||
onDone={props.restoreFocus}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</NewSessionDesignView>
|
||||
<ProviderTip
|
||||
ready={props.providerReady}
|
||||
connected={props.providerConnected}
|
||||
openProviders={props.onOpenProviders}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderTip(props: { ready: Accessor<boolean>; connected: Accessor<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,
|
||||
)
|
||||
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={() => setPersistedState("dismissedAt", Date.now())}
|
||||
>
|
||||
<IconV2 name="xmark-small" />
|
||||
</button>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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<typeof createNewSessionWorkspaceController>
|
||||
@@ -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 (
|
||||
<NewSessionView
|
||||
rightMount={rightMount}
|
||||
statusVisible={settings.visibility.status}
|
||||
statusLabel={() => language.t("status.popover.trigger")}
|
||||
promptReady={props.draft.prompt.ready}
|
||||
promptReadyPromise={props.draft.prompt.readyPromise}
|
||||
restoreFocus={props.draft.input.restoreFocus}
|
||||
composer={() => <PromptInputV2Composer controller={props.draft.input} />}
|
||||
projectEmpty={props.project.empty}
|
||||
projectSelected={props.project.selected}
|
||||
projectAdd={() => <PromptProjectAddButton controller={props.project} />}
|
||||
projectSelector={() => <PromptProjectSelector controller={props.project} placement="bottom" />}
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user