fix(app): narrow desktop v2 beta changes

This commit is contained in:
LukeParkerDev 2026-05-25 15:49:19 +10:00
parent 2a5a1d8ec0
commit 834ce320b7
17 changed files with 187 additions and 559 deletions

View file

@ -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<PromptInputProps> = (props) => {
const sdk = useSDK()
const navigate = useNavigate()
const queryOptions = useQueryOptions()
const sync = useSync()
@ -133,6 +133,7 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<string>()
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<ComposerPickerState>(() => ({
@ -1537,39 +1401,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSearchClear: () => setPicker("projectSearch", ""),
searchRef: (el) => (projectSearchRef = el),
}))
const branchPickerState = createMemo<ComposerPickerState>(() => ({
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<ComposerAgentControlState>(() => ({
title: language.t("command.agent.cycle"),
keybind: command.keybind("agent.cycle"),
@ -1746,9 +1577,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={newSession() && selectedProject()}>
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
<ComposerPicker state={projectPickerState()} />
<Show when={branchOptions().length > 0}>
<ComposerPicker state={branchPickerState()} />
</Show>
</div>
</Show>
</div>
@ -1795,7 +1623,7 @@ export const PromptInput: Component<PromptInputProps> = (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()

View file

@ -20,7 +20,6 @@ const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
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"

View file

@ -184,9 +184,7 @@ type PromptSubmitInput = {
resetHistoryNavigation: () => void
setMode: (mode: "normal" | "shell") => void
setPopover: (popover: "at" | "slash" | null) => void
newSessionProjectDirectory?: Accessor<string | undefined>
newSessionWorktree?: Accessor<string | undefined>
newSessionWorktreeBranch?: Accessor<string | undefined>
onNewSessionWorktreeReset?: () => void
shouldQueue?: Accessor<boolean>
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,

View file

@ -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 }) {
<div class="flex items-center gap-0">
<Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}>
<StatusPopover variant="v2" />
<StatusPopoverV2 />
</Tooltip>
</Show>
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>

View file

@ -444,18 +444,6 @@ export const SettingsGeneral: Component = () => {
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showTerminal.title")}
description={language.t("settings.general.row.showTerminal.description")}
>
<div data-action="settings-show-terminal">
<Switch
checked={settings.general.showTerminal()}
onChange={(checked) => settings.general.setShowTerminal(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}

View file

@ -363,22 +363,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
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<boolean> }) {
)
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(() => <x.DialogSelectServer />, defaultServer.refresh)
})
},
}
return (
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
@ -435,7 +404,68 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</Tabs.List>
<Tabs.Content value="servers">
<ServerStatusList state={serverState} />
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}>
{(s) => {
const key = ServerConnection.key(s)
const blocked = () => health[key]?.healthy === false
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
classList={{
"hover:bg-surface-raised-base-hover": !blocked(),
"cursor-not-allowed": blocked(),
}}
aria-disabled={blocked()}
onClick={() => {
if (blocked()) return
navigate("/")
queueMicrotask(() => server.setActive(key))
}}
>
<ServerHealthIndicator health={health[key]} />
<ServerRow
conn={s}
dimmed={blocked()}
status={health[key]}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={key === defaultServer.key()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")}
</span>
</Show>
}
>
<div class="flex-1" />
<Show when={server.current && key === ServerConnection.key(server.current)}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>
</button>
)
}}
</For>
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
}}
>
{language.t("status.popover.action.manageServers")}
</Button>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="mcp">

View file

