瀏覽代碼

feat(app): refine desktop v2 home and session controls (#28788)

Luke Parker 2 月之前
父節點
當前提交
13c9d097a0

+ 379 - 119
packages/app/src/components/prompt-input.tsx

@@ -4,6 +4,8 @@ import {
   createEffect,
   createEffect,
   on,
   on,
   Component,
   Component,
+  splitProps,
+  For,
   Show,
   Show,
   onCleanup,
   onCleanup,
   createMemo,
   createMemo,
@@ -11,7 +13,10 @@ import {
   createResource,
   createResource,
   Switch,
   Switch,
   Match,
   Match,
+  type ComponentProps,
+  type JSX,
 } from "solid-js"
 } from "solid-js"
+import { Popover as KobaltePopover } from "@kobalte/core/popover"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
 import { useLocal } from "@/context/local"
 import { useLocal } from "@/context/local"
 import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
 import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
@@ -26,12 +31,14 @@ import {
   FileAttachmentPart,
   FileAttachmentPart,
 } from "@/context/prompt"
 } from "@/context/prompt"
 import { useLayout } from "@/context/layout"
 import { useLayout } from "@/context/layout"
+import { useNavigate } from "@solidjs/router"
 import { useSDK } from "@/context/sdk"
 import { useSDK } from "@/context/sdk"
+import { useServer } from "@/context/server"
 import { useSync } from "@/context/sync"
 import { useSync } from "@/context/sync"
 import { useComments } from "@/context/comments"
 import { useComments } from "@/context/comments"
 import { Button } from "@opencode-ai/ui/button"
 import { Button } from "@opencode-ai/ui/button"
 import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
 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 { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -44,6 +51,7 @@ import { Persist, persisted } from "@/utils/persist"
 import { usePermission } from "@/context/permission"
 import { usePermission } from "@/context/permission"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { usePlatform } from "@/context/platform"
+import { useSettings } from "@/context/settings"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { createSessionTabs } from "@/pages/session/helpers"
 import { createSessionTabs } from "@/pages/session/helpers"
 import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
 import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
@@ -68,14 +76,16 @@ import { ImagePreview } from "@opencode-ai/ui/image-preview"
 import { useQueries } from "@tanstack/solid-query"
 import { useQueries } from "@tanstack/solid-query"
 import { useQueryOptions } from "@/context/server-sync"
 import { useQueryOptions } from "@/context/server-sync"
 import { pathKey } from "@/utils/path-key"
 import { pathKey } from "@/utils/path-key"
-import { getFilename } from "@opencode-ai/core/util/path"
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { displayName } from "@/pages/layout/helpers"
+
+const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
 
 
 interface PromptInputProps {
 interface PromptInputProps {
   class?: string
   class?: string
   variant?: "dock" | "new-session"
   variant?: "dock" | "new-session"
   ref?: (el: HTMLDivElement) => void
   ref?: (el: HTMLDivElement) => void
   newSessionWorktree?: string
   newSessionWorktree?: string
-  onNewSessionWorktreeChange?: (worktree: string) => void
   onNewSessionWorktreeReset?: () => void
   onNewSessionWorktreeReset?: () => void
   edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
   edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
   onEditLoaded?: () => void
   onEditLoaded?: () => void
@@ -113,11 +123,9 @@ const EXAMPLES = [
   "prompt.example.25",
   "prompt.example.25",
 ] as const
 ] as const
 
 
-const MAIN_WORKTREE = "main"
-const CREATE_WORKTREE = "create"
-
 export const PromptInput: Component<PromptInputProps> = (props) => {
 export const PromptInput: Component<PromptInputProps> = (props) => {
   const sdk = useSDK()
   const sdk = useSDK()
+  const navigate = useNavigate()
   const queryOptions = useQueryOptions()
   const queryOptions = useQueryOptions()
 
 
   const sync = useSync()
   const sync = useSync()
@@ -125,6 +133,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const files = useFile()
   const files = useFile()
   const prompt = usePrompt()
   const prompt = usePrompt()
   const layout = useLayout()
   const layout = useLayout()
+  const server = useServer()
   const comments = useComments()
   const comments = useComments()
   const dialog = useDialog()
   const dialog = useDialog()
   const providers = useProviders()
   const providers = useProviders()
@@ -132,11 +141,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const permission = usePermission()
   const permission = usePermission()
   const language = useLanguage()
   const language = useLanguage()
   const platform = usePlatform()
   const platform = usePlatform()
+  const settings = useSettings()
   const { params, tabs, view } = useSessionLayout()
   const { params, tabs, view } = useSessionLayout()
   let editorRef!: HTMLDivElement
   let editorRef!: HTMLDivElement
   let fileInputRef: HTMLInputElement | undefined
   let fileInputRef: HTMLInputElement | undefined
   let scrollRef!: HTMLDivElement
   let scrollRef!: HTMLDivElement
   let slashPopoverRef!: HTMLDivElement
   let slashPopoverRef!: HTMLDivElement
+  let projectSearchRef: HTMLInputElement | undefined
 
 
   const mirror = { input: false }
   const mirror = { input: false }
   const inset = 56
   const inset = 56
@@ -277,6 +288,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     mode: "normal",
     mode: "normal",
     applyingHistory: false,
     applyingHistory: false,
   })
   })
