Apply PR #28788: feat(app): improve desktop v2 startup and controls

This commit is contained in:
opencode-agent[bot] 2026-05-22 09:51:43 +00:00
commit f24d9e3f78
32 changed files with 2039 additions and 702 deletions

View file

@ -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 (
<div class="w-full flex items-center justify-between gap-x-3">
<div class="flex flex-col gap-0.5 min-w-0">
@ -95,8 +97,9 @@ export const DialogSelectMcp: Component = () => {
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={enabled()}
disabled={toggle.isPending && toggle.variables === i.name}
disabled={connecting() || (toggle.isPending && toggle.variables === i.name)}
onChange={() => {
if (connecting()) return
if (toggle.isPending) return
toggle.mutate(i.name)
}}

View file

@ -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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (props) => {
return "Ask anything, / for commands, @ for context..."
}
const modelControl = () => (
<Show when={!providersLoading()}>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-4 text-v2-text-text-faint group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">{local.model.current()?.name ?? language.t("dialog.model.select.title")}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
model={local.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-4 text-v2-text-text-faint group",
"data-action": "prompt-model",
}}
onClose={restoreFocus}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">{local.model.current()?.name ?? language.t("dialog.model.select.title")}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
</Show>
)
const modelControlState = createMemo<ComposerModelControlState>(() => ({
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(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
},
}))
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<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.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<ComposerPickerState>(() => ({
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<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"),
options: agentNames(),
current: local.agent.current()?.name ?? "",
style: control(),
onSelect: (value) => {
local.agent.set(value)
restoreFocus()
},
}))
const newProjectTriggerState = createMemo<ComposerPickerTriggerState>(() => ({
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<PromptInputProps> = (props) => {
/>
<Switch>
<Match when={USE_V2_INPUT}>
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
onSubmit={handleSubmit}
classList={{
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
"border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class,
}}
>
<div class="flex flex-col gap-3">
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
onSubmit={handleSubmit}
classList={{
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
"border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class,
}}
>
<PromptDragOverlay
type={store.draggingType}
label={language.t(
@ -1450,7 +1652,7 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (props) => {
aria-label={language.t("prompt.action.attachFile")}
/>
</TooltipKeybind>
<Show when={newSession()}>
<div class="relative">
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center">
<Icon name="sliders" size="small" />
</div>
<Select
size="normal"
options={worktrees()}
current={currentWorktree()}
label={worktreeLabel}
onSelect={(value) => {
if (value) props.onNewSessionWorktreeChange?.(value)
restoreFocus()
}}
class="max-w-[175px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate pl-5 text-[13px] font-[440] leading-4 text-v2-text-text-faint"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-workspace" }}
variant="ghost"
/>
</div>
<Show when={showAgentControl()}>
<ComposerAgentControl state={agentControlState()} />
</Show>
{modelControl()}
<Show when={newSession() && !selectedProject()}>
<ComposerPickerTrigger state={newProjectTriggerState()} />
</Show>
<ComposerModelControl state={modelControlState()} />
</div>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
@ -1556,7 +1742,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>
</Tooltip>
</div>
</DockShellForm>
</DockShellForm>
<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>
</Match>
<Match when>
<DockShellForm
@ -1600,7 +1795,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onMouseDown={(e) => {
const target = e.target
if (!(target instanceof HTMLElement)) return
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
if (target.closest('[data-action^="prompt-"]')) {
return
}
editorRef?.focus()
@ -1892,3 +2087,226 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
)
}
type ComposerPickerItemState = {
icon: IconProps["name"]
label: string
selected?: boolean
onSelect: () => void
}
type ComposerPickerTriggerState = {
action: string
icon?: IconProps["name"]
label: string
class?: string
style: JSX.CSSProperties | undefined
onPress: () => void
}
type ComposerPickerState = {
open: boolean
trigger: ComposerPickerTriggerState
search: string
searchPlaceholder: string
clearLabel: string
items: ComposerPickerItemState[]
action: ComposerPickerItemState
listClass?: string
searchRef: (el: HTMLInputElement) => void
onOpenChange: (open: boolean) => void
onSearchInput: (value: string) => void
onSearchClear: () => void
}
type ComposerAgentControlState = {
title: string
keybind: string
options: string[]
current: string
style: JSX.CSSProperties | undefined
onSelect: (value: string | undefined) => void
}
type ComposerModelControlState = {
loading: boolean
paid: boolean
title: string
keybind: string
model: ReturnType<typeof useLocal>["model"]
providerID?: string
modelName: string
style: JSX.CSSProperties | undefined
onClose: () => void
onUnpaidClick: () => void
}
function ComposerPickerTrigger(props: ComponentProps<"button"> & { state: ComposerPickerTriggerState }) {
const [local, rest] = splitProps(props, ["state", "class", "style", "onClick"])
return (
<button
{...rest}
data-action={local.state.action}
type="button"
class={`flex h-7 min-w-0 items-center gap-1.5 rounded px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none ${local.state.class ?? ""}`}
style={local.state.style}
onClick={() => local.state.onPress()}
>
<Show when={local.state.icon}>
{(icon) => <Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />}
</Show>
<span class="min-w-0 truncate leading-5">{local.state.label}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</button>
)
}
function ComposerPickerMenuItem(props: { state: ComposerPickerItemState }) {
return (
<button
type="button"
class="flex h-7 w-full items-center gap-2 rounded px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={props.state.onSelect}
>
<Icon name={props.state.icon} size="small" class="shrink-0 text-v2-icon-icon-base" />
<span class="min-w-0 flex-1 truncate leading-5">{props.state.label}</span>
<Show when={props.state.selected}>
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
)
}
function ComposerPicker(props: { state: ComposerPickerState }) {
return (
<KobaltePopover
open={props.state.open}
placement="bottom-start"
gutter={4}
modal={false}
onOpenChange={props.state.onOpenChange}
>
<KobaltePopover.Trigger as={ComposerPickerTrigger} state={props.state.trigger} />
<KobaltePopover.Portal>
<KobaltePopover.Content
class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div class={`flex flex-col p-0.5 ${props.state.listClass ?? ""}`}>
<div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={props.state.searchRef}
value={props.state.search}
placeholder={props.state.searchPlaceholder}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => props.state.onSearchInput(event.currentTarget.value)}
/>
<Show when={props.state.search.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onClick={props.state.onSearchClear}
aria-label={props.state.clearLabel}
>
<Icon name="close-small" size="small" />
</button>
</Show>
</div>
<For each={props.state.items}>{(item) => <ComposerPickerMenuItem state={item} />}</For>
</div>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<ComposerPickerMenuItem state={props.state.action} />
</div>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
)
}
function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
return (
<div class="relative">
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
<Icon name="sliders" size="small" />
</div>
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<Select
size="normal"
options={props.state.options}
current={props.state.current}
onSelect={props.state.onSelect}
class="max-w-[175px] justify-start text-v2-text-text-faint [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate pl-5 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
triggerStyle={props.state.style}
triggerProps={{ "data-action": "prompt-agent" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
)
}
function ComposerModelControl(props: { state: ComposerModelControlState }) {
return (
<Show when={!props.state.loading}>
<Show
when={props.state.paid}
fallback={
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
style={props.state.style}
onClick={props.state.onUnpaidClick}
>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<ModelSelectorPopover
model={props.state.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: props.state.style,
class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
"data-action": "prompt-model",
}}
onClose={props.state.onClose}
>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
</Show>
)
}

View file

@ -20,6 +20,7 @@ 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"
@ -50,7 +51,10 @@ const clientFor = (directory: string) => {
abort: async () => ({ data: undefined }),
},
worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }),
create: async (input: unknown) => {
createdWorktrees.push({ directory, input })
return { data: { directory: `${directory}/new` } }
},
},
}
}
@ -211,6 +215,7 @@ 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]
@ -281,6 +286,93 @@ 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,7 +184,9 @@ 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
@ -313,18 +315,25 @@ 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 = sdk.client
let client =
projectDirectory === sdk.directory
? sdk.client
: sdk.createClient({
directory: projectDirectory,
throwOnError: true,
})
if (isNewSession) {
if (worktreeSelection === "create") {
const branch = input.newSessionWorktreeBranch?.()?.trim()
const createdWorktree = await client.worktree
.create({ directory: projectDirectory })
.create(branch ? { directory: projectDirectory, worktreeCreateInput: { branch } } : { directory: projectDirectory })
.then((x) => x.data)
.catch((err) => {
showToast({
@ -349,6 +358,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
sessionDirectory = worktreeSelection
}
if (projectDirectory !== sdk.directory) globalSync.child(projectDirectory)
if (sessionDirectory !== projectDirectory) {
client = sdk.createClient({
directory: sessionDirectory,

View file

@ -25,6 +25,8 @@ import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist"
import { StatusPopover } 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"
const OPEN_APPS = [
"vscode",
@ -43,6 +45,8 @@ const OPEN_APPS = [
"sublime-text",
] as const
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type OpenApp = (typeof OPEN_APPS)[number]
type OS = "macos" | "windows" | "linux" | "unknown"
@ -153,11 +157,11 @@ export function SessionHeader() {
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const isDesktopBeta = platform.platform === "desktop" && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
const search = createMemo(() => !isDesktopBeta || settings.general.showSearch())
const tree = createMemo(() => !isDesktopBeta || settings.general.showFileTree())
const term = createMemo(() => !isDesktopBeta || settings.general.showTerminal())
const status = createMemo(() => !isDesktopBeta || settings.general.showStatus())
const isDesktopV2 = platform.platform === "desktop" && USE_V2_TITLEBAR
const search = createMemo(() => (isDesktopV2 ? settings.general.showSearch() : true))
const tree = createMemo(() => (isDesktopV2 ? settings.general.showFileTree() : true))
const term = createMemo(() => (isDesktopV2 ? settings.general.showTerminal() : true))
const status = createMemo(() => (isDesktopV2 ? settings.general.showStatus() : true))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
@ -231,6 +235,14 @@ export function SessionHeader() {
const tint = createMemo(() =>
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
)
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
statusVisible: status(),
statusLabel: language.t("status.popover.trigger"),
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: command.keybind("review.toggle"),
reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(),
}))
const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return
@ -311,193 +323,235 @@ export function SessionHeader() {
<Show when={rightMount()}>
{(mount) => (
<Portal mount={mount()}>
<div class="flex items-center gap-2">
<Show when={projectDirectory()}>
<div class="hidden xl:flex items-center">
<Show
when={canOpen()}
fallback={
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
</div>
}
>
<div class="flex items-center">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
onClick={() => openDir(current().id)}
disabled={opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
</Show>
<Show
when={isDesktopV2}
fallback={
<div class="flex items-center gap-2">
<Show when={projectDirectory()}>
<div class="hidden xl:flex items-center">
<Show
when={canOpen()}
fallback={
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
</div>
</Button>
<DropdownMenu
gutter={4}
placement="bottom-end"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
variant="ghost"
disabled={opening()}
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
aria-label={language.t("session.header.open.menu")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="!px-1 !py-1">
{language.t("session.header.openIn")}
</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
class="mt-1"
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
selectApp(value as OpenApp)
}}
>
<For each={options()}>
{(o) => (
<DropdownMenu.RadioItem
value={o.id}
disabled={opening()}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<AppIcon id={o.icon} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => {
setMenu("open", false)
copyPath()
}
>
<div class="flex items-center">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
onClick={() => openDir(current().id)}
disabled={opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
</Show>
</div>
</Button>
<DropdownMenu
gutter={4}
placement="bottom-end"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
variant="ghost"
disabled={opening()}
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<Icon name="copy" size="small" class="text-icon-weak" />
</div>
<DropdownMenu.ItemLabel>
{language.t("session.header.open.copyPath")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
aria-label={language.t("session.header.open.menu")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="!px-1 !py-1">
{language.t("session.header.openIn")}
</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
class="mt-1"
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
selectApp(value as OpenApp)
}}
>
<For each={options()}>
{(o) => (
<DropdownMenu.RadioItem
value={o.id}
disabled={opening()}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<AppIcon id={o.icon} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => {
setMenu("open", false)
copyPath()
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<Icon name="copy" size="small" class="text-icon-weak" />
</div>
<DropdownMenu.ItemLabel>
{language.t("session.header.open.copyPath")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
</Show>
</div>
</Show>
</div>
</Show>
<div class="flex items-center gap-1">
<Show when={status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
<Show when={term()}>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
</Show>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
>
<Button
variant="ghost"
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
>
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
</Button>
</TooltipKeybind>
<Show when={tree()}>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
<div class="flex items-center gap-1">
<Show when={status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
<Show when={term()}>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</Show>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
</Show>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
>
<Button
variant="ghost"
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
>
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
</Button>
</TooltipKeybind>
<Show when={tree()}>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</Show>
</div>
</div>
</div>
</div>
</div>
}
>
<SessionHeaderV2Actions state={v2ActionsState()} />
</Show>
</Portal>
)}
</Show>
</>
)
}
type SessionHeaderV2ActionsState = {
statusVisible: boolean
statusLabel: string
reviewLabel: string
reviewKeybind: string
reviewOpened: boolean
onReviewToggle: () => void
}
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
return (
<div class="flex items-center gap-0">
<Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}>
<StatusPopover variant="v2" />
</Tooltip>
</Show>
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
state={props.state.reviewOpened ? "pressed" : undefined}
onClick={props.state.onReviewToggle}
aria-label={props.state.reviewLabel}
aria-expanded={props.state.reviewOpened}
aria-controls="review-panel"
icon={<IconV2 name="sidebar-right" />}
/>
</TooltipKeybind>
</div>
)
}

View file

@ -1,43 +1,7 @@
import type { JSX } from "solid-js"
import { createMemo } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
import { WordmarkV2 } from "@opencode-ai/ui/v2/components/wordmark-v2.jsx"
const MAIN_WORKTREE = "main"
export function NewSessionDesignView(props: { worktree: string; children: JSX.Element }) {
const globalSync = useGlobalSync()
const layout = useLayout()
const navigate = useNavigate()
const sdk = useSDK()
const server = useServer()
const sync = useSync()
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
const projects = createMemo(() => {
const roots = globalSync.data.project.map((project) => project.worktree)
if (roots.includes(projectRoot())) return roots
return [projectRoot(), ...roots]
})
const branch = createMemo(() => sync.data.vcs?.branch ?? MAIN_WORKTREE)
const openProject = (directory: string | undefined) => {
if (!directory) return
if (directory === projectRoot()) return
layout.projects.open(directory)
server.projects.touch(directory)
navigate(`/${base64Encode(directory)}/session`)
}
export function NewSessionDesignView(props: { children: JSX.Element }) {
return (
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
@ -45,31 +9,6 @@ export function NewSessionDesignView(props: { worktree: string; children: JSX.El
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
<div class="mt-8">
{props.children}
<div class="mt-3 flex h-7 items-center gap-0 pl-2">
<Select
size="normal"
variant="ghost"
options={projects()}
current={projectRoot()}
label={getFilename}
onSelect={openProject}
class="max-w-[203px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate text-[length:13px] font-[440] text-v2-text-text-faint"
/>
<div class="relative">
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center">
<Icon name="branch" size="small" />
</div>
<Select
size="normal"
variant="ghost"
options={[branch()]}
current={branch()}
class="max-w-[240px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate pl-5 font-[440] text-v2-text-text-faint"
/>
</div>
</div>
</div>
</div>
</div>

View file

@ -467,6 +467,18 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showCustomAgents.title")}
description={language.t("settings.general.row.showCustomAgents.description")}
>
<div data-action="settings-show-custom-agents">
<Switch
checked={settings.general.showCustomAgents()}
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
@ -781,7 +793,6 @@ export const SettingsGeneral: Component = () => {
</Show>
)
console.log(import.meta.env)
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
@ -803,7 +814,7 @@ export const SettingsGeneral: Component = () => {
<DisplaySection />
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<AdvancedSection />
</Show>
</div>

View file

@ -166,7 +166,167 @@ const useMcpToggleMutation = () => {
}))
}
export function StatusPopoverBody(props: { shown: Accessor<boolean>; close?: () => void }) {
type ServerStatusState = {
servers: () => ServerStatusItem[]
defaultKey: () => ServerConnection.Key | undefined
ariaLabel: string
serversLabel: string
defaultLabel: string
manageLabel: string
onManage: () => void
}
type ServerStatusItem = {
key: ServerConnection.Key
conn: ServerConnection.Any
health?: ServerHealth
blocked: boolean
active: boolean
onSelect: () => void
}
export function StatusPopoverServerBody(props: { shown: Accessor<boolean> }) {
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
dialogDead = true
dialogRun += 1
})
const servers = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
})
const health = useServerHealth(servers, props.shown)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
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))
},
}
}),
)
return (
<ServerStatusPopoverView
state={{
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)
})
},
}}
/>
)
}
function ServerStatusPopoverView(props: { state: ServerStatusState }) {
return (
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
<Tabs
aria-label={props.state.ariaLabel}
class="tabs bg-background-strong rounded-xl overflow-hidden"
data-component="tabs"
data-active="servers"
defaultValue="servers"
variant="alt"
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{props.state.servers().length > 0 ? `${props.state.servers().length} ` : ""}
{props.state.serversLabel}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="servers">
<ServerStatusList state={props.state} />
</Tabs.Content>
</Tabs>
</div>
)
}
function ServerStatusList(props: { state: ServerStatusState }) {
return (
<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={props.state.servers()}>
{(item) => {
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": !item.blocked,
"cursor-not-allowed": item.blocked,
}}
aria-disabled={item.blocked}
onClick={() => {
if (item.blocked) return
item.onSelect()
}}
>
<ServerHealthIndicator health={item.health} />
<ServerRow
conn={item.conn}
dimmed={item.blocked}
status={item.health}
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={item.key === props.state.defaultKey()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{props.state.defaultLabel}
</span>
</Show>
}
>
<div class="flex-1" />
<Show when={item.active}>
<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={props.state.onManage}>
{props.state.manageLabel}
</Button>
</div>
</div>
)
}
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync()
const server = useServer()
const platform = usePlatform()
@ -204,6 +364,22 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean>; close?: ()
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)
@ -214,6 +390,21 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean>; close?: ()
)
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)]">
@ -245,76 +436,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean>; close?: ()
</Tabs.List>
<Tabs.Content value="servers">
<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
props.close?.()
navigate("/")
const activate = () => {
if (location.pathname !== "/") {
setTimeout(activate, 16)
return
}
setTimeout(() => server.setActive(key), 0)
}
setTimeout(activate, 0)
}}
>
<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>
<ServerStatusList state={serverState} />
</Tabs.Content>
<Tabs.Content value="mcp">
@ -330,15 +452,17 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean>; close?: ()
{(name) => {
const status = () => mcpStatus(name)
const enabled = () => status() === "connected"
const connecting = () => status() === "connecting"
return (
<button
type="button"
class="flex items-center gap-2 w-full min-h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => {
if (connecting()) return
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
disabled={connecting() || (toggleMcp.isPending && toggleMcp.variables === name)}
>
<div
classList={{
@ -346,6 +470,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean>; close?: ()
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base animate-pulse": status() === "connecting",
"bg-icon-warning-base":
status() === "needs_auth" || status() === "needs_client_registration",
}}
@ -363,8 +488,9 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean>; close?: ()
<div onClick={(event) => event.stopPropagation()}>
<Switch
checked={enabled()}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
disabled={connecting() || (toggleMcp.isPending && toggleMcp.variables === name)}
onChange={() => {
if (connecting()) return
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}

View file

@ -1,14 +1,22 @@
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
import { Popover } from "@opencode-ai/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show } from "solid-js"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const DirectoryBody = 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() {
export function StatusPopover(props: { variant?: "v2"; scope?: "server" }) {
if (props.scope === "server") return <ServerStatusPopover variant={props.variant} />
return <DirectoryStatusPopover variant={props.variant} />
}
function DirectoryStatusPopover(props: { variant?: "v2" }) {
const language = useLanguage()
const server = useServer()
const sync = useSync()
@ -22,49 +30,137 @@ export function StatusPopover() {
if (warn) return "warning" as const
})
const healthy = createMemo(() => server.healthy() === true && !mcpIssue())
const state = createMemo<StatusPopoverState>(() => ({
variant: props.variant,
shown: shown(),
ready: ready(),
healthy: healthy(),
serverHealth: server.healthy(),
issue: mcpIssue(),
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<DirectoryBody shown={shown} />
</StatusPopoverBody>
),
}))
return <StatusPopoverView state={state()} />
}
function ServerStatusPopover(props: { variant?: "v2" }) {
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,
serverHealth: server.healthy(),
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<ServerBody shown={shown} />
</StatusPopoverBody>
),
}))
return <StatusPopoverView state={state()} />
}
type StatusPopoverState = {
variant?: "v2"
shown: boolean
ready: boolean
healthy: boolean
serverHealth: boolean | undefined
issue?: "critical" | "warning"
label: string
onOpenChange: (value: boolean) => void
body: () => JSX.Element
}
function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
return (
<Show when={props.shown}>
<Suspense
fallback={<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />}
>
{props.children}
</Suspense>
</Show>
)
}
function StatusPopoverView(props: { state: StatusPopoverState }) {
const statusDotClass = () => ({
"absolute rounded-full": true,
"bg-icon-success-base": props.state.ready && props.state.healthy,
"bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
"bg-icon-critical-base":
props.state.serverHealth === false ||
(props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
"bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
})
const popoverProps = {
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" as const,
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={shown()}
onOpenChange={setShown}
open={props.state.shown}
onOpenChange={props.state.onOpenChange}
triggerAs={Button}
triggerProps={{
variant: "ghost",
class: "titlebar-icon w-8 h-6 p-0 box-border",
"aria-label": language.t("status.popover.trigger"),
"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={shown() ? "status-active" : "status"} size="small" />
<Icon name={props.state.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 classList={statusDotClass()} class="-top-px -right-px size-1.5" />
</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}
{...popoverProps}
>
<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} close={() => setShown(false)} />
</Suspense>
</Show>
{props.state.body()}
</Popover>
)
}

View file

@ -23,8 +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 "./status-popover"
import { SDKProvider } from "@/context/sdk"
import { StatusPopover } from "@/components/status-popover"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@ -115,7 +114,23 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" || settings.general.showNavigation())
const nav = createMemo(() => (USE_V2_TITLEBAR ? settings.general.showNavigation() : true))
const updateState = createMemo<TitlebarUpdatePillState>(() => {
const version = props.update?.version()
return {
visible: version !== undefined,
installing: props.update?.installing() ?? false,
label: "Update",
ariaLabel: language.t("toast.update.action.installRestart"),
title: version ? `Update ${version}` : undefined,
onInstall: () => props.update?.install(),
}
})
const v2RightState = createMemo<TitlebarV2RightState>(() => ({
update: updateState(),
statusVisible: !params.dir && settings.general.showStatus(),
statusLabel: language.t("status.popover.trigger"),
}))
const back = () => {
const next = backPath(history)
@ -465,16 +480,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
</Show>
<div class="min-w-0 flex-1" />
</div>
<Show when={currentSessionTab()?.dir} keyed>
{(dir) => (
<SDKProvider directory={dir}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</SDKProvider>
)}
</Show>
<TitlebarUpdatePill update={props.update} />
<TitlebarV2Right state={v2RightState()} />
<Show when={windows() && !electronWindows()}>
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
@ -641,28 +647,50 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
)
}
function TitlebarUpdatePill(props: { update?: TitlebarUpdate }) {
const language = useLanguage()
const version = () => props.update?.version()
type TitlebarUpdatePillState = {
visible: boolean
installing: boolean
label: string
ariaLabel: string
title?: string
onInstall: () => void
}
type TitlebarV2RightState = {
update: TitlebarUpdatePillState
statusVisible: boolean
statusLabel: string
}
function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
return (
<Show when={version() !== undefined}>
<button
type="button"
class="h-5 shrink-0 rounded-[27px] bg-[var(--v2-background-bg-accent)] px-2.5 text-[11px] font-[530] leading-[1.1] tracking-[-0.04px] text-[var(--v2-text-text-contrast)] disabled:opacity-60"
onClick={() => props.update?.install()}
disabled={props.update?.installing()}
aria-label={language.t("toast.update.action.installRestart")}
title={version() ? `Update ${version()}` : undefined}
>
Update
</button>
</Show>
<div class="flex shrink-0 items-center justify-end gap-0">
<TitlebarUpdatePill state={props.state.update} />
<Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}>
<StatusPopover variant="v2" scope="server" />
</Tooltip>
</Show>
<div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
</div>
)
}
function DesktopTitlebarIconButton(props: Parameters<typeof IconButtonV2>[0]) {
return
function TitlebarUpdatePill(props: { state: TitlebarUpdatePillState }) {
return (
<Show when={props.state.visible}>
<button
type="button"
class="h-5 shrink-0 rounded-[27px] bg-[var(--v2-background-bg-layer-03)] px-2.5 text-[11px] font-[530] leading-4 tracking-[0.05px] text-[var(--v2-text-text-base)] disabled:opacity-60"
onClick={props.state.onInstall}
disabled={props.state.installing}
aria-label={props.state.ariaLabel}
title={props.state.title}
>
{props.state.label}
</button>
</Show>
)
}
function TabNavItem(props: {
@ -682,10 +710,10 @@ function TabNavItem(props: {
>
<a
href={props.href}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
>
<ProjectTabAvatar project={props.project} directory={props.directory} />
<span class="text-clip">{props.title}</span>
<span class="text-clip leading-5">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
@ -727,12 +755,12 @@ function NewSessionTabItem(props: { href: string; title: string; onClose: () =>
<a
href={props.href}
aria-current="page"
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-none text-[var(--v2-text-text-base)]"
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-[var(--v2-text-text-base)]"
>
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
<IconV2 name="edit" />
</span>
<span class="truncate">{props.title}</span>
<span class="truncate leading-5">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
<IconButtonV2

View file

@ -1,7 +1,17 @@
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
import {
batch,
createContext,
createSignal,
getOwner,
onCleanup,
onMount,
type ParentProps,
untrack,
useContext,
} from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error"
@ -49,6 +59,8 @@ export const loadMcpQuery = (directory: string, sdk: OpencodeClient) =>
queryOptions({
queryKey: [directory, "mcp"] as const,
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
refetchInterval: (query) =>
Object.values(query.state.data ?? {}).some((status) => status.status === "connecting") ? 1000 : false,
})
export const loadLspQuery = (directory: string, sdk: OpencodeClient) =>
@ -132,6 +144,7 @@ function createGlobalSync() {
let bootingRoot = false
let eventFrame: number | undefined
let eventTimer: ReturnType<typeof setTimeout> | undefined
const [childVersion, setChildVersion] = createSignal(0)
onCleanup(() => {
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
@ -204,6 +217,7 @@ function createGlobalSync() {
onBootstrap: (directory) => {
void bootstrapInstance(directory)
},
onCreate: () => setChildVersion((value) => value + 1),
onDispose: (directory) => {
const key = directoryKey(directory)
queue.clear(key)
@ -434,6 +448,8 @@ function createGlobalSync() {
return globalStore.error
},
child: children.child,
childVersion,
existing: children.existing,
peek: children.peek,
queryOptions: queryOptionsApi,
// bootstrap,

View file

@ -20,6 +20,7 @@ describe("createChildStoreManager", () => {
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onCreate() {},
onDispose() {},
translate: (key) => key,
queryOptions: {} as any,

View file

@ -24,6 +24,7 @@ export function createChildStoreManager(input: {
isBooting: (directory: string) => boolean
isLoadingSessions: (directory: string) => boolean
onBootstrap: (directory: string) => void
onCreate: () => void
onDispose: (directory: string) => void
translate: (key: string, vars?: Record<string, string | number>) => string
queryOptions: QueryOptionsApi
@ -246,6 +247,7 @@ export function createChildStoreManager(input: {
part_text_accum_delta: {},
})
children[key] = child
input.onCreate()
disposers.set(key, dispose)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
@ -292,6 +294,10 @@ export function createChildStoreManager(input: {
return childStore
}
function existing(directory: string) {
return children[directoryKey(directory)]
}
function peek(directory: string, options: ChildOptions = {}) {
const key = directoryKey(directory)
const childStore = ensureChild(directory)
@ -333,6 +339,7 @@ export function createChildStoreManager(input: {
return {
children,
ensureChild,
existing,
child,
peek,
projectMeta,

View file

@ -2,6 +2,7 @@ import { Binary } from "@opencode-ai/core/util/binary"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type {
Message,
McpStatus,
Part,
PermissionRequest,
Project,
@ -182,6 +183,11 @@ export function applyDirectoryEvent(input: {
input.setStore("session_status", props.sessionID, reconcile(props.status))
break
}
case "mcp.status.changed": {
const props = event.properties as { name: string; status: McpStatus }
input.setStore("mcp", props.name, reconcile(props.status))
break
}
case "message.updated": {
const info = clean((event.properties as { info: Message }).info)
const messages = input.store.message[info.sessionID]

View file

@ -386,17 +386,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = globalSync.child(project.worktree, { bootstrap: false })
const projectID = childStore.project
const childStore = globalSync.existing(project.worktree)?.[0]
const projectID = childStore?.project
const metadata = projectID
? globalSync.data.project.find((x) => x.id === projectID)
: globalSync.data.project.find((x) => x.worktree === project.worktree)
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// Without this, different subdirectories of the same git repo would share the same
// icon from the database instead of using their individual overrides.
// Use child metadata only after the workspace is already loaded. Creating child
// stores here fans out workspace bootstrap requests while rendering the project list.
const base = { ...metadata, ...project }
if (childStore.icon) {
if (childStore?.icon) {
return { ...base, icon: { ...base.icon, override: childStore.icon } }
}
return base
@ -475,6 +474,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
for (const project of projects) {
if (!project.id) continue
if (project.id === "global") continue
if (!globalSync.existing(project.worktree)) continue
globalSync.project.icon(project.worktree, project.icon?.override)
}
})
@ -521,28 +521,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
})
let sessionFrame: number | undefined
let sessionTimer: number | undefined
onMount(() => {
sessionFrame = requestAnimationFrame(() => {
sessionFrame = undefined
sessionTimer = window.setTimeout(() => {
sessionTimer = undefined
void Promise.all(
server.projects.list().map((project) => {
return globalSync.project.loadSessions(project.worktree)
}),
)
}, 0)
})
})
onCleanup(() => {
if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame)
if (sessionTimer !== undefined) window.clearTimeout(sessionTimer)
})
return {
ready,
handoff: {

View file

@ -32,6 +32,7 @@ export interface Settings {
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
showSessionProgressBar: boolean
showCustomAgents: boolean
}
updates: {
startup: boolean
@ -117,6 +118,7 @@ const defaultSettings: Settings = {
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
showSessionProgressBar: true,
showCustomAgents: false,
},
updates: {
startup: true,
@ -236,6 +238,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowSessionProgressBar(value: boolean) {
setStore("general", "showSessionProgressBar", value)
},
showCustomAgents: withFallback(() => store.general?.showCustomAgents, defaultSettings.general.showCustomAgents),
setShowCustomAgents(value: boolean) {
setStore("general", "showCustomAgents", value)
},
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),

View file

@ -221,6 +221,7 @@ export const dict = {
"common.loading": "Loading",
"common.loading.ellipsis": "...",
"common.cancel": "Cancel",
"common.clear": "Clear",
"common.open": "Open",
"common.connect": "Connect",
"common.disconnect": "Disconnect",
@ -303,6 +304,7 @@ export const dict = {
"dialog.plugins.empty": "Plugins configured in opencode.json",
"mcp.status.connected": "connected",
"mcp.status.connecting": "connecting",
"mcp.status.failed": "failed",
"mcp.status.needs_auth": "needs auth",
"mcp.status.disabled": "disabled",
@ -531,6 +533,7 @@ export const dict = {
"home.project.add": "Add project",
"home.sessions.search.placeholder": "Search sessions",
"home.sessions.empty": "No sessions found",
"home.sessions.empty.description": "Start a new session for this project",
"home.sessions.group.today": "Today",
"home.sessions.group.yesterday": "Yesterday",
"home.sessions.group.older": "Older",
@ -584,6 +587,12 @@ export const dict = {
"session.revertDock.restore": "Restore message",
"session.new.title": "Build anything",
"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",
@ -773,6 +782,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
"settings.general.row.showCustomAgents.title": "Custom agents",
"settings.general.row.showCustomAgents.description": "Show the agent picker in the v2 desktop composer",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",

View file

@ -9,6 +9,7 @@ import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
import { ButtonV2 } from "@opencode-ai/ui/v2/components/button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { MenuV2 } from "@opencode-ai/ui/v2/components/menu-v2.jsx"
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
import { useNavigate } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
@ -63,6 +64,7 @@ function HomeDesign() {
const navigate = useNavigate()
const server = useServer()
const language = useLanguage()
const notification = useNotification()
const [state, setState] = createStore({ search: "", project: undefined as string | undefined })
const projects = createMemo(() => layout.projects.list())
@ -78,7 +80,9 @@ function HomeDesign() {
const sessionLoad = useQuery(() => ({
queryKey: ["home", "sessions", ...projectDirectories()] as const,
queryFn: async () => {
await Promise.all(projectDirectories().map((directory) => sync.project.loadSessions(directory)))
const [root] = projectDirectories()
if (!root) return null
await sync.project.loadSessions(root)
return null
},
}))
@ -86,11 +90,16 @@ function HomeDesign() {
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const records = createMemo(() =>
[
const records = createMemo(() => {
sync.childVersion()
return [
...new Map(
projectDirectories()
.flatMap((directory) => sortedRootSessions(sync.child(directory, { bootstrap: false })[0], Date.now()))
.flatMap((directory) => {
const store = sync.existing(directory)?.[0]
if (!store) return []
return sortedRootSessions(store, Date.now())
})
.map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const),
).values(),
]
@ -109,8 +118,8 @@ function HomeDesign() {
if (!value) return true
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)
})
.slice(0, HOME_SESSION_LIMIT),
)
.slice(0, HOME_SESSION_LIMIT)
})
const groups = createMemo(() => groupSessions(records(), language))
function selectProject(directory: string) {
@ -135,6 +144,18 @@ function HomeDesign() {
navigate(`/${base64Encode(project.worktree)}/session`)
}
function openProjectNewSession(directory: string) {
layout.projects.open(directory)
server.projects.touch(directory)
navigate(`/${base64Encode(directory)}/session`)
}
const showEditProjectDialog = (project: LocalProject) => {
void import("@/components/dialog-edit-project").then((x) => {
dialog.show(() => <x.DialogEditProject project={project} />)
})
}
function openSession(session: Session) {
const project = projectForSession(session, projects(), projectByID())
layout.projects.open(project?.worktree ?? session.directory)
@ -179,7 +200,15 @@ function HomeDesign() {
projects={projects()}
selected={selectedProject()?.worktree}
selectProject={selectProject}
openNewSession={openProjectNewSession}
chooseProject={() => void chooseProject()}
editProject={showEditProjectDialog}
closeProject={(directory) => {
layout.projects.close(directory)
if (state.project === directory) setState("project", undefined)
}}
clearNotifications={notification.project.markViewed}
unseenCount={notification.project.unseenCount}
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
@ -189,41 +218,60 @@ function HomeDesign() {
class="min-w-0 flex-1 flex flex-col overflow-y-hidden pt-12"
aria-label={language.t("sidebar.project.recentSessions")}
>
<HomeSessionSearch
value={state.search}
placeholder={language.t("home.sessions.search.placeholder")}
onInput={(value) => setState("search", value)}
/>
<div class="mt-3 overflow-auto flex-1">
<div class="pt-3 flex flex-col gap-6">
<Show when={!sessionLoad.isLoading} fallback={<HomeSessionSkeleton label={language.t("common.loading")} />}>
<Show
when={groups().length > 0}
fallback={
<div class="flex min-w-0 flex-col gap-4">
<HomeSessionGroupHeader title={language.t("home.sessions.empty")} onNewSession={openNewSession} />
</div>
}
>
<For each={groups()}>
{(group, index) => (
<div class="flex min-w-0 flex-col gap-4">
<HomeSessionGroupHeader
title={group.title}
onNewSession={index() === 0 ? openNewSession : undefined}
/>
<div class="flex min-w-0 flex-col gap-px">
<For each={group.sessions}>
{(record) => <HomeSessionRow record={record} openSession={openSession} />}
</For>
<Show
when={selectedProject()}
fallback={
<HomeEmptyState
icon="folder-add-left"
title={language.t("home.empty.title")}
description={language.t("home.empty.description")}
action={language.t("home.project.add")}
onAction={() => void chooseProject()}
/>
}
>
<HomeSessionSearch
value={state.search}
placeholder={language.t("home.sessions.search.placeholder")}
onInput={(value) => setState("search", value)}
clearLabel={language.t("common.clear")}
onClear={() => setState("search", "")}
/>
<div class="mt-3 overflow-auto flex-1">
<div class="pt-3 flex flex-col gap-6">
<Show when={!sessionLoad.isLoading} fallback={<HomeSessionSkeleton label={language.t("common.loading")} />}>
<Show
when={groups().length > 0}
fallback={
<HomeEmptyState
icon="edit"
title={language.t("home.sessions.empty")}
description={language.t("home.sessions.empty.description")}
action={language.t("command.session.new")}
onAction={openNewSession}
/>
}
>
<For each={groups()}>
{(group, index) => (
<div class="flex min-w-0 flex-col gap-4">
<HomeSessionGroupHeader
title={group.title}
onNewSession={index() === 0 ? openNewSession : undefined}
/>
<div class="flex min-w-0 flex-col gap-px">
<For each={group.sessions}>
{(record) => <HomeSessionRow record={record} openSession={openSession} />}
</For>
</div>
</div>
</div>
)}
</For>
)}
</For>
</Show>
</Show>
</Show>
</div>
</div>
</div>
</Show>
</section>
</div>
)
@ -233,7 +281,12 @@ function HomeProjectColumn(props: {
projects: LocalProject[]
selected?: string
selectProject: (directory: string) => void
openNewSession: (directory: string) => void
chooseProject: () => void
editProject: (project: LocalProject) => void
closeProject: (directory: string) => void
clearNotifications: (directory: string) => void
unseenCount: (directory: string) => number
openSettings: () => void
openHelp: () => void
language: ReturnType<typeof useLanguage>
@ -268,18 +321,17 @@ function HomeProjectColumn(props: {
>
<For each={props.projects}>
{(project) => (
<button
type="button"
data-component="home-project-row"
class={HOME_PROJECT_NAV_ROW}
classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected === project.worktree }}
data-selected={props.selected === project.worktree ? "" : undefined}
aria-current={props.selected === project.worktree ? "page" : undefined}
onClick={() => props.selectProject(project.worktree)}
>
<HomeProjectAvatar project={project} />
<span>{displayName(project)}</span>
</button>
<HomeProjectRow
project={project}
selected={props.selected === project.worktree}
unseenCount={props.unseenCount(project.worktree)}
selectProject={props.selectProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
language={props.language}
/>
)}
</For>
</Show>
@ -306,6 +358,83 @@ function HomeProjectColumn(props: {
)
}
function HomeProjectRow(props: {
project: LocalProject
selected: boolean
unseenCount: number
selectProject: (directory: string) => void
openNewSession: (directory: string) => void
editProject: (project: LocalProject) => void
closeProject: (directory: string) => void
clearNotifications: (directory: string) => void
language: ReturnType<typeof useLanguage>
}) {
const name = createMemo(() => displayName(props.project))
return (
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px] hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover">
<button
type="button"
data-component="home-project-row"
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected }}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
onClick={() => props.selectProject(props.project.worktree)}
>
<HomeProjectAvatar project={props.project} />
<span>{name()}</span>
</button>
<div class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 group-focus-within/project:opacity-100">
<IconButtonV2
data-action="home-project-new-session"
variant="ghost-muted"
size="small"
icon={<IconV2 name="edit" />}
aria-label={props.language.t("command.session.new")}
onClick={(event) => {
event.stopPropagation()
props.openNewSession(props.project.worktree)
}}
/>
<MenuV2 gutter={4} modal={false} placement="bottom-end">
<MenuV2.Trigger
as={IconButtonV2}
data-action="home-project-menu"
variant="ghost-muted"
size="small"
icon={<IconV2 name="menu" />}
aria-label={props.language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
<Icon name="new-session" size="small" />
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
<Icon name="edit" size="small" />
{props.language.t("common.edit")}
</MenuV2.Item>
<MenuV2.Item
disabled={props.unseenCount === 0}
onSelect={() => props.clearNotifications(props.project.worktree)}
>
<Icon name="circle-check" size="small" />
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
<Icon name="close" size="small" />
{props.language.t("common.close")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</div>
</div>
)
}
function HomeProjectAvatar(props: { project: LocalProject }) {
const name = createMemo(() => displayName(props.project))
return (
@ -320,7 +449,13 @@ function HomeProjectAvatar(props: { project: LocalProject }) {
)
}
function HomeSessionSearch(props: { value: string; placeholder: string; onInput: (value: string) => void }) {
function HomeSessionSearch(props: {
value: string
placeholder: string
clearLabel: string
onInput: (value: string) => void
onClear: () => void
}) {
return (
<label class="ml-4 flex h-9 w-[calc(100%_-_48px)] sticky top-0 inset-x-0 items-center gap-2 rounded-[6px] bg-v2-background-bg-deep px-3 py-1 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out focus-within:bg-v2-background-bg-base focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]">
<IconV2 name="magnifying-glass" size="small" />
@ -331,10 +466,46 @@ function HomeSessionSearch(props: { value: string; placeholder: string; onInput:
aria-label={props.placeholder}
onInput={(event) => props.onInput(event.currentTarget.value)}
/>
<Show when={props.value.trim()}>
<button
type="button"
class="flex size-5 shrink-0 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
aria-label={props.clearLabel}
onClick={(event) => {
event.preventDefault()
props.onClear()
}}
>
<Icon name="close-small" size="small" />
</button>
</Show>
</label>
)
}
function HomeEmptyState(props: {
icon: Parameters<typeof IconV2>[0]["name"]
title: string
description: string
action: string
onAction: () => void
}) {
return (
<div class="flex min-h-[320px] flex-1 flex-col items-center justify-center gap-4 px-6 text-center">
<div class="flex size-10 items-center justify-center rounded-[10px] bg-v2-background-bg-deep text-v2-icon-icon-muted shadow-[var(--v2-elevation-raised)]">
<IconV2 name={props.icon} />
</div>
<div class="flex max-w-[320px] flex-col gap-1">
<div class="text-v2-text-text-base [font-weight:530]">{props.title}</div>
<div class="text-v2-text-text-muted [font-weight:440]">{props.description}</div>
</div>
<ButtonV2 variant="neutral" size="normal" icon={props.icon} onClick={props.onAction}>
{props.action}
</ButtonV2>
</div>
)
}
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
const language = useLanguage()
return (

View file

@ -1661,7 +1661,6 @@ export default function Page() {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeChange={(value) => setStore("newSessionWorktree", value)}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
onSubmit={() => {
comments.clear()
@ -1798,7 +1797,7 @@ export default function Page() {
</Match>
<Match when={true}>
<Show when={USE_NEW_SESSION_DESIGN} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<NewSessionDesignView worktree={newSessionWorktree()}>
<NewSessionDesignView>
{composerRegion("inline")}
</NewSessionDesignView>
</Show>

View file

@ -25,7 +25,6 @@ export function SessionComposerRegion(props: {
placement?: "dock" | "inline"
inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string
onNewSessionWorktreeChange?: (worktree: string) => void
onNewSessionWorktreeReset: () => void
onSubmit: () => void
onResponseSubmit: () => void
@ -265,7 +264,6 @@ export function SessionComposerRegion(props: {
variant={props.placement === "inline" ? "new-session" : undefined}
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeChange={props.onNewSessionWorktreeChange}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
edit={props.followup?.edit}
onEditLoaded={props.followup?.onEditLoaded}

View file

@ -28,6 +28,8 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
@ -58,12 +60,8 @@ export function SessionSidePanel(props: {
const { sessionKey, tabs, view, params } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = createMemo(
() =>
platform.platform !== "desktop" ||
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
settings.general.showFileTree(),
)
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
const shown = createMemo(() => (desktopV2() ? settings.general.showFileTree() : true))
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())

View file

@ -20,6 +20,8 @@ import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void
@ -70,10 +72,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const shown = () =>
platform.platform !== "desktop" ||
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
settings.general.showFileTree()
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
const shown = () => (desktopV2() ? settings.general.showFileTree() : true)
const messages = () => {
const id = params.id

View file

@ -143,6 +143,9 @@ export const McpListCommand = effectCmd({
} else if (status.status === "disabled") {
statusIcon = "○"
statusText = "disabled"
} else if (status.status === "connecting") {
statusIcon = "…"
statusText = "connecting"
} else if (status.status === "needs_auth") {
statusIcon = "⚠"
statusText = "needs authentication"

View file

@ -26,7 +26,7 @@ import { BusEvent } from "../bus/bus-event"
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import open from "open"
import { Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect"
import { Effect, Exit, Layer, Option, Context, Schema, Stream, Scope, Semaphore } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
@ -75,6 +75,9 @@ const StatusConnected = Schema.Struct({ status: Schema.Literal("connected") }).a
const StatusDisabled = Schema.Struct({ status: Schema.Literal("disabled") }).annotate({
identifier: "MCPStatusDisabled",
})
const StatusConnecting = Schema.Struct({ status: Schema.Literal("connecting") }).annotate({
identifier: "MCPStatusConnecting",
})
const StatusFailed = Schema.Struct({ status: Schema.Literal("failed"), error: Schema.String }).annotate({
identifier: "MCPStatusFailed",
})
@ -89,12 +92,21 @@ const StatusNeedsClientRegistration = Schema.Struct({
export const Status = Schema.Union([
StatusConnected,
StatusDisabled,
StatusConnecting,
StatusFailed,
StatusNeedsAuth,
StatusNeedsClientRegistration,
]).annotate({ identifier: "MCPStatus", discriminator: "status" })
export type Status = Schema.Schema.Type<typeof Status>
export const StatusChanged = BusEvent.define(
"mcp.status.changed",
Schema.Struct({
name: Schema.String,
status: Status,
}),
)
// Store transports for OAuth servers to allow finishing auth
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
@ -233,6 +245,7 @@ interface State {
status: Record<string, Status>
clients: Record<string, MCPClient>
defs: Record<string, MCPToolDef[]>
revision: Record<string, number>
}
export interface Interface {
@ -473,6 +486,7 @@ export const layer = Layer.effect(
return { mcpClient, status, defs: listed } satisfies CreateResult
})
const cfgSvc = yield* Config.Service
const startupLock = Semaphore.makeUnsafe(1)
const descendants = Effect.fnUntraced(
function* (pid: number) {
@ -512,43 +526,133 @@ export const layer = Layer.effect(
})
}
function failedStatus(error: unknown): Status {
return { status: "failed", error: error instanceof Error ? error.message : String(error) }
}
function bump(s: State, name: string) {
const next = (s.revision[name] ?? 0) + 1
s.revision[name] = next
return next
}
function closeClient(s: State, name: string) {
const client = s.clients[name]
delete s.defs[name]
if (!client) return Effect.void
return Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
}
function closeCreateResult(result: CreateResult) {
const client = result.mcpClient
if (!client) return Effect.void
return Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
}
const setStatus = Effect.fnUntraced(function* (s: State, name: string, status: Status) {
s.status[name] = status
yield* bus.publish(StatusChanged, { name, status }).pipe(Effect.ignore)
return status
})
const storeClient = Effect.fnUntraced(function* (
s: State,
name: string,
client: MCPClient,
listed: MCPToolDef[],
timeout?: number,
) {
const bridge = yield* EffectBridge.make()
yield* closeClient(s, name)
s.clients[name] = client
s.defs[name] = listed
watch(s, name, client, bridge, timeout)
return yield* setStatus(s, name, { status: "connected" })
})
const applyCreateResult = Effect.fnUntraced(function* (
s: State,
name: string,
result: CreateResult,
timeout?: number,
) {
const client = result.mcpClient
if (!client) {
yield* closeClient(s, name)
delete s.clients[name]
return yield* setStatus(s, name, result.status)
}
if (!result.defs) {
yield* closeCreateResult(result)
yield* closeClient(s, name)
delete s.clients[name]
return yield* setStatus(s, name, { status: "failed", error: "Failed to get tools" })
}
return yield* storeClient(s, name, client, result.defs, timeout)
})
const createSafely = (key: string, mcp: ConfigMCP.Info) =>
create(key, mcp).pipe(
Effect.catch((error) => {
log.error("mcp startup failed", { key, error })
return Effect.succeed({ status: failedStatus(error) } satisfies CreateResult)
}),
)
const startConfigured = Effect.fn("MCP.startConfigured")(function* (
s: State,
entries: ReadonlyArray<readonly [string, ConfigMCP.Info]>,
) {
yield* startupLock.withPermits(1)(
Effect.forEach(
entries,
([key, mcp]) =>
Effect.gen(function* () {
const revision = s.revision[key] ?? 0
const result = yield* createSafely(key, mcp)
if ((s.revision[key] ?? 0) !== revision) {
yield* closeCreateResult(result)
return
}
yield* applyCreateResult(s, key, result, mcp.timeout)
}),
{ concurrency: "unbounded", discard: true },
),
)
})
const state = yield* InstanceState.make<State>(
Effect.fn("MCP.state")(function* () {
const cfg = yield* cfgSvc.get()
const bridge = yield* EffectBridge.make()
const scope = yield* Scope.Scope
const config = cfg.mcp ?? {}
const s: State = {
status: {},
clients: {},
defs: {},
revision: {},
}
yield* Effect.forEach(
Object.entries(config),
([key, mcp]) =>
Effect.gen(function* () {
if (!isMcpConfigured(mcp)) {
log.error("Ignoring MCP config entry without type", { key })
return
}
const configured = Object.entries(config).flatMap(([key, mcp]) => {
if (!isMcpConfigured(mcp)) {
log.error("Ignoring MCP config entry without type", { key })
return []
}
if (mcp.enabled === false) {
s.status[key] = { status: "disabled" }
return
}
if (mcp.enabled === false) {
s.status[key] = { status: "disabled" }
return []
}
const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.void))
if (!result) return
s.status[key] = { status: "connecting" }
return [[key, mcp] as const]
})
s.status[key] = result.status
if (result.mcpClient) {
s.clients[key] = result.mcpClient
s.defs[key] = result.defs!
watch(s, key, result.mcpClient, bridge, mcp.timeout)
}
}),
{ concurrency: "unbounded" },
)
if (configured.length > 0) {
yield* startConfigured(s, configured).pipe(Effect.ignore, Effect.forkIn(scope), Effect.asVoid)
}
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
@ -577,29 +681,6 @@ export const layer = Layer.effect(
}),
)
function closeClient(s: State, name: string) {
const client = s.clients[name]
delete s.defs[name]
if (!client) return Effect.void
return Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
}
const storeClient = Effect.fnUntraced(function* (
s: State,
name: string,
client: MCPClient,
listed: MCPToolDef[],
timeout?: number,
) {
const bridge = yield* EffectBridge.make()
yield* closeClient(s, name)
s.status[name] = { status: "connected" }
s.clients[name] = client
s.defs[name] = listed
watch(s, name, client, bridge, timeout)
return s.status[name]
})
const status = Effect.fn("MCP.status")(function* () {
const s = yield* InstanceState.get(state)
@ -622,16 +703,15 @@ export const layer = Layer.effect(
const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCP.Info) {
const s = yield* InstanceState.get(state)
const result = yield* create(name, mcp)
s.status[name] = result.status
if (!result.mcpClient) {
yield* closeClient(s, name)
delete s.clients[name]
return result.status
const revision = bump(s, name)
yield* setStatus(s, name, mcp.enabled === false ? { status: "disabled" } : { status: "connecting" })
const result = yield* createSafely(name, mcp)
if ((s.revision[name] ?? 0) !== revision) {
yield* closeCreateResult(result)
return s.status[name] ?? result.status
}
return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout)
return yield* applyCreateResult(s, name, result, mcp.timeout)
})
const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCP.Info) {
@ -651,9 +731,10 @@ export const layer = Layer.effect(
const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) {
const s = yield* InstanceState.get(state)
bump(s, name)
yield* closeClient(s, name)
delete s.clients[name]
s.status[name] = { status: "disabled" }
yield* setStatus(s, name, { status: "disabled" })
})
const tools = Effect.fn("MCP.tools")(function* () {

View file

@ -54,6 +54,7 @@ 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"),
@ -87,6 +88,7 @@ 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",
@ -165,6 +167,17 @@ 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,6 +103,10 @@ 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
}) {
@ -158,6 +162,7 @@ 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,6 +46,7 @@ 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" }),
),
@ -134,9 +135,10 @@ function failedRemoves(...chunks: string[]) {
// ---------------------------------------------------------------------------
export interface Interface {
readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect<Info, Error>
readonly makeWorktreeInfo: (options?: { name?: string; branch?: 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>
@ -185,17 +187,18 @@ 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.detached ? undefined : `opencode/${name}`
const branch = input.branch ?? (input.detached ? undefined : `opencode/${name}`)
const directory = pathSvc.join(input.root, name)
if (yield* fs.exists(directory).pipe(Effect.orDie)) continue
if (branch) {
if (branch && !input.branch) {
const ref = `refs/heads/${branch}`
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree })
if (branchCheck.code === 0) continue
@ -208,6 +211,7 @@ export const layer: Layer.Layer<
const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (input?: {
name?: string
branch?: string
detached?: boolean
}) {
const ctx = yield* InstanceState.context
@ -218,14 +222,24 @@ 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) : "", detached: input?.detached })
return yield* candidate({
root,
name: input?.name ? slugify(input.name) : input?.branch ? slugify(input.branch) : "",
branch: input?.branch?.trim() || undefined,
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
? ["worktree", "add", "--no-checkout", "-b", info.branch, info.directory]
? branchExists
? ["worktree", "add", "--no-checkout", info.directory, info.branch]
: ["worktree", "add", "--no-checkout", "-b", info.branch, info.directory]
: ["worktree", "add", "--no-checkout", "--detach", info.directory, "HEAD"],
{ cwd: ctx.worktree },
)
@ -297,11 +311,27 @@ export const layer: Layer.Layer<
})
const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
const info = yield* makeWorktreeInfo({ name: input?.name })
const info = yield* makeWorktreeInfo({ name: input?.name, branch: input?.branch })
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)))
@ -603,7 +633,7 @@ export const layer: Layer.Layer<
return true
})
return Service.of({ makeWorktreeInfo, createFromInfo, create, list, remove, reset })
return Service.of({ makeWorktreeInfo, createFromInfo, create, branches, list, remove, reset })
}),
)

View file

@ -1,7 +1,9 @@
import { expect, mock, beforeEach } from "bun:test"
import { Effect, Exit } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import type { MCP as MCPNS } from "../../src/mcp/index"
import { testEffect } from "../lib/effect"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
// --- Mock infrastructure ---
@ -25,6 +27,10 @@ let lastCreatedClientName: string | undefined
let connectShouldFail = false
let connectShouldHang = false
let connectError = "Mock transport cannot connect"
let connectHook: (() => Promise<void>) | undefined
let activeConnects = 0
let maxActiveConnects = 0
let connectStarts = 0
// Tracks how many Client instances were created (detects leaks)
let clientCreateCount = 0
// Tracks how many times transport.close() is called across all mock transports
@ -52,6 +58,19 @@ function getOrCreateClientState(name?: string): MockClientState {
return state
}
async function runMockConnect() {
activeConnects++
connectStarts++
maxActiveConnects = Math.max(maxActiveConnects, activeConnects)
try {
await connectHook?.()
if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
if (connectShouldFail) throw new Error(connectError)
} finally {
activeConnects--
}
}
// Mock transport that succeeds or fails based on connectShouldFail / connectShouldHang
class MockStdioTransport {
stderr: null = null
@ -59,8 +78,7 @@ class MockStdioTransport {
// oxlint-disable-next-line no-useless-constructor
constructor(_opts: any) {}
async start() {
if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
if (connectShouldFail) throw new Error(connectError)
return runMockConnect()
}
async close() {
transportCloseCount++
@ -71,8 +89,7 @@ class MockStreamableHTTP {
// oxlint-disable-next-line no-useless-constructor
constructor(_url: URL, _opts?: any) {}
async start() {
if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
if (connectShouldFail) throw new Error(connectError)
return runMockConnect()
}
async close() {
transportCloseCount++
@ -84,8 +101,7 @@ class MockSSE {
// oxlint-disable-next-line no-useless-constructor
constructor(_url: URL, _opts?: any) {}
async start() {
if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
if (connectShouldFail) throw new Error(connectError)
return runMockConnect()
}
async close() {
transportCloseCount++
@ -173,6 +189,10 @@ beforeEach(() => {
connectShouldFail = false
connectShouldHang = false
connectError = "Mock transport cannot connect"
connectHook = undefined
activeConnects = 0
maxActiveConnects = 0
connectStarts = 0
clientCreateCount = 0
transportCloseCount = 0
})
@ -188,6 +208,91 @@ function statusName(status: Record<string, MCPNS.Status> | MCPNS.Status, server:
return status[server]?.status
}
function deferred() {
let resolve = () => {}
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
it.instance(
"status() returns connecting without waiting for configured startup",
() =>
MCP.Service.use((mcp: MCPNS.Interface) =>
Effect.gen(function* () {
const connect = deferred()
connectHook = () => connect.promise
const status = yield* awaitWithTimeout(mcp.status(), "mcp status blocked on startup", "200 millis")
expect(status["slow-server"]?.status).toBe("connecting")
yield* pollWithTimeout(
Effect.sync(() => (connectStarts === 1 ? true : undefined)),
"configured mcp startup did not begin",
)
connect.resolve()
yield* pollWithTimeout(
Effect.gen(function* () {
const next = yield* mcp.status()
return next["slow-server"]?.status === "connected" ? true : undefined
}),
"configured mcp startup did not complete",
)
}),
),
{
config: {
mcp: {
"slow-server": {
type: "local",
command: ["echo", "test"],
},
},
},
},
)
it.live("configured MCP startup runs for one project at a time", () =>
Effect.gen(function* () {
const connect = deferred()
connectHook = () => connect.promise
const config = {
mcp: {
"slow-server": {
type: "local" as const,
command: ["echo", "test"],
},
},
}
const first = yield* tmpdirScoped({ config })
const second = yield* tmpdirScoped({ config })
const mcp = yield* MCP.Service
yield* mcp.status().pipe(provideInstance(first))
yield* pollWithTimeout(
Effect.sync(() => (connectStarts === 1 ? true : undefined)),
"first configured mcp startup did not begin",
)
yield* mcp.status().pipe(provideInstance(second))
yield* Effect.sleep("100 millis")
expect(connectStarts).toBe(1)
expect(maxActiveConnects).toBe(1)
connect.resolve()
yield* pollWithTimeout(
Effect.sync(() => (connectStarts === 2 && activeConnects === 0 ? true : undefined)),
"second configured mcp startup did not run after first completed",
)
expect(maxActiveConnects).toBe(1)
}).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)),
)
// ========================================================================
// Test: tools() are cached after connect
// ========================================================================

View file

@ -110,6 +110,19 @@ 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",
() =>
@ -181,6 +194,20 @@ 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",
() =>
@ -214,6 +241,21 @@ 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

@ -274,6 +274,8 @@ import type {
VcsGetResponses,
VcsStatusErrors,
VcsStatusResponses,
WorktreeBranchesErrors,
WorktreeBranchesResponses,
WorktreeCreateErrors,
WorktreeCreateInput,
WorktreeCreateResponses,
@ -1388,6 +1390,36 @@ 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

@ -29,6 +29,7 @@ export type Event =
| EventSessionIdle
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventMcpStatusChanged
| EventCommandExecuted
| EventProjectUpdated
| EventSessionCompacted
@ -352,6 +353,40 @@ export type SessionStatus =
type: "busy"
}
export type McpStatusConnected = {
status: "connected"
}
export type McpStatusDisabled = {
status: "disabled"
}
export type McpStatusConnecting = {
status: "connecting"
}
export type McpStatusFailed = {
status: "failed"
error: string
}
export type McpStatusNeedsAuth = {
status: "needs_auth"
}
export type McpStatusNeedsClientRegistration = {
status: "needs_client_registration"
error: string
}
export type McpStatus =
| McpStatusConnected
| McpStatusDisabled
| McpStatusConnecting
| McpStatusFailed
| McpStatusNeedsAuth
| McpStatusNeedsClientRegistration
export type Project = {
id: string
worktree: string
@ -830,6 +865,7 @@ export type GlobalEvent = {
| EventSessionIdle
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventMcpStatusChanged
| EventCommandExecuted
| EventProjectUpdated
| EventSessionCompacted
@ -1447,6 +1483,7 @@ export type WorktreeError = {
export type WorktreeCreateInput = {
name?: string
branch?: string
/**
* Additional startup script to run after the project's start command
*/
@ -1660,35 +1697,6 @@ export type FormatterStatus = {
enabled: boolean
}
export type McpStatusConnected = {
status: "connected"
}
export type McpStatusDisabled = {
status: "disabled"
}
export type McpStatusFailed = {
status: "failed"
error: string
}
export type McpStatusNeedsAuth = {
status: "needs_auth"
}
export type McpStatusNeedsClientRegistration = {
status: "needs_client_registration"
error: string
}
export type McpStatus =
| McpStatusConnected
| McpStatusDisabled
| McpStatusFailed
| McpStatusNeedsAuth
| McpStatusNeedsClientRegistration
export type McpUnsupportedOAuthError = {
error: string
}
@ -2635,6 +2643,15 @@ export type EventMcpBrowserOpenFailed = {
}
}
export type EventMcpStatusChanged = {
id: string
type: "mcp.status.changed"
properties: {
name: string
status: McpStatus
}
}
export type EventCommandExecuted = {
id: string
type: "command.executed"
@ -4397,6 +4414,34 @@ 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

View file

@ -17,6 +17,18 @@ const icons = {
viewBox: "0 0 20 20",
body: `<path d="M7.91683 7.91927V6.2526H12.0835V8.7526L10.0002 10.0026V12.0859M10.0002 13.7526V13.7609M17.9168 10.0026C17.9168 14.3749 14.3724 17.9193 10.0002 17.9193C5.62791 17.9193 2.0835 14.3749 2.0835 10.0026C2.0835 5.63035 5.62791 2.08594 10.0002 2.08594C14.3724 2.08594 17.9168 5.63035 17.9168 10.0026Z" stroke="currentColor" stroke-linecap="square"/>`,
},
"sidebar-right": {
viewBox: "0 0 20 20",
body: `<path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor"/>`,
},
status: {
viewBox: "0 0 20 20",
body: `<path d="M2 10V18H18V10M2 10V2H18V10M2 10H18M5 6H9M5 14H9" stroke="currentColor"/>`,
},
"status-active": {
viewBox: "0 0 20 20",
body: `<path d="M18 2H2V10H18V2Z" fill="currentColor" fill-opacity="0.1"/><path d="M2 18H18V10H2V18Z" fill="currentColor" fill-opacity="0.1"/><path d="M2 10V18H18V10M2 10V2H18V10M2 10H18M5 6H9M5 14H9" stroke="currentColor"/>`,
},
"magnifying-glass": {
viewBox: "0 0 16 16",
body: `<path d="M13 13L10.6418 10.6418M11.9552 7.47761C11.9552 9.95053 9.95053 11.9552 7.47761 11.9552C5.0047 11.9552 3 9.95053 3 7.47761C3 5.0047 5.0047 3 7.47761 3C9.95053 3 11.9552 5.0047 11.9552 7.47761Z" stroke="currentColor" stroke-linecap="square" vector-effect="non-scaling-stroke"/>`,