@ -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 <ServerStatusPopover variant={props.variant} />
return <DirectoryStatusPopover variant={props.variant} />
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 (
<Popover
open={shown()}
onOpenChange={setShown}
triggerAs={Button}
triggerProps={{
variant: "ghost",
class: "titlebar-icon w-8 h-6 p-0 box-border",
"aria-label": language.t("status.popover.trigger"),
style: { scale: 1 },
}}
trigger={
<div class="relative size-4">
<div class="badge-mask-tight size-4 flex items-center justify-center">
<Icon name={shown() ? "status-active" : "status"} size="small" />
</div>
<div
classList={{
"absolute -top-px -right-px size-1.5 rounded-full": true,
"bg-icon-success-base": ready() && healthy(),
"bg-icon-warning-base": ready() && server.healthy() === true && mcpIssue() === "warning",
"bg-icon-critical-base":
server.healthy() === false || (ready() && server.healthy() === true && mcpIssue() === "critical"),
"bg-border-weak-base": server.healthy() === undefined || !ready(),
}}
/>
</div>
}
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}
>
<Show when={shown()}>
<Suspense
fallback={
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
}
>
<Body shown={shown} />
</Suspense>
</Show>
</Popover>
)
}
function DirectoryStatusPopover(props: { variant?: "v2" }) {
export function StatusPopoverV2(props: { scope?: "server" }) {
if (props.scope === "server") return <ServerStatusPopover />
return <DirectoryStatusPopover />
}
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<StatusPopoverState>(() => ({
variant: props.variant,
shown: shown(),
ready: ready(),
healthy: healthy(),
@ -41,7 +101,7 @@ function DirectoryStatusPopover(props: { variant?: "v2" }) {
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<DirectoryBody shown={shown} />
<Body shown={shown} />
</StatusPopoverBody>
),
}))
@ -49,12 +109,11 @@ function DirectoryStatusPopover(props: { variant?: "v2" }) {
return <StatusPopoverView state={state()} />
}
function ServerStatusPopover(props: { variant?: "v2" }) {
function ServerStatusPopover() {
const language = useLanguage()
const server = useServer()
const [shown, setShown] = createSignal(false)
const state = createMemo<StatusPopoverState>(() => ({
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 (
<Popover
open={props.state.shown}
onOpenChange={props.state.onOpenChange}
triggerAs={IconButtonV2}
triggerProps={{
variant: "ghost-muted",
size: "large",
class: "!w-9 shrink-0",
state: props.state.shown ? "pressed" : undefined,
"aria-label": props.state.label,
}}
trigger={
<div class="relative size-4">
<IconV2 name={props.state.shown ? "status-active" : "status"} />
<div classList={statusDotClass()} class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]" />
</div>
}
{...popoverProps}
>
{props.state.body()}
</Popover>
)
}
return (
<Popover
open={props.state.shown}
onOpenChange={props.state.onOpenChange}
triggerAs={Button}
triggerAs={IconButtonV2}
triggerProps={{
variant: "ghost",
class: "titlebar-icon w-8 h-6 p-0 box-border",
variant: "ghost-muted",
size: "large",
class: "!w-9 shrink-0",
state: props.state.shown ? "pressed" : undefined,
"aria-label": props.state.label,
style: { scale: 1 },
}}
trigger={
<div class="relative size-4">
<div class="badge-mask-tight size-4 flex items-center justify-center">
<Icon name={props.state.shown ? "status-active" : "status"} size="small" />
</div>
<div classList={statusDotClass()} class="-top-px -right-px size-1.5" />
<IconV2 name={props.state.shown ? "status-active" : "status"} />
<div classList={statusDotClass()} class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]" />
</div>
}
{...popoverProps}

View file

@ -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<void>
@ -668,7 +668,7 @@ function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
<TitlebarUpdatePill state={props.state.update} />
<Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}>
<StatusPopover variant="v2" scope="server" />
<StatusPopoverV2 scope="server" />
</Tooltip>
</Show>
<div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />

View file

@ -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",

View file

@ -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<typeof useLanguage>
@ -316,7 +325,7 @@ function HomeProjectColumn(props: {
<HomeProjectRow
project={project}
selected={props.selected === project.worktree}
unseenCount={props.unseenCount(project.worktree)}
unseenCount={props.unseenCount(project)}
selectProject={props.selectProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
@ -358,7 +367,7 @@ function HomeProjectRow(props: {
openNewSession: (directory: string) => void
editProject: (project: LocalProject) => void
closeProject: (directory: string) => void
clearNotifications: (directory: string) => void
clearNotifications: (project: LocalProject) => void
language: ReturnType<typeof useLanguage>
}) {
const name = createMemo(() => displayName(props.project))
@ -409,7 +418,7 @@ function HomeProjectRow(props: {
</MenuV2.Item>
<MenuV2.Item
disabled={props.unseenCount === 0}
onSelect={() => props.clearNotifications(props.project.worktree)}
onSelect={() => props.clearNotifications(props.project)}
>
<Icon name="circle-check" size="small" />
{props.language.t("sidebar.project.clearNotifications")}

View file

@ -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,

View file

@ -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)

View file

@ -46,7 +46,6 @@ export type Info = Schema.Schema.Type<typeof Info>
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<Info, Error>
readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect<Info, Error>
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void, Error>
readonly create: (input?: CreateInput) => Effect.Effect<Info, Error>
readonly branches: () => Effect.Effect<string[], Error>
readonly list: () => Effect.Effect<(Omit<Info, "branch"> & { branch?: string })[], Error>
readonly remove: (input: RemoveInput) => Effect.Effect<boolean, Error>
readonly reset: (input: ResetInput) => Effect.Effect<boolean, Error>
@ -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 })
}),
)

View file

@ -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",
() =>

View file

@ -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()

View file

@ -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<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<WorktreeBranchesResponses, WorktreeBranchesErrors, ThrowOnError>({
url: "/experimental/worktree/branch",
...options,
...params,
})
}
/**
* Reset worktree
*

View file

@ -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<string>
}
export type WorktreeBranchesResponse = WorktreeBranchesResponses[keyof WorktreeBranchesResponses]
export type WorktreeResetData = {
body?: WorktreeResetInput
path?: never