Преглед изворни кода

feat(app): add desktop v2 home, session entry, and titlebar (#28442)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
Luke Parker пре 2 месеци
родитељ
комит
b207e32249

+ 30 - 17
packages/app/e2e/smoke/session-timeline.spec.ts

@@ -1,4 +1,5 @@
 import { expect, test, type Page } from "@playwright/test"
+import { base64Encode } from "@opencode-ai/core/util/encode"
 import { fixture, pageMessages } from "./session-timeline.fixture"
 import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
 import { mockOpenCodeServer } from "../utils/mock-server"
@@ -37,12 +38,12 @@ test.describe("smoke: session timeline", () => {
       project: fixture.project,
       pageMessages,
     })
-    await configureSmokePage(page)
+    await configureSmokePage(page, fixture.directory)
 
-    await openProject(page, "SmokeProject")
-    await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
-    await expectSessionReady(page, "smoke-project")
-    await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
+    await selectHomeProject(page, fixture.project.name)
+    await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle)
+    await expectSessionReady(page)
+    await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
     const expectedPartIDs = fixture.expected.targetPartIDs
     const expectedMessageIDs = fixture.expected.targetMessageIDs
     await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
@@ -50,7 +51,7 @@ test.describe("smoke: session timeline", () => {
   })
 })
 
-async function configureSmokePage(page: Page) {
+async function configureSmokePage(page: Page, directory: string) {
   await page.addInitScript(() => {
     localStorage.setItem(
       "settings.v3",
@@ -63,7 +64,23 @@ async function configureSmokePage(page: Page) {
         },
       }),
     )
+  })
 
