diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 5a28173ead..f6046fb625 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -11,6 +11,7 @@ import { pathKey } from "@/utils/path-key" const statusLabels = { connected: "mcp.status.connected", + connecting: "mcp.status.connecting", failed: "mcp.status.failed", needs_auth: "mcp.status.needs_auth", needs_client_registration: "mcp.status.needs_client_registration", @@ -79,6 +80,7 @@ export const DialogSelectMcp: Component = () => { if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error } const enabled = () => status() === "connected" + const connecting = () => status() === "connecting" return (
@@ -95,8 +97,9 @@ export const DialogSelectMcp: Component = () => {
e.stopPropagation()}> { + if (connecting()) return if (toggle.isPending) return toggle.mutate(i.name) }} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 4e075fd95e..79007860d8 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -4,6 +4,8 @@ import { createEffect, on, Component, + splitProps, + For, Show, onCleanup, createMemo, @@ -11,7 +13,10 @@ import { createResource, Switch, Match, + type ComponentProps, + type JSX, } from "solid-js" +import { Popover as KobaltePopover } from "@kobalte/core/popover" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" @@ -31,7 +36,7 @@ import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" import { Button } from "@opencode-ai/ui/button" import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" -import { Icon } from "@opencode-ai/ui/icon" +import { Icon, type IconProps } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -44,6 +49,7 @@ import { Persist, persisted } from "@/utils/persist" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" @@ -65,17 +71,17 @@ import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" import { ImagePreview } from "@opencode-ai/ui/image-preview" -import { useQueries } from "@tanstack/solid-query" -import { useQueryOptions } from "@/context/global-sync" +import { createQuery, useQueries } from "@tanstack/solid-query" +import { useGlobalSync, useQueryOptions } from "@/context/global-sync" import { pathKey } from "@/utils/path-key" import { getFilename } from "@opencode-ai/core/util/path" +import { displayName } from "@/pages/layout/helpers" interface PromptInputProps { class?: string variant?: "dock" | "new-session" ref?: (el: HTMLDivElement) => void newSessionWorktree?: string - onNewSessionWorktreeChange?: (worktree: string) => void onNewSessionWorktreeReset?: () => void edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } onEditLoaded?: () => void @@ -132,11 +138,15 @@ export const PromptInput: Component = (props) => { const permission = usePermission() const language = useLanguage() const platform = usePlatform() + const settings = useSettings() + const globalSync = useGlobalSync() const { params, tabs, view } = useSessionLayout() let editorRef!: HTMLDivElement let fileInputRef: HTMLInputElement | undefined let scrollRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement + let projectSearchRef: HTMLInputElement | undefined + let branchSearchRef: HTMLInputElement | undefined const mirror = { input: false } const inset = 56 @@ -277,6 +287,15 @@ export const PromptInput: Component = (props) => { mode: "normal", applyingHistory: false, }) + const [picker, setPicker] = createStore({ + projectDirectory: undefined as string | undefined, + sessionDirectory: undefined as string | undefined, + worktreeName: undefined as string | undefined, + projectOpen: false, + branchOpen: false, + projectSearch: "", + branchSearch: "", + }) const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) const motion = (value: number) => ({ @@ -1110,7 +1129,9 @@ export const PromptInput: Component = (props) => { }, setMode: (mode) => setStore("mode", mode), setPopover: (popover) => setStore("popover", popover), - newSessionWorktree: () => props.newSessionWorktree, + newSessionProjectDirectory, + newSessionWorktree, + newSessionWorktreeBranch: () => picker.worktreeName, onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, shouldQueue: props.shouldQueue, onQueue: props.onQueue, @@ -1303,89 +1324,269 @@ export const PromptInput: Component = (props) => { return "Ask anything, / for commands, @ for context..." } - const modelControl = () => ( - - 0} - fallback={ - - - - } - > - - - - - - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - - - - - - ) + const modelControlState = createMemo(() => ({ + loading: providersLoading(), + paid: providers.paid().length > 0, + title: language.t("command.model.choose"), + keybind: command.keybind("model.choose"), + model: local.model, + providerID: local.model.current()?.provider?.id, + modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"), + style: control(), + onClose: restoreFocus, + onUnpaidClick: () => { + void import("@/components/dialog-select-model-unpaid").then((x) => { + dialog.show(() => ) + }) + }, + })) const newSession = () => props.variant === "new-session" - const worktrees = createMemo(() => [MAIN_WORKTREE, ...(sync.project?.sandboxes ?? []), CREATE_WORKTREE]) - const currentWorktree = createMemo(() => { - if (worktrees().includes(props.newSessionWorktree ?? MAIN_WORKTREE)) - return props.newSessionWorktree ?? MAIN_WORKTREE - return MAIN_WORKTREE - }) - const worktreeLabel = (value: string) => { - if (value === MAIN_WORKTREE) return MAIN_WORKTREE - if (value === CREATE_WORKTREE) return language.t("session.new.worktree.create") - return getFilename(value) + const projects = createMemo(() => layout.projects.list()) + const projectForDirectory = (directory: string | undefined) => { + if (!directory) return + const key = pathKey(directory) + return projects().find( + (project) => + pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key), + ) } + const inheritedProject = createMemo(() => projectForDirectory(sdk.directory) ?? projects()[0]) + const selectedProject = createMemo(() => { + const selected = picker.projectDirectory + if (selected) return projects().find((project) => pathKey(project.worktree) === pathKey(selected)) + return inheritedProject() + }) + const selectedProjectStore = createMemo(() => { + const project = selectedProject() + if (!project) return + return globalSync.child(project.worktree, { bootstrap: false })[0] + }) + const workspaceQuery = createQuery(() => { + const project = selectedProject() + return { + queryKey: ["prompt-input", "workspaces", project?.worktree ?? ""] as const, + enabled: newSession() && picker.branchOpen && project?.vcs === "git", + staleTime: 10_000, + queryFn: async () => { + if (!project) return [] + await sdk.client.experimental.workspace.syncList({ directory: project.worktree }) + return sdk.client.experimental.workspace + .list({ directory: project.worktree }) + .then((x) => (x.data ?? []).filter((workspace) => workspace.type === "worktree" && workspace.directory)) + }, + } + }) + const workspaceByDirectory = createMemo( + () => + new Map( + (workspaceQuery.data ?? []).flatMap((workspace) => + workspace.directory ? [[pathKey(workspace.directory), workspace] as const] : [], + ), + ), + ) + const branchQuery = createQuery(() => { + const project = selectedProject() + return { + queryKey: ["prompt-input", "branches", project?.worktree ?? ""] as const, + enabled: newSession() && picker.branchOpen && project?.vcs === "git", + staleTime: 10_000, + queryFn: async () => { + if (!project) return [] + return sdk.client.worktree.branches({ directory: project.worktree }).then((x) => x.data ?? []) + }, + } + }) + const rootBranch = createMemo(() => { + const project = selectedProject() + if (!project) return "main" + const store = selectedProjectStore() + if (store?.vcs?.default_branch) return store.vcs.default_branch + if (pathKey(project.worktree) === pathKey(sdk.directory)) return sync.data.vcs?.default_branch ?? sync.data.vcs?.branch ?? "main" + return "main" + }) + const branchOptions = createMemo(() => { + const project = selectedProject() + if (!project || project.vcs !== "git") return [] + const existing = new Set() + const options: Array<{ directory: string; branch: string; create?: boolean }> = [ + { directory: project.worktree, branch: rootBranch() }, + ...(project.sandboxes ?? []).map((directory) => ({ + directory, + branch: + workspaceByDirectory().get(pathKey(directory))?.branch ?? + (pathKey(directory) === pathKey(sdk.directory) ? sync.data.vcs?.branch : undefined) ?? + getFilename(directory), + })), + ] + for (const option of options) existing.add(option.branch) + return [ + ...options, + ...(branchQuery.data ?? []) + .filter((branch) => !existing.has(branch)) + .map((branch) => ({ directory: CREATE_WORKTREE, branch, create: true })), + ] + }) + const inheritedSessionDirectory = createMemo(() => { + const project = selectedProject() + if (!project) return + if (pathKey(project.worktree) === pathKey(projectForDirectory(sdk.directory)?.worktree ?? "")) return sdk.directory + return project.worktree + }) + const selectedSessionDirectory = createMemo(() => { + const project = selectedProject() + if (!project) return props.newSessionWorktree ?? MAIN_WORKTREE + if (picker.sessionDirectory === CREATE_WORKTREE) return CREATE_WORKTREE + const sessionDirectory = picker.sessionDirectory + if (sessionDirectory && branchOptions().some((option) => pathKey(option.directory) === pathKey(sessionDirectory))) { + return sessionDirectory + } + return inheritedSessionDirectory() ?? project.worktree + }) + const currentBranch = createMemo(() => { + if (selectedSessionDirectory() === CREATE_WORKTREE) { + return { + directory: CREATE_WORKTREE, + branch: picker.worktreeName ?? language.t("session.new.branch.new"), + } + } + const selected = selectedSessionDirectory() + return branchOptions().find((option) => pathKey(option.directory) === pathKey(selected)) ?? branchOptions()[0] + }) + const projectResults = createMemo(() => { + const search = picker.projectSearch.trim().toLowerCase() + if (!search) return projects() + return projects().filter((project) => displayName(project).toLowerCase().includes(search)) + }) + const branchResults = createMemo(() => { + const search = picker.branchSearch.trim().toLowerCase() + if (!search) return branchOptions() + return branchOptions().filter((option) => option.branch.toLowerCase().includes(search)) + }) + const showAgentControl = createMemo(() => settings.general.showCustomAgents() && agentNames().length > 0) + const branchActionLabel = createMemo(() => { + const search = picker.branchSearch.trim() + if (search && branchResults().length === 0) return language.t("session.new.branch.add", { branch: search }) + return language.t("session.new.branch.new") + }) + function newSessionProjectDirectory() { + return selectedProject()?.worktree + } + function newSessionWorktree() { + const project = selectedProject() + if (!project) return props.newSessionWorktree + const selected = selectedSessionDirectory() + if (selected === CREATE_WORKTREE) return CREATE_WORKTREE + if (selected === project.worktree) return MAIN_WORKTREE + return selected + } + const selectProject = (worktree: string) => { + setPicker({ + projectDirectory: worktree, + sessionDirectory: undefined, + worktreeName: undefined, + projectOpen: false, + projectSearch: "", + }) + restoreFocus() + } + const selectBranch = (directory: string, name?: string) => { + setPicker({ + sessionDirectory: directory, + worktreeName: name, + branchOpen: false, + branchSearch: "", + }) + restoreFocus() + } + + const projectPickerState = createMemo(() => ({ + open: picker.projectOpen, + trigger: { + action: "prompt-project", + icon: "folder", + label: selectedProject() ? displayName(selectedProject()!) : language.t("session.new.project.new"), + class: "max-w-[203px]", + style: control(), + onPress: () => setPicker("projectOpen", true), + }, + search: picker.projectSearch, + searchPlaceholder: language.t("session.new.project.search"), + clearLabel: language.t("common.clear"), + items: projectResults().map((project) => ({ + icon: "folder", + label: displayName(project), + selected: selectedProject()?.worktree === project.worktree, + onSelect: () => selectProject(project.worktree), + })), + action: { + icon: "plus", + label: language.t("session.new.project.add"), + onSelect: () => { + setPicker("projectOpen", false) + command.trigger("project.open") + }, + }, + onOpenChange: (open) => { + setPicker("projectOpen", open) + if (open) requestAnimationFrame(() => projectSearchRef?.focus()) + }, + onSearchInput: (value) => setPicker("projectSearch", value), + onSearchClear: () => setPicker("projectSearch", ""), + searchRef: (el) => (projectSearchRef = el), + })) + const branchPickerState = createMemo(() => ({ + open: picker.branchOpen, + trigger: { + action: "prompt-branch", + icon: "branch", + label: currentBranch()?.branch ?? language.t("session.new.branch.new"), + class: "max-w-[160px]", + style: control(), + onPress: () => setPicker("branchOpen", true), + }, + search: picker.branchSearch, + searchPlaceholder: language.t("session.new.branch.search"), + clearLabel: language.t("common.clear"), + items: branchResults().map((branch) => ({ + icon: "branch", + label: branch.branch, + selected: currentBranch()?.directory === branch.directory && currentBranch()?.branch === branch.branch, + onSelect: () => selectBranch(branch.directory, branch.create ? branch.branch : undefined), + })), + action: { + icon: "plus", + label: branchActionLabel(), + onSelect: () => selectBranch(CREATE_WORKTREE, picker.branchSearch.trim() || undefined), + }, + onOpenChange: (open) => { + setPicker("branchOpen", open) + if (open) requestAnimationFrame(() => branchSearchRef?.focus()) + }, + onSearchInput: (value) => setPicker("branchSearch", value), + onSearchClear: () => setPicker("branchSearch", ""), + searchRef: (el) => (branchSearchRef = el), + listClass: "max-h-[200px] overflow-y-auto", + })) + const agentControlState = createMemo(() => ({ + title: language.t("command.agent.cycle"), + keybind: command.keybind("agent.cycle"), + options: agentNames(), + current: local.agent.current()?.name ?? "", + style: control(), + onSelect: (value) => { + local.agent.set(value) + restoreFocus() + }, + })) + const newProjectTriggerState = createMemo(() => ({ + action: "prompt-project", + icon: "folder-add-left", + label: language.t("session.new.project.new"), + class: "max-w-[160px]", + style: control(), + onPress: () => command.trigger("project.open"), + })) const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" @@ -1409,15 +1610,16 @@ export const PromptInput: Component = (props) => { /> - +
+ = (props) => { onMouseDown={(e) => { const target = e.target if (!(target instanceof HTMLElement)) return - if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) return + if (target.closest('[data-action^="prompt-"]')) return editorRef?.focus() }} > @@ -1515,29 +1717,13 @@ export const PromptInput: Component = (props) => { aria-label={language.t("prompt.action.attachFile")} /> - -
-
- -
- props.state.onSearchInput(event.currentTarget.value)} + /> + + + +
+ {(item) => } +
+
+
+ +
+ + + + ) +} + +function ComposerAgentControl(props: { state: ComposerAgentControlState }) { + return ( +
+
+ +
+ + -
-
- -
-