+  const [picker, setPicker] = createStore({
+    projectOpen: false,
+    projectSearch: "",
+  })
 
 
   const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
   const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
   const motion = (value: number) => ({
   const motion = (value: number) => ({
@@ -1303,91 +1318,122 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     return "Ask anything, / for commands, @ for context..."
     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 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 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 selectedProject = createMemo(() => projectForDirectory(sdk.directory))
+  const projectResults = createMemo(() => {
+    const search = picker.projectSearch.trim().toLowerCase()
+    if (!search) return projects()
+    return projects().filter((project) => displayName(project).toLowerCase().includes(search))
   })
   })
-  const 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 showAgentControl = createMemo(() => settings.general.showCustomAgents() && agentNames().length > 0)
+  const selectProject = (worktree: string) => {
+    setPicker({
+      projectOpen: false,
+      projectSearch: "",
+    })
+    if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "")) {
+      restoreFocus()
+      return
+    }
+    layout.projects.open(worktree)
+    server.projects.touch(worktree)
+    navigate(`/${base64Encode(worktree)}/session`)
+  }
+  const addProject = async () => {
+    const select = (result: string | string[] | null) => {
+      const directory = Array.isArray(result) ? result[0] : result
+      if (!directory) return
+      selectProject(directory)
+    }
+    if (platform.openDirectoryPickerDialog && server.isLocal()) {
+      select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") }))
+      return
+    }
+    void import("@/components/dialog-select-directory").then((x) => {
+      dialog.show(() => <x.DialogSelectDirectory onSelect={select} />, () => select(null))
+    })
   }
   }
 
 