+  await page.addInitScript((directory) => {
+    localStorage.setItem(
+      "opencode.global.dat:server",
+      JSON.stringify({
+        projects: {
+          local: [{ worktree: directory, expanded: true }],
+        },
+        lastProject: {
+          local: directory,
+        },
+      }),
+    )
+  }, directory)
+
+  await page.addInitScript(() => {
     const smoke = window as SmokeWindow
     smoke.__timelineSmokeErrorToasts = []
     smoke.__timelineSmokeForbiddenText = []
@@ -392,21 +409,17 @@ function expectCompleteScroll(
   expect(expectedPartIDs.length).toBe(331)
 }
 
-async function openProject(page: Page, projectName: string) {
+async function selectHomeProject(page: Page, projectName: string) {
   await page.goto("/")
-  await page.getByRole("button", { name: new RegExp(projectName, "i") }).click()
+  await page.locator('[data-component="home-project-row"]').filter({ hasText: new RegExp(projectName, "i") }).click()
+  await expect(page).toHaveURL(/\/$/)
 }
 
-async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
-  // Use evaluate to click to avoid strict visibility/animation issues during rapid e2e navigation
-  await page
-    .locator(`a[href*="${sessionId}"]`)
-    .first()
-    .evaluate((el) => (el as HTMLElement).click())
+async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
+  await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
   await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
 }
 
-async function expectSessionReady(page: Page, projectName: string) {
-  await expect(page.getByText(projectName).first()).toBeVisible()
-  await expect(page.getByText("Ask anything...")).toBeVisible()
+async function expectSessionReady(page: Page) {
+  await expect(page.getByRole("textbox", { name: /Ask anything/i })).toBeVisible()
 }

+ 1 - 1
packages/app/src/components/dialog-edit-project.tsx

@@ -12,7 +12,7 @@ import { type LocalProject, getAvatarColors } from "@/context/layout"
 import { getFilename } from "@opencode-ai/core/util/path"
 import { Avatar } from "@opencode-ai/ui/avatar"
 import { useLanguage } from "@/context/language"
-import { getProjectAvatarSource } from "@/pages/layout/sidebar-items"
+import { getProjectAvatarSource } from "@/pages/layout/helpers"
 
 const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
 

+ 553 - 274
packages/app/src/components/prompt-input.tsx

@@ -1,6 +1,17 @@
 import { useFilteredList } from "@opencode-ai/ui/hooks"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
-import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal, createResource } from "solid-js"
+import {
+  createEffect,
+  on,
+  Component,
+  Show,
+  onCleanup,
+  createMemo,
+  createSignal,
+  createResource,
+  Switch,
+  Match,
+} from "solid-js"
 import { createStore } from "solid-js/store"
 import { useLocal } from "@/context/local"
 import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
@@ -57,11 +68,14 @@ import { ImagePreview } from "@opencode-ai/ui/image-preview"
 import { useQueries } from "@tanstack/solid-query"
 import { useQueryOptions } from "@/context/global-sync"
 import { pathKey } from "@/utils/path-key"
+import { getFilename } from "@opencode-ai/core/util/path"
 
 interface PromptInputProps {
   class?: string
+  variant?: "dock" | "new-session"
   ref?: (el: HTMLDivElement) => void
   newSessionWorktree?: string
+  onNewSessionWorktreeChange?: (worktree: string) => void
   onNewSessionWorktreeReset?: () => void
   edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
   onEditLoaded?: () => void
@@ -99,6 +113,9 @@ const EXAMPLES = [
   "prompt.example.25",
 ] as const
 
+const MAIN_WORKTREE = "main"
+const CREATE_WORKTREE = "create"
+
 export const PromptInput: Component<PromptInputProps> = (props) => {
   const sdk = useSDK()
   const queryOptions = useQueryOptions()
@@ -1055,6 +1072,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     readClipboardImage: platform.readClipboardImage,
   })
 
+  const fileAttachmentInput = () => (
+    <input
+      ref={(el) => (fileInputRef = el)}
+      type="file"
+      multiple
+      accept={ACCEPTED_FILE_TYPES.join(",")}
+      class="hidden"
+      onChange={(e) => {
+        const list = e.currentTarget.files
+        if (list) void addAttachments(Array.from(list))
+        e.currentTarget.value = ""
+      }}
+    />
+  )
+
   const variants = createMemo(() => ["default", ...local.model.variant.list()])
   const accepting = createMemo(() => {
     const id = params.id
@@ -1266,8 +1298,99 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     (p) => p,
   )
 
+  const designPlaceholder = () => {
+    if (store.mode === "shell") return placeholder()
+    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 newSession = () => props.variant === "new-session"
+  const worktrees = createMemo(() => [MAIN_WORKTREE, ...(sync.project?.sandboxes ?? []), CREATE_WORKTREE])
+  const currentWorktree = createMemo(() => {
+    if (worktrees().includes(props.newSessionWorktree ?? MAIN_WORKTREE))
+      return props.newSessionWorktree ?? MAIN_WORKTREE
+    return MAIN_WORKTREE
+  })
+  const worktreeLabel = (value: string) => {
+    if (value === MAIN_WORKTREE) return MAIN_WORKTREE
+    if (value === CREATE_WORKTREE) return language.t("session.new.worktree.create")
+    return getFilename(value)
+  }
+
+  const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
+
   return (
-    <div class="relative size-full _max-h-[320px] flex flex-col gap-0">
+    <div class="relative size-full flex flex-col gap-0">
       {(promptReady(), null)}
       <PromptPopover
         popover={store.popover}
@@ -1284,121 +1407,138 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         commandKeybind={command.keybind}
         t={(key) => language.t(key as Parameters<typeof language.t>[0])}
       />
-      <DockShellForm
-        onSubmit={handleSubmit}
-        classList={{
-          "group/prompt-input": true,
-          "focus-within:shadow-xs-border": true,
-          "border-icon-info-active border-dashed": store.draggingType !== null,
-          [props.class ?? ""]: !!props.class,
-        }}
-      >
-        <PromptDragOverlay
-          type={store.draggingType}
-          label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
-        />
-        <PromptContextItems
-          items={contextItems()}
-          active={(item) => {
-            const active = comments.active()
-            return !!item.commentID && item.commentID === active?.id && item.path === active?.file
-          }}
-          openComment={openComment}
-          remove={(item) => {
-            if (item.commentID) comments.remove(item.path, item.commentID)
-            prompt.context.remove(item.key)
-          }}
-          t={(key) => language.t(key as Parameters<typeof language.t>[0])}
-        />
-        <PromptImageAttachments
-          attachments={imageAttachments()}
-          onOpen={(attachment) =>
-            dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
-          }
-          onRemove={removeAttachment}
-          removeLabel={language.t("prompt.attachment.remove")}
-        />
-        <div
-          class="relative"
-          onMouseDown={(e) => {
-            const target = e.target
-            if (!(target instanceof HTMLElement)) return
-            if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
-              return
-            }
-            editorRef?.focus()
-          }}
-        >
-          <div
-            class="relative max-h-[240px] overflow-y-auto no-scrollbar"
-            ref={(el) => (scrollRef = el)}
-            style={{ "scroll-padding-bottom": space }}
+      <Switch>
+        <Match when={USE_V2_INPUT}>
+          <DockShellForm
+            data-component={newSession() ? "session-new-composer" : "session-composer"}
+            onSubmit={handleSubmit}
+            classList={{
+              "group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
+              "border-icon-info-active border-dashed": store.draggingType !== null,
+              [props.class ?? ""]: !!props.class,
+            }}
           >
-            <div
-              data-component="prompt-input"
-              ref={(el) => {
-                editorRef = el
-                props.ref?.(el)
+            <PromptDragOverlay
+              type={store.draggingType}
+              label={language.t(
+                store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
+              )}
+            />
+            <PromptContextItems
+              items={contextItems()}
+              active={(item) => {
+                const active = comments.active()
+                return !!item.commentID && item.commentID === active?.id && item.path === active?.file
               }}
-              role="textbox"
-              aria-multiline="true"
-              aria-label={placeholder()}
-              contenteditable="true"
-              autocapitalize={store.mode === "normal" ? "sentences" : "off"}
-              autocorrect={store.mode === "normal" ? "on" : "off"}
-              spellcheck={store.mode === "normal"}
-              inputMode="text"
-              // @ts-expect-error
-              autocomplete="off"
-              onInput={handleInput}
-              onPaste={handlePaste}
-              onCompositionStart={handleCompositionStart}
-              onCompositionEnd={handleCompositionEnd}
-              onBlur={handleBlur}
-              onKeyDown={handleKeyDown}
-              classList={{
-                "select-text": true,
-                "w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
-                "[&_[data-type=file]]:text-syntax-property": true,
-                "[&_[data-type=agent]]:text-syntax-type": true,
-                "font-mono!": store.mode === "shell",
+              openComment={openComment}
+              remove={(item) => {
+                if (item.commentID) comments.remove(item.path, item.commentID)
+                prompt.context.remove(item.key)
               }}
-              style={{ "padding-bottom": space }}
+              t={(key) => language.t(key as Parameters<typeof language.t>[0])}
+            />
+            <PromptImageAttachments
+              attachments={imageAttachments()}
+              onOpen={(attachment) =>
+                dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
+              }
+              onRemove={removeAttachment}
+              removeLabel={language.t("prompt.attachment.remove")}
             />
             <div
-              class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
-              classList={{ "font-mono!": store.mode === "shell" }}
-              style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }}
+              class="relative min-h-[52px]"
+              onMouseDown={(e) => {
+                const target = e.target
+                if (!(target instanceof HTMLElement)) return
+                if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) return
+                editorRef?.focus()
+              }}
             >
-              {placeholder()}
+              <div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
+                <div
+                  data-component="prompt-input"
+                  ref={(el) => {
+                    editorRef = el
+                    props.ref?.(el)
+                  }}
+                  role="textbox"
+                  aria-multiline="true"
+                  aria-label={designPlaceholder()}
+                  contenteditable="true"
+                  autocapitalize={store.mode === "normal" ? "sentences" : "off"}
+                  autocorrect={store.mode === "normal" ? "on" : "off"}
+                  spellcheck={store.mode === "normal"}
+                  inputMode="text"
+                  // @ts-expect-error
+                  autocomplete="off"
+                  onInput={handleInput}
+                  onPaste={handlePaste}
+                  onCompositionStart={handleCompositionStart}
+                  onCompositionEnd={handleCompositionEnd}
+                  onBlur={handleBlur}
+                  onKeyDown={handleKeyDown}
+                  classList={{
+                    "select-text": true,
+                    "min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]": true,
+                    "[&_[data-type=file]]:text-syntax-property": true,
+                    "[&_[data-type=agent]]:text-syntax-type": true,
+                    "font-mono!": store.mode === "shell",
+                  }}
+                />
+                <div
+                  data-component={newSession() ? "session-new-design-text" : "session-composer-text"}
+                  class="absolute top-0 inset-x-0 px-4 pt-4 pointer-events-none whitespace-nowrap truncate leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]"
+                  classList={{ "font-mono!": store.mode === "shell", hidden: prompt.dirty() }}
+                >
+                  {designPlaceholder()}
+                </div>
+              </div>
             </div>
-          </div>
-
-          <div
-            aria-hidden="true"
-            class="pointer-events-none absolute inset-x-0 bottom-0"
-            style={{
-              height: space,
-              background:
-                "linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
-            }}
-          />
-
-          <div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
-            <input
-              ref={fileInputRef}
-              type="file"
-              multiple
-              accept={ACCEPTED_FILE_TYPES.join(",")}
-              class="hidden"
-              onChange={(e) => {
-                const list = e.currentTarget.files
-                if (list) void addAttachments(Array.from(list))
-                e.currentTarget.value = ""
-              }}
-            />
-
-            <div class="flex items-center gap-1 pointer-events-auto">
+            <div class="flex h-11 items-center px-2">
+              <div class="flex min-w-0 flex-1 items-center gap-0">
+                {fileAttachmentInput()}
+                <TooltipKeybind
+                  placement="top"
+                  title={language.t("prompt.action.attachFile")}
+                  keybind={command.keybind("file.attach")}
+                >
+                  <IconButton
+                    data-action="prompt-attach"
+                    type="button"
+                    icon="plus"
+                    variant="ghost"
+                    class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
+                    style={buttons()}
+                    onClick={pick}
+                    disabled={store.mode !== "normal"}
+                    tabIndex={store.mode === "normal" ? undefined : -1}
+                    aria-label={language.t("prompt.action.attachFile")}
+                  />
+                </TooltipKeybind>
+                <Show when={newSession()}>
+                  <div class="relative">
+                    <div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center">
+                      <Icon name="sliders" size="small" />
+                    </div>
+                    <Select
+                      size="normal"
+                      options={worktrees()}
+                      current={currentWorktree()}
+                      label={worktreeLabel}
+                      onSelect={(value) => {
+                        if (value) props.onNewSessionWorktreeChange?.(value)
+                        restoreFocus()
+                      }}
+                      class="max-w-[175px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
+                      valueClass="truncate pl-5 text-[13px] font-[440] leading-4 text-v2-text-text-faint"
+                      triggerStyle={control()}
+                      triggerProps={{ "data-action": "prompt-workspace" }}
+                      variant="ghost"
+                    />
+                  </div>
+                </Show>
+                {modelControl()}
+              </div>
               <Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
                 <IconButton
                   data-action="prompt-submit"
@@ -1407,209 +1547,348 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   tabIndex={store.mode === "normal" ? undefined : -1}
                   icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
                   variant="primary"
-                  class="size-8"
+                  class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
+                  style={{
+                    "background-image":
+                      "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
+                  }}
                   aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
                 />
               </Tooltip>
             </div>
-          </div>
-
-          <div class="pointer-events-none absolute bottom-2 left-2">
+          </DockShellForm>
+        </Match>
+        <Match when>
+          <DockShellForm
+            onSubmit={handleSubmit}
+            classList={{
+              "group/prompt-input": true,
+              "focus-within:shadow-xs-border": true,
+              "border-icon-info-active border-dashed": store.draggingType !== null,
+              [props.class ?? ""]: !!props.class,
+            }}
+          >
+            <PromptDragOverlay
+              type={store.draggingType}
+              label={language.t(
+                store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
+              )}
+            />
+            <PromptContextItems
+              items={contextItems()}
+              active={(item) => {
+                const active = comments.active()
+                return !!item.commentID && item.commentID === active?.id && item.path === active?.file
+              }}
+              openComment={openComment}
+              remove={(item) => {
+                if (item.commentID) comments.remove(item.path, item.commentID)
+                prompt.context.remove(item.key)
+              }}
+              t={(key) => language.t(key as Parameters<typeof language.t>[0])}
+            />
+            <PromptImageAttachments
+              attachments={imageAttachments()}
+              onOpen={(attachment) =>
+                dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
+              }
+              onRemove={removeAttachment}
+              removeLabel={language.t("prompt.attachment.remove")}
+            />
             <div
-              aria-hidden={store.mode !== "normal"}
-              class="pointer-events-auto"
-              style={{
-                "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
+              class="relative"
+              onMouseDown={(e) => {
+                const target = e.target
+                if (!(target instanceof HTMLElement)) return
+                if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
+                  return
+                }
+                editorRef?.focus()
               }}
             >
-              <TooltipKeybind
-                placement="top"
-                title={language.t("prompt.action.attachFile")}
-                keybind={command.keybind("file.attach")}
+              <div
+                class="relative max-h-[240px] overflow-y-auto no-scrollbar"
+                ref={(el) => (scrollRef = el)}
+                style={{ "scroll-padding-bottom": space }}
               >
-                <Button
-                  data-action="prompt-attach"
-                  type="button"
-                  variant="ghost"
-                  class="size-8 p-0"
-                  style={buttons()}
-                  onClick={pick}
-                  disabled={store.mode !== "normal"}
-                  tabIndex={store.mode === "normal" ? undefined : -1}
-                  aria-label={language.t("prompt.action.attachFile")}
+                <div
+                  data-component="prompt-input"
+                  ref={(el) => {
+                    editorRef = el
+                    props.ref?.(el)
+                  }}
+                  role="textbox"
+                  aria-multiline="true"
+                  aria-label={placeholder()}
+                  contenteditable="true"
+                  autocapitalize={store.mode === "normal" ? "sentences" : "off"}
+                  autocorrect={store.mode === "normal" ? "on" : "off"}
+                  spellcheck={store.mode === "normal"}
+                  inputMode="text"
+                  // @ts-expect-error
+                  autocomplete="off"
+                  onInput={handleInput}
+                  onPaste={handlePaste}
+                  onCompositionStart={handleCompositionStart}
+                  onCompositionEnd={handleCompositionEnd}
+                  onBlur={handleBlur}
+                  onKeyDown={handleKeyDown}
+                  classList={{
+                    "select-text": true,
+                    "w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
+                    "[&_[data-type=file]]:text-syntax-property": true,
+                    "[&_[data-type=agent]]:text-syntax-type": true,
+                    "font-mono!": store.mode === "shell",
+                  }}
+                  style={{ "padding-bottom": space }}
+                />
+                <div
+                  class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
+                  classList={{ "font-mono!": store.mode === "shell" }}
+                  style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }}
                 >
-                  <Icon name="plus" class="size-4.5" />
-                </Button>
-              </TooltipKeybind>
-            </div>
-          </div>
-        </div>
-      </DockShellForm>
-      <Show when={store.mode === "normal" || store.mode === "shell"}>
-        <DockTray attach="top">
-          <div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
-            <div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
+                  {placeholder()}
+                </div>
+              </div>
+
               <div
-                class="h-7 flex items-center gap-1.5 min-w-0 absolute inset-0"
+                aria-hidden="true"
+                class="pointer-events-none absolute inset-x-0 bottom-0"
                 style={{
-                  padding: "0 0px 0 8px",
-                  ...shell(),
+                  height: space,
+                  background:
+                    "linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
                 }}
-              >
-                <Icon name="console" />
-                <span class="truncate text-13-medium text-text-base">{language.t("prompt.mode.shell")}</span>
-                <div class="flex-1" />
-                <Button
-                  variant="ghost"
-                  class="text-text-base"
-                  onClick={() => {
-                    setStore("mode", "normal")
+              />
+
+              <div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
+                <input
+                  ref={fileInputRef}
+                  type="file"
+                  multiple
+                  accept={ACCEPTED_FILE_TYPES.join(",")}
+                  class="hidden"
+                  onChange={(e) => {
+                    const list = e.currentTarget.files
+                    if (list) void addAttachments(Array.from(list))
+                    e.currentTarget.value = ""
+                  }}
+                />
+
+                <div class="flex items-center gap-1 pointer-events-auto">
+                  <Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
+                    <IconButton
+                      data-action="prompt-submit"
+                      type="submit"
+                      disabled={!working() && blank()}
+                      tabIndex={store.mode === "normal" ? undefined : -1}
+                      icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
+                      variant="primary"
+                      class="size-8"
+                      aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
+                    />
+                  </Tooltip>
+                </div>
+              </div>
+
+              <div class="pointer-events-none absolute bottom-2 left-2">
+                <div
+                  aria-hidden={store.mode !== "normal"}
+                  class="pointer-events-auto"
+                  style={{
+                    "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
                   }}
                 >
-                  {language.t("common.cancel")}
-                </Button>
+                  <TooltipKeybind
+                    placement="top"
+                    title={language.t("prompt.action.attachFile")}
+                    keybind={command.keybind("file.attach")}
+                  >
+                    <Button
+                      data-action="prompt-attach"
+                      type="button"
+                      variant="ghost"
+                      class="size-8 p-0"
+                      style={buttons()}
+                      onClick={pick}
+                      disabled={store.mode !== "normal"}
+                      tabIndex={store.mode === "normal" ? undefined : -1}
+                      aria-label={language.t("prompt.action.attachFile")}
+                    >
+                      <Icon name="plus" class="size-4.5" />
+                    </Button>
+                  </TooltipKeybind>
+                </div>
               </div>
-              <div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
-                <Show when={!agentsLoading()}>
+            </div>
+          </DockShellForm>
+          <Show when={store.mode === "normal" || store.mode === "shell"}>
+            <DockTray attach="top">
+              <div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
+                <div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
                   <div
-                    data-component="prompt-agent-control"
-                    style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
+                    class="h-7 flex items-center gap-1.5 min-w-0 absolute inset-0"
+                    style={{
+                      padding: "0 0px 0 8px",
+                      ...shell(),
+                    }}
                   >
-                    <TooltipKeybind
-                      placement="top"
-                      gutter={4}
-                      title={language.t("command.agent.cycle")}
-                      keybind={command.keybind("agent.cycle")}
+                    <Icon name="console" />
+                    <span class="truncate text-13-medium text-text-base">{language.t("prompt.mode.shell")}</span>
+                    <div class="flex-1" />
+                    <Button
+                      variant="ghost"
+                      class="text-text-base"
+                      onClick={() => {
+                        setStore("mode", "normal")
+                      }}
                     >
-                      <Select
-                        size="normal"
-                        options={agentNames()}
-                        current={local.agent.current()?.name ?? ""}
-                        onSelect={(value) => {
-                          local.agent.set(value)
-                          restoreFocus()
-                        }}
-                        class="capitalize max-w-[160px] text-text-base"
-                        valueClass="truncate text-13-regular text-text-base"
-                        triggerStyle={control()}
-                        triggerProps={{ "data-action": "prompt-agent" }}
-                        variant="ghost"
-                      />
-                    </TooltipKeybind>
+                      {language.t("common.cancel")}
+                    </Button>
                   </div>
-                </Show>
-                <Show when={!providersLoading()}>
-                  <Show when={store.mode !== "shell"}>
-                    <div
-                      data-component="prompt-model-control"
-                      style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
-                    >
-                      <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-[320px] text-13-regular text-text-base 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" />
-                            </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-[320px] text-13-regular text-text-base 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" />
-                          </ModelSelectorPopover>
-                        </TooltipKeybind>
-                      </Show>
-                    </div>
-                    <Show when={variants().length > 2}>
+                  <div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
+                    <Show when={!agentsLoading()}>
                       <div
-                        data-component="prompt-variant-control"
-                        style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
+                        data-component="prompt-agent-control"
+                        style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
                       >
                         <TooltipKeybind
                           placement="top"
                           gutter={4}
-                          title={language.t("command.model.variant.cycle")}
-                          keybind={command.keybind("model.variant.cycle")}
+                          title={language.t("command.agent.cycle")}
+                          keybind={command.keybind("agent.cycle")}
                         >
                           <Select
                             size="normal"
-                            options={variants()}
-                            current={local.model.variant.current() ?? "default"}
-                            label={(x) => (x === "default" ? language.t("common.default") : x)}
+                            options={agentNames()}
+                            current={local.agent.current()?.name ?? ""}
                             onSelect={(value) => {
-                              local.model.variant.set(value === "default" ? undefined : value)
+                              local.agent.set(value)
                               restoreFocus()
                             }}
                             class="capitalize max-w-[160px] text-text-base"
                             valueClass="truncate text-13-regular text-text-base"
                             triggerStyle={control()}
-                            triggerProps={{ "data-action": "prompt-model-variant" }}
+                            triggerProps={{ "data-action": "prompt-agent" }}
                             variant="ghost"
                           />
                         </TooltipKeybind>
                       </div>
                     </Show>
-                  </Show>
-                </Show>
+                    <Show when={!providersLoading()}>
+                      <Show when={store.mode !== "shell"}>
+                        <div
+                          data-component="prompt-model-control"
+                          style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
+                        >
+                          <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-[320px] text-13-regular text-text-base 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" />
+                                </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-[320px] text-13-regular text-text-base 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" />
+                              </ModelSelectorPopover>
+                            </TooltipKeybind>
+                          </Show>
+                        </div>
+                        <Show when={variants().length > 2}>
+                          <div
+                            data-component="prompt-variant-control"
+                            style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
+                          >
+                            <TooltipKeybind
+                              placement="top"
+                              gutter={4}
+                              title={language.t("command.model.variant.cycle")}
+                              keybind={command.keybind("model.variant.cycle")}
+                            >
+                              <Select
+                                size="normal"
+                                options={variants()}
+                                current={local.model.variant.current() ?? "default"}
+                                label={(x) => (x === "default" ? language.t("common.default") : x)}
+                                onSelect={(value) => {
+                                  local.model.variant.set(value === "default" ? undefined : value)
+                                  restoreFocus()
+                                }}
+                                class="capitalize max-w-[160px] text-text-base"
+                                valueClass="truncate text-13-regular text-text-base"
+                                triggerStyle={control()}
+                                triggerProps={{ "data-action": "prompt-model-variant" }}
+                                variant="ghost"
+                              />
+                            </TooltipKeybind>
+                          </div>
+                        </Show>
+                      </Show>
+                    </Show>
+                  </div>
+                </div>
               </div>
-            </div>
-          </div>
-        </DockTray>
-      </Show>
+            </DockTray>
+          </Show>
+        </Match>
+      </Switch>
     </div>
   )
 }

+ 1 - 0
packages/app/src/components/session/index.ts

@@ -3,3 +3,4 @@ export { SessionContextTab } from "./session-context-tab"
 export { SortableTab, FileVisual } from "./session-sortable-tab"
 export { SortableTerminalTab } from "./session-sortable-terminal-tab"
 export { NewSessionView } from "./session-new-view"
+export { NewSessionDesignView } from "./session-new-design-view"

+ 78 - 0
packages/app/src/components/session/session-new-design-view.tsx

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

+ 200 - 91
packages/app/src/components/titlebar.tsx

@@ -7,8 +7,9 @@ import { Button } from "@opencode-ai/ui/button"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { useTheme } from "@opencode-ai/ui/theme/context"
 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 { useLayout } from "@/context/layout"
+import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
 import { usePlatform } from "@/context/platform"
 import { useCommand } from "@/context/command"
 import { useLanguage } from "@/context/language"
@@ -18,6 +19,9 @@ import { applyPath, backPath, forwardPath } from "./titlebar-history"
 import { useGlobalSync } from "@/context/global-sync"
 import { decodeDirectory } from "@/pages/directory-layout"
 import { iife } from "@opencode-ai/core/util/iife"
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
+import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
 
 type TauriDesktopWindow = {
   startDragging?: () => Promise<void>
@@ -40,13 +44,21 @@ type TauriApi = {
 const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
 const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
 const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
-const titlebarHeight = 40
+const legacyTitlebarHeight = 40
+const v2TitlebarHeight = 44
 const minTitlebarZoom = 0.25
 const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
+const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
 
 const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
 
-export function Titlebar() {
+export type TitlebarUpdate = {
+  version: () => string | undefined
+  installing: () => boolean
+  install: () => void
+}
+
+export function Titlebar(props: { update?: TitlebarUpdate }) {
   const layout = useLayout()
   const platform = usePlatform()
   const command = useCommand()
@@ -59,14 +71,16 @@ export function Titlebar() {
 
   const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
   const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
+  const electronWindows = createMemo(() => windows() && !tauriApi())
   const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
   const web = createMemo(() => platform.platform === "web")
   const zoom = () => platform.webviewZoom?.() ?? 1
   const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
   const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
   const minHeight = () => {
-    if (mac()) return `${titlebarHeight / zoom()}px`
-    if (windows()) return `${titlebarHeight / Math.min(titlebarZoom(), 1)}px`
+    const height = USE_V2_TITLEBAR ? v2TitlebarHeight : legacyTitlebarHeight
+    if (mac()) return `${height / zoom()}px`
+    if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
     return undefined
   }
   const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
@@ -183,19 +197,47 @@ export function Titlebar() {
 
   return (
     <header
-      class="h-10 shrink-0 bg-background-base relative overflow-hidden flex flex-row"
-      style={{ "min-height": minHeight(), "padding-left": mac() ? `${84 / zoom()}px` : 0 }}
+      classList={{
+        "shrink-0 relative overflow-hidden flex flex-row": true,
+        "h-11 bg-v2-background-bg-deep": USE_V2_TITLEBAR,
+        "h-10 bg-background-base": !USE_V2_TITLEBAR,
+      }}
+      style={{
+        "min-height": minHeight(),
+        "padding-left": mac() ? `${84 / zoom()}px` : 0,
+        width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
+        "max-width": electronWindows()
+          ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
+          : undefined,
+        "align-self": electronWindows() ? "flex-start" : undefined,
+      }}
       data-tauri-drag-region
       onMouseDown={drag}
       onDblClick={maximize}
     >
       <Switch>
-        <Match when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
+        <Match when={USE_V2_TITLEBAR}>
           {(_) => {
             const globalSync = useGlobalSync()
             const navigate = useNavigate()
+            const homeMatch = useMatch(() => "/")
+
+            const openNewSession = () => {
+              if (params.dir) {
+                navigate(`/${params.dir}/session`)
+                return
+              }
+
+              const project = layout.projects.list()[0]
+              if (!project) {
+                navigate("/")
+                return
+              }
+
+              navigate(`/${base64Encode(project.worktree)}/session`)
+            }
 
-            type Tab = { dir: string; sessionId: string; params: any; href: string }
+            type Tab = { dir: string; sessionId: string; href: string }
 
             const [tabsStore, tabsStoreActions] = iife(() => {
               const [store, setStore] = createStore<Tab[]>(
@@ -205,7 +247,6 @@ export function Titlebar() {
                     {
                       dir: decodeDirectory(params.dir) ?? "",
                       sessionId: params.id,
-                      params: { id: params.id, dir: params.dir },
                       href: makeSessionHref(params.dir, params.id),
                     },
                   ]
@@ -248,11 +289,15 @@ export function Titlebar() {
               tabsStoreActions.addTab({
                 dir: decodeDirectory(params.dir) ?? "",
                 sessionId: params.id,
-                params: { id: params.id, dir: params.dir },
                 href: makeSessionHref(params.dir, params.id),
               })
             })
 
+            const projects = createMemo(() => layout.projects.list())
+            const projectByID = createMemo(
+              () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
+            )
+
             const tabsEnriched = iife(() => {
               const base = mapArray(
                 () => tabsStore,
@@ -267,78 +312,73 @@ export function Titlebar() {
             })
 
             return (
-              <div class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3">
+              <div
+                class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3 py-2"
+                classList={{
+                  "pl-2": mac(),
+                  "pl-4": !mac(),
+                }}
+              >
                 <ChannelIndicator />
                 <Show when={windows() || linux()}>
-                  <WindowsAppMenu command={command} platform={platform} />
+                  <WindowsAppMenu command={command} platform={platform} variant="v2" />
                 </Show>
                 <IconButtonV2
-                  as="a"
-                  href="/"
                   variant="ghost-muted"
                   size="large"
-                  class="!w-8"
-                  state={!!useMatch(() => "/")() ? "pressed" : undefined}
-                >
-                  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
-                    <path
-                      d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z"
-                      stroke="currentColor"
-                      stroke-miterlimit="10"
-                      stroke-linecap="square"
-                    />
-                  </svg>
-                </IconButtonV2>
-                <div class="flex flex-row items-center gap-2">
-                  <For each={tabsEnriched()}>
-                    {(tab, i) => (
-                      <>
-                        {i() !== 0 && <div class="w-[1.5px] h-3 rounded-full bg-[var(--v2-background-bg-layer-02)]" />}
-                        <TabNavItem
-                          href={tab.href}
-                          title={tab.info.title}
-                          onClose={() => tabsStoreActions.removeTab(tab.href)}
-                          hideClose={tabsEnriched().length < 2}
-                        />
-                      </>
-                    )}
-                  </For>
-                </div>
-                <button>
-                  <div class="p-1.5">
-                    <svg
-                      xmlns="http://www.w3.org/2000/svg"
-                      width="16"
-                      height="16"
-                      viewBox="0 0 16 16"
-                      fill="none"
-                      class="size-4"
-                    >
-                      <path
-                        d="M7.99978 2.88867V13.1109M2.88867 7.99978H13.1109"
-                        stroke="#808080"
-                        stroke-linejoin="round"
-                      />
-                    </svg>
+                  as="a"
+                  href="/"
+                  class="!w-9"
+                  icon={<IconV2 name="grid-plus" />}
+                  state={!!homeMatch() ? "pressed" : undefined}
+                />
+
+                <div class="flex min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden">
+                  <div class="flex min-w-0 flex-row items-center gap-1.5 overflow-hidden">
+                    <For each={tabsEnriched()}>
+                      {(tab, i) => (
+                        <>
+                          {i() !== 0 && (
+                            <div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
+                          )}
+                          <TabNavItem
+                            href={tab.href}
+                            title={tab.info.title}
+                            project={projectForSession(tab.info, projects(), projectByID())}
+                            directory={tab.dir}
+                            onClose={() => tabsStoreActions.removeTab(tab.href)}
+                            hideClose={tabsEnriched().length < 2}
+                          />
+                        </>
+                      )}
+                    </For>
                   </div>
-                </button>
-
-                <div class="flex-1" />
-                {/*<button class="px-2.5 py-1.5 bg-[rgba(0,0,0,0.08)] rounded-[6px]">
-                  <svg
-                    xmlns="http://www.w3.org/2000/svg"
-                    width="16"
-                    height="16"
-                    viewBox="0 0 16 16"
-                    fill="none"
-                    class="size-4"
+                  <Show
+                    when={creating() && params.dir}
+                    fallback={
+                      <IconButtonV2
+                        type="button"
+                        variant="ghost-muted"
+                        size="large"
+                        class="shrink-0"
+                        icon={<IconV2 name="plus" />}
+                        onClick={openNewSession}
+                        aria-label={language.t("command.session.new")}
+                      />
+                    }
                   >
-                    <path
-                      d="M10.4443 2.44436V13.5555M1.55546 13.5554H14.4443V2.44434H1.55542L1.55546 13.5554Z"
-                      stroke="#3A3A3A"
+                    <NewSessionTabItem
+                      href={`/${params.dir}/session`}
+                      title={language.t("command.session.new")}
+                      onClose={() => navigate(tabsEnriched().at(-1)?.href ?? "/")}
                     />
-                  </svg>
-                </button>*/}
+                  </Show>
+                  <div class="min-w-0 flex-1" />
+                </div>
+                <TitlebarUpdatePill update={props.update} />
+                <Show when={windows() && !electronWindows()}>
+                  <div data-tauri-decorum-tb class="flex flex-row" />
+                </Show>
               </div>
             )
           }}
@@ -358,7 +398,7 @@ export function Titlebar() {
                 <WindowsAppMenu command={command} platform={platform} />
               </Show>
               <Show when={mac()}>
-                <div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
+                {/*<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />*/}
                 <div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
                   <IconButton
                     icon="menu"
@@ -502,19 +542,51 @@ export function Titlebar() {
   )
 }
 
-function TabNavItem(props: { href: string; title: string; hideClose?: boolean; onClose: () => void }) {
+function TitlebarUpdatePill(props: { update?: TitlebarUpdate }) {
+  const language = useLanguage()
+  const version = () => props.update?.version()
+
+  return (
+    <Show when={version() !== undefined}>
+      <button
+        type="button"
+        class="h-5 shrink-0 rounded-[27px] bg-[var(--v2-background-bg-accent)] px-2.5 text-[11px] font-[530] leading-[1.1] tracking-[-0.04px] text-[var(--v2-text-text-contrast)] disabled:opacity-60"
+        onClick={() => props.update?.install()}
+        disabled={props.update?.installing()}
+        aria-label={language.t("toast.update.action.installRestart")}
+        title={version() ? `Update ${version()}` : undefined}
+      >
+        Update
+      </button>
+    </Show>
+  )
+}
+
+function DesktopTitlebarIconButton(props: Parameters<typeof IconButtonV2>[0]) {
+  return
+}
+
+function TabNavItem(props: {
+  href: string
+  title: string
+  project?: LocalProject
+  directory: string
+  hideClose?: boolean
+  onClose: () => void
+}) {
   const match = useMatch(() => props.href)
   const isActive = () => !!match()
   return (
     <div
-      class="group flex flex-row items-center max-w-60 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] bg-[var(--tab-bg)] h-7 rounded-[6px] relative overflow-hidden"
+      class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
       data-active={isActive()}
     >
       <a
         href={props.href}
-        class="w-full h-full pl-1.5 flex-1 max-w-full flex flex-row items-center overflow-hidden font-medium"
+        class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-none tracking-[-0.04px] text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
       >
-        {props.title}
+        <ProjectTabAvatar project={props.project} directory={props.directory} />
+        <span class="truncate">{props.title}</span>
       </a>
 
       <div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
@@ -530,23 +602,60 @@ function TabNavItem(props: { href: string; title: string; hideClose?: boolean; o
           variant="ghost-muted"
           class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
           onClick={props.onClose}
-          icon={
-            <svg
-              xmlns="http://www.w3.org/2000/svg"
-              width="16"
-              height="16"
-              viewBox="0 0 16 16"
-              fill="none"
-              class="size-4"
-            >
-              <path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor" />
-            </svg>
-          }
+          icon={<IconV2 name="xmark-small" />}
         />
       </div>
     </div>
   )
 }
+
+function ProjectTabAvatar(props: { project?: LocalProject; directory: string }) {
+  return (
+    <AvatarV2
+      fallback={displayName(props.project ?? { worktree: props.directory })}
+      src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
+      kind="org"
+      size="small"
+      {...getAvatarColors(props.project?.icon?.color)}
+      class="size-4 rounded"
+    />
+  )
+}
+
+function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) {
+  return (
+    <div class="group relative flex h-7 w-[135px] min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]">
+      <a
+        href={props.href}
+        aria-current="page"
+        class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-none text-[var(--v2-text-text-base)]"
+      >
+        <span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
+          <IconV2 name="edit" />
+        </span>
+        <span class="truncate">{props.title}</span>
+      </a>
+      <div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
+        <IconButtonV2
+          size="small"
+          variant="ghost-muted"
+          onMouseDown={(event) => {
+            event.preventDefault()
+            event.stopPropagation()
+          }}
+          onClick={(event) => {
+            event.preventDefault()
+            event.stopPropagation()
+            props.onClose()
+          }}
+          icon={<IconV2 name="xmark-small" />}
+          aria-label="Close tab"
+        />
+      </div>
+    </div>
+  )
+}
+
 function ChannelIndicator() {
   return (
     <>

+ 26 - 9
packages/app/src/components/windows-app-menu.tsx

@@ -2,6 +2,8 @@ import { Show, type JSX } from "solid-js"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
+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 { useCommand } from "@/context/command"
 import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
@@ -10,6 +12,7 @@ import { usePlatform } from "@/context/platform"
 export function WindowsAppMenu(props: {
   command: ReturnType<typeof useCommand>
   platform: ReturnType<typeof usePlatform>
+  variant?: "legacy" | "v2"
 }) {
   let lastFocused: HTMLElement | undefined
 
@@ -45,15 +48,29 @@ export function WindowsAppMenu(props: {
 
   return (
     <DropdownMenu gutter={4} modal={false} placement="bottom-start">
-      <DropdownMenu.Trigger
-        as={IconButton}
-        icon="menu"
-        variant="ghost"
-        class="titlebar-icon rounded-md shrink-0"
-        aria-label="OpenCode menu"
-        onPointerDown={rememberFocus}
-        onKeyDown={rememberFocus}
-      />
+      {props.variant === "v2" ? (
+        <div data-component="desktop-icon-button" class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1">
+          <DropdownMenu.Trigger
+            as={IconButtonV2}
+            variant="ghost-muted"
+            size="large"
+            icon={<IconV2 name="menu" />}
+            aria-label="OpenCode menu"
+            onPointerDown={rememberFocus}
+            onKeyDown={rememberFocus}
+          />
+        </div>
+      ) : (
+        <DropdownMenu.Trigger
+          as={IconButton}
+          icon="menu"
+          variant="ghost"
+          class="titlebar-icon rounded-md shrink-0"
+          aria-label="OpenCode menu"
+          onPointerDown={rememberFocus}
+          onKeyDown={rememberFocus}
+        />
+      )}
       <DropdownMenu.Portal>
         <DropdownMenu.Content class="desktop-app-menu">
           <DropdownMenu.Group>

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

@@ -526,6 +526,14 @@ export const dict = {
   "home.recentProjects": "Recent projects",
   "home.empty.title": "No recent projects",
   "home.empty.description": "Get started by opening a local project",
+  "home.title": "Home",
+  "home.projects": "Projects",
+  "home.project.add": "Add project",
+  "home.sessions.search.placeholder": "Search sessions",
+  "home.sessions.empty": "No sessions found",
+  "home.sessions.group.today": "Today",
+  "home.sessions.group.yesterday": "Yesterday",
+  "home.sessions.group.older": "Older",
 
   "session.tab.session": "Session",
   "session.tab.review": "Review",

+ 8 - 0
packages/app/src/i18n/zh.ts

@@ -498,6 +498,14 @@ export const dict = {
   "home.recentProjects": "最近项目",
   "home.empty.title": "没有最近项目",
   "home.empty.description": "通过打开本地项目开始使用",
+  "home.title": "主页",
+  "home.projects": "项目",
+  "home.project.add": "添加项目",
+  "home.sessions.search.placeholder": "搜索会话",
+  "home.sessions.empty": "未找到会话",
+  "home.sessions.group.today": "今天",
+  "home.sessions.group.yesterday": "昨天",
+  "home.sessions.group.older": "更早",
 
   "session.tab.session": "会话",
   "session.tab.review": "审查",

+ 0 - 2
packages/app/src/index.css

@@ -43,9 +43,7 @@
     width: 100%;
     height: 100%;
     border-radius: 999px;
-    background: var(--session-progress-color);
     clip-path: inset(0 100% 0 0 round 999px);
-    animation: session-progress-whip var(--session-progress-ms, 1800ms) infinite;
     will-change: clip-path;
   }
 

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

@@ -1,7 +1,15 @@
-import { createMemo, For, Match, Switch } from "solid-js"
+import type { Session } from "@opencode-ai/sdk/v2/client"
+import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useQuery } from "@tanstack/solid-query"
 import { Button } from "@opencode-ai/ui/button"
 import { Logo } from "@opencode-ai/ui/logo"
-import { useLayout } from "@/context/layout"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
+import { ButtonV2 } from "@opencode-ai/ui/v2/components/button-v2.jsx"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
+import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
 import { useNavigate } from "@solidjs/router"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { Icon } from "@opencode-ai/ui/icon"
@@ -10,11 +18,44 @@ import { DateTime } from "luxon"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectDirectory } from "@/components/dialog-select-directory"
 import { DialogSelectServer } from "@/components/dialog-select-server"
+import { DialogSelectModel } from "@/components/dialog-select-model"
 import { useServer } from "@/context/server"
 import { useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
+import { useNotification } from "@/context/notification"
+import { usePermission } from "@/context/permission"
+import { displayName, getProjectAvatarSource, projectForSession, sortedRootSessions } from "@/pages/layout/helpers"
+import { getFilename } from "@opencode-ai/core/util/path"
+import { sessionTitle } from "@/utils/session-title"
+import { pathKey } from "@/utils/path-key"
+import { messageAgentColor } from "@/utils/agent"
+import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
+
+const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
+const HOME_SESSION_LIMIT = 15
+const HOME_ROW =
+  "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left [font-weight:530] text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
+const HOME_PROJECT_NAV_ROW = `${HOME_ROW} h-8 gap-1.5 px-3 [&>span]:min-w-0 [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap`
+const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
+
+type HomeSessionRecord = {
+  session: Session
+  project: LocalProject
+  projectName: string
+}
+
+type HomeSessionGroup = {
+  id: "today" | "yesterday" | "older"
+  title: string
+  sessions: HomeSessionRecord[]
+}
 
 export default function Home() {
+  if (USE_HOME_DESIGN) return <HomeDesign />
+  return <LegacyHome />
+}
+
+function HomeDesign() {
   const sync = useGlobalSync()
   const layout = useLayout()
   const platform = usePlatform()
@@ -22,6 +63,412 @@ export default function Home() {
   const navigate = useNavigate()
   const server = useServer()
   const language = useLanguage()
+  const [state, setState] = createStore({ search: "", project: undefined as string | undefined })
+
+  const projects = createMemo(() => layout.projects.list())
+  const selectedProject = createMemo(
+    () => projects().find((project) => project.worktree === state.project) ?? projects()[0],
+  )
+  const projectDirectories = createMemo(() => {
+    const project = selectedProject()
+    if (!project) return []
+    return [project.worktree, ...(project.sandboxes ?? [])]
+  })
+  const search = createMemo(() => state.search.trim())
+  const sessionLoad = useQuery(() => ({
+    queryKey: ["home", "sessions", ...projectDirectories()] as const,
+    queryFn: async () => {
+      await Promise.all(projectDirectories().map((directory) => sync.project.loadSessions(directory)))
+      return null
+    },
+  }))
+
+  const projectByID = createMemo(
+    () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
+  )
+  const records = createMemo(() =>
+    [
+      ...new Map(
+        projectDirectories()
+          .flatMap((directory) => sortedRootSessions(sync.child(directory, { bootstrap: false })[0], Date.now()))
+          .map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const),
+      ).values(),
+    ]
+      .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
+      .flatMap((session) => {
+        const project = projectForSession(session, projects(), projectByID())
+        if (!project) return []
+        return {
+          session,
+          project,
+          projectName: displayName(project),
+        }
+      })
+      .filter((record) => {
+        const value = search().toLowerCase()
+        if (!value) return true
+        return `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)
+      })
+      .slice(0, HOME_SESSION_LIMIT),
+  )
+  const groups = createMemo(() => groupSessions(records(), language))
+
+  function selectProject(directory: string) {
+    if (!projects().some((project) => project.worktree === directory)) return
+    setState("project", directory)
+  }
+
+  function addProject(directory: string) {
+    layout.projects.open(directory)
+    server.projects.touch(directory)
+    setState("project", directory)
+  }
+
+  function openNewSession() {
+    const project = selectedProject()
+    if (!project) {
+      void chooseProject()
+      return
+    }
+    layout.projects.open(project.worktree)
+    server.projects.touch(project.worktree)
+    navigate(`/${base64Encode(project.worktree)}/session`)
+  }
+
+  function openSession(session: Session) {
+    const project = projectForSession(session, projects(), projectByID())
+    layout.projects.open(project?.worktree ?? session.directory)
+    server.projects.touch(project?.worktree ?? session.directory)
+    navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
+  }
+
+  async function chooseProject() {
+    function resolve(result: string | string[] | null) {
+      if (Array.isArray(result)) {
+        result.forEach(addProject)
+        if (result[0]) setState("project", result[0])
+        return
+      }
+      if (result) addProject(result)
+    }
+
+    if (platform.openDirectoryPickerDialog && server.isLocal()) {
+      const result = await platform.openDirectoryPickerDialog?.({
+        title: language.t("command.project.open"),
+        multiple: true,
+      })
+      resolve(result)
+      return
+    }
+
+    dialog.show(
+      () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
+      () => resolve(null),
+    )
+  }
+
+  function openSettings() {
+    void import("@/components/dialog-settings").then((x) => {
+      dialog.show(() => <x.DialogSettings />)
+    })
+  }
+
+  return (
+    <div class="size-full overflow-y-auto bg-background-base rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden">
+      <div class="mx-auto grid w-full max-w-[1080px] gap-8 px-6 pb-16 pt-12 lg:grid-cols-[280px_minmax(0,720px)]">
+        <HomeProjectColumn
+          projects={projects()}
+          selected={selectedProject()?.worktree}
+          selectProject={selectProject}
+          chooseProject={() => void chooseProject()}
+          openSettings={openSettings}
+          openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
+          language={language}
+        />
+
+        <section class="min-w-0" 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-6 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>
+                      </div>
+                    </div>
+                  )}
+                </For>
+              </Show>
+            </Show>
+          </div>
+        </section>
+      </div>
+    </div>
+  )
+}
+
+function HomeProjectColumn(props: {
+  projects: LocalProject[]
+  selected?: string
+  selectProject: (directory: string) => void
+  chooseProject: () => void
+  openSettings: () => void
+  openHelp: () => void
+  language: ReturnType<typeof useLanguage>
+}) {
+  return (
+    <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={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
+        <IconButtonV2
+          data-action="home-add-project"
+          variant="ghost-muted"
+          size="large"
+          class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
+          icon={<IconV2 name="folder-add-left" />}
+          onClick={props.chooseProject}
+          aria-label={props.language.t("home.project.add")}
+        />
+      </div>
+      <div class="mt-4 flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
+        <Show
+          when={props.projects.length > 0}
+          fallback={
+            <button
+              type="button"
+              class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
+              onClick={props.chooseProject}
+            >
+              <IconV2 name="folder-add-left" size="small" />
+              <span>{props.language.t("home.project.add")}</span>
+            </button>
+          }
+        >
+          <For each={props.projects}>
+            {(project) => (
+              <button
+                type="button"
+                data-component="home-project-row"
+                class={HOME_PROJECT_NAV_ROW}
+                classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected === project.worktree }}
+                data-selected={props.selected === project.worktree ? "" : undefined}
+                aria-current={props.selected === project.worktree ? "page" : undefined}
+                onClick={() => props.selectProject(project.worktree)}
+              >
+                <HomeProjectAvatar project={project} />
+                <span>{displayName(project)}</span>
+              </button>
+            )}
+          </For>
+        </Show>
+      </div>
+      <div class="mt-4 flex min-w-0 flex-col gap-1">
+        <button
+          type="button"
+          class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
+          onClick={props.openSettings}
+        >
+          <IconV2 name="settings-gear" size="small" />
+          <span>{props.language.t("sidebar.settings")}</span>
+        </button>
+        <button
+          type="button"
+          class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
+          onClick={props.openHelp}
+        >
+          <IconV2 name="help" size="small" />
+          <span>{props.language.t("sidebar.help")}</span>
+        </button>
+      </div>
+    </aside>
+  )
+}
+
+function HomeProjectAvatar(props: { project: LocalProject }) {
+  const name = createMemo(() => displayName(props.project))
+  return (
+    <AvatarV2
+      fallback={name()}
+      src={getProjectAvatarSource(props.project.id, props.project.icon)}
+      kind="org"
+      size="small"
+      {...getAvatarColors(props.project.icon?.color)}
+      class="size-4 rounded"
+    />
+  )
+}
+
+function HomeSessionSearch(props: { value: string; placeholder: string; onInput: (value: string) => void }) {
+  return (
+    <label class="ml-4 flex h-9 w-[calc(100%_-_48px)] 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" />
+      <input
+        class="min-w-0 flex-1 border-0 bg-transparent text-v2-text-text-base outline-0 [font-weight:440] placeholder:text-v2-text-text-faint"
+        value={props.value}
+        placeholder={props.placeholder}
+        aria-label={props.placeholder}
+        onInput={(event) => props.onInput(event.currentTarget.value)}
+      />
+    </label>
+  )
+}
+
+function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
+  const language = useLanguage()
+  return (
+    <div class="flex h-7 min-w-0 items-center justify-between px-4">
+      <div class={HOME_SECTION_LABEL}>{props.title}</div>
+      <Show when={props.onNewSession}>
+        {(onNewSession) => (
+          <ButtonV2
+            data-action="home-new-session"
+            variant="ghost"
+            size="normal"
+            icon="edit"
+            class="h-7 px-2 text-v2-text-text-muted [font-weight:530]"
+            onClick={onNewSession()}
+          >
+            {language.t("command.session.new")}
+          </ButtonV2>
+        )}
+      </Show>
+    </div>
+  )
+}
+
+function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (session: Session) => void }) {
+  const globalSync = useGlobalSync()
+  const notification = useNotification()
+  const permission = usePermission()
+  const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false })
+  const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
+  const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id))
+  const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id))
+  const hasPermissions = createMemo(
+    () =>
+      !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => {
+        return !permission.autoResponds(item, props.record.session.directory)
+      }),
+  )
+  const isWorking = createMemo(() => {
+    if (hasPermissions()) return false
+    return sessionStore.session_working(props.record.session.id)
+  })
+  const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent))
+  const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0)
+
+  return (
+    <button
+      type="button"
+      data-component="home-session-row"
+      class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
+      onClick={() => props.openSession(props.record.session)}
+    >
+      <Show when={showStatus()}>
+        <div
+          class="flex size-4 shrink-0 items-center justify-center"
+          style={{ color: tint() ?? "var(--icon-interactive-base)" }}
+        >
+          <Switch>
+            <Match when={isWorking()}>
+              <Spinner class="size-[15px]" />
+            </Match>
+            <Match when={hasPermissions()}>
+              <div class="size-1.5 rounded-full bg-surface-warning-strong" />
+            </Match>
+            <Match when={hasError()}>
+              <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
+            </Match>
+            <Match when={unseenCount() > 0}>
+              <div class="size-1.5 rounded-full bg-text-interactive-base" />
+            </Match>
+          </Switch>
+        </div>
+      </Show>
+      <span
+        class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
+      >
+        {title()}
+      </span>
+      <Show when={props.record.projectName}>
+        <span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]">
+          {props.record.projectName}
+        </span>
+      </Show>
+    </button>
+  )
+}
+
+function HomeSessionSkeleton(props: { label: string }) {
+  return (
+    <div class="flex min-w-0 flex-col gap-4">
+      <div class="flex h-7 min-w-0 items-center justify-between px-4">
+        <div class={HOME_SECTION_LABEL}>{props.label}</div>
+      </div>
+      <div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
+        <For each={[0, 1, 2, 3]}>{() => <div class="h-10 rounded-[6px] bg-v2-background-bg-deep opacity-70" />}</For>
+      </div>
+    </div>
+  )
+}
+
+function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
+  const now = DateTime.local()
+  const yesterday = now.minus({ days: 1 })
+  const todaySessions = records.filter((record) =>
+    DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
+  )
+  const yesterdaySessions = records.filter((record) =>
+    DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
+  )
+  const olderSessions = records.filter((record) => {
+    const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
+    return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
+  })
+  const olderTitle =
+    todaySessions.length === 0 && yesterdaySessions.length === 0
+      ? language.t("sidebar.project.recentSessions")
+      : language.t("home.sessions.group.older")
+
+  return [
+    { id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions },
+    { id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions },
+    { id: "older" as const, title: olderTitle, sessions: olderSessions },
+  ].filter((group) => group.sessions.length > 0)
+}
+
+function LegacyHome() {
+  const sync = useGlobalSync()
+  const layout = useLayout()
+  const platform = usePlatform()
+  const dialog = useDialog()
+  const navigate = useNavigate()
+  const server = useServer()
+  const language = useLanguage()
+
+  const [promptText, setPromptText] = createSignal("")
+  const [selectedAgent, setSelectedAgent] = createSignal("frontend-specialist")
+  const [showProjectsDropdown, setShowProjectsDropdown] = createSignal(false)
+
   const homedir = createMemo(() => sync.data.path.home)
   const recent = createMemo(() => {
     return sync.data.project
@@ -30,6 +477,8 @@ export default function Home() {
       .slice(0, 5)
   })
 
+  const currentProject = createMemo(() => recent()[0]?.worktree)
+
   const serverDotClass = createMemo(() => {
     const healthy = server.healthy()
     if (healthy === true) return "bg-icon-success-base"
@@ -68,69 +517,185 @@ export default function Home() {
     }
   }
 
+  function handleModelSelect() {
+    dialog.show(() => <DialogSelectModel />)
+  }
+
+  function toggleAgent() {
+    const agents = ["frontend-specialist", "build", "general"]
+    const nextIndex = (agents.indexOf(selectedAgent()) + 1) % agents.length
+    setSelectedAgent(agents[nextIndex])
+  }
+
+  function handleSubmit() {
+    const projectToOpen = currentProject()
+    if (projectToOpen) {
+      openProject(projectToOpen)
+    } else {
+      chooseProject()
+    }
+  }
+
+  const activeModelName = createMemo(() => {
+    const model = sync.data.config.model
+    if (!model) return "GPT-5.7 Pro"
+    const parts = model.split("/")
+    return parts[parts.length - 1]
+  })
+
   return (
-    <div class="mx-auto mt-55 w-full md:w-auto px-4">
-      <Logo class="md:w-xl opacity-12" />
-      <Button
-        size="large"
-        variant="ghost"
-        class="mt-4 mx-auto text-14-regular text-text-weak"
-        onClick={() => dialog.show(() => <DialogSelectServer />)}
-      >
-        <div
-          classList={{
-            "size-2 rounded-full": true,
-            [serverDotClass()]: true,
-          }}
-        />
-        {server.name}
-      </Button>
+    <div class="mx-auto mt-24 w-full max-w-2xl px-6 flex flex-col items-center">
+      <div class="flex flex-col items-center gap-3 mb-10">
+        <div onClick={chooseProject} class="cursor-pointer hover:opacity-25 transition-opacity duration-200">
+          <Logo class="w-48 opacity-15" />
+        </div>
+        <Button
+          size="normal"
+          variant="ghost"
+          class="text-12-regular text-text-weak px-3"
+          onClick={() => dialog.show(() => <DialogSelectServer />)}
+        >
+          <div
+            classList={{
+              "size-1.5 rounded-full mr-2": true,
+              [serverDotClass()]: true,
+            }}
+          />
+          {server.name}
+        </Button>
+      </div>
+
       <Switch>
-        <Match when={sync.data.project.length > 0}>
-          <div class="mt-20 w-full flex flex-col gap-4">
-            <div class="flex gap-2 items-center justify-between pl-3">
-              <div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
-              <Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
-                {language.t("command.project.open")}
-              </Button>
-            </div>
-            <ul class="flex flex-col gap-2">
-              <For each={recent()}>
-                {(project) => (
+        <Match when={recent().length > 0}>
+          <div class="w-full flex flex-col items-center gap-6">
+            <div class="text-20-medium text-text-strong text-center">{language.t("session.new.title")}</div>
+
+            <div class="w-full bg-surface-base border border-border-base rounded-xl p-4 flex flex-col gap-3 shadow-md relative">
+              <textarea
+                class="bg-transparent border-none outline-none text-14-regular text-text-base placeholder-text-weak w-full resize-none h-20 focus:outline-none"
+                placeholder="Ask anything, / for commands, @ for context..."
+                value={promptText()}
+                onInput={(e) => setPromptText(e.currentTarget.value)}
+                onKeyDown={(e) => {
+                  if (e.key === "Enter" && !e.shiftKey) {
+                    e.preventDefault()
+                    handleSubmit()
+                  }
+                }}
+              />
+
+              <div class="flex flex-wrap items-center gap-2 pt-3 border-t border-border-weak-base">
+                <Button
+                  size="small"
+                  variant="ghost"
+                  class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
+                  onClick={toggleAgent}
+                >
+                  <Icon name="sliders" size="small" class="shrink-0" />
+                  <span>Agent: {selectedAgent()}</span>
+                </Button>
+
+                <Button
+                  size="small"
+                  variant="ghost"
+                  class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
+                  onClick={handleModelSelect}
+                >
+                  <Icon name="brain" size="small" class="shrink-0" />
+                  <span>Model: {activeModelName()}</span>
+                </Button>
+
+                <div class="relative">
                   <Button
-                    size="large"
+                    size="small"
                     variant="ghost"
-                    class="text-14-mono text-left justify-between px-3"
-                    onClick={() => openProject(project.worktree)}
+                    class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
+                    onClick={() => setShowProjectsDropdown(!showProjectsDropdown())}
                   >
-                    {project.worktree.replace(homedir(), "~")}
-                    <div class="text-14-regular text-text-weak">
-                      {DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
-                    </div>
+                    <Icon name="folder" size="small" class="shrink-0" />
+                    <span>Project: {currentProject() ? getFilename(currentProject()) : "Select Project"}</span>
                   </Button>
-                )}
-              </For>
-            </ul>
-          </div>
-        </Match>
-        <Match when={!sync.ready}>
-          <div class="mt-30 mx-auto flex flex-col items-center gap-3">
-            <div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
-            <Button class="px-3" onClick={chooseProject}>
-              {language.t("command.project.open")}
-            </Button>
+
+                  <Show when={showProjectsDropdown()}>
+                    <div class="absolute left-0 mt-1 w-64 bg-surface-raised-base border border-border-base rounded-lg p-2 shadow-lg z-50 flex flex-col gap-1">
+                      <div class="text-10-semibold text-text-weak px-2 py-1 uppercase tracking-wider">
+                        {language.t("home.recentProjects")}
+                      </div>
+                      <For each={recent()}>
+                        {(project) => (
+                          <button
+                            class="text-12-mono text-left px-2 py-1.5 hover:bg-surface-raised-base-hover rounded flex items-center justify-between w-full"
+                            onClick={() => {
+                              openProject(project.worktree)
+                              setShowProjectsDropdown(false)
+                            }}
+                          >
+                            <span class="truncate">{getFilename(project.worktree)}</span>
+                            <span class="text-10-regular text-text-weak shrink-0 pl-2">
+                              {DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
+                            </span>
+                          </button>
+                        )}
+                      </For>
+                      <div class="border-t border-border-weak-base my-1" />
+                      <button
+                        class="text-12-medium text-text-strong text-left px-2 py-1.5 hover:bg-surface-raised-base-hover rounded flex items-center gap-2 w-full"
+                        onClick={() => {
+                          setShowProjectsDropdown(false)
+                          chooseProject()
+                        }}
+                      >
+                        <Icon name="folder-add-left" size="small" />
+                        {language.t("command.project.open")}
+                      </button>
+                    </div>
+                  </Show>
+                </div>
+
+                <Button
+                  size="small"
+                  variant="ghost"
+                  class="text-12-medium text-text-weak flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base border border-border-weak-base rounded-md cursor-default pointer-events-none"
+                >
+                  <Icon name="branch" size="small" class="shrink-0" />
+                  <span>Branch: dev</span>
+                </Button>
+              </div>
+            </div>
           </div>
         </Match>
+
         <Match when={true}>
-          <div class="mt-30 mx-auto flex flex-col items-center gap-3">
-            <Icon name="folder-add-left" size="large" />
-            <div class="flex flex-col gap-1 items-center justify-center">
-              <div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
-              <div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
+          <div class="w-full flex flex-col items-center gap-6">
+            <div class="text-20-medium text-text-strong text-center">{language.t("home.empty.title")}</div>
+
+            <div class="w-full bg-surface-base border border-border-base rounded-xl p-4 flex flex-col gap-3 shadow-md">
+              <div class="text-14-regular text-text-weak w-full min-h-[4rem] cursor-pointer" onClick={chooseProject}>
+                Ask anything, / for commands, @ for context...
+              </div>
+
+              <div class="flex flex-wrap items-center gap-2 pt-3 border-t border-border-weak-base">
+                <Button
+                  size="small"
+                  variant="ghost"
+                  class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
+                  onClick={chooseProject}
+                >
+                  <Icon name="folder" size="small" class="shrink-0" />
+                  <span>Open project</span>
+                </Button>
+
+                <Button
+                  size="small"
+                  variant="ghost"
+                  class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
+                  onClick={handleModelSelect}
+                >
+                  <Icon name="brain" size="small" class="shrink-0" />
+                  <span>Model: {activeModelName()}</span>
+                </Button>
+              </div>
             </div>
-            <Button class="px-3 mt-1" onClick={chooseProject}>
-              {language.t("command.project.open")}
-            </Button>
           </div>
         </Match>
       </Switch>

+ 94 - 58
packages/app/src/pages/layout.tsx

@@ -14,6 +14,7 @@ import {
 } from "solid-js"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { useLocation, useNavigate, useParams } from "@solidjs/router"
+import { useQuery } from "@tanstack/solid-query"
 import { useLayout, LocalProject } from "@/context/layout"
 import { useGlobalSync } from "@/context/global-sync"
 import { Persist, persisted } from "@/utils/persist"
@@ -61,7 +62,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
 import { useCommand, type CommandOption } from "@/context/command"
 import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
 import { DebugBar } from "@/components/debug-bar"
-import { Titlebar } from "@/components/titlebar"
+import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
 import { useServer } from "@/context/server"
 import { useLanguage, type Locale } from "@/context/language"
 import { pathKey } from "@/utils/path-key"
@@ -88,6 +89,8 @@ import {
 import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
 import { SidebarContent } from "./layout/sidebar-shell"
 
+const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
+
 export default function Layout(props: ParentProps) {
   const [store, setStore, , ready] = persisted(
     Persist.global("layout.page", ["layout.page.v1"]),
@@ -151,7 +154,7 @@ export default function Layout(props: ParentProps) {
   const currentDir = createMemo(() => route().dir)
 
   const [state, setState] = createStore({
-    autoselect: !initialDirectory,
+    autoselect: !initialDirectory && !USE_HOME_DESIGN,
     busyWorkspaces: {} as Record<string, boolean>,
     hoverProject: undefined as string | undefined,
     scrollSessionKey: undefined as string | undefined,
@@ -162,6 +165,35 @@ export default function Layout(props: ParentProps) {
     peeked: false,
   })
 
+  const [update, setUpdate] = createStore({
+    installing: false,
+  })
+  const updateQuery = useQuery(() => ({
+    queryKey: ["desktop", "update"] as const,
+    enabled: () =>
+      !!platform.checkUpdate && !!platform.updateAndRestart && settings.ready() && settings.updates.startup(),
+    queryFn: () => platform.checkUpdate?.() ?? Promise.resolve({ updateAvailable: false, version: undefined }),
+    refetchInterval: (query) => (query.state.data?.updateAvailable ? false : 10 * 60 * 1000),
+  }))
+  const updateVersion = () => {
+    if (!settings.ready()) return
+    if (!settings.updates.startup()) return
+    if (!updateQuery.data?.updateAvailable) return
+    return updateQuery.data.version ?? ""
+  }
+  const installUpdate = () => {
+    if (!platform.updateAndRestart) return
+    setUpdate("installing", true)
+    void platform.updateAndRestart().catch(() => {
+      setUpdate("installing", false)
+    })
+  }
+  const titlebarUpdate: TitlebarUpdate = {
+    version: updateVersion,
+    installing: () => update.installing,
+    install: installUpdate,
+  }
+
   const editor = createInlineEditorController()
   const setBusy = (directory: string, value: boolean) => {
     const key = pathKey(directory)
@@ -364,58 +396,6 @@ export default function Layout(props: ParentProps) {
     setLocale(next)
   }
 
-  const useUpdatePolling = () =>
-    onMount(() => {
-      if (!platform.checkUpdate || !platform.updateAndRestart) return
-
-      let toastId: number | undefined
-      let interval: ReturnType<typeof setInterval> | undefined
-
-      const pollUpdate = () =>
-        platform.checkUpdate!().then(({ updateAvailable, version }) => {
-          if (!updateAvailable) return
-          if (toastId !== undefined) return
-          toastId = showToast({
-            persistent: true,
-            icon: "download",
-            title: language.t("toast.update.title"),
-            description: language.t("toast.update.description", { version: version ?? "" }),
-            actions: [
-              {
-                label: language.t("toast.update.action.installRestart"),
-                onClick: async () => {
-                  await platform.updateAndRestart!()
-                },
-              },
-              {
-                label: language.t("toast.update.action.notYet"),
-                onClick: "dismiss",
-              },
-            ],
-          })
-        })
-
-      createEffect(() => {
-        if (!settings.ready()) return
-
-        if (!settings.updates.startup()) {
-          if (interval === undefined) return
-          clearInterval(interval)
-          interval = undefined
-          return
-        }
-
-        if (interval !== undefined) return
-        void pollUpdate()
-        interval = setInterval(pollUpdate, 10 * 60 * 1000)
-      })
-
-      onCleanup(() => {
-        if (interval === undefined) return
-        clearInterval(interval)
-      })
-    })
-
   const useSDKNotificationToasts = () =>
     onMount(() => {
       const toastBySession = new Map<string, number>()
@@ -535,7 +515,6 @@ export default function Layout(props: ParentProps) {
       })
     })
 
-  useUpdatePolling()
   useSDKNotificationToasts()
 
   function scrollToSession(sessionId: string, sessionKey: string) {
@@ -1838,8 +1817,10 @@ export default function Layout(props: ParentProps) {
   )
 
   createEffect(() => {
-    const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
-    document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
+    document.documentElement.style.setProperty(
+      "--dialog-left-margin",
+      USE_HOME_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
+    )
   })
 
   const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
@@ -2380,10 +2361,31 @@ export default function Layout(props: ParentProps) {
     />
   )
 
+  if (USE_HOME_DESIGN) {
+    return (
+      <div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
+        {autoselecting() ?? ""}
+        <Titlebar update={titlebarUpdate} />
+        <div class="flex-1 min-h-0 min-w-0 flex">
+          <main class="size-full overflow-x-hidden flex flex-col items-start contain-strict p-2 pt-0">
+            <Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
+              {props.children}
+            </Show>
+          </main>
+          {import.meta.env.DEV && <DebugBar />}
+        </div>
+        <Toast.Region />
+      </div>
+    )
+  }
+
   return (
     <div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
       {autoselecting() ?? ""}
-      <Titlebar />
+      <Titlebar update={titlebarUpdate} />
+      <Show when={updateVersion() !== undefined}>
+        <UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
+      </Show>
       <div class="flex-1 min-h-0 min-w-0 flex">
         <div class="flex-1 min-h-0 relative">
           <div class="size-full relative overflow-x-hidden">
@@ -2531,3 +2533,37 @@ export default function Layout(props: ParentProps) {
     </div>
   )
 }
+
+function UpdateAvailableToast(props: {
+  version: string
+  install: () => void
+  language: ReturnType<typeof useLanguage>
+}) {
+  let toastId: number | undefined
+
+  onMount(() => {
+    toastId = showToast({
+      persistent: true,
+      icon: "download",
+      title: props.language.t("toast.update.title"),
+      description: props.language.t("toast.update.description", { version: props.version }),
+      actions: [
+        {
+          label: props.language.t("toast.update.action.installRestart"),
+          onClick: props.install,
+        },
+        {
+          label: props.language.t("toast.update.action.notYet"),
+          onClick: "dismiss",
+        },
+      ],
+    })
+  })
+
+  onCleanup(() => {
+    if (toastId === undefined) return
+    toaster.dismiss(toastId)
+  })
+
+  return null
+}

+ 24 - 0
packages/app/src/pages/layout/helpers.ts

@@ -55,6 +55,30 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri
 export const displayName = (project: { name?: string; worktree: string }) =>
   project.name || getFilename(project.worktree)
 
+const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
+
+export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
+  if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg"
+  if (icon?.override) return icon.override
+  if (icon?.color) return undefined
+  return icon?.url
+}
+
+export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
+  session: Session,
+  projects: T[],
+  byID: Map<string, T>,
+) {
+  const direct = byID.get(session.projectID)
+  if (direct) return direct
+  const directory = pathKey(session.directory)
+  return projects.find(
+    (project) =>
+      pathKey(project.worktree) === directory ||
+      project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
+  )
+}
+
 export const errorMessage = (err: unknown, fallback: string) => {
   if (err && typeof err === "object" && "data" in err) {
     const data = (err as { data?: { message?: string } }).data

+ 1 - 10
packages/app/src/pages/layout/sidebar-items.tsx

@@ -15,16 +15,7 @@ import { usePermission } from "@/context/permission"
 import { messageAgentColor } from "@/utils/agent"
 import { sessionTitle } from "@/utils/session-title"
 import { sessionPermissionRequest } from "../session/composer/session-request-tree"
-import { childSessionOnPath, hasProjectPermissions } from "./helpers"
-
-const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
-
-export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
-  if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg"
-  if (icon?.override) return icon?.override
-  if (icon?.color) return undefined
-  return icon?.url
-}
+import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
 
 export const ProjectIcon = (props: {
   project: LocalProject

+ 71 - 52
packages/app/src/pages/session.tsx

@@ -30,7 +30,7 @@ import { Button } from "@opencode-ai/ui/button"
 import { showToast } from "@opencode-ai/ui/toast"
 import { checksum } from "@opencode-ai/core/util/encode"
 import { useLocation, useSearchParams } from "@solidjs/router"
-import { NewSessionView, SessionHeader } from "@/components/session"
+import { NewSessionDesignView, NewSessionView, SessionHeader } from "@/components/session"
 import { useComments } from "@/context/comments"
 import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
 import { useGlobalSync } from "@/context/global-sync"
@@ -73,6 +73,7 @@ const emptyFollowups: FollowupItem[] = []
 
 type ChangeMode = "git" | "branch" | "turn"
 type VcsMode = "git" | "branch"
+const USE_NEW_SESSION_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
 
 type SessionHistoryWindowInput = {
   sessionID: () => string | undefined
@@ -1648,8 +1649,69 @@ export default function Page() {
 
   useUsageExceededDialogs()
 
+  const composerRegion = (placement: "dock" | "inline") => (
+    <SessionComposerRegion
+      state={composer}
+      ready={!store.deferRender && messagesReady()}
+      centered={placement === "dock" && centered()}
+      placement={placement}
+      inputRef={(el) => {
+        inputRef = el
+      }}
+      newSessionWorktree={newSessionWorktree()}
+      onNewSessionWorktreeChange={(value) => setStore("newSessionWorktree", value)}
+      onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
+      onSubmit={() => {
+        comments.clear()
+        resumeScroll()
+      }}
+      onResponseSubmit={resumeScroll}
+      followup={
+        params.id && !isChildSession()
+          ? {
+              queue: queueEnabled,
+              items: followupDock(),
+              sending: sendingFollowup(),
+              edit: editingFollowup(),
+              onQueue: queueFollowup,
+              onAbort: () => {
+                const id = params.id
+                if (!id) return
+                setFollowup("paused", id, true)
+              },
+              onSend: (id) => {
+                void sendFollowup(params.id!, id, { manual: true })
+              },
+              onEdit: editFollowup,
+              onEditLoaded: clearFollowupEdit,
+            }
+          : undefined
+      }
+      revert={
+        rolled().length > 0
+          ? {
+              items: rolled(),
+              restoring: restoring(),
+              disabled: reverting(),
+              onRestore: restore,
+            }
+          : undefined
+      }
+      setPromptDockRef={(el) => {
+        promptDock = el
+      }}
+    />
+  )
+
+  const USE_NEW_LAYOUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
+
   return (
-    <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
+    <div
+      class="relative bg-background-base size-full overflow-hidden flex flex-col"
+      classList={{
+        "p-2 pt-0 bg-v2-background-bg-deep": USE_NEW_LAYOUT,
+      }}
+    >
       {sessionSync() ?? ""}
       <SessionHeader />
       <div class="flex-1 min-h-0 flex flex-col md:flex-row">
@@ -1684,6 +1746,7 @@ export default function Page() {
             "@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
             "transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
               !size.active() && !ui.reviewSnap,
+            "rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": USE_NEW_LAYOUT && !!params.id,
           }}
           style={{
             width: sessionPanelWidth(),
@@ -1740,60 +1803,16 @@ export default function Page() {
                 </Show>
               </Match>
               <Match when={true}>
-                <NewSessionView worktree={newSessionWorktree()} />
+                <Show when={USE_NEW_SESSION_DESIGN} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
+                  <NewSessionDesignView worktree={newSessionWorktree()}>
+                    {composerRegion("inline")}
+                  </NewSessionDesignView>
+                </Show>
               </Match>
             </Switch>
           </div>
 
-          <SessionComposerRegion
-            state={composer}
-            ready={!store.deferRender && messagesReady()}
-            centered={centered()}
-            inputRef={(el) => {
-              inputRef = el
-            }}
-            newSessionWorktree={newSessionWorktree()}
-            onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
-            onSubmit={() => {
-              comments.clear()
-              resumeScroll()
-            }}
-            onResponseSubmit={resumeScroll}
-            followup={
-              params.id && !isChildSession()
-                ? {
-                    queue: queueEnabled,
-                    items: followupDock(),
-                    sending: sendingFollowup(),
-                    edit: editingFollowup(),
-                    onQueue: queueFollowup,
-                    onAbort: () => {
-                      const id = params.id
-                      if (!id) return
-                      setFollowup("paused", id, true)
-                    },
-                    onSend: (id) => {
-                      void sendFollowup(params.id!, id, { manual: true })
-                    },
-                    onEdit: editFollowup,
-                    onEditLoaded: clearFollowupEdit,
-                  }
-                : undefined
-            }
-            revert={
-              rolled().length > 0
-                ? {
-                    items: rolled(),
-                    restoring: restoring(),
-                    disabled: reverting(),
-                    onRestore: restore,
-                  }
-                : undefined
-            }
-            setPromptDockRef={(el) => {
-              promptDock = el
-            }}
-          />
+          <Show when={params.id || !USE_NEW_SESSION_DESIGN}>{composerRegion("dock")}</Show>
 
           <Show when={desktopReviewOpen()}>
             <div onPointerDown={() => size.start()}>

+ 9 - 1
packages/app/src/pages/session/composer/session-composer-region.tsx

@@ -22,8 +22,10 @@ export function SessionComposerRegion(props: {
   state: SessionComposerState
   ready: boolean
   centered: boolean
+  placement?: "dock" | "inline"
   inputRef: (el: HTMLDivElement) => void
   newSessionWorktree: string
+  onNewSessionWorktreeChange?: (worktree: string) => void
   onNewSessionWorktreeReset: () => void
   onSubmit: () => void
   onResponseSubmit: () => void
@@ -142,11 +144,15 @@ export function SessionComposerRegion(props: {
     <div
       ref={props.setPromptDockRef}
       data-component="session-prompt-dock"
-      class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
+      classList={{
+        "w-full flex flex-col justify-center items-center pointer-events-none": true,
+        "shrink-0 pb-3 bg-background-stronger": props.placement !== "inline",
+      }}
     >
       <div
         classList={{
           "w-full px-3 pointer-events-auto": true,
+          "max-w-[720px] px-0": props.placement === "inline",
           "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
         }}
       >
@@ -256,8 +262,10 @@ export function SessionComposerRegion(props: {
                 fallback={
                   <Show when={!props.state.blocked()}>
                     <PromptInput
+                      variant={props.placement === "inline" ? "new-session" : undefined}
                       ref={props.inputRef}
                       newSessionWorktree={props.newSessionWorktree}
+                      onNewSessionWorktreeChange={props.onNewSessionWorktreeChange}
                       onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
                       edit={props.followup?.edit}
                       onEditLoaded={props.followup?.onEditLoaded}

+ 7 - 6
packages/app/src/pages/session/message-timeline.tsx

@@ -1236,7 +1236,6 @@ export function MessageTimeline(props: {
         onClick={props.onAutoScrollInteraction}
         class="relative min-w-0 w-full h-full"
         style={{
-          "--session-title-height": showHeader() ? "40px" : "0px",
           "--sticky-accordion-top": showHeader() ? "48px" : "0px",
         }}
       >
@@ -1260,12 +1259,14 @@ export function MessageTimeline(props: {
                 data-component="session-progress"
                 data-state={workingStatus()}
                 aria-hidden="true"
-                style={{
-                  "--session-progress-color": tint() ?? "var(--icon-interactive-base)",
-                  "--session-progress-ms": `${bar.ms}ms`,
-                }}
               >
-                <div data-component="session-progress-bar" />
+                <div
+                  data-component="session-progress-bar"
+                  style={{
+                    background: tint() ?? "var(--icon-interactive-base)",
+                    animation: `session-progress-whip ${bar.ms}ms infinite`,
+                  }}
+                />
               </div>
             </Show>
             <div class="h-12 w-full flex items-center justify-between gap-2">

+ 17 - 3
packages/desktop/src/main/updater.ts

@@ -4,6 +4,9 @@ import { UPDATER_ENABLED } from "./constants"
 import { getLogger } from "./logging"
 
 const { autoUpdater } = pkg
+type UpdateCheckResult = { updateAvailable: boolean; version?: string; failed?: boolean }
+let downloadedVersion: string | undefined
+let pendingCheck: Promise<UpdateCheckResult> | undefined
 
 export function setupAutoUpdater() {
   if (!UPDATER_ENABLED) return
@@ -22,8 +25,18 @@ export function setupAutoUpdater() {
   })
 }
 
-export async function checkUpdate() {
+export async function checkUpdate(): Promise<UpdateCheckResult> {
   if (!UPDATER_ENABLED) return { updateAvailable: false }
+  if (downloadedVersion) return { updateAvailable: true, version: downloadedVersion }
+  if (pendingCheck) return pendingCheck
+
+  pendingCheck = checkAndDownloadUpdate().finally(() => {
+    pendingCheck = undefined
+  })
+  return pendingCheck
+}
+
+async function checkAndDownloadUpdate(): Promise<UpdateCheckResult> {
   const logger = getLogger()
   logger.log("checking for updates", {
     currentVersion: app.getVersion(),
@@ -49,6 +62,7 @@ export async function checkUpdate() {
     }
     logger.log("update available", { version })
     await autoUpdater.downloadUpdate()
+    downloadedVersion = version
     logger.log("update download completed", { version })
     return { updateAvailable: true, version }
   } catch (error) {
@@ -58,9 +72,9 @@ export async function checkUpdate() {
 }
 
 export async function installUpdate(killSidecar: () => Promise<void>) {
-  const result = await checkUpdate()
+  const result = downloadedVersion ? { updateAvailable: true, version: downloadedVersion } : await checkUpdate()
   const logger = getLogger()
-  if (!result.updateAvailable) {
+  if (!result.updateAvailable || !downloadedVersion) {
     logger.log("install update skipped", {
       reason: result.failed ? "update check failed" : "no update available",
     })

+ 55 - 0
packages/ui/script/colors.txt

@@ -227,3 +227,58 @@
 --border-color: #FFFFFF;
 --button-ghost-hover: var(--smoke-light-alpha-2);
 --button-ghost-hover2: var(--smoke-light-alpha-3);
+
+--v2-background-bg-base: var(--v2-grey-100);
+--v2-background-bg-deep: var(--v2-grey-200);
+--v2-background-bg-layer-01: var(--v2-grey-200);
+--v2-background-bg-layer-02: var(--v2-grey-300);
+--v2-background-bg-layer-03: var(--v2-grey-400);
+--v2-background-bg-inverse: var(--v2-grey-1000);
+--v2-background-bg-contrast: var(--v2-grey-900);
+--v2-background-bg-button-neutral: var(--v2-grey-100);
+--v2-background-bg-accent: var(--v2-blue-600);
+
+--v2-text-text-base: var(--v2-grey-1000);
+--v2-text-text-muted: var(--v2-grey-700);
+--v2-text-text-faint: var(--v2-grey-600);
+--v2-text-text-inverse: var(--v2-grey-100);
+--v2-text-text-contrast: var(--v2-grey-100);
+--v2-text-text-accent: var(--v2-blue-600);
+--v2-text-text-accent-hover: var(--v2-blue-700);
+
+--v2-icon-icon-base: var(--v2-grey-800);
+--v2-icon-icon-muted: var(--v2-grey-600);
+--v2-icon-icon-inverse: var(--v2-grey-100);
+--v2-icon-icon-contrast: var(--v2-grey-200);
+--v2-icon-icon-accent: var(--v2-blue-600);
+--v2-icon-icon-accent-hover: var(--v2-blue-700);
+
+--v2-border-border-muted: var(--v2-alpha-dark-8);
+--v2-border-border-base: var(--v2-alpha-dark-10);
+--v2-border-border-strong: var(--v2-alpha-dark-20);
+--v2-border-border-inverse: var(--v2-grey-1000);
+--v2-border-border-focus: var(--v2-blue-500);
+
+--v2-overlay-simple-overlay-hover: var(--v2-alpha-dark-4);
+--v2-overlay-simple-overlay-pressed: var(--v2-alpha-dark-8);
+--v2-overlay-simple-overlay-contrast-hover: var(--v2-alpha-light-12);
+--v2-overlay-simple-overlay-contrast-pressed: var(--v2-alpha-light-24);
+--v2-overlay-simple-overlay-scrim: var(--v2-alpha-dark-40);
+--v2-overlay-gradient-depth-overlay-depth-top: var(--v2-alpha-light-100);
+--v2-overlay-gradient-depth-overlay-depth-bot: var(--v2-alpha-light-0);
+--v2-overlay-simple-tab-active-scrim: #fafafa00;
+--v2-overlay-simple-tab-hover-scrim: #eeeeee00;
+--v2-overlay-simple-tab-scrim: #fafafa00;
+
+--v2-state-bg-success: var(--v2-green-100);
+--v2-state-fg-success: var(--v2-green-800);
+--v2-state-border-success: var(--v2-green-300);
+--v2-state-bg-warning: var(--v2-yellow-100);
+--v2-state-fg-warning: var(--v2-yellow-800);
+--v2-state-border-warning: var(--v2-yellow-300);
+--v2-state-bg-danger: var(--v2-red-100);
+--v2-state-fg-danger: var(--v2-red-800);
+--v2-state-border-danger: var(--v2-red-300);
+--v2-state-bg-info: var(--v2-blue-100);
+--v2-state-fg-info: var(--v2-blue-800);
+  --v2-state-border-info: var(--v2-blue-300);

+ 1 - 1
packages/ui/src/components/logo.tsx

@@ -1,4 +1,4 @@
-import { ComponentProps } from "solid-js"
+import { type ComponentProps } from "solid-js"
 
 export const Mark = (props: { class?: string }) => {
   return (

+ 50 - 1
packages/ui/src/styles/tailwind/colors.css

@@ -232,4 +232,53 @@
   --color-border-color: var(--border-color);
   --color-button-ghost-hover: var(--button-ghost-hover);
   --color-button-ghost-hover2: var(--button-ghost-hover2);
-}
+  --color-v2-background-bg-base: var(--v2-background-bg-base);
+  --color-v2-background-bg-deep: var(--v2-background-bg-deep);
+  --color-v2-background-bg-layer-01: var(--v2-background-bg-layer-01);
+  --color-v2-background-bg-layer-02: var(--v2-background-bg-layer-02);
+  --color-v2-background-bg-layer-03: var(--v2-background-bg-layer-03);
+  --color-v2-background-bg-inverse: var(--v2-background-bg-inverse);
+  --color-v2-background-bg-contrast: var(--v2-background-bg-contrast);
+  --color-v2-background-bg-button-neutral: var(--v2-background-bg-button-neutral);
+  --color-v2-background-bg-accent: var(--v2-background-bg-accent);
+  --color-v2-text-text-base: var(--v2-text-text-base);
+  --color-v2-text-text-muted: var(--v2-text-text-muted);
+  --color-v2-text-text-faint: var(--v2-text-text-faint);
+  --color-v2-text-text-inverse: var(--v2-text-text-inverse);
+  --color-v2-text-text-contrast: var(--v2-text-text-contrast);
+  --color-v2-text-text-accent: var(--v2-text-text-accent);
+  --color-v2-text-text-accent-hover: var(--v2-text-text-accent-hover);
+  --color-v2-icon-icon-base: var(--v2-icon-icon-base);
+  --color-v2-icon-icon-muted: var(--v2-icon-icon-muted);
+  --color-v2-icon-icon-inverse: var(--v2-icon-icon-inverse);
+  --color-v2-icon-icon-contrast: var(--v2-icon-icon-contrast);
+  --color-v2-icon-icon-accent: var(--v2-icon-icon-accent);
+  --color-v2-icon-icon-accent-hover: var(--v2-icon-icon-accent-hover);
+  --color-v2-border-border-muted: var(--v2-border-border-muted);
+  --color-v2-border-border-base: var(--v2-border-border-base);
+  --color-v2-border-border-strong: var(--v2-border-border-strong);
+  --color-v2-border-border-inverse: var(--v2-border-border-inverse);
+  --color-v2-border-border-focus: var(--v2-border-border-focus);
+  --color-v2-overlay-simple-overlay-hover: var(--v2-overlay-simple-overlay-hover);
+  --color-v2-overlay-simple-overlay-pressed: var(--v2-overlay-simple-overlay-pressed);
+  --color-v2-overlay-simple-overlay-contrast-hover: var(--v2-overlay-simple-overlay-contrast-hover);
+  --color-v2-overlay-simple-overlay-contrast-pressed: var(--v2-overlay-simple-overlay-contrast-pressed);
+  --color-v2-overlay-simple-overlay-scrim: var(--v2-overlay-simple-overlay-scrim);
+  --color-v2-overlay-gradient-depth-overlay-depth-top: var(--v2-overlay-gradient-depth-overlay-depth-top);
+  --color-v2-overlay-gradient-depth-overlay-depth-bot: var(--v2-overlay-gradient-depth-overlay-depth-bot);
+  --color-v2-overlay-simple-tab-active-scrim: var(--v2-overlay-simple-tab-active-scrim);
+  --color-v2-overlay-simple-tab-hover-scrim: var(--v2-overlay-simple-tab-hover-scrim);
+  --color-v2-overlay-simple-tab-scrim: var(--v2-overlay-simple-tab-scrim);
+  --color-v2-state-bg-success: var(--v2-state-bg-success);
+  --color-v2-state-fg-success: var(--v2-state-fg-success);
+  --color-v2-state-border-success: var(--v2-state-border-success);
+  --color-v2-state-bg-warning: var(--v2-state-bg-warning);
+  --color-v2-state-fg-warning: var(--v2-state-fg-warning);
+  --color-v2-state-border-warning: var(--v2-state-border-warning);
+  --color-v2-state-bg-danger: var(--v2-state-bg-danger);
+  --color-v2-state-fg-danger: var(--v2-state-fg-danger);
+  --color-v2-state-border-danger: var(--v2-state-border-danger);
+  --color-v2-state-bg-info: var(--v2-state-bg-info);
+  --color-v2-state-fg-info: var(--v2-state-fg-info);
+  --color-v2-state-border-info: var(--v2-state-border-info);
+}

+ 5 - 5
packages/ui/src/v2/components/avatar-v2.css

@@ -1,6 +1,6 @@
 [data-component="avatar"] {
-  --avatar-bg: var(--background-bg-layer-02);
-  --avatar-fg: var(--text-text-muted);
+  --avatar-bg: var(--v2-background-bg-layer-02);
+  --avatar-fg: var(--v2-text-text-muted);
   --avatar-radius: 9999px;
   --avatar-font-size: 11px;
   --avatar-tracking: 0.05px;
@@ -13,8 +13,8 @@
   width: 28px;
   height: 28px;
   border-radius: var(--avatar-radius);
-  border: 0.5px solid var(--border-border-base);
-  font-family: var(--font-sans);
+  border: 0.5px solid var(--v2-border-border-base);
+  font-family: var(--font-family-sans);
   font-weight: 530;
   font-size: var(--avatar-font-size);
   line-height: 1;
@@ -30,7 +30,7 @@
   content: "";
   position: absolute;
   inset: 0;
-  background: linear-gradient(180deg, var(--alpha-light-16) 0%, var(--alpha-light-0) 100%);
+  background: linear-gradient(180deg, var(--v2-alpha-light-16) 0%, var(--v2-alpha-light-0) 100%);
   pointer-events: none;
 }
 

+ 24 - 24
packages/ui/src/v2/components/button-v2.css

@@ -16,7 +16,7 @@
   font-weight: 530;
   font-size: 13px;
   line-height: 1;
-  color: var(--text-text-base);
+  color: var(--v2-text-text-base);
   text-shadow: none;
   font-variant-numeric: tabular-nums;
   letter-spacing: -0.04px;
@@ -30,7 +30,7 @@
 }
 
 [data-component="button-v2"]:is(:focus-visible, [data-state="focus"]):not(:disabled) {
-  outline: 2px solid var(--border-border-focus);
+  outline: 2px solid var(--v2-border-border-focus);
   outline-offset: 2.5px;
 }
 
@@ -68,21 +68,21 @@
 
 /* Neutral */
 [data-component="button-v2"][data-variant="neutral"] {
-  background-color: var(--background-bg-button-neutral);
-  color: var(--text-text-base);
-  box-shadow: var(--elevation-button-neutral);
+  background-color: var(--v2-background-bg-button-neutral);
+  color: var(--v2-text-text-base);
+  box-shadow: var(--v2-elevation-button-neutral);
 }
 
 [data-component="button-v2"][data-variant="neutral"]:is(:hover, [data-state="hover"]):not(:disabled) {
   background-image:
-    linear-gradient(90deg, var(--overlay-simple-overlay-hover) 0%, var(--overlay-simple-overlay-hover) 100%),
-    linear-gradient(90deg, var(--background-bg-button-neutral) 0%, var(--background-bg-button-neutral) 100%);
+    linear-gradient(90deg, var(--v2-overlay-simple-overlay-hover) 0%, var(--v2-overlay-simple-overlay-hover) 100%),
+    linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%);
 }
 
 [data-component="button-v2"][data-variant="neutral"]:is(:active, [data-state="pressed"]):not(:disabled) {
   background-image:
-    linear-gradient(90deg, var(--overlay-simple-overlay-pressed) 0%, var(--overlay-simple-overlay-pressed) 100%),
-    linear-gradient(90deg, var(--background-bg-button-neutral) 0%, var(--background-bg-button-neutral) 100%);
+    linear-gradient(90deg, var(--v2-overlay-simple-overlay-pressed) 0%, var(--v2-overlay-simple-overlay-pressed) 100%),
+    linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%);
 }
 
 [data-component="button-v2"][data-variant="neutral"]:is(:disabled, [data-state="disabled"]) {
@@ -93,33 +93,33 @@
 /* Contrast */
 [data-component="button-v2"][data-variant="contrast"] {
   background-image:
-    linear-gradient(180deg, var(--alpha-light-20) 0%, var(--alpha-light-0) 100%),
-    linear-gradient(90deg, var(--background-bg-contrast) 0%, var(--background-bg-contrast) 100%);
-  color: var(--text-text-contrast);
+    linear-gradient(180deg, var(--v2-alpha-light-20) 0%, var(--v2-alpha-light-0) 100%),
+    linear-gradient(90deg, var(--v2-background-bg-contrast) 0%, var(--v2-background-bg-contrast) 100%);
+  color: var(--v2-text-text-contrast);
   text-shadow: 0 0.5px 0.5px rgba(0, 0, 0, 0.3);
-  box-shadow: var(--elevation-button-contrast);
+  box-shadow: var(--v2-elevation-button-contrast);
 }
 
 [data-component="button-v2"][data-variant="contrast"]:is(:hover, [data-state="hover"]):not(:disabled) {
   background-image:
     linear-gradient(
       90deg,
-      var(--overlay-simple-overlay-contrast-hover) 0%,
-      var(--overlay-simple-overlay-contrast-hover) 100%
+      var(--v2-overlay-simple-overlay-contrast-hover) 0%,
+      var(--v2-overlay-simple-overlay-contrast-hover) 100%
     ),
-    linear-gradient(180deg, var(--alpha-light-20) 0%, var(--alpha-light-0) 100%),
-    linear-gradient(90deg, var(--background-bg-contrast) 0%, var(--background-bg-contrast) 100%);
+    linear-gradient(180deg, var(--v2-alpha-light-20) 0%, var(--v2-alpha-light-0) 100%),
+    linear-gradient(90deg, var(--v2-background-bg-contrast) 0%, var(--v2-background-bg-contrast) 100%);
 }
 
 [data-component="button-v2"][data-variant="contrast"]:is(:active, [data-state="pressed"]):not(:disabled) {
   background-image:
     linear-gradient(
       90deg,
-      var(--overlay-simple-overlay-contrast-pressed) 0%,
-      var(--overlay-simple-overlay-contrast-pressed) 100%
+      var(--v2-overlay-simple-overlay-contrast-pressed) 0%,
+      var(--v2-overlay-simple-overlay-contrast-pressed) 100%
     ),
-    linear-gradient(180deg, var(--alpha-light-20) 0%, var(--alpha-light-0) 100%),
-    linear-gradient(90deg, var(--background-bg-contrast) 0%, var(--background-bg-contrast) 100%);
+    linear-gradient(180deg, var(--v2-alpha-light-20) 0%, var(--v2-alpha-light-0) 100%),
+    linear-gradient(90deg, var(--v2-background-bg-contrast) 0%, var(--v2-background-bg-contrast) 100%);
 }
 
 [data-component="button-v2"][data-variant="contrast"]:is(:disabled, [data-state="disabled"]) {
@@ -130,15 +130,15 @@
 /* Ghost */
 [data-component="button-v2"][data-variant="ghost"] {
   background-color: transparent;
-  color: var(--text-text-base);
+  color: var(--v2-text-text-base);
 }
 
 [data-component="button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled) {
-  background-color: var(--overlay-simple-overlay-hover);
+  background-color: var(--v2-overlay-simple-overlay-hover);
 }
 
 [data-component="button-v2"][data-variant="ghost"]:is(:active, [data-state="pressed"]):not(:disabled) {
-  background-color: var(--overlay-simple-overlay-pressed);
+  background-color: var(--v2-overlay-simple-overlay-pressed);
 }
 
 [data-component="button-v2"][data-variant="ghost"]:is(:disabled, [data-state="disabled"]) {

+ 73 - 8
packages/ui/src/v2/components/icon.tsx

@@ -1,29 +1,94 @@
-import { type ComponentProps, splitProps } from "solid-js"
+import { onMount, type ComponentProps, splitProps } from "solid-js"
+
+const icons = {
+  edit: {
+    viewBox: "0 0 20 20",
+    body: `<path d="M17.0832 17.0807V17.5807H17.5832V17.0807H17.0832ZM2.9165 17.0807H2.4165V17.5807H2.9165V17.0807ZM2.9165 2.91406V2.41406H2.4165V2.91406H2.9165ZM9.58317 3.41406H10.0832V2.41406H9.58317V2.91406V3.41406ZM17.5832 10.4141V9.91406H16.5832V10.4141H17.0832H17.5832ZM6.24984 11.2474L5.89628 10.8938L5.74984 11.0403V11.2474H6.24984ZM6.24984 13.7474H5.74984V14.2474H6.24984V13.7474ZM8.74984 13.7474V14.2474H8.95694L9.10339 14.101L8.74984 13.7474ZM15.2082 2.28906L15.5617 1.93551L15.2082 1.58196L14.8546 1.93551L15.2082 2.28906ZM17.7082 4.78906L18.0617 5.14262L18.4153 4.78906L18.0617 4.43551L17.7082 4.78906ZM17.0832 17.0807V16.5807H2.9165V17.0807V17.5807H17.0832V17.0807ZM2.9165 17.0807H3.4165V2.91406H2.9165H2.4165V17.0807H2.9165ZM2.9165 2.91406V3.41406H9.58317V2.91406V2.41406H2.9165V2.91406ZM17.0832 10.4141H16.5832V17.0807H17.0832H17.5832V10.4141H17.0832ZM6.24984 11.2474H5.74984V13.7474H6.24984H6.74984V11.2474H6.24984ZM6.24984 13.7474V14.2474H8.74984V13.7474V13.2474H6.24984V13.7474ZM6.24984 11.2474L6.60339 11.6009L15.5617 2.64262L15.2082 2.28906L14.8546 1.93551L5.89628 10.8938L6.24984 11.2474ZM15.2082 2.28906L14.8546 2.64262L17.3546 5.14262L17.7082 4.78906L18.0617 4.43551L15.5617 1.93551L15.2082 2.28906ZM17.7082 4.78906L17.3546 4.43551L8.39628 13.3938L8.74984 13.7474L9.10339 14.101L18.0617 5.14262L17.7082 4.78906Z" fill="currentColor"/>`,
+  },
+  "folder-add-left": {
+    viewBox: "0 0 20 20",
+    body: `<path d="M2.08333 9.58268V2.91602H8.33333L10 5.41602H17.9167V16.2493H8.75M3.75 12.0827V14.5827M3.75 14.5827V17.0827M3.75 14.5827H1.25M3.75 14.5827H6.25" stroke="currentColor" stroke-linecap="square"/>`,
+  },
+  "grid-plus": {
+    viewBox: "0 0 16 16",
+    body: `<path d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
+  },
+  help: {
+    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"/>`,
+  },
+  "magnifying-glass": {
+    viewBox: "0 0 16 16",
+    body: `<path d="M13 13L10.6418 10.6418M11.9552 7.47761C11.9552 9.95053 9.95053 11.9552 7.47761 11.9552C5.0047 11.9552 3 9.95053 3 7.47761C3 5.0047 5.0047 3 7.47761 3C9.95053 3 11.9552 5.0047 11.9552 7.47761Z" stroke="currentColor" stroke-linecap="square" vector-effect="non-scaling-stroke"/>`,
+  },
+  menu: {
+    viewBox: "0 0 16 16",
+    body: `<path d="M2 8H14M2 4.664H14M2 11.336H14" stroke="currentColor"/>`,
+  },
+  plus: {
+    viewBox: "0 0 16 16",
+    body: `<path d="M8 2.88867V13.1109" stroke="currentColor" stroke-linejoin="round"/><path d="M2.88867 8H13.1109" stroke="currentColor" stroke-linejoin="round"/>`,
+  },
+  "settings-gear": {
+    viewBox: "0 0 20 20",
+    body: `<path d="M7.62516 4.46094L5.05225 3.86719L3.86475 5.05469L4.4585 7.6276L2.0835 9.21094V10.7943L4.4585 12.3776L3.86475 14.9505L5.05225 16.138L7.62516 15.5443L9.2085 17.9193H10.7918L12.3752 15.5443L14.9481 16.138L16.1356 14.9505L15.5418 12.3776L17.9168 10.7943V9.21094L15.5418 7.6276L16.1356 5.05469L14.9481 3.86719L12.3752 4.46094L10.7918 2.08594H9.2085L7.62516 4.46094Z" stroke="currentColor"/><path d="M12.5002 10.0026C12.5002 11.3833 11.3809 12.5026 10.0002 12.5026C8.61945 12.5026 7.50016 11.3833 7.50016 10.0026C7.50016 8.62189 8.61945 7.5026 10.0002 7.5026C11.3809 7.5026 12.5002 8.62189 12.5002 10.0026Z" stroke="currentColor"/>`,
+  },
+  "xmark-small": {
+    viewBox: "0 0 16 16",
+    body: `<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor"/>`,
+  },
+}
+
+const spriteID = "opencode-v2-icon-sprite"
+const symbol = (name: keyof typeof icons) => `opencode-v2-icon-${name}`
+let spriteInserted = false
+
+function ensureSprite() {
+  if (spriteInserted) return
+  if (typeof document === "undefined") return
+  if (document.getElementById(spriteID)) {
+    spriteInserted = true
+    return
+  }
+
+  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
+  svg.id = spriteID
+  svg.setAttribute("aria-hidden", "true")
+  svg.setAttribute("width", "0")
+  svg.setAttribute("height", "0")
+  svg.style.position = "absolute"
+  svg.style.overflow = "hidden"
+  svg.innerHTML = Object.entries(icons)
+    .map(([name, icon]) => `<symbol id="${symbol(name as keyof typeof icons)}" viewBox="${icon.viewBox}">${icon.body}</symbol>`)
+    .join("")
+  document.body.insertBefore(svg, document.body.firstChild)
+  spriteInserted = true
+}
 
 export interface IconProps extends ComponentProps<"svg"> {
-  name: string
+  name: keyof typeof icons | (string & {})
   size?: "small" | "normal" | "large"
 }
 
-/**
- * Placeholder icon component
- */
 export function Icon(props: IconProps) {
   const [split, rest] = splitProps(props, ["name", "size"])
+  const iconName = () => (icons[split.name as keyof typeof icons] ? (split.name as keyof typeof icons) : "plus")
+  const icon = () => icons[iconName()]
   const pixelSize = split.size === "small" ? 14 : split.size === "large" ? 20 : 16
+  onMount(ensureSprite)
+
   return (
     <svg
       {...rest}
       data-slot="icon-svg"
       width={pixelSize}
       height={pixelSize}
-      viewBox="0 0 16 16"
+      viewBox={icon().viewBox}
       fill="none"
       xmlns="http://www.w3.org/2000/svg"
       aria-hidden={rest["aria-hidden"] ?? "true"}
     >
-      <path d="M8 2.88867V13.1109" stroke="currentColor" stroke-linejoin="round" />
-      <path d="M2.88867 8H13.1109" stroke="currentColor" stroke-linejoin="round" />
+      <use href={`#${symbol(iconName())}`} />
     </svg>
   )
 }

+ 4 - 2
packages/ui/src/v2/components/select-v2.tsx

@@ -42,7 +42,7 @@ const CheckSmall = () => (
 
 export type SelectV2Props<T> = Omit<
   ComponentProps<typeof Kobalte<T, { category: string; options: T[] }>>,
-  "value" | "onChange" | "children" | "options" | "itemComponent" | "sectionComponent" | "defaultValue" | "multiple"
+  "value" | "onSelect" | "children" | "options" | "itemComponent" | "sectionComponent" | "defaultValue" | "multiple"
 > & {
   placeholder?: string
   options: T[]
@@ -58,6 +58,7 @@ export type SelectV2Props<T> = Omit<
   invalid?: boolean
   numeric?: boolean
   children?: (item: T) => JSX.Element
+  valueClass?: string
 }
 
 export function SelectV2<T>(props: SelectV2Props<T>) {
@@ -78,6 +79,7 @@ export function SelectV2<T>(props: SelectV2Props<T>) {
     "invalid",
     "numeric",
     "disabled",
+    "valueClass",
   ])
 
   const state: { key?: string; cleanup?: void | (() => void) } = {}
@@ -172,7 +174,7 @@ export function SelectV2<T>(props: SelectV2Props<T>) {
         }}
       >
         <div data-slot="select-v2-value">
-          <Kobalte.Value<T> data-slot="select-v2-value-text">
+          <Kobalte.Value<T> data-slot="select-v2-value-text" class={local.valueClass}>
             {(st) => {
               const selected = st.selectedOption()
               if (local.label && selected != null) return local.label(selected)

+ 92 - 0
packages/ui/src/v2/components/wordmark-v2.tsx

@@ -0,0 +1,92 @@
+import { createUniqueId, type ComponentProps } from "solid-js"
+
+export function WordmarkV2(props: Pick<ComponentProps<"svg">, "class">) {
+  const filter = createUniqueId()
+  const mask = createUniqueId()
+  const maskGradient = createUniqueId()
+
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      viewBox="0 0 720.002 129.001"
+      fill="none"
+      preserveAspectRatio="none"
+      classList={{ [props.class ?? ""]: !!props.class }}
+    >
+      <g opacity="0.16" filter={`url(#${filter})`} mask={`url(#${mask})`}>
+        <path
+          opacity="0.7"
+          d="M55.3846 36.8583H18.4615V92.144H55.3846V36.8583ZM73.8462 110.573H0V18.4297H73.8462V110.573Z"
+          fill="currentColor"
+        />
+        <path
+          opacity="0.7"
+          d="M110.774 92.144H147.697V36.8583H110.774V92.144ZM166.159 110.573H110.774V129.001H92.3125V18.4297H166.159V110.573Z"
+          fill="currentColor"
+        />
+        <path
+          opacity="0.7"
+          d="M258.463 73.7154H203.079V92.144H258.463V110.573H184.617V18.4297H258.463V73.7154ZM203.079 55.2868H240.002V36.8583H203.079V55.2868Z"
+          fill="currentColor"
+        />
+        <path
+          opacity="0.7"
+          d="M332.306 36.8583H295.383V110.573H276.922V18.4297H332.306V36.8583ZM350.768 110.573H332.306V36.8583H350.768V110.573Z"
+          fill="currentColor"
+        />
+        <path
+          opacity="0.7"
+          d="M443.081 36.8583H387.696V92.144H443.081V110.573H369.234V18.4297H443.081V36.8583Z"
+          fill="currentColor"
+        />
+        <path
+          opacity="0.7"
+          d="M516.924 36.8583H480.001V92.144H516.924V36.8583ZM535.385 110.573H461.539V18.4297H535.385V110.573Z"
+          fill="currentColor"
+        />
+        <path
+          opacity="0.7"
+          d="M609.228 36.8571H572.305V92.1429H609.228V36.8571ZM627.69 110.571H553.844V18.4286H609.228V0H627.69V110.571Z"
+          fill="currentColor"
+        />
+        <path
+          opacity="0.7"
+          d="M664.618 36.8583V55.2868H701.541V36.8583H664.618ZM720.002 73.7154H664.618V92.144H720.002V110.573H646.156V18.4297H720.002V73.7154Z"
+          fill="currentColor"
+        />
+      </g>
+      <defs>
+        <mask id={mask} maskUnits="userSpaceOnUse" x="0" y="0" width="720" height="129">
+          <rect width="720" height="129" fill={`url(#${maskGradient})`} />
+        </mask>
+        <linearGradient id={maskGradient} x1="360" y1="0" x2="360" y2="112" gradientUnits="userSpaceOnUse">
+          <stop stop-color="white" stop-opacity="0.7" />
+          <stop offset="1" stop-color="white" stop-opacity="0" />
+        </linearGradient>
+        <filter
+          id={filter}
+          x="0"
+          y="0"
+          width="720.002"
+          height="130.001"
+          filterUnits="userSpaceOnUse"
+          color-interpolation-filters="sRGB"
+        >
+          <feFlood flood-opacity="0" result="BackgroundImageFix" />
+          <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
+          <feColorMatrix
+            in="SourceAlpha"
+            type="matrix"
+            values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
+            result="hardAlpha"
+          />
+          <feOffset dy="1" />
+          <feGaussianBlur stdDeviation="1" />
+          <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
+          <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" />
+          <feBlend mode="normal" in2="shape" result="effect1_innerShadow_4938_16028" />
+        </filter>
+      </defs>
+    </svg>
+  )
+}