diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 9ab28403c3..85613f319f 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -31,7 +31,9 @@ import { FileAttachmentPart, } from "@/context/prompt" import { useLayout } from "@/context/layout" +import { useNavigate } from "@solidjs/router" import { useSDK } from "@/context/sdk" +import { useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" import { Button } from "@opencode-ai/ui/button" @@ -71,10 +73,10 @@ 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 { createQuery, useQueries } from "@tanstack/solid-query" -import { useQueryOptions, useServerSync } from "@/context/server-sync" +import { useQueries } from "@tanstack/solid-query" +import { useQueryOptions } from "@/context/server-sync" import { pathKey } from "@/utils/path-key" -import { getFilename } from "@opencode-ai/core/util/path" +import { base64Encode } from "@opencode-ai/core/util/encode" import { displayName } from "@/pages/layout/helpers" const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" @@ -121,11 +123,9 @@ const EXAMPLES = [ "prompt.example.25", ] as const -const MAIN_WORKTREE = "main" -const CREATE_WORKTREE = "create" - export const PromptInput: Component = (props) => { const sdk = useSDK() + const navigate = useNavigate() const queryOptions = useQueryOptions() const sync = useSync() @@ -133,6 +133,7 @@ export const PromptInput: Component = (props) => { const files = useFile() const prompt = usePrompt() const layout = useLayout() + const server = useServer() const comments = useComments() const dialog = useDialog() const providers = useProviders() @@ -141,14 +142,12 @@ export const PromptInput: Component = (props) => { const language = useLanguage() const platform = usePlatform() const settings = useSettings() - const serverSync = useServerSync() 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 @@ -290,13 +289,8 @@ export const PromptInput: Component = (props) => { 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 }) @@ -1131,9 +1125,7 @@ export const PromptInput: Component = (props) => { }, setMode: (mode) => setStore("mode", mode), setPopover: (popover) => setStore("popover", popover), - newSessionProjectDirectory: USE_V2_INPUT ? newSessionProjectDirectory : undefined, - newSessionWorktree: USE_V2_INPUT ? newSessionWorktree : () => props.newSessionWorktree, - newSessionWorktreeBranch: USE_V2_INPUT ? () => picker.worktreeName : undefined, + newSessionWorktree: () => props.newSessionWorktree, onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, shouldQueue: props.shouldQueue, onQueue: props.onQueue, @@ -1353,153 +1345,25 @@ export const PromptInput: Component = (props) => { 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 serverSync.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.isSuccess ? 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.isSuccess ? 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 selectedProject = createMemo(() => projectForDirectory(sdk.directory)) 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() + if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "")) { + restoreFocus() + return + } + layout.projects.open(worktree) + server.projects.touch(worktree) + navigate(`/${base64Encode(worktree)}/session`) } const projectPickerState = createMemo(() => ({ @@ -1537,39 +1401,6 @@ export const PromptInput: Component = (props) => { 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"), @@ -1746,9 +1577,6 @@ export const PromptInput: Component = (props) => {
- 0}> - -
@@ -1795,7 +1623,7 @@ export const PromptInput: Component = (props) => { onMouseDown={(e) => { const target = e.target if (!(target instanceof HTMLElement)) return - if (target.closest('[data-action^="prompt-"]')) { + if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { return } editorRef?.focus() diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 569a30487d..b1b289a276 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -20,7 +20,6 @@ const storedSessions: Record> = {} const promoted: Array<{ directory: string; sessionID: string }> = [] const sentShell: string[] = [] const syncedDirectories: string[] = [] -const createdWorktrees: Array<{ directory: string; input: unknown }> = [] let params: { id?: string } = {} let selected = "/repo/worktree-a" @@ -51,10 +50,7 @@ const clientFor = (directory: string) => { abort: async () => ({ data: undefined }), }, worktree: { - create: async (input: unknown) => { - createdWorktrees.push({ directory, input }) - return { data: { directory: `${directory}/new` } } - }, + create: async () => ({ data: { directory: `${directory}/new` } }), }, } } @@ -215,7 +211,6 @@ beforeEach(() => { params = {} sentShell.length = 0 syncedDirectories.length = 0 - createdWorktrees.length = 0 selected = "/repo/worktree-a" variant = undefined for (const key of Object.keys(storedSessions)) delete storedSessions[key] @@ -286,93 +281,6 @@ describe("prompt submit worktree selection", () => { expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }]) }) - test("creates new sessions in the selected project", async () => { - const submit = createPromptSubmit({ - info: () => undefined, - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => false, - mode: () => "shell", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - newSessionProjectDirectory: () => "/repo/other", - newSessionWorktree: () => "main", - onNewSessionWorktreeReset: () => undefined, - onSubmit: () => undefined, - }) - - await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) - - expect(createdClients).toEqual(["/repo/other"]) - expect(createdSessions).toEqual(["/repo/other"]) - expect(sentShell).toEqual(["/repo/other"]) - expect(promoted).toEqual([{ directory: "/repo/other", sessionID: "session-1" }]) - }) - - test("passes selected branch when creating a worktree", async () => { - const submit = createPromptSubmit({ - info: () => undefined, - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => false, - mode: () => "shell", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - newSessionProjectDirectory: () => "/repo/main", - newSessionWorktree: () => "create", - newSessionWorktreeBranch: () => "figma-plugin", - onNewSessionWorktreeReset: () => undefined, - onSubmit: () => undefined, - }) - - await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) - - expect(createdWorktrees).toEqual([ - { directory: "/repo/main", input: { directory: "/repo/main", worktreeCreateInput: { branch: "figma-plugin" } } }, - ]) - expect(createdSessions).toEqual(["/repo/main/new"]) - }) - - test("ignores new-session project selection for active sessions", async () => { - params = { id: "session-1" } - - const submit = createPromptSubmit({ - info: () => ({ id: "session-1" }), - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => false, - mode: () => "shell", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - newSessionProjectDirectory: () => "/repo/other", - newSessionWorktree: () => "main", - onSubmit: () => undefined, - }) - - await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) - - expect(createdClients).toEqual([]) - expect(sentShell).toEqual(["/repo/main"]) - }) - test("includes the selected variant on optimistic prompts", async () => { params = { id: "session-1" } variant = "high" diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 92c46f2c82..1570da016e 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -184,9 +184,7 @@ type PromptSubmitInput = { resetHistoryNavigation: () => void setMode: (mode: "normal" | "shell") => void setPopover: (popover: "at" | "slash" | null) => void - newSessionProjectDirectory?: Accessor newSessionWorktree?: Accessor - newSessionWorktreeBranch?: Accessor onNewSessionWorktreeReset?: () => void shouldQueue?: Accessor onQueue?: (draft: FollowupDraft) => void @@ -315,25 +313,18 @@ export function createPromptSubmit(input: PromptSubmitInput) { input.addToHistory(currentPrompt, mode) input.resetHistoryNavigation() + const projectDirectory = sdk.directory const isNewSession = !params.id - const projectDirectory = isNewSession ? (input.newSessionProjectDirectory?.() ?? sdk.directory) : sdk.directory const shouldAutoAccept = isNewSession && input.autoAccept() const worktreeSelection = input.newSessionWorktree?.() || "main" let sessionDirectory = projectDirectory - let client = - projectDirectory === sdk.directory - ? sdk.client - : sdk.createClient({ - directory: projectDirectory, - throwOnError: true, - }) + let client = sdk.client if (isNewSession) { if (worktreeSelection === "create") { - const branch = input.newSessionWorktreeBranch?.()?.trim() const createdWorktree = await client.worktree - .create(branch ? { directory: projectDirectory, worktreeCreateInput: { branch } } : { directory: projectDirectory }) + .create({ directory: projectDirectory }) .then((x) => x.data) .catch((err) => { showToast({ @@ -358,8 +349,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { sessionDirectory = worktreeSelection } - if (projectDirectory !== sdk.directory) serverSync.child(projectDirectory) - if (sessionDirectory !== projectDirectory) { client = sdk.createClient({ directory: sessionDirectory, diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 272998a8be..f028f7cea2 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -24,7 +24,7 @@ import { useSessionLayout } from "@/pages/session/session-layout" import { messageAgentColor } from "@/utils/agent" import { decode64 } from "@/utils/base64" import { Persist, persisted } from "@/utils/persist" -import { StatusPopover } from "../status-popover" +import { StatusPopover, StatusPopoverV2 } from "../status-popover" import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx" import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx" @@ -535,7 +535,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
- + diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index b71ffa2c42..243aa6c36e 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -444,18 +444,6 @@ export const SettingsGeneral: Component = () => {
- -
- settings.general.setShowTerminal(checked)} - /> -
-
- }) { const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const toggleMcp = useMcpToggleMutation() const defaultServer = useDefaultServerKey(platform.getDefaultServer) - const serverItems = createMemo(() => - sortedServers().map((conn) => { - const key = ServerConnection.key(conn) - return { - key, - conn, - health: health[key], - blocked: health[key]?.healthy === false, - active: !!server.current && key === ServerConnection.key(server.current), - onSelect: () => { - navigate("/") - queueMicrotask(() => server.setActive(key)) - }, - } - }), - ) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) @@ -389,21 +373,6 @@ export function StatusPopoverBody(props: { shown: Accessor }) { ) const pluginCount = createMemo(() => plugins().length) const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json")) - const serverState: ServerStatusState = { - servers: serverItems, - defaultKey: defaultServer.key, - ariaLabel: language.t("status.popover.ariaLabel"), - serversLabel: language.t("status.popover.tab.servers"), - defaultLabel: language.t("common.default"), - manageLabel: language.t("status.popover.action.manageServers"), - onManage: () => { - const run = ++dialogRun - void import("./dialog-select-server").then((x) => { - if (dialogDead || dialogRun !== run) return - dialog.show(() => , defaultServer.refresh) - }) - }, - } return (
@@ -435,7 +404,68 @@ export function StatusPopoverBody(props: { shown: Accessor }) { - +
+
+ + {(s) => { + const key = ServerConnection.key(s) + const blocked = () => health[key]?.healthy === false + return ( + + ) + }} + + + +
+
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index c70a0ac5b9..8e253180bc 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -8,15 +8,76 @@ import { useLanguage } from "@/context/language" import { useServer } from "@/context/server" import { useSync } from "@/context/sync" -const DirectoryBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) +const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody }))) -export function StatusPopover(props: { variant?: "v2"; scope?: "server" }) { - if (props.scope === "server") return - return +export function StatusPopover() { + const language = useLanguage() + const server = useServer() + const sync = useSync() + const [shown, setShown] = createSignal(false) + const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready) + const mcpIssue = createMemo(() => { + const mcp = Object.values(sync.data.mcp ?? {}) + const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration") + const warn = mcp.some((item) => item.status === "needs_auth") + if (failed) return "critical" as const + if (warn) return "warning" as const + }) + const healthy = createMemo(() => server.healthy() === true && !mcpIssue()) + + return ( + +
+ +
+
+
+ } + class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl" + gutter={4} + placement="bottom-end" + shift={-168} + > + + + } + > + + + +
+ ) } -function DirectoryStatusPopover(props: { variant?: "v2" }) { +export function StatusPopoverV2(props: { scope?: "server" }) { + if (props.scope === "server") return + return +} + +function DirectoryStatusPopover() { const language = useLanguage() const server = useServer() const sync = useSync() @@ -31,7 +92,6 @@ function DirectoryStatusPopover(props: { variant?: "v2" }) { }) const healthy = createMemo(() => server.healthy() === true && !mcpIssue()) const state = createMemo(() => ({ - variant: props.variant, shown: shown(), ready: ready(), healthy: healthy(), @@ -41,7 +101,7 @@ function DirectoryStatusPopover(props: { variant?: "v2" }) { onOpenChange: setShown, body: () => ( - + ), })) @@ -49,12 +109,11 @@ function DirectoryStatusPopover(props: { variant?: "v2" }) { return } -function ServerStatusPopover(props: { variant?: "v2" }) { +function ServerStatusPopover() { const language = useLanguage() const server = useServer() const [shown, setShown] = createSignal(false) const state = createMemo(() => ({ - variant: props.variant, shown: shown(), ready: server.healthy() !== undefined, healthy: server.healthy() === true, @@ -72,7 +131,6 @@ function ServerStatusPopover(props: { variant?: "v2" }) { } type StatusPopoverState = { - variant?: "v2" shown: boolean ready: boolean healthy: boolean @@ -113,49 +171,22 @@ function StatusPopoverView(props: { state: StatusPopoverState }) { shift: -168, } - if (props.state.variant === "v2") { - return ( - - -
-
- } - {...popoverProps} - > - {props.state.body()} -
- ) - } - return ( -
- -
-
+ +
} {...popoverProps} diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 5da911f440..e7bb9cc9ea 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -23,7 +23,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx" import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" import { makeEventListener } from "@solid-primitives/event-listener" -import { StatusPopover } from "@/components/status-popover" +import { StatusPopoverV2 } from "@/components/status-popover" type TauriDesktopWindow = { startDragging?: () => Promise @@ -668,7 +668,7 @@ function TitlebarV2Right(props: { state: TitlebarV2RightState }) { - +
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 07825fbad7..f58257b07d 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -589,9 +589,6 @@ export const dict = { "session.new.project.new": "New project", "session.new.project.search": "Search projects", "session.new.project.add": "Add project", - "session.new.branch.search": "Search branches", - "session.new.branch.new": "New branch", - "session.new.branch.add": "Add branch: {{branch}}", "session.new.worktree.main": "Main branch", "session.new.worktree.mainWithBranch": "Main branch ({{branch}})", "session.new.worktree.create": "Create new worktree", @@ -772,7 +769,7 @@ export const dict = { "settings.general.row.followup.option.queue": "Queue", "settings.general.row.followup.option.steer": "Steer", "settings.general.row.showFileTree.title": "File tree", - "settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions", + "settings.general.row.showFileTree.description": "Show the file tree panel in desktop sessions", "settings.general.row.showNavigation.title": "Navigation controls", "settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar", "settings.general.row.showSearch.title": "Command palette", diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 8f99a5c14c..37f83747df 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -70,10 +70,11 @@ function HomeDesign() { const selectedProject = createMemo( () => projects().find((project) => project.worktree === state.project) ?? projects()[0], ) + const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])] const projectDirectories = createMemo(() => { const project = selectedProject() if (!project) return [] - return [project.worktree, ...(project.sandboxes ?? [])] + return directories(project) }) const search = createMemo(() => state.search.trim()) const sessionLoad = useQuery(() => ({ @@ -148,6 +149,14 @@ function HomeDesign() { }) } + const unseenCount = (project: LocalProject) => + directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0) + + const clearNotifications = (project: LocalProject) => + directories(project) + .filter((directory) => notification.project.unseenCount(directory) > 0) + .forEach((directory) => notification.project.markViewed(directory)) + function openSession(session: Session) { const project = projectForSession(session, projects(), projectByID()) layout.projects.open(project?.worktree ?? session.directory) @@ -199,8 +208,8 @@ function HomeDesign() { layout.projects.close(directory) if (state.project === directory) setState("project", undefined) }} - clearNotifications={notification.project.markViewed} - unseenCount={notification.project.unseenCount} + clearNotifications={clearNotifications} + unseenCount={unseenCount} openSettings={openSettings} openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} language={language} @@ -277,8 +286,8 @@ function HomeProjectColumn(props: { chooseProject: () => void editProject: (project: LocalProject) => void closeProject: (directory: string) => void - clearNotifications: (directory: string) => void - unseenCount: (directory: string) => number + clearNotifications: (project: LocalProject) => void + unseenCount: (project: LocalProject) => number openSettings: () => void openHelp: () => void language: ReturnType @@ -316,7 +325,7 @@ function HomeProjectColumn(props: { void editProject: (project: LocalProject) => void closeProject: (directory: string) => void - clearNotifications: (directory: string) => void + clearNotifications: (project: LocalProject) => void language: ReturnType }) { const name = createMemo(() => displayName(props.project)) @@ -409,7 +418,7 @@ function HomeProjectRow(props: { props.clearNotifications(props.project.worktree)} + onSelect={() => props.clearNotifications(props.project)} > {props.language.t("sidebar.project.clearNotifications")} diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 9e1b92d7d1..4cda970e87 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -54,7 +54,6 @@ export const ToolListQuery = Schema.Struct({ }) const WorktreeList = Schema.Array(Schema.String) -const WorktreeBranchList = Schema.Array(Schema.String) const WorktreeErrorName = Schema.Union([ Schema.Literal("WorktreeNotGitError"), Schema.Literal("WorktreeNameGenerationFailedError"), @@ -88,7 +87,6 @@ export const ExperimentalPaths = { tool: "/experimental/tool", toolIDs: "/experimental/tool/ids", worktree: "/experimental/worktree", - worktreeBranch: "/experimental/worktree/branch", worktreeReset: "/experimental/worktree/reset", session: "/experimental/session", resource: "/experimental/resource", @@ -167,17 +165,6 @@ export const ExperimentalApi = HttpApi.make("experimental") description: "List all sandbox worktrees for the current project.", }), ), - HttpApiEndpoint.get("worktreeBranch", ExperimentalPaths.worktreeBranch, { - query: WorkspaceRoutingQuery, - success: described(WorktreeBranchList, "List of git branch names"), - error: WorktreeApiError, - }).annotateMerge( - OpenApi.annotations({ - identifier: "worktree.branches", - summary: "List git branches", - description: "List all local git branches for the current project.", - }), - ), HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, { disableCodecs: true, query: WorkspaceRoutingQuery, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index f2467a3090..56a8de3ffa 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -103,10 +103,6 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper return yield* project.sandboxes(ctx.project.id) }) - const worktreeBranch = Effect.fn("ExperimentalHttpApi.worktreeBranch")(function* () { - return yield* mapWorktreeError(worktreeSvc.branches()) - }) - const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: { payload: Worktree.CreateInput | undefined }) { @@ -162,7 +158,6 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper .handle("tool", tool) .handle("toolIDs", toolIDs) .handle("worktree", worktree) - .handle("worktreeBranch", worktreeBranch) .handle("worktreeCreate", worktreeCreate) .handle("worktreeRemove", worktreeRemove) .handle("worktreeReset", worktreeReset) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index ced1390035..a1d4f89c2a 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -46,7 +46,6 @@ export type Info = Schema.Schema.Type export const CreateInput = Schema.Struct({ name: Schema.optional(Schema.String), - branch: Schema.optional(Schema.String), startCommand: Schema.optional( Schema.String.annotate({ description: "Additional startup script to run after the project's start command" }), ), @@ -135,10 +134,9 @@ function failedRemoves(...chunks: string[]) { // --------------------------------------------------------------------------- export interface Interface { - readonly makeWorktreeInfo: (options?: { name?: string; branch?: string; detached?: boolean }) => Effect.Effect + readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect readonly create: (input?: CreateInput) => Effect.Effect - readonly branches: () => Effect.Effect readonly list: () => Effect.Effect<(Omit & { branch?: string })[], Error> readonly remove: (input: RemoveInput) => Effect.Effect readonly reset: (input: ResetInput) => Effect.Effect @@ -187,18 +185,17 @@ export const layer: Layer.Layer< const candidate = Effect.fn("Worktree.candidate")(function* (input: { root: string name?: string - branch?: string detached?: boolean }) { const ctx = yield* InstanceState.context for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) { const name = input.name ? (attempt === 0 ? input.name : `${input.name}-${Slug.create()}`) : Slug.create() - const branch = input.branch ?? (input.detached ? undefined : `opencode/${name}`) + const branch = input.detached ? undefined : `opencode/${name}` const directory = pathSvc.join(input.root, name) if (yield* fs.exists(directory).pipe(Effect.orDie)) continue - if (branch && !input.branch) { + if (branch) { const ref = `refs/heads/${branch}` const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree }) if (branchCheck.code === 0) continue @@ -211,7 +208,6 @@ export const layer: Layer.Layer< const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (input?: { name?: string - branch?: string detached?: boolean }) { const ctx = yield* InstanceState.context @@ -222,24 +218,14 @@ export const layer: Layer.Layer< const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id) yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie) - return yield* candidate({ - root, - name: input?.name ? slugify(input.name) : input?.branch ? slugify(input.branch) : "", - branch: input?.branch?.trim() || undefined, - detached: input?.detached, - }) + return yield* candidate({ root, name: input?.name ? slugify(input.name) : "", detached: input?.detached }) }) const setup = Effect.fnUntraced(function* (info: Info) { const ctx = yield* InstanceState.context - const branchExists = info.branch - ? (yield* git(["show-ref", "--verify", "--quiet", `refs/heads/${info.branch}`], { cwd: ctx.worktree })).code === 0 - : false const created = yield* git( info.branch - ? branchExists - ? ["worktree", "add", "--no-checkout", info.directory, info.branch] - : ["worktree", "add", "--no-checkout", "-b", info.branch, info.directory] + ? ["worktree", "add", "--no-checkout", "-b", info.branch, info.directory] : ["worktree", "add", "--no-checkout", "--detach", info.directory, "HEAD"], { cwd: ctx.worktree }, ) @@ -311,27 +297,11 @@ export const layer: Layer.Layer< }) const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) { - const info = yield* makeWorktreeInfo({ name: input?.name, branch: input?.branch }) + const info = yield* makeWorktreeInfo({ name: input?.name }) yield* createFromInfo(info, input?.startCommand) return info }) - const branches = Effect.fn("Worktree.branches")(function* () { - const ctx = yield* InstanceState.context - if (ctx.project.vcs !== "git") return [] - - const result = yield* git(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd: ctx.worktree }) - if (result.code !== 0) { - return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git branches" }) - } - - return result.text - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .sort((a, b) => a.localeCompare(b)) - }) - const canonical = Effect.fnUntraced(function* (input: string) { const abs = pathSvc.resolve(input) const real = yield* fs.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs))) @@ -633,7 +603,7 @@ export const layer: Layer.Layer< return true }) - return Service.of({ makeWorktreeInfo, createFromInfo, create, branches, list, remove, reset }) + return Service.of({ makeWorktreeInfo, createFromInfo, create, list, remove, reset }) }), ) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 145fb83942..688b818bee 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -110,19 +110,6 @@ describe("Worktree", () => { { git: true }, ) - it.instance( - "uses requested branch as branch and directory base", - () => - Effect.gen(function* () { - const svc = yield* Worktree.Service - const info = yield* svc.makeWorktreeInfo({ branch: "feat/new-sidebar" }) - - expect(info.name).toBe("feat-new-sidebar") - expect(info.branch).toBe("feat/new-sidebar") - }), - { git: true }, - ) - it.instance( "slugifies the provided name", () => @@ -194,20 +181,6 @@ describe("Worktree", () => { }) describe("create + remove lifecycle", () => { - it.instance( - "lists local branches", - () => - Effect.gen(function* () { - const test = yield* TestInstance - const svc = yield* Worktree.Service - - yield* git(test.directory, ["branch", "feat/new-sidebar"]) - - expect(yield* svc.branches()).toEqual(expect.arrayContaining(["feat/new-sidebar"])) - }), - { git: true }, - ) - it.instance( "create returns worktree info and remove cleans up", () => @@ -241,21 +214,6 @@ describe("Worktree", () => { { git: true }, ) - wintest( - "create uses requested branch", - () => - withCreatedWorktree({ branch: "figma-plugin" }, ({ info, ready }) => - Effect.gen(function* () { - expect(info.name).toBe("figma-plugin") - expect(info.branch).toBe("figma-plugin") - expect(ready.branch).toBe("figma-plugin") - - expect((yield* git(info.directory, ["symbolic-ref", "--short", "HEAD"])).trim()).toBe("figma-plugin") - }), - ), - { git: true }, - ) - it.instance( "create with custom name", () => diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index fdc11ad722..f2b132cb73 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -463,7 +463,6 @@ const scenarios: Scenario[] = [ .json(200, array, "status"), http.protected.get("/experimental/tool/ids", "tool.ids").json(200, array), http.protected.get("/experimental/worktree", "worktree.list").json(200, array), - http.protected.get("/experimental/worktree/branch", "worktree.branches").json(200, array), http.protected .post("/experimental/worktree", "worktree.create") .mutating() diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 26701aa120..cd17e70fdf 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -274,8 +274,6 @@ import type { VcsGetResponses, VcsStatusErrors, VcsStatusResponses, - WorktreeBranchesErrors, - WorktreeBranchesResponses, WorktreeCreateErrors, WorktreeCreateInput, WorktreeCreateResponses, @@ -1390,36 +1388,6 @@ export class Worktree extends HeyApiClient { }) } - /** - * List git branches - * - * List all local git branches for the current project. - */ - public branches( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/worktree/branch", - ...options, - ...params, - }) - } - /** * Reset worktree * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2199c9afc1..aae1b06ad3 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1448,7 +1448,6 @@ export type WorktreeError = { export type WorktreeCreateInput = { name?: string - branch?: string /** * Additional startup script to run after the project's start command */ @@ -4434,34 +4433,6 @@ export type WorktreeCreateResponses = { export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses] -export type WorktreeBranchesData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree/branch" -} - -export type WorktreeBranchesErrors = { - /** - * WorktreeError | InvalidRequestError - */ - 400: WorktreeError | InvalidRequestError -} - -export type WorktreeBranchesError = WorktreeBranchesErrors[keyof WorktreeBranchesErrors] - -export type WorktreeBranchesResponses = { - /** - * List of git branch names - */ - 200: Array -} - -export type WorktreeBranchesResponse = WorktreeBranchesResponses[keyof WorktreeBranchesResponses] - export type WorktreeResetData = { body?: WorktreeResetInput path?: never