-  const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
+  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)
+        void addProject()
+      },
+    },
+    onOpenChange: (open) => {
+      setPicker("projectOpen", open)
+      if (open) requestAnimationFrame(() => projectSearchRef?.focus())
+    },
+    onSearchInput: (value) => setPicker("projectSearch", value),
+    onSearchClear: () => setPicker("projectSearch", ""),
+    searchRef: (el) => (projectSearchRef = el),
+  }))
+  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: () => void addProject(),
+  }))
 
 
   return (
   return (
     <div class="relative size-full flex flex-col gap-0">
     <div class="relative size-full flex flex-col gap-0">
@@ -1409,15 +1455,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       />
       />
       <Switch>
       <Switch>
         <Match when={USE_V2_INPUT}>
         <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
             <PromptDragOverlay
               type={store.draggingType}
               type={store.draggingType}
               label={language.t(
               label={language.t(
@@ -1450,7 +1497,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               onMouseDown={(e) => {
               onMouseDown={(e) => {
                 const target = e.target
                 const target = e.target
                 if (!(target instanceof HTMLElement)) return
                 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()
                 editorRef?.focus()
               }}
               }}
             >
             >
@@ -1515,29 +1562,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     aria-label={language.t("prompt.action.attachFile")}
                     aria-label={language.t("prompt.action.attachFile")}
                   />
                   />
                 </TooltipKeybind>
                 </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>
                 </Show>
-                {modelControl()}
+                <Show when={newSession() && !selectedProject()}>
+                  <ComposerPickerTrigger state={newProjectTriggerState()} />
+                </Show>
+                <ComposerModelControl state={modelControlState()} />
               </div>
               </div>
               <Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
               <Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
                 <IconButton
                 <IconButton
@@ -1556,7 +1587,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 />
                 />
               </Tooltip>
               </Tooltip>
             </div>
             </div>
-          </DockShellForm>
+            </DockShellForm>
+            <Show when={newSession() && selectedProject()}>
+              <div class="flex h-7 min-w-0 items-center gap-0 px-2">
+                <ComposerPicker state={projectPickerState()} />
+              </div>
+            </Show>
+          </div>
         </Match>
         </Match>
         <Match when>
         <Match when>
           <DockShellForm
           <DockShellForm
@@ -1892,3 +1929,226 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     </div>
     </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>
+  )
+}

+ 231 - 177
packages/app/src/components/session/session-header.tsx

@@ -24,7 +24,9 @@ import { useSessionLayout } from "@/pages/session/session-layout"
 import { messageAgentColor } from "@/utils/agent"
 import { messageAgentColor } from "@/utils/agent"
 import { decode64 } from "@/utils/base64"
 import { decode64 } from "@/utils/base64"
 import { Persist, persisted } from "@/utils/persist"
 import { Persist, persisted } from "@/utils/persist"
-import { StatusPopover } from "../status-popover"
+import { StatusPopover, StatusPopoverV2 } from "../status-popover"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
 
 
 const OPEN_APPS = [
 const OPEN_APPS = [
   "vscode",
   "vscode",
@@ -43,6 +45,8 @@ const OPEN_APPS = [
   "sublime-text",
   "sublime-text",
 ] as const
 ] as const
 
 
+const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
+
 type OpenApp = (typeof OPEN_APPS)[number]
 type OpenApp = (typeof OPEN_APPS)[number]
 type OS = "macos" | "windows" | "linux" | "unknown"
 type OS = "macos" | "windows" | "linux" | "unknown"
 
 
@@ -153,11 +157,11 @@ export function SessionHeader() {
   })
   })
   const hotkey = createMemo(() => command.keybind("file.open"))
   const hotkey = createMemo(() => command.keybind("file.open"))
   const os = createMemo(() => detectOS(platform))
   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>>>({
   const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
     finder: true,
     finder: true,
@@ -231,6 +235,14 @@ export function SessionHeader() {
   const tint = createMemo(() =>
   const tint = createMemo(() =>
     messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
     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) => {
   const selectApp = (app: OpenApp) => {
     if (!options().some((item) => item.id === app)) return
     if (!options().some((item) => item.id === app)) return
@@ -311,193 +323,235 @@ export function SessionHeader() {
       <Show when={rightMount()}>
       <Show when={rightMount()}>
         {(mount) => (
         {(mount) => (
           <Portal mount={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">
+            <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>
+                        }
+                      >
+                        <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(),
+                                }}
+                                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 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
                         <Button
                           variant="ghost"
                           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")}
+                          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 name="copy" size="small" class="text-icon-base" />
-                          <span class="text-12-regular text-text-strong">
-                            {language.t("session.header.open.copyPath")}
-                          </span>
+                          <Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
                         </Button>
                         </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">
+                      </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
                         <Button
                           variant="ghost"
                           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 })}
+                          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"
                         >
                         >
-                          <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>
+                          <Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
                         </Button>
                         </Button>
-                        <DropdownMenu
-                          gutter={4}
-                          placement="bottom-end"
-                          open={menu.open}
-                          onOpenChange={(open) => setMenu("open", open)}
+                      </TooltipKeybind>
+
+                      <Show when={tree()}>
+                        <TooltipKeybind
+                          title={language.t("command.fileTree.toggle")}
+                          keybind={command.keybind("fileTree.toggle")}
                         >
                         >
-                          <DropdownMenu.Trigger
-                            as={IconButton}
-                            icon="chevron-down"
+                          <Button
                             variant="ghost"
                             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()
+                            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 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>
+                          </Button>
+                        </TooltipKeybind>
+                      </Show>
                     </div>
                     </div>
-                  </Show>
+                  </div>
                 </div>
                 </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="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>
+              }
+            >
+              <SessionHeaderV2Actions state={v2ActionsState()} />
+            </Show>
           </Portal>
           </Portal>
         )}
         )}
       </Show>
       </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}>
+          <StatusPopoverV2 />
+        </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>
+  )
+}

+ 1 - 62
packages/app/src/components/session/session-new-design-view.tsx

@@ -1,43 +1,7 @@
 import type { JSX } from "solid-js"
 import type { JSX } from "solid-js"
-import { createMemo } from "solid-js"
-import { useNavigate } from "@solidjs/router"
-import { useServerSync } from "@/context/server-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"
 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 serverSync = useServerSync()
-  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 = serverSync.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 (
   return (
     <div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep">
     <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">
       <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" />
           <WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
           <div class="mt-8">
           <div class="mt-8">
             {props.children}
             {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>
         </div>
       </div>
       </div>

+ 11 - 12
packages/app/src/components/settings-general.tsx

@@ -445,25 +445,25 @@ export const SettingsGeneral: Component = () => {
         </SettingsRow>
         </SettingsRow>
 
 
         <SettingsRow
         <SettingsRow
-          title={language.t("settings.general.row.showTerminal.title")}
-          description={language.t("settings.general.row.showTerminal.description")}
+          title={language.t("settings.general.row.showStatus.title")}
+          description={language.t("settings.general.row.showStatus.description")}
         >
         >
-          <div data-action="settings-show-terminal">
+          <div data-action="settings-show-status">
             <Switch
             <Switch
-              checked={settings.general.showTerminal()}
-              onChange={(checked) => settings.general.setShowTerminal(checked)}
+              checked={settings.general.showStatus()}
+              onChange={(checked) => settings.general.setShowStatus(checked)}
             />
             />
           </div>
           </div>
         </SettingsRow>
         </SettingsRow>
 
 
         <SettingsRow
         <SettingsRow
-          title={language.t("settings.general.row.showStatus.title")}
-          description={language.t("settings.general.row.showStatus.description")}
+          title={language.t("settings.general.row.showCustomAgents.title")}
+          description={language.t("settings.general.row.showCustomAgents.description")}
         >
         >
-          <div data-action="settings-show-status">
+          <div data-action="settings-show-custom-agents">
             <Switch
             <Switch
-              checked={settings.general.showStatus()}
-              onChange={(checked) => settings.general.setShowStatus(checked)}
+              checked={settings.general.showCustomAgents()}
+              onChange={(checked) => settings.general.setShowCustomAgents(checked)}
             />
             />
           </div>
           </div>
         </SettingsRow>
         </SettingsRow>
@@ -781,7 +781,6 @@ export const SettingsGeneral: Component = () => {
     </Show>
     </Show>
   )
   )
 
 
-  console.log(import.meta.env)
   return (
   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="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)]">
       <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
@@ -803,7 +802,7 @@ export const SettingsGeneral: Component = () => {
 
 
         <DisplaySection />
         <DisplaySection />
 
 
-        <Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
+        <Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
           <AdvancedSection />
           <AdvancedSection />
         </Show>
         </Show>
       </div>
       </div>

+ 153 - 0
packages/app/src/components/status-popover-body.tsx

@@ -133,6 +133,159 @@ const useMcpToggleMutation = () => {
   }))
   }))
 }
 }
 
 
+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() {
+  const servers = useServers()
+  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 sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health))
+  const defaultServer = useDefaultServerKey(platform.getDefaultServer)
+  const serverItems = createMemo(() =>
+    sortedServers().map((conn) => {
+      const key = ServerConnection.key(conn)
+      return {
+        key,
+        conn,
+        health: servers.health[key],
+        blocked: servers.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> }) {
 export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
   const sync = useSync()
   const sync = useSync()
   const servers = useServers()
   const servers = useServers()

+ 132 - 1
packages/app/src/components/status-popover.tsx

@@ -1,13 +1,16 @@
 import { Button } from "@opencode-ai/ui/button"
 import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
 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 { 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 { useLanguage } from "@/context/language"
 import { useServer } from "@/context/server"
 import { useServer } from "@/context/server"
 import { useSync } from "@/context/sync"
 import { useSync } from "@/context/sync"
 import { useServers } from "@/context/servers"
 import { useServers } from "@/context/servers"
 
 
 const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
 const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
+const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
 
 
 export function StatusPopover() {
 export function StatusPopover() {
   const language = useLanguage()
   const language = useLanguage()
@@ -70,3 +73,131 @@ export function StatusPopover() {
     </Popover>
     </Popover>
   )
   )
 }
 }
+
+export function StatusPopoverV2(props: { scope?: "server" }) {
+  if (props.scope === "server") return <ServerStatusPopover />
+  return <DirectoryStatusPopover />
+}
+
+function DirectoryStatusPopover() {
+  const language = useLanguage()
+  const server = useServer()
+  const servers = useServers()
+  const sync = useSync()
+  const [shown, setShown] = createSignal(false)
+  const serverHealth = () => servers.health[server.key]?.healthy
+  const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready)
+  const mcpIssue = createMemo(() => {
+    const mcp = Object.values(sync.data.mcp ?? {})
+    const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
+    const warn = mcp.some((item) => item.status === "needs_auth")
+    if (failed) return "critical" as const
+    if (warn) return "warning" as const
+  })
+  const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
+  const state = createMemo<StatusPopoverState>(() => ({
+    shown: shown(),
+    ready: ready(),
+    healthy: healthy(),
+    serverHealth: serverHealth(),
+    issue: mcpIssue(),
+    label: language.t("status.popover.trigger"),
+    onOpenChange: setShown,
+    body: () => (
+      <StatusPopoverBody shown={shown()}>
+        <Body shown={shown} />
+      </StatusPopoverBody>
+    ),
+  }))
+
+  return <StatusPopoverView state={state()} />
+}
+
+function ServerStatusPopover() {
+  const language = useLanguage()
+  const server = useServer()
+  const servers = useServers()
+  const [shown, setShown] = createSignal(false)
+  const serverHealth = () => servers.health[server.key]?.healthy
+  const state = createMemo<StatusPopoverState>(() => ({
+    shown: shown(),
+    ready: serverHealth() !== undefined,
+    healthy: serverHealth() === true,
+    serverHealth: serverHealth(),
+    label: language.t("status.popover.trigger"),
+    onOpenChange: setShown,
+    body: () => (
+      <StatusPopoverBody shown={shown()}>
+        <ServerBody />
+      </StatusPopoverBody>
+    ),
+  }))
+
+  return <StatusPopoverView state={state()} />
+}
+
+type StatusPopoverState = {
+  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,
+  }
+
+  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>
+  )
+}

+ 59 - 31
packages/app/src/components/titlebar.tsx

@@ -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 { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
 import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
 import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { makeEventListener } from "@solid-primitives/event-listener"
-import { StatusPopover } from "./status-popover"
-import { SDKProvider } from "@/context/sdk"
+import { StatusPopoverV2 } from "@/components/status-popover"
 
 
 type TauriDesktopWindow = {
 type TauriDesktopWindow = {
   startDragging?: () => Promise<void>
   startDragging?: () => Promise<void>
@@ -115,7 +114,23 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
   const canBack = createMemo(() => history.index > 0)
   const canBack = createMemo(() => history.index > 0)
   const canForward = createMemo(() => history.index < history.stack.length - 1)
   const canForward = createMemo(() => history.index < history.stack.length - 1)
   const hasProjects = createMemo(() => layout.projects.list().length > 0)
   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 back = () => {
     const next = backPath(history)
     const next = backPath(history)
@@ -465,16 +480,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                   </Show>
                   </Show>
                   <div class="min-w-0 flex-1" />
                   <div class="min-w-0 flex-1" />
                 </div>
                 </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()}>
                 <Show when={windows() && !electronWindows()}>
                   <div data-tauri-decorum-tb class="flex flex-row" />
                   <div data-tauri-decorum-tb class="flex flex-row" />
                 </Show>
                 </Show>
@@ -641,30 +647,52 @@ 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 (
   return (
-    <Show when={version() !== undefined}>
+    <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}>
+          <StatusPopoverV2 scope="server" />
+        </Tooltip>
+      </Show>
+      <div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
+    </div>
+  )
+}
+
+function TitlebarUpdatePill(props: { state: TitlebarUpdatePillState }) {
+  return (
+    <Show when={props.state.visible}>
       <button
       <button
         type="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}
+        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}
       >
       >
-        Update
+        {props.state.label}
       </button>
       </button>
     </Show>
     </Show>
   )
   )
 }
 }
 
 
-function DesktopTitlebarIconButton(props: Parameters<typeof IconButtonV2>[0]) {
-  return
-}
-
 function TabNavItem(props: {
 function TabNavItem(props: {
   href: string
   href: string
   title: string
   title: string
@@ -682,10 +710,10 @@ function TabNavItem(props: {
     >
     >
       <a
       <a
         href={props.href}
         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} />
         <ProjectTabAvatar project={props.project} directory={props.directory} />
-        <span class="text-clip">{props.title}</span>
+        <span class="text-clip leading-5">{props.title}</span>
       </a>
       </a>
 
 
       <div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
       <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
       <a
         href={props.href}
         href={props.href}
         aria-current="page"
         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">
         <span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
           <IconV2 name="edit" />
           <IconV2 name="edit" />
         </span>
         </span>
-        <span class="truncate">{props.title}</span>
+        <span class="truncate leading-5">{props.title}</span>
       </a>
       </a>
       <div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
       <div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
         <IconButtonV2
         <IconButtonV2

+ 6 - 0
packages/app/src/context/settings.tsx

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

+ 8 - 1
packages/app/src/i18n/en.ts

@@ -221,6 +221,7 @@ export const dict = {
   "common.loading": "Loading",
   "common.loading": "Loading",
   "common.loading.ellipsis": "...",
   "common.loading.ellipsis": "...",
   "common.cancel": "Cancel",
   "common.cancel": "Cancel",
+  "common.clear": "Clear",
   "common.open": "Open",
   "common.open": "Open",
   "common.connect": "Connect",
   "common.connect": "Connect",
   "common.disconnect": "Disconnect",
   "common.disconnect": "Disconnect",
@@ -531,6 +532,7 @@ export const dict = {
   "home.project.add": "Add project",
   "home.project.add": "Add project",
   "home.sessions.search.placeholder": "Search sessions",
   "home.sessions.search.placeholder": "Search sessions",
   "home.sessions.empty": "No sessions found",
   "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.today": "Today",
   "home.sessions.group.yesterday": "Yesterday",
   "home.sessions.group.yesterday": "Yesterday",
   "home.sessions.group.older": "Older",
   "home.sessions.group.older": "Older",
@@ -584,6 +586,9 @@ export const dict = {
   "session.revertDock.restore": "Restore message",
   "session.revertDock.restore": "Restore message",
 
 
   "session.new.title": "Build anything",
   "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.worktree.main": "Main branch",
   "session.new.worktree.main": "Main branch",
   "session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
   "session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
   "session.new.worktree.create": "Create new worktree",
   "session.new.worktree.create": "Create new worktree",
@@ -764,7 +769,7 @@ export const dict = {
   "settings.general.row.followup.option.queue": "Queue",
   "settings.general.row.followup.option.queue": "Queue",
   "settings.general.row.followup.option.steer": "Steer",
   "settings.general.row.followup.option.steer": "Steer",
   "settings.general.row.showFileTree.title": "File tree",
   "settings.general.row.showFileTree.title": "File tree",
-  "settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions",
+  "settings.general.row.showFileTree.description": "Show the file tree panel in desktop sessions",
   "settings.general.row.showNavigation.title": "Navigation controls",
   "settings.general.row.showNavigation.title": "Navigation controls",
   "settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
   "settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
   "settings.general.row.showSearch.title": "Command palette",
   "settings.general.row.showSearch.title": "Command palette",
@@ -773,6 +778,8 @@ export const dict = {
   "settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
   "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.title": "Server status",
   "settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
   "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.title": "Show reasoning summaries",
   "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
   "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
   "settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
   "settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",

+ 1 - 1
packages/app/src/i18n/uk.ts

@@ -760,7 +760,7 @@ export const dict = {
   "settings.general.row.followup.option.steer": "Керування",
   "settings.general.row.followup.option.steer": "Керування",
   "settings.general.row.showFileTree.title": "Дерево файлів",
   "settings.general.row.showFileTree.title": "Дерево файлів",
   "settings.general.row.showFileTree.description":
   "settings.general.row.showFileTree.description":
-    "Показувати перемикач і панель дерева файлів у сесіях на робочому столі",
+    "Показувати панель дерева файлів у сесіях на робочому столі",
   "settings.general.row.showNavigation.title": "Елементи навігації",
   "settings.general.row.showNavigation.title": "Елементи навігації",
   "settings.general.row.showNavigation.description": "Показувати кнопки назад і вперед у заголовку робочого столу",
   "settings.general.row.showNavigation.description": "Показувати кнопки назад і вперед у заголовку робочого столу",
   "settings.general.row.showSearch.title": "Палітра команд",
   "settings.general.row.showSearch.title": "Палітра команд",

+ 238 - 55
packages/app/src/pages/home.tsx

@@ -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 { ButtonV2 } from "@opencode-ai/ui/v2/components/button-v2.jsx"
 import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.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 { 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 { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
 import { useNavigate } from "@solidjs/router"
 import { useNavigate } from "@solidjs/router"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { base64Encode } from "@opencode-ai/core/util/encode"
@@ -63,16 +64,18 @@ function HomeDesign() {
   const navigate = useNavigate()
   const navigate = useNavigate()
   const server = useServer()
   const server = useServer()
   const language = useLanguage()
   const language = useLanguage()
+  const notification = useNotification()
   const [state, setState] = createStore({ search: "", project: undefined as string | undefined })
   const [state, setState] = createStore({ search: "", project: undefined as string | undefined })
 
 
   const projects = createMemo(() => layout.projects.list())
   const projects = createMemo(() => layout.projects.list())
   const selectedProject = createMemo(
   const selectedProject = createMemo(
     () => projects().find((project) => project.worktree === state.project) ?? projects()[0],
     () => projects().find((project) => project.worktree === state.project) ?? projects()[0],
   )
   )
+  const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
   const projectDirectories = createMemo(() => {
   const projectDirectories = createMemo(() => {
     const project = selectedProject()
     const project = selectedProject()
     if (!project) return []
     if (!project) return []
-    return [project.worktree, ...(project.sandboxes ?? [])]
+    return directories(project)
   })
   })
   const search = createMemo(() => state.search.trim())
   const search = createMemo(() => state.search.trim())
   const sessionLoad = useQuery(() => ({
   const sessionLoad = useQuery(() => ({
@@ -135,6 +138,26 @@ function HomeDesign() {
     navigate(`/${base64Encode(project.worktree)}/session`)
     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} />)
+    })
+  }
+
+  const unseenCount = (project: LocalProject) =>
+    directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
+
+  const clearNotifications = (project: LocalProject) =>
+    directories(project)
+      .filter((directory) => notification.project.unseenCount(directory) > 0)
+      .forEach((directory) => notification.project.markViewed(directory))
+
   function openSession(session: Session) {
   function openSession(session: Session) {
     const project = projectForSession(session, projects(), projectByID())
     const project = projectForSession(session, projects(), projectByID())
     layout.projects.open(project?.worktree ?? session.directory)
     layout.projects.open(project?.worktree ?? session.directory)
@@ -178,7 +201,15 @@ function HomeDesign() {
       <HomeProjectColumn
       <HomeProjectColumn
         selectedProject={state.project}
         selectedProject={state.project}
         selectProject={selectProject}
         selectProject={selectProject}
+        openNewSession={openProjectNewSession}
         chooseProject={() => void chooseProject()}
         chooseProject={() => void chooseProject()}
+        editProject={showEditProjectDialog}
+        closeProject={(directory) => {
+          layout.projects.close(directory)
+          if (state.project === directory) setState("project", undefined)
+        }}
+        clearNotifications={clearNotifications}
+        unseenCount={unseenCount}
         openSettings={openSettings}
         openSettings={openSettings}
         openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
         openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
         language={language}
         language={language}
@@ -188,41 +219,60 @@ function HomeDesign() {
         class="min-w-0 flex-1 flex flex-col overflow-y-hidden pt-12"
         class="min-w-0 flex-1 flex flex-col overflow-y-hidden pt-12"
         aria-label={language.t("sidebar.project.recentSessions")}
         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>
-                    </div>
-                  )}
-                </For>
+                    )}
+                  </For>
+                </Show>
               </Show>
               </Show>
-            </Show>
+            </div>
           </div>
           </div>
-        </div>
+        </Show>
       </section>
       </section>
     </div>
     </div>
   )
   )
@@ -231,7 +281,12 @@ function HomeDesign() {
 function HomeProjectColumn(props: {
 function HomeProjectColumn(props: {
   selectedProject?: string
   selectedProject?: string
   selectProject: (directory: string) => void
   selectProject: (directory: string) => void
+  openNewSession: (directory: string) => void
   chooseProject: () => void
   chooseProject: () => void
+  editProject: (project: LocalProject) => void
+  closeProject: (directory: string) => void
+  clearNotifications: (project: LocalProject) => void
+  unseenCount: (project: LocalProject) => number
   openSettings: () => void
   openSettings: () => void
   openHelp: () => void
   openHelp: () => void
   language: ReturnType<typeof useLanguage>
   language: ReturnType<typeof useLanguage>
@@ -239,10 +294,6 @@ function HomeProjectColumn(props: {
   const servers = useServers()
   const servers = useServers()
   const layout = useLayout()
   const layout = useLayout()
   const projects = createMemo(() => layout.projects.list())
   const projects = createMemo(() => layout.projects.list())
-  const selectedProject = createMemo(
-    () => projects().find((project) => project.worktree === props.selectedProject) ?? projects()[0],
-  )
-
   return (
   return (
     <aside class="flex min-w-0 flex-col lg:pt-[52px]" aria-label={props.language.t("home.projects")}>
     <aside class="flex min-w-0 flex-col lg:pt-[52px]" aria-label={props.language.t("home.projects")}>
       <div class="flex h-7 min-w-0 items-center justify-between pl-3">
       <div class="flex h-7 min-w-0 items-center justify-between pl-3">
@@ -265,6 +316,12 @@ function HomeProjectColumn(props: {
             selectedProject={props.selectedProject}
             selectedProject={props.selectedProject}
             onSelectedProjectChange={props.selectProject}
             onSelectedProjectChange={props.selectProject}
             onChooseProject={props.chooseProject}
             onChooseProject={props.chooseProject}
+            openNewSession={props.openNewSession}
+            editProject={props.editProject}
+            closeProject={props.closeProject}
+            clearNotifications={props.clearNotifications}
+            unseenCount={props.unseenCount}
+            language={props.language}
           />
           />
         }
         }
       >
       >
@@ -310,6 +367,12 @@ function HomeProjectColumn(props: {
                   selectedProject={props.selectedProject}
                   selectedProject={props.selectedProject}
                   onSelectedProjectChange={props.selectProject}
                   onSelectedProjectChange={props.selectProject}
                   onChooseProject={props.chooseProject}
                   onChooseProject={props.chooseProject}
+                  openNewSession={props.openNewSession}
+                  editProject={props.editProject}
+                  closeProject={props.closeProject}
+                  clearNotifications={props.clearNotifications}
+                  unseenCount={props.unseenCount}
+                  language={props.language}
                 />
                 />
               </Show>
               </Show>
             </div>
             </div>
@@ -338,6 +401,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: (project: LocalProject) => 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)}
+              >
+                <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 }) {
 function HomeProjectAvatar(props: { project: LocalProject }) {
   const name = createMemo(() => displayName(props.project))
   const name = createMemo(() => displayName(props.project))
   return (
   return (
@@ -352,7 +492,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 (
   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)]">
     <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" />
       <IconV2 name="magnifying-glass" size="small" />
@@ -363,10 +509,46 @@ function HomeSessionSearch(props: { value: string; placeholder: string; onInput:
         aria-label={props.placeholder}
         aria-label={props.placeholder}
         onInput={(event) => props.onInput(event.currentTarget.value)}
         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>
     </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 }) {
 function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
   const language = useLanguage()
   const language = useLanguage()
   return (
   return (
@@ -621,9 +803,13 @@ function ProjectList(props: {
   selectedProject?: string
   selectedProject?: string
   onSelectedProjectChange?(project: string): void
   onSelectedProjectChange?(project: string): void
   onChooseProject?(): void
   onChooseProject?(): void
+  openNewSession: (directory: string) => void
+  editProject: (project: LocalProject) => void
+  closeProject: (directory: string) => void
+  clearNotifications: (project: LocalProject) => void
+  unseenCount: (project: LocalProject) => number
+  language: ReturnType<typeof useLanguage>
 }) {
 }) {
-  const language = useLanguage()
-
   return (
   return (
     <Show
     <Show
       when={props.projects.length > 0}
       when={props.projects.length > 0}
@@ -634,27 +820,24 @@ function ProjectList(props: {
           onClick={() => props.onChooseProject?.()}
           onClick={() => props.onChooseProject?.()}
         >
         >
           <IconV2 name="folder-add-left" size="small" />
           <IconV2 name="folder-add-left" size="small" />
-          <span>{language.t("home.project.add")}</span>
+          <span>{props.language.t("home.project.add")}</span>
         </button>
         </button>
       }
       }
     >
     >
       <div class="flex flex-col gap-1">
       <div class="flex flex-col gap-1">
         <For each={props.projects}>
         <For each={props.projects}>
           {(project) => (
           {(project) => (
-            <button
-              type="button"
-              data-component="home-project-row"
-              class={HOME_PROJECT_NAV_ROW}
-              classList={{
-                "bg-v2-overlay-simple-overlay-hover": props.selectedProject === project.worktree,
-              }}
-              data-selected={props.selectedProject === project.worktree ? "" : undefined}
-              aria-current={props.selectedProject === project.worktree ? "page" : undefined}
-              onClick={() => props.onSelectedProjectChange?.(project.worktree)}
-            >
-              <HomeProjectAvatar project={project} />
-              <span>{displayName(project)}</span>
-            </button>
+            <HomeProjectRow
+              project={project}
+              selected={props.selectedProject === project.worktree}
+              unseenCount={props.unseenCount(project)}
+              selectProject={(directory) => props.onSelectedProjectChange?.(directory)}
+              openNewSession={props.openNewSession}
+              editProject={props.editProject}
+              closeProject={props.closeProject}
+              clearNotifications={props.clearNotifications}
+              language={props.language}
+            />
           )}
           )}
         </For>
         </For>
       </div>
       </div>

+ 1 - 2
packages/app/src/pages/session.tsx

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

+ 0 - 2
packages/app/src/pages/session/composer/session-composer-region.tsx

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

+ 4 - 6
packages/app/src/pages/session/session-side-panel.tsx

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

+ 4 - 4
packages/app/src/pages/session/use-session-commands.tsx

@@ -20,6 +20,8 @@ import { extractPromptFromParts } from "@/utils/prompt"
 import { UserMessage } from "@opencode-ai/sdk/v2"
 import { UserMessage } from "@opencode-ai/sdk/v2"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { useSessionLayout } from "@/pages/session/session-layout"
 
 
+const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
+
 export type SessionCommandContext = {
 export type SessionCommandContext = {
   navigateMessageByOffset: (offset: number) => void
   navigateMessageByOffset: (offset: number) => void
   setActiveMessage: (message: UserMessage | undefined) => void
   setActiveMessage: (message: UserMessage | undefined) => void
@@ -70,10 +72,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   })
   })
   const activeFileTab = tabState.activeFileTab
   const activeFileTab = tabState.activeFileTab
   const closableTab = tabState.closableTab
   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 messages = () => {
     const id = params.id
     const id = params.id

+ 12 - 0
packages/ui/src/v2/components/icon.tsx

@@ -17,6 +17,18 @@ const icons = {
     viewBox: "0 0 20 20",
     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"/>`,
     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": {
   "magnifying-glass": {
     viewBox: "0 0 16 16",
     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"/>`,
     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"/>`,