1
0
Эх сурвалжийг харах

refactor(app): split home view controllers (#38607)

Brendan Allan 3 долоо хоног өмнө
parent
commit
589ef16128

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

@@ -67,7 +67,8 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref }
 import { createSessionLineage } from "@/pages/session/session-lineage"
 
 import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
-import { NewHome, LegacyHome } from "@/pages/home"
+import { NewHome } from "@/pages/home"
+import { LegacyHome } from "@/pages/home/legacy-home"
 
 const NewSession = lazy(() => import("@/pages/new-session"))
 

+ 54 - 17
packages/app/src/components/server/server-row-menu.tsx

@@ -15,9 +15,47 @@ export const ServerRowMenu: Component<{
 }> = (props) => {
   const language = useLanguage()
   const key = ServerConnection.key(props.server)
-  const builtin = ServerConnection.builtin(props.server)
-  const isDefault = () => props.controller.defaultKey() === key
+  return (
+    <ServerRowMenuView
+      server={props.server}
+      labels={serverMenuLabels(language)}
+      canDefault={props.controller.canDefault()}
+      isDefault={props.controller.defaultKey() === key}
+      onEdit={props.onEdit}
+      onSetDefault={() => props.controller.setDefault(key)}
+      onRemoveDefault={() => props.controller.setDefault(null)}
+      onRemove={() => props.controller.handleRemove(key)}
+      open={props.open}
+      onOpenChange={props.onOpenChange}
+    />
+  )
+}
+
+export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
+  return {
+    more: language.t("common.moreOptions"),
+    server: language.t("settings.section.server"),
+    edit: language.t("dialog.server.menu.edit"),
+    default: language.t("dialog.server.menu.default"),
+    defaultRemove: language.t("dialog.server.menu.defaultRemove"),
+    delete: language.t("dialog.server.menu.delete"),
+  }
+}
 
+export const ServerRowMenuView: Component<{
+  server: ServerConnection.Any
+  labels: ReturnType<typeof serverMenuLabels>
+  canDefault: boolean
+  isDefault: boolean
+  onEdit: (server: ServerConnection.Http) => void
+  onSetDefault: () => void
+  onRemoveDefault: () => void
+  onRemove: () => void
+  open?: boolean
+  onOpenChange?: (open: boolean) => void
+}> = (props) => {
+  const builtin = () => ServerConnection.builtin(props.server)
+  const httpServer = () => (props.server.type === "http" ? props.server : undefined)
   return (
     <MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
       <MenuV2.Trigger
@@ -25,31 +63,30 @@ export const ServerRowMenu: Component<{
         variant="ghost-muted"
         size="small"
         icon={<IconV2 name="outline-dots" />}
-        aria-label={language.t("common.moreOptions")}
+        aria-label={props.labels.more}
       />
       <MenuV2.Portal>
         <MenuV2.Content>
           <MenuV2.Group>
-            <MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
+            <MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel>
             <MenuV2.Item
-              disabled={builtin || props.server.type !== "http"}
-              onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
+              disabled={builtin() || !httpServer()}
+              onSelect={() => {
+                const server = httpServer()
+                if (server) props.onEdit(server)
+              }}
             >
-              {language.t("dialog.server.menu.edit")}
+              {props.labels.edit}
             </MenuV2.Item>
-            <Show when={props.controller.canDefault() && !isDefault()}>
-              <MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
-                {language.t("dialog.server.menu.default")}
-              </MenuV2.Item>
+            <Show when={props.canDefault && !props.isDefault}>
+              <MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
             </Show>
-            <Show when={props.controller.canDefault() && isDefault()}>
-              <MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
-                {language.t("dialog.server.menu.defaultRemove")}
-              </MenuV2.Item>
+            <Show when={props.canDefault && props.isDefault}>
+              <MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
             </Show>
             <MenuV2.Separator />
-            <MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
-              {language.t("dialog.server.menu.delete")}
+            <MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
+              {props.labels.delete}
             </MenuV2.Item>
           </MenuV2.Group>
         </MenuV2.Content>

+ 35 - 1911
packages/app/src/pages/home.tsx

@@ -1,1926 +1,50 @@
-import type { Session } from "@opencode-ai/sdk/v2/client"
-import {
-  type ComponentProps,
-  createEffect,
-  createMemo,
-  createResource,
-  createRoot,
-  createSignal,
-  For,
-  Match,
-  on,
-  onCleanup,
-  onMount,
-  Show,
-  startTransition,
-  Switch,
-} from "solid-js"
-import { makeEventListener } from "@solid-primitives/event-listener"
-import { createStore, produce } from "solid-js/store"
-import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
-import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
-import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
-import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
-import { RestrictToElement } from "@dnd-kit/dom/modifiers"
-import { useQuery } from "@tanstack/solid-query"
-import { Button } from "@opencode-ai/ui/button"
-import { Logo } from "@opencode-ai/ui/logo"
-import { Spinner } from "@opencode-ai/ui/spinner"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
-import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
-import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
-import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
-import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
-import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
-import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
-import { getProjectAvatarVariant, useLayout, type HomeProjectSelection, 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"
-import { usePlatform } from "@/context/platform"
-import { DateTime } from "luxon"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { useDirectoryPicker } from "@/components/directory-picker"
-import { useSettingsCommand } from "@/components/settings-dialog"
-import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
-import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
-import { ServerConnection, serverName, useServer } from "@/context/server"
-import { sessionHasOpenTab, useTabs } from "@/context/tabs"
-import { useServerSync } from "@/context/server-sync"
-import { useLanguage } from "@/context/language"
-import { useNotification } from "@/context/notification"
-import {
-  closeHomeProject,
-  displayName,
-  errorMessage,
-  getProjectAvatarSource,
-  homeProjectDirectories,
-  projectForSession,
-  toggleHomeProjectSelection,
-} from "@/pages/layout/helpers"
-import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
-import { sessionTitle } from "@/utils/session-title"
-import { pathKey } from "@/utils/path-key"
-import { useGlobal } from "@/context/global"
-import { useCommand } from "@/context/command"
-import { Binary } from "@opencode-ai/core/util/binary"
-import { ServerRowMenu } from "@/components/server/server-row-menu"
-import { ServerHealthIndicator } from "@/components/server/server-row"
-import { type ServerHealth } from "@/utils/server-health"
-import { Persist, persisted } from "@/utils/persist"
-import { useMarked } from "@opencode-ai/ui/context/marked"
-import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
-import { archiveHomeSession } from "./home-session-archive"
-import { shouldOpenSessionInBackground } from "./home-session-open"
-import { showToast } from "@/utils/toast"
-import { fileManagerApp } from "@/utils/file-manager"
-import {
-  loadHomeSessionIndex,
-  retainHomeSessions,
-  type HomeSessionEvents,
-} from "@/context/global-sync/home-session-index"
-
-const HOME_SESSION_LIMIT = 64
-const HOME_SESSION_HEADER_STICKY_TOP = 12
-const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
-const HOME_SESSION_HEADER_FADE_DISTANCE = 16
-
-function containHomeWheel(event: WheelEvent, viewport: HTMLElement) {
-  if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
-  if (!(event.target instanceof Element)) return
-
-  const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
-  if (
-    scrollable !== viewport &&
-    scrollable &&
-    (event.deltaY < 0
-      ? scrollable.scrollTop > 0
-      : scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
-  )
-    return
-
-  event.preventDefault()
-}
-const SHOW_HOME_SESSION_ARCHIVE = false
-const HOME_ROW_LAYOUT =
-  "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
-const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
-const HOME_ROW = `${HOME_ROW_BASE} [font-weight:530] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover`
-const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
-const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]`
-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[]
-}
-
-const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
-const HOME_SEARCH_RESULT_ROW =
-  "flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-[18px] pr-6 text-left transition-[background-color] 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_SEARCH_RESULT_TITLE =
-  "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:530]"
-const HOME_SEARCH_RESULT_META =
-  "min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]"
-
-let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
-
-function buildHomeSessionRecords(input: {
-  sessions: () => Session[]
-  projectDirectories: () => string[]
-  projects: () => LocalProject[]
-  projectByID: () => Map<string, LocalProject>
-}) {
-  const directories = new Set(input.projectDirectories().map(pathKey))
-  const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
-  return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
-    .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
-    .flatMap((session) => {
-      const directory = pathKey(session.directory)
-      const project =
-        input
-          .projects()
-          .find(
-            (item) =>
-              pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
-          ) ?? projectForSession(session, input.projects(), input.projectByID())
-      if (!project) return []
-      return {
-        session,
-        project,
-        projectName: displayName(project),
-      }
-    })
-}
-
-function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) {
-  return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query)
-}
-
-function homeSessionSearchKey(record: HomeSessionRecord) {
-  return `${pathKey(record.session.directory)}:${record.session.id}`
-}
-
-function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
-  let viewport: HTMLDivElement | undefined
-  let content: HTMLDivElement | undefined
-  let positionFrame: number | undefined
-  let resizeObserver: ResizeObserver | undefined
-  let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
-  const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
-  const headerOffsets = new Map<HomeSessionGroup["id"], number>()
-  const [state, setState] = createStore({
-    titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
-  })
-
-  createEffect(() => {
-    const items = groups()
-    const ids = new Set(items.map((group) => group.id))
-    headerRefs.forEach((_, id) => {
-      if (!ids.has(id)) headerRefs.delete(id)
-    })
-    headerOffsets.forEach((_, id) => {
-      if (!ids.has(id)) headerOffsets.delete(id)
-    })
-    if (items.length === 0) {
-      content = undefined
-      bindResizeObserver()
-    }
-    queuePositionUpdate()
-  })
-
-  onCleanup(() => {
-    if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
-    resizeObserver?.disconnect()
-  })
-
-  function setViewport(el: HTMLDivElement) {
-    viewport = el
-    bindResizeObserver()
-    queuePositionUpdate()
-  }
-
-  function setContentRef(el: HTMLDivElement) {
-    content = el
-    bindResizeObserver()
-    queuePositionUpdate()
-  }
-
-  function setHeaderRef(id: HomeSessionGroup["id"], el: HTMLDivElement) {
-    headerRefs.set(id, el)
-    queuePositionUpdate()
-  }
-
-  function queuePositionUpdate() {
-    if (typeof requestAnimationFrame === "undefined") {
-      updatePositionCache()
-      return
-    }
-    if (positionFrame !== undefined) return
-    positionFrame = requestAnimationFrame(() => {
-      positionFrame = undefined
-      updatePositionCache()
-    })
-  }
-
-  function updatePositionCache() {
-    if (!viewport) return
-    const header = groups()
-      .map((group) => headerRefs.get(group.id))
-      .find((el) => el !== undefined)
-    if (header && typeof getComputedStyle === "function") {
-      const top = Number.parseFloat(getComputedStyle(header).top)
-      if (Number.isFinite(top)) stickyTop = top
-    }
-    groups().forEach((group) => {
-      const el = headerRefs.get(group.id)
-      if (!el) return
-      headerOffsets.set(group.id, el.offsetTop)
-    })
-    update(viewport.scrollTop)
-  }
-
-  function update(scrollTop: number) {
-    const items = groups()
-    items.forEach((group, index) => {
-      const nextOffset = items
-        .slice(index + 1)
-        .map((item) => headerOffsets.get(item.id))
-        .find((offset) => offset !== undefined)
-      const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
-      const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
-      const opacity =
-        nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
-      setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
-    })
-  }
-
-  function titleOpacity(id: HomeSessionGroup["id"]) {
-    return state.titleOpacity[id] ?? 1
-  }
-
-  function bindResizeObserver() {
-    resizeObserver?.disconnect()
-    if (typeof ResizeObserver === "undefined") return
-    resizeObserver = new ResizeObserver(() => queuePositionUpdate())
-    if (viewport) resizeObserver.observe(viewport)
-    if (content) resizeObserver.observe(content)
-  }
-
-  return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
-}
-
-// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
-// tab in the background without navigating, matching browser conventions.
-function isBackgroundOpen(event: MouseEvent) {
-  return shouldOpenSessionInBackground({
-    button: event.button,
-    mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
-    meta: event.metaKey,
-    ctrl: event.ctrlKey,
-    shift: event.shiftKey,
-    alt: event.altKey,
-  })
-}
-
-type OpenSessionOptions = { background?: boolean }
+import { createHomeController } from "./home/home-controller"
+import { createHomeProjectsController } from "./home/home-projects-controller"
+import { HomeUtilityNav } from "./home/home-projects-view"
+import { HomeProjects } from "./home/home-projects"
+import { createHomeScrollController } from "./home/home-scroll-controller"
+import { createHomeSessionSearchController } from "./home/home-session-search-controller"
+import { createHomeSessionsController } from "./home/home-sessions-controller"
+import { HomeSessions } from "./home/home-sessions"
 
 export function NewHome() {
-  const sync = useServerSync()
-  const layout = useLayout()
-  const platform = usePlatform()
-  const pickDirectory = useDirectoryPicker()
-  const dialog = useDialog()
-  const navigate = useNavigate()
-  const server = useServer()
-  const language = useLanguage()
-  const global = useGlobal()
-  const tabs = useTabs()
-  const command = useCommand()
-  const notification = useNotification()
-  const marked = useMarked()
-  const openSettings = useSettingsCommand()
-  let focusSessionSearch: (() => void) | undefined
-  let sessionViewport: HTMLDivElement | undefined
-  const [sessionThumbTrack, setSessionThumbTrack] = createSignal<HTMLDivElement>()
-  const [sessionHoverTarget, setSessionHoverTarget] = createSignal<HTMLElement>()
-  const [state, setState] = createStore({
-    search: "",
-    searchFocused: false,
-  })
-  const selection = layout.home.selection
-
-  const focusedServer = createMemo(
-    () => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current,
-  )
-  const focusedServerCtx = createMemo(() => {
-    const conn = focusedServer()
-    if (!conn) return
-    return global.ensureServerCtx(conn)
-  })
-  const focusedSync = () => focusedServerCtx()?.sync ?? sync()
-  const homeSessions = () => focusedSync().homeSessions
-  const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
-  const recentlyClosed = createMemo(
-    () => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
-  )
-  const homedir = createMemo(() => focusedSync().data.path.home ?? "")
-  const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
-  const newSessionProject = createMemo(
-    () =>
-      selectedProject() ??
-      projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ??
-      projects()[0],
-  )
-  const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
-  const projectDirectories = createMemo(() => {
-    const project = selectedProject()
-    if (!project) return projects().flatMap(directories)
-    return directories(project)
-  })
-  const search = createMemo(() => state.search.trim())
-  const searchPlaceholder = createMemo(() => {
-    const project = selectedProject()
-    if (project) {
-      return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) })
-    }
-    if (global.servers.list().length > 1) {
-      const conn = focusedServer()
-      if (conn) {
-        return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) })
-      }
-    }
-    return language.t("home.sessions.search.placeholder")
-  })
-  const sessionEventLoad = useQuery(() => ({
-    queryKey: homeSessions().eventsKey,
-    queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
-    initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
-    enabled: false,
-  }))
-  const sessionLoad = useQuery(() => ({
-    queryKey: homeSessions().indexKey,
-    enabled: !!focusedServerCtx(),
-    queryFn: async ({ signal }) => {
-      const ctx = focusedServerCtx()
-      if (!ctx) return { sessions: [], eventSequence: 0 }
-      const cache = homeSessions()
-      const eventSequence = cache.eventSequence()
-      const index = await loadHomeSessionIndex(
-        (input, options) => ctx.sdk.client.v2.session.list(input, options),
-        eventSequence,
-        signal,
-      )
-      cache.complete(eventSequence)
-      return index
-    },
-    retry: false,
-    staleTime: 30_000,
-    refetchOnMount: true,
-    refetchOnReconnect: true,
-  }))
-
-  const projectByID = createMemo(
-    () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
-  )
-  const indexedSessions = createMemo(() =>
-    retainHomeSessions(
-      homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
-      HOME_SESSION_LIMIT,
-      Date.now(),
-    ),
-  )
-  const allRecords = createMemo(() =>
-    buildHomeSessionRecords({
-      sessions: indexedSessions,
-      projectDirectories,
-      projects,
-      projectByID,
-    }),
-  )
-  const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
-  const searchResults = createMemo(() => {
-    const query = search().toLowerCase()
-    if (!query) return []
-    return allRecords().filter((record) => matchesHomeSessionSearch(record, query))
-  })
-  const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
-  const groups = createMemo(() => groupSessions(records(), language))
-  const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups)
-  const prefetched = new Set<string>()
-
-  createEffect(() => {
-    const ctx = focusedServerCtx()
-    if (!ctx) return
-    records()
-      .slice(0, 2)
-      .forEach((record) => {
-        const key = `${ServerConnection.key(focusedServer()!)}\0${record.session.id}`
-        if (prefetched.has(key)) return
-        prefetched.add(key)
-        createRoot((dispose) => {
-          try {
-            void ctx.sync.session
-              .sync(record.session.id)
-              .then(() => {
-                return Promise.all(
-                  (ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) =>
-                    (ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => {
-                      if (part.type !== "text" || !part.text) return []
-                      return preloadMarkdown(part.text, part.id, marked)
-                    }),
-                  ),
-                )
-              })
-              .catch(() => {})
-              .finally(dispose)
-          } catch {
-            dispose()
-          }
-        })
-      })
-  })
-
-  function setSelection(next: HomeProjectSelection) {
-    layout.home.setSelection(next)
-  }
-
-  function closeSearch() {
-    setState("search", "")
-    setState("searchFocused", false)
-  }
-
-  function selectSearchSession(session: Session, options?: OpenSessionOptions) {
-    openSession(session, options)
-    // Background opens keep the search visible so several results can be
-    // opened in a row.
-    if (!options?.background) closeSearch()
-  }
-
-  command.register("home", () => [
-    {
-      id: "command.palette",
-      title: language.t("command.palette"),
-      hidden: true,
-      onSelect: async () => {
-        const conn = focusedServer()
-        if (!conn) return
-        const ctx = global.ensureServerCtx(conn)
-        const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
-        void dialog.show(() => (
-          <DialogHomeCommandPaletteV2
-            server={conn}
-            onSelectSession={(entry) => {
-              if (!entry.sessionID || !entry.directory || !entry.server) return
-              const sessionID = entry.sessionID
-              const server = entry.server
-              const directory = entry.project?.worktree ?? entry.directory
-              ctx.projects.open(directory)
-              ctx.projects.touch(directory)
-              void startTransition(() => {
-                const tab = tabs.addSessionTab({ server, sessionId: sessionID })
-                tabs.select(tab)
-              })
-            }}
-          />
-        ))
-      },
-    },
-    {
-      id: "home.sessions.search.focus",
-      title: searchPlaceholder(),
-      keybind: "mod+f",
-      hidden: true,
-      onSelect: () => focusSessionSearch?.(),
-    },
-  ])
-
-  createEffect(() => {
-    const list = global.servers.list()
-    if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
-    const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
-    if (conn) setSelection({ server: ServerConnection.key(conn) })
-  })
-
-  createEffect(() => {
-    const pending = pendingHomeNavigation
-    if (!pending || pending.server !== server.key) return
-    pendingHomeNavigation = undefined
-    navigate(pending.href)
-  })
-
-  function focusServer(conn: ServerConnection.Any) {
-    setSelection({ server: ServerConnection.key(conn) })
-  }
-
-  function selectProject(conn: ServerConnection.Any, directory: string) {
-    const key = ServerConnection.key(conn)
-    if (global.servers.health[key]?.healthy === false) return
-    if (
-      !global
-        .ensureServerCtx(conn)
-        .projects.list()
-        .some((project) => project.worktree === directory)
-    )
-      return
-    setSelection(toggleHomeProjectSelection(selection(), key, directory))
-  }
-
-  function addProjects(conn: ServerConnection.Any, directories: string[]) {
-    const directory = directories[0]
-    if (!directory) return
-    const ctx = global.ensureServerCtx(conn)
-    directories.forEach(ctx.projects.open)
-    ctx.projects.touch(directory)
-    setSelection({ server: ServerConnection.key(conn), directory })
-  }
-
-  function openNewSession() {
-    const conn = focusedServer()
-    const project = newSessionProject()
-    if (!conn || !project) return
-    openProjectNewSession(conn, project.worktree)
-  }
-
-  function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
-    const ctx = global.ensureServerCtx(conn)
-    ctx.projects.open(directory)
-    ctx.projects.touch(directory)
-    tabs.newDraft({ server: ServerConnection.key(conn), directory })
-  }
-
-  function editProject(conn: ServerConnection.Any, project: LocalProject) {
-    void import("@/components/dialog-edit-project-v2").then((x) => {
-      void dialog.show(() => <x.DialogEditProjectV2 server={conn} project={project} />)
-    })
-  }
-
-  function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
-    const state = notification.ensureServerState(ServerConnection.key(conn))
-    return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
-  }
-
-  function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
-    const state = notification.ensureServerState(ServerConnection.key(conn))
-    directories(project)
-      .filter((directory) => state.project.unseenCount(directory) > 0)
-      .forEach((directory) => state.project.markViewed(directory))
-  }
-
-  function openSession(session: Session, options?: OpenSessionOptions) {
-    const directoryKey = pathKey(session.directory)
-    const project =
-      projects().find(
-        (item) =>
-          pathKey(item.worktree) === directoryKey ||
-          item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
-      ) ?? projectForSession(session, projects(), projectByID())
-    const conn = focusedServer()
-    if (!conn) return
-    const directory = project?.worktree ?? session.directory
-    const ctx = global.ensureServerCtx(conn)
-    ctx.projects.open(directory)
-    if (options?.background) {
-      tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
-      return
-    }
-    ctx.projects.touch(directory)
-    startTransition(() => {
-      const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
-      tabs.select(tab)
-    })
-  }
-
-  async function archiveSession(session: Session) {
-    const conn = focusedServer()
-    const ctx = focusedServerCtx()
-    if (!conn || !ctx) return
-    const [, setStore] = ctx.sync.child(session.directory)
-    await archiveHomeSession({
-      server: ServerConnection.key(conn),
-      session,
-      archive: (sessionID) => ctx.sdk.api.session.archive({ sessionID, directory: session.directory }),
-      remove: () =>
-        setStore(
-          produce((draft) => {
-            const match = Binary.search(draft.session, session.id, (s) => s.id)
-            if (match.found) draft.session.splice(match.index, 1)
-          }),
-        ),
-      onError: (error) =>
-        showToast({
-          title: language.t("common.requestFailed"),
-          description: errorMessage(error, language.t("common.requestFailed")),
-        }),
-    })
-  }
-
-  function chooseProject(conn: ServerConnection.Any) {
-    if (global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
-
-    function resolve(result: string | string[] | null) {
-      addProjects(conn, homeProjectDirectories(result))
-    }
-
-    pickDirectory({
-      server: conn,
-      title: language.t("command.project.open"),
-      multiple: true,
-      onSelect: resolve,
-    })
-  }
-
+  const home = createHomeController()
+  const projects = createHomeProjectsController(home)
+  const sessions = createHomeSessionsController(home)
+  const search = createHomeSessionSearchController(home, sessions)
+  const scroll = createHomeScrollController(sessions.data.groups)
   return (
-    <div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
+    <div
+      class={`
+        m-2 min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
+        bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
+      `}
+    >
       <ScrollView
         class="h-full [container-type:size]"
-        thumbContainer={sessionThumbTrack}
-        thumbHoverTarget={sessionHoverTarget}
-        viewportRef={(el) => {
-          sessionViewport = el
-          sessionHeaderOpacity.setViewport(el)
-        }}
-        onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
-        onWheel={(event) => {
-          if (!sessionViewport) return
-          if (event.target instanceof Node && sessionViewport.contains(event.target)) return
-          containHomeWheel(event, sessionViewport)
-        }}
+        thumbContainer={scroll.viewport.thumbTrack}
+        thumbHoverTarget={scroll.viewport.hoverTarget}
+        viewportRef={scroll.viewport.setViewport}
+        onScroll={(event) => scroll.viewport.update(event.currentTarget.scrollTop)}
+        onWheel={scroll.viewport.containOuterWheel}
       >
-        <div class="mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
-          <HomeProjectColumn
-            projects={projects()}
-            recentlyClosed={recentlyClosed()}
-            homedir={homedir()}
-            selected={selection()}
-            focusServer={focusServer}
-            selectProject={selectProject}
-            openNewSession={openProjectNewSession}
-            openRecentProject={(conn, directory) => addProjects(conn, [directory])}
-            chooseProject={(conn) => void chooseProject(conn)}
-            editProject={editProject}
-            closeProject={(conn, directory) => {
-              const next = closeHomeProject(
-                selection(),
-                ServerConnection.key(conn),
-                global.ensureServerCtx(conn).projects,
-                directory,
-              )
-              if (next) setSelection(next)
-            }}
-            clearNotifications={clearNotifications}
-            unseenCount={unseenCount}
-            openSettings={openSettings}
-            openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
-            language={language}
-            onWheel={(event) => {
-              if (sessionViewport) containHomeWheel(event, sessionViewport)
-            }}
-          />
-
-          <section
-            ref={setSessionHoverTarget}
-            class="min-h-0 min-w-0 flex-1 flex flex-col"
-            aria-label={language.t("sidebar.project.recentSessions")}
-          >
-            <div
-              class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12"
-              onWheel={(event) => {
-                if (sessionViewport) containHomeWheel(event, sessionViewport)
-              }}
-            >
-              <HomeSessionSearch
-                value={state.search}
-                placeholder={searchPlaceholder()}
-                open={searchOpen()}
-                loading={sessionLoad.isLoading}
-                results={searchResults()}
-                showProjectName={!selectedProject()}
-                server={selection().server}
-                noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
-                bindFocus={(focus) => {
-                  focusSessionSearch = focus
-                }}
-                onInput={(value) => setState("search", value)}
-                onFocus={() => setState("searchFocused", true)}
-                onClose={closeSearch}
-                onSelect={selectSearchSession}
-              />
-              <Show when={groups().length > 0 && newSessionProject()}>
-                <div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
-                  <ButtonV2
-                    data-action="home-new-session"
-                    variant="ghost-muted"
-                    size="normal"
-                    icon="edit"
-                    class="pointer-events-auto h-7 px-2 [font-weight:530]"
-                    onClick={openNewSession}
-                  >
-                    {language.t("command.session.new")}
-                  </ButtonV2>
-                </div>
-              </Show>
-            </div>
-            {/* Sticky chrome for the portaled session scrollbar — matches old sessions ScrollView bounds */}
-            <div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
-              <div
-                ref={setSessionThumbTrack}
-                data-component="home-session-scroll-track"
-                class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
-              />
-            </div>
-            <div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
-              <Show
-                when={!sessionLoad.isLoading}
-                fallback={
-                  <div class="pt-3">
-                    <HomeSessionSkeleton label={language.t("common.loading")} />
-                  </div>
-                }
-              >
-                <Show
-                  when={groups().length > 0}
-                  fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
-                >
-                  <div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
-                    <For each={groups()}>
-                      {(group, index) => (
-                        <>
-                          <HomeSessionGroupHeader
-                            title={group.title}
-                            titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
-                            ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
-                            elevated={index() === 0}
-                          />
-                          <div
-                            class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
-                          >
-                            <For each={group.sessions}>
-                              {(record) => (
-                                <HomeSessionRow
-                                  record={record}
-                                  showProjectName={!selectedProject()}
-                                  server={selection().server}
-                                  openSession={openSession}
-                                  archiveSession={archiveSession}
-                                />
-                              )}
-                            </For>
-                          </div>
-                        </>
-                      )}
-                    </For>
-                  </div>
-                </Show>
-              </Show>
-            </div>
-          </section>
+        <div
+          class={`
+            mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3
+            lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6
+          `}
+        >
+          <HomeProjects projects={projects} scroll={scroll} />
+          <HomeSessions sessions={sessions} search={search} scroll={scroll} />
           <HomeUtilityNav
             class="flex lg:hidden"
-            openSettings={openSettings}
-            openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
-            language={language}
+            onOpenSettings={projects.utility.settings}
+            onOpenHelp={projects.utility.help}
+            language={projects.copy.language}
           />
         </div>
       </ScrollView>
     </div>
   )
 }
-
-function HomeProjectColumn(props: {
-  projects: LocalProject[]
-  recentlyClosed: LocalProject[]
-  homedir: string
-  selected: HomeProjectSelection
-  focusServer: (server: ServerConnection.Any) => void
-  selectProject: (server: ServerConnection.Any, directory: string) => void
-  openNewSession: (server: ServerConnection.Any, directory: string) => void
-  openRecentProject: (server: ServerConnection.Any, directory: string) => void
-  chooseProject: (server: ServerConnection.Any) => void
-  editProject: (server: ServerConnection.Any, project: LocalProject) => void
-  closeProject: (server: ServerConnection.Any, directory: string) => void
-  clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
-  unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
-  openSettings: () => void
-  openHelp: () => void
-  language: ReturnType<typeof useLanguage>
-  onWheel: (event: WheelEvent) => void
-}) {
-  const global = useGlobal()
-  const dialog = useDialog()
-  const controller = useServerManagementController({ navigateOnAdd: false })
-  const [_state, setState, _, ready] = persisted(
-    Persist.global("home.servers", ["home.servers.v1"]),
-    createStore({ collapsed: {} as Record<string, boolean> }),
-  )
-  const [state] = createResource(
-    () => ready.promise ?? Promise.resolve(),
-    (p) => p.then(() => _state),
-    { initialValue: _state },
-  )
-
-  return (
-    <aside
-      class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]"
-      aria-label={props.language.t("home.projects")}
-      onWheel={(event) => {
-        if (event.target === event.currentTarget) return
-        props.onWheel(event)
-      }}
-    >
-      <div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
-        <div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
-        <Show
-          when={global.servers.list().length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}
-        >
-          <TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
-            <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" />}
-              disabled={global.servers.health[ServerConnection.key(global.servers.list()[0]!)]?.healthy === false}
-              onClick={() => props.chooseProject(global.servers.list()[0]!)}
-              aria-label={props.language.t("home.project.add")}
-            />
-          </TooltipV2>
-        </Show>
-      </div>
-      <ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
-        <Show
-          when={global.servers.list().length > 1}
-          fallback={
-            <div class="pr-3">
-              <Show
-                when={props.projects.length > 0}
-                fallback={
-                  <HomeProjectEmpty
-                    server={global.servers.list()[0]!}
-                    recentlyClosed={props.recentlyClosed}
-                    homedir={props.homedir}
-                    chooseProject={props.chooseProject}
-                    openRecentProject={props.openRecentProject}
-                    language={props.language}
-                  />
-                }
-              >
-                <HomeProjectList {...props} server={global.servers.list()[0]!} />
-              </Show>
-            </div>
-          }
-        >
-          <div class="flex min-w-0 flex-col gap-4 pr-3">
-            <For each={global.servers.list()}>
-              {(item) => {
-                const key = ServerConnection.key(item)
-                const healthy = () => !!global.servers.health[key]?.healthy
-                const serverCtx = global.ensureServerCtx(item)
-                const projects = () => serverCtx.projects.list()
-                const hasProjects = () => projects().length > 0
-                const collapsed = () => !!state().collapsed[key]
-                return (
-                  <div class="flex min-w-0 flex-col gap-1">
-                    <HomeServerRow
-                      server={item}
-                      selected={props.selected.server === key && !props.selected.directory}
-                      collapsed={collapsed()}
-                      health={global.servers.health[key]}
-                      controller={controller}
-                      focusServer={props.focusServer}
-                      chooseProject={props.chooseProject}
-                      openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
-                      toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
-                      language={props.language}
-                    />
-                    <Show when={healthy() && hasProjects() && !collapsed()}>
-                      <div class="mx-3 h-px bg-v2-border-border-base" />
-                      <HomeProjectList {...props} server={item} projects={projects()} />
-                    </Show>
-                  </div>
-                )
-              }}
-            </For>
-          </div>
-        </Show>
-      </ScrollView>
-      <HomeUtilityNav
-        class="mb-8 mt-4 hidden shrink-0 lg:flex"
-        openSettings={props.openSettings}
-        openHelp={props.openHelp}
-        language={props.language}
-      />
-    </aside>
-  )
-}
-
-function HomeUtilityNav(props: {
-  class?: string
-  openSettings: () => void
-  openHelp: () => void
-  language: ReturnType<typeof useLanguage>
-}) {
-  return (
-    <div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
-      <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 class={HOME_PROJECT_NAV_LABEL}>{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 class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
-      </button>
-    </div>
-  )
-}
-
-function HomeServerRow(props: {
-  server: ServerConnection.Any
-  selected: boolean
-  collapsed: boolean
-  health: ServerHealth | undefined
-  controller: ReturnType<typeof useServerManagementController>
-  focusServer: (server: ServerConnection.Any) => void
-  chooseProject: (server: ServerConnection.Any) => void
-  openEdit: (server: ServerConnection.Http) => void
-  toggleCollapsed: () => void
-  language: ReturnType<typeof useLanguage>
-}) {
-  const global = useGlobal()
-  const [state, setState] = createStore({ menuOpen: false })
-  const healthy = () => !!props.health?.healthy
-  const canToggle = () => healthy() && global.ensureServerCtx(props.server).projects.list().length > 0
-  return (
-    <div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
-      <button
-        type="button"
-        class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
-        data-selected={props.selected ? "" : undefined}
-        disabled={!healthy()}
-        onClick={() => props.focusServer(props.server)}
-      >
-        <span
-          data-action="home-server-collapse"
-          class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted"
-          classList={{
-            "hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
-            "cursor-default opacity-40": !canToggle(),
-          }}
-          aria-label={
-            props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
-          }
-          aria-disabled={!canToggle()}
-          aria-expanded={canToggle() ? !props.collapsed : undefined}
-          onClick={(event) => {
-            event.preventDefault()
-            event.stopPropagation()
-            if (!canToggle()) return
-            props.toggleCollapsed()
-          }}
-          onPointerDown={(event) => event.preventDefault()}
-        >
-          <IconV2
-            name="chevron-down"
-            size="small"
-            class="transition-transform duration-150 ease-in-out"
-            style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
-          />
-        </span>
-        <div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
-          <ServerHealthIndicator health={props.health} />
-        </div>
-        <span class="flex min-w-0 items-center gap-1">
-          <span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
-          <Show when={props.server.label}>
-            {(label) => (
-              <span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
-                {label()}
-              </span>
-            )}
-          </Show>
-        </span>
-      </button>
-      <div
-        class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
-        data-menu={state.menuOpen}
-      >
-        <ServerRowMenu
-          server={props.server}
-          controller={props.controller}
-          onEdit={props.openEdit}
-          open={state.menuOpen}
-          onOpenChange={(open) => setState("menuOpen", open)}
-        />
-        <TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
-          <IconButtonV2
-            data-action="home-add-project"
-            variant="ghost-muted"
-            size="small"
-            icon={<IconV2 name="folder-add-left" />}
-            aria-label={props.language.t("home.project.add")}
-            disabled={props.health?.healthy === false}
-            onClick={() => props.chooseProject(props.server)}
-          />
-        </TooltipV2>
-      </div>
-    </div>
-  )
-}
-
-type HomeProjectListProps = {
-  server: ServerConnection.Any
-  projects: LocalProject[]
-  selected: HomeProjectSelection
-  selectProject: (server: ServerConnection.Any, directory: string) => void
-  openNewSession: (server: ServerConnection.Any, directory: string) => void
-  editProject: (server: ServerConnection.Any, project: LocalProject) => void
-  closeProject: (server: ServerConnection.Any, directory: string) => void
-  clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
-  unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
-  language: ReturnType<typeof useLanguage>
-}
-
-function HomeProjectList(props: HomeProjectListProps) {
-  const global = useGlobal()
-  let listRef!: HTMLDivElement
-  const projects = () => global.ensureServerCtx(props.server).projects
-
-  return (
-    <DragDropProvider
-      sensors={(defaults) => [
-        ...defaults.filter((sensor) => sensor !== PointerSensor),
-        PointerSensor.configure({
-          activationConstraints: (event) =>
-            event.pointerType === "touch"
-              ? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })]
-              : [new PointerActivationConstraints.Distance({ value: 4 })],
-          preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"),
-        }),
-      ]}
-      modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
-      plugins={(defaults) => [
-        ...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
-        AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
-        Feedback.configure({ dropAnimation: null }),
-      ]}
-      onDragEnd={(event) => {
-        const source = event.operation.source
-        if (event.canceled || !isSortable(source)) return
-        if (source.initialIndex !== source.index) projects().move(source.id.toString(), source.index)
-        if (props.selected.server !== ServerConnection.key(props.server))
-          props.selectProject(props.server, source.id.toString())
-      }}
-    >
-      <div class="flex min-w-0 flex-col gap-1" ref={listRef}>
-        {/* Keyed on worktree strings: the enriched project objects are
-            recreated on every store or sync update, so iterating them directly
-            remounts all rows — killing any in-flight drag activation (the
-            row's sortable unregisters on unmount) and discarding animations.
-            String keys keep row elements alive and move them on reorder. */}
-        <For each={props.projects.map((project) => project.worktree)}>
-          {(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
-        </For>
-      </div>
-    </DragDropProvider>
-  )
-}
-
-function HomeProjectSlot(
-  props: HomeProjectListProps & {
-    worktree: string
-    index: () => number
-  },
-) {
-  const project = createMemo(() => props.projects.find((item) => item.worktree === props.worktree))
-
-  return (
-    <Show when={project()}>
-      {(item) => (
-        <HomeProjectRow
-          project={item()}
-          server={props.server}
-          index={props.index}
-          serverSelected={props.selected.server === ServerConnection.key(props.server)}
-          selected={
-            props.selected.server === ServerConnection.key(props.server) && props.selected.directory === props.worktree
-          }
-          unseenCount={props.unseenCount(props.server, item())}
-          selectProject={props.selectProject}
-          openNewSession={props.openNewSession}
-          editProject={props.editProject}
-          closeProject={props.closeProject}
-          clearNotifications={props.clearNotifications}
-          language={props.language}
-        />
-      )}
-    </Show>
-  )
-}
-
-function HomeProjectEmpty(props: {
-  server: ServerConnection.Any
-  recentlyClosed: LocalProject[]
-  homedir: string
-  chooseProject: (server: ServerConnection.Any) => void
-  openRecentProject: (server: ServerConnection.Any, directory: string) => void
-  language: ReturnType<typeof useLanguage>
-}) {
-  const global = useGlobal()
-  const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
-  return (
-    <div class="flex min-w-0 flex-col gap-1">
-      <button
-        type="button"
-        data-action="home-add-project-row"
-        class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
-        disabled={unreachable()}
-        onClick={() => props.chooseProject(props.server)}
-      >
-        <IconV2 name="folder-add-left" size="small" />
-        <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
-      </button>
-      <Show when={props.recentlyClosed.length > 0}>
-        <div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
-          <div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
-        </div>
-        <For each={props.recentlyClosed}>
-          {(project) => (
-            <HomeRecentlyClosedRow
-              project={project}
-              server={props.server}
-              homedir={props.homedir}
-              openRecentProject={props.openRecentProject}
-              language={props.language}
-            />
-          )}
-        </For>
-      </Show>
-    </div>
-  )
-}
-
-function HomeRecentlyClosedRow(props: {
-  project: LocalProject
-  server: ServerConnection.Any
-  homedir: string
-  openRecentProject: (server: ServerConnection.Any, directory: string) => void
-  language: ReturnType<typeof useLanguage>
-}) {
-  const global = useGlobal()
-  const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
-  const path = () => {
-    const home = props.homedir
-    const worktree = props.project.worktree
-    if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
-    return worktree
-  }
-  return (
-    <TooltipV2 placement="right" value={path()}>
-      <button
-        type="button"
-        data-component="home-recently-closed-row"
-        class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
-        disabled={unreachable()}
-        onClick={() => props.openRecentProject(props.server, props.project.worktree)}
-      >
-        <HomeProjectAvatar project={props.project} outline />
-        <span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
-      </button>
-    </TooltipV2>
-  )
-}
-
-function HomeProjectRow(props: {
-  project: LocalProject
-  server: ServerConnection.Any
-  index: () => number
-  serverSelected: boolean
-  selected: boolean
-  unseenCount: number
-  selectProject: (server: ServerConnection.Any, directory: string) => void
-  openNewSession: (server: ServerConnection.Any, directory: string) => void
-  editProject: (server: ServerConnection.Any, project: LocalProject) => void
-  closeProject: (server: ServerConnection.Any, directory: string) => void
-  clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
-  language: ReturnType<typeof useLanguage>
-}) {
-  const global = useGlobal()
-  const platform = usePlatform()
-  const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
-  const [state, setState] = createStore({ menuOpen: false })
-  const sortable = useSortable({
-    get id() {
-      return props.project.worktree
-    },
-    get index() {
-      return props.index()
-    },
-  })
-  let pointerDownSelected: boolean | undefined
-  const canRevealInFileManager = () =>
-    platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)
-  const fileManagerActionLabel = () =>
-    props.language.t(
-      fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown").actionLabel,
-    )
-  const revealInFileManager = () => {
-    if (!platform.openPath) return
-    platform.openPath(props.project.worktree).catch((err: unknown) =>
-      showToast({
-        title: props.language.t("common.requestFailed"),
-        description: errorMessage(err, props.language.t("common.requestFailed")),
-      }),
-    )
-  }
-  return (
-    <div
-      ref={sortable.ref}
-      class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
-      classList={{ "z-10": sortable.isDragSource() }}
-    >
-      <button
-        type="button"
-        data-component="home-project-row"
-        class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
-        classList={{
-          "bg-v2-background-bg-layer-01 text-v2-text-text-base [box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]":
-            sortable.isDragSource(),
-        }}
-        data-selected={props.selected ? "" : undefined}
-        aria-current={props.selected ? "page" : undefined}
-        disabled={serverUnreachable()}
-        onPointerDown={(event) => {
-          // Same-server mouse selection happens on pointerdown (like tabs),
-          // but only ever selects; selectProject toggles, and deselecting here
-          // would fire on every drag before the threshold is met. Cross-server
-          // selection waits for click so reordering a remote server's projects
-          // does not focus that server and load its session index. Touch is
-          // excluded so flick-scrolling the list cannot select rows.
-          pointerDownSelected = undefined
-          if (event.button !== 0 || event.pointerType === "touch") return
-          if (!props.serverSelected) return
-          pointerDownSelected = props.selected
-          if (!props.selected) props.selectProject(props.server, props.project.worktree)
-        }}
-        onClick={(event) => {
-          // The drag sensor calls preventDefault on post-drag clicks; never
-          // toggle selection as part of a reorder.
-          if (event.defaultPrevented) return
-          // Keyboard activation and touch taps keep the original toggle.
-          if (event.detail === 0 || pointerDownSelected === undefined) {
-            props.selectProject(props.server, props.project.worktree)
-            return
-          }
-          // Mouse: pointerdown already selected unselected rows; a plain click
-          // on an already-selected row toggles it off.
-          if (pointerDownSelected) props.selectProject(props.server, props.project.worktree)
-          pointerDownSelected = undefined
-        }}
-      >
-        <HomeProjectAvatar project={props.project} />
-        <span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
-      </button>
-      <div
-        class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
-        data-menu={state.menuOpen}
-      >
-        <MenuV2
-          gutter={6}
-          modal={false}
-          placement="bottom-end"
-          open={state.menuOpen}
-          onOpenChange={(open) => setState("menuOpen", open)}
-        >
-          <MenuV2.Trigger
-            as={IconButtonV2}
-            data-action="home-project-menu"
-            variant="ghost-muted"
-            size="small"
-            icon={<IconV2 name="outline-dots" />}
-            aria-label={props.language.t("common.moreOptions")}
-          />
-          <MenuV2.Portal>
-            <MenuV2.Content>
-              <MenuV2.Item onSelect={() => props.openNewSession(props.server, props.project.worktree)}>
-                {props.language.t("command.session.new")}
-              </MenuV2.Item>
-              <MenuV2.Item onSelect={() => props.editProject(props.server, props.project)}>
-                {props.language.t("dialog.project.edit.title")}
-              </MenuV2.Item>
-              <Show when={canRevealInFileManager()}>
-                <MenuV2.Item onSelect={revealInFileManager}>{fileManagerActionLabel()}</MenuV2.Item>
-              </Show>
-              <MenuV2.Item
-                disabled={props.unseenCount === 0}
-                onSelect={() => props.clearNotifications(props.server, props.project)}
-              >
-                {props.language.t("sidebar.project.clearNotifications")}
-              </MenuV2.Item>
-              <MenuV2.Separator />
-              <MenuV2.Item onSelect={() => props.closeProject(props.server, props.project.worktree)}>
-                {props.language.t("common.close")}
-              </MenuV2.Item>
-            </MenuV2.Content>
-          </MenuV2.Portal>
-        </MenuV2>
-        <IconButtonV2
-          data-action="home-project-new-session"
-          variant="ghost-muted"
-          size="small"
-          icon={<IconV2 name="edit" />}
-          aria-label={props.language.t("command.session.new")}
-          onClick={() => props.openNewSession(props.server, props.project.worktree)}
-        />
-      </div>
-    </div>
-  )
-}
-
-function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
-  const name = createMemo(() => displayName(props.project))
-  return (
-    <ProjectAvatar
-      fallback={name()}
-      src={props.outline ? undefined : getProjectAvatarSource(props.project.id, props.project.icon)}
-      variant={props.outline ? "outline" : getProjectAvatarVariant(props.project.icon?.color)}
-    />
-  )
-}
-
-function HomeSessionLeading(props: {
-  project: LocalProject
-  session: Session
-  server: ServerConnection.Key
-  revealProjectOnHover: boolean
-}) {
-  const tabs = useTabs()
-  const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
-  return (
-    <div class="relative shrink-0">
-      <Show when={hasOpenTab()}>
-        <span
-          aria-hidden="true"
-          class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
-          style={{ right: "calc(100% + 4px)" }}
-        />
-      </Show>
-      <SessionTabAvatar
-        project={props.project}
-        directory={props.session.directory}
-        sessionId={props.session.id}
-        server={props.server}
-        revealProjectOnHover={props.revealProjectOnHover}
-      />
-    </div>
-  )
-}
-
-function HomeSessionSearch(props: {
-  value: string
-  placeholder: string
-  open: boolean
-  loading: boolean
-  results: HomeSessionRecord[]
-  showProjectName: boolean
-  server: ServerConnection.Key
-  noResultsLabel: string
-  bindFocus: (focus: () => void) => void
-  onInput: (value: string) => void
-  onFocus: () => void
-  onClose: () => void
-  onSelect: (session: Session, options?: OpenSessionOptions) => void
-}) {
-  const language = useLanguage()
-  const [store, setStore] = createStore({ active: "" })
-  let root: HTMLDivElement | undefined
-  let input: HTMLInputElement | undefined
-  let listRef: HTMLDivElement | undefined
-
-  const focusInput = () => {
-    input?.focus()
-    props.onFocus()
-  }
-
-  onMount(() => {
-    props.bindFocus(focusInput)
-  })
-
-  const syncActive = (results: HomeSessionRecord[]) => {
-    if (results.length === 0) {
-      setStore("active", "")
-      return
-    }
-    if (!results.some((record) => homeSessionSearchKey(record) === store.active)) {
-      setStore("active", homeSessionSearchKey(results[0]))
-    }
-  }
-
-  createEffect(() => syncActive(props.results))
-
-  createEffect(
-    on(
-      () => props.value,
-      () => syncActive(props.results),
-    ),
-  )
-
-  const scrollActiveIntoView = () => {
-    const key = store.active
-    if (!key || !listRef) return
-    const element = listRef.querySelector<HTMLElement>(`[data-key="${key}"]`)
-    element?.scrollIntoView({ block: "nearest" })
-  }
-
-  const moveActive = (delta: number) => {
-    const results = props.results
-    if (results.length === 0) return
-    const index = results.findIndex((record) => homeSessionSearchKey(record) === store.active)
-    const start = index === -1 ? 0 : index
-    const next = (start + delta + results.length) % results.length
-    setStore("active", homeSessionSearchKey(results[next]))
-    scrollActiveIntoView()
-  }
-
-  const selectActive = () => {
-    const record = props.results.find((item) => homeSessionSearchKey(item) === store.active)
-    if (!record) return
-    props.onSelect(record.session)
-  }
-
-  onCleanup(
-    makeEventListener(document, "pointerdown", (event) => {
-      if (!props.open) return
-      const target = event.target
-      if (!(target instanceof Node)) return
-      if (root?.contains(target)) return
-      props.onClose()
-    }),
-  )
-
-  return (
-    <div class="w-full">
-      <div ref={root} data-component="home-session-search" class="relative z-30 w-full">
-        <Show when={props.open}>
-          <div
-            data-component="home-session-search-panel"
-            class="absolute flex flex-col overflow-hidden rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]"
-            style={{
-              top: "-6px",
-              left: "-6px",
-              width: "calc(100% + 12px)",
-            }}
-          >
-            <div class="flex flex-col pt-9">
-              <div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
-                <Show
-                  when={!props.loading}
-                  fallback={
-                    <div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
-                      <Spinner class="size-4" />
-                    </div>
-                  }
-                >
-                  <Show
-                    when={props.results.length > 0}
-                    fallback={
-                      <p class="my-1.5 px-4 pb-2 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
-                        {props.noResultsLabel}
-                      </p>
-                    }
-                  >
-                    <div class="flex flex-col">
-                      <p class="my-1.5 pl-[18px] pr-6 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
-                        {language.t("home.sessions.search.sessions")}
-                      </p>
-                      <ScrollView class="max-h-80" viewportRef={(el) => (listRef = el)}>
-                        <div class="flex flex-col gap-px pb-2">
-                          <For each={props.results}>
-                            {(record) => (
-                              <HomeSessionSearchResultRow
-                                record={record}
-                                showProjectName={props.showProjectName}
-                                server={props.server}
-                                selected={store.active === homeSessionSearchKey(record)}
-                                onHighlight={() => setStore("active", homeSessionSearchKey(record))}
-                                onSelect={(session, options) => props.onSelect(session, options)}
-                              />
-                            )}
-                          </For>
-                        </div>
-                      </ScrollView>
-                    </div>
-                  </Show>
-                </Show>
-              </div>
-            </div>
-          </div>
-        </Show>
-        <label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02">
-          <IconV2 name="magnifying-glass" />
-          <input
-            ref={input}
-            class="relative z-20 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}
-            aria-expanded={props.open}
-            aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
-            aria-autocomplete="list"
-            aria-activedescendant={
-              store.active && props.open ? `home-session-search-option-${store.active}` : undefined
-            }
-            onFocus={() => props.onFocus()}
-            onInput={(event) => props.onInput(event.currentTarget.value)}
-            onKeyDown={(event) => {
-              if (event.key === "Escape") {
-                event.preventDefault()
-                props.onClose()
-                input?.blur()
-                return
-              }
-              if (!props.open || props.results.length === 0) return
-              if (event.altKey || event.metaKey) return
-              if (event.key === "ArrowDown") {
-                event.preventDefault()
-                moveActive(1)
-                return
-              }
-              if (event.key === "ArrowUp") {
-                event.preventDefault()
-                moveActive(-1)
-                return
-              }
-              if (event.key === "Enter" && !event.isComposing) {
-                event.preventDefault()
-                selectActive()
-              }
-            }}
-          />
-          <Show when={props.value}>
-            <IconButtonV2
-              type="button"
-              variant="ghost-muted"
-              size="small"
-              class="relative z-20 shrink-0"
-              icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
-              aria-label={props.placeholder}
-              onClick={() => {
-                props.onClose()
-                input?.focus()
-              }}
-            />
-          </Show>
-        </label>
-      </div>
-    </div>
-  )
-}
-
-function HomeSessionSearchResultRow(props: {
-  record: HomeSessionRecord
-  showProjectName: boolean
-  server: ServerConnection.Key
-  selected: boolean
-  onHighlight: () => void
-  onSelect: (session: Session, options?: OpenSessionOptions) => void
-}) {
-  const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
-  const showProjectName = () => props.showProjectName && props.record.projectName
-
-  const key = () => homeSessionSearchKey(props.record)
-
-  return (
-    <button
-      type="button"
-      id={`home-session-search-option-${key()}`}
-      data-key={key()}
-      data-component="home-session-search-row"
-      role="option"
-      aria-selected={props.selected}
-      classList={{
-        [HOME_SEARCH_RESULT_ROW]: true,
-        "bg-v2-overlay-simple-overlay-hover": props.selected,
-        group: !!showProjectName(),
-      }}
-      onMouseEnter={() => props.onHighlight()}
-      onMouseDown={(event) => {
-        if (event.button === 1) event.preventDefault()
-      }}
-      onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })}
-      onAuxClick={(event) => {
-        if (!isBackgroundOpen(event)) return
-        event.preventDefault()
-        props.onSelect(props.record.session, { background: true })
-      }}
-    >
-      <HomeSessionLeading
-        project={props.record.project}
-        session={props.record.session}
-        server={props.server}
-        revealProjectOnHover={!!showProjectName()}
-      />
-      <div class="flex min-w-0 flex-1 items-center gap-1.5">
-        <span
-          class={`${HOME_SEARCH_RESULT_TITLE} ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
-        >
-          {title()}
-        </span>
-        <Show when={showProjectName()}>
-          <span class={HOME_SEARCH_RESULT_META}>{props.record.projectName}</span>
-        </Show>
-      </div>
-    </button>
-  )
-}
-
-function HomeSessionGroupHeader(props: {
-  title: string
-  titleOpacity: number
-  ref: ComponentProps<"div">["ref"]
-  elevated?: boolean
-}) {
-  return (
-    <div
-      ref={props.ref}
-      class={`pointer-events-none sticky top-[84px] lg:top-[108px] flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
-    >
-      <div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
-        {props.title}
-      </div>
-    </div>
-  )
-}
-
-function HomeSessionRow(props: {
-  record: HomeSessionRecord
-  showProjectName: boolean
-  server: ServerConnection.Key
-  openSession: (session: Session, options?: OpenSessionOptions) => void
-  archiveSession: (session: Session) => Promise<void>
-}) {
-  const language = useLanguage()
-  const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
-  const showProjectName = () => props.showProjectName && props.record.projectName
-
-  return (
-    <div
-      class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
-      classList={{ group: !!showProjectName() }}
-    >
-      <button
-        type="button"
-        data-component="home-session-row"
-        class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
-        onMouseDown={(event) => {
-          if (event.button === 1) event.preventDefault()
-        }}
-        onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })}
-        onAuxClick={(event) => {
-          if (!isBackgroundOpen(event)) return
-          event.preventDefault()
-          props.openSession(props.record.session, { background: true })
-        }}
-      >
-        <HomeSessionLeading
-          project={props.record.project}
-          session={props.record.session}
-          server={props.server}
-          revealProjectOnHover={!!showProjectName()}
-        />
-        <span
-          class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
-        >
-          {title()}
-        </span>
-        <Show when={showProjectName()}>
-          <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>
-      <Show when={SHOW_HOME_SESSION_ARCHIVE}>
-        <div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
-          <TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
-            <IconButtonV2
-              data-action="home-session-archive"
-              variant="ghost-muted"
-              size="large"
-              icon={<IconV2 name="archive" />}
-              aria-label={language.t("common.archive")}
-              onClick={(event) => {
-                event.preventDefault()
-                event.stopPropagation()
-                void props.archiveSession(props.record.session)
-              }}
-            />
-          </TooltipV2>
-        </div>
-      </Show>
-    </div>
-  )
-}
-
-function HomeSessionsEmpty(props: { onNewSession?: () => void }) {
-  const language = useLanguage()
-  return (
-    <div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
-      <div class="shrink-0 text-[13px] leading-[13px] tracking-[-0.04px] text-v2-text-text-base [font-weight:530]">
-        {language.t("home.sessions.empty")}
-      </div>
-      <p class="mb-1 text-center text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
-        {language.t("home.sessions.empty.description")}
-      </p>
-      <Show when={props.onNewSession}>
-        {(onNewSession) => (
-          <ButtonV2 data-action="home-new-session" variant="neutral" size="normal" icon="edit" onClick={onNewSession()}>
-            {language.t("command.session.new")}
-          </ButtonV2>
-        )}
-      </Show>
-    </div>
-  )
-}
-
-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)
-}
-
-export function LegacyHome() {
-  const sync = useServerSync()
-  const platform = usePlatform()
-  const pickDirectory = useDirectoryPicker()
-  const dialog = useDialog()
-  const navigate = useNavigate()
-  const global = useGlobal()
-  const server = useServer()
-  const language = useLanguage()
-  const homedir = createMemo(() => sync().data.path.home)
-  const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
-  const recent = createMemo(() => {
-    return sync()
-      .data.project.slice()
-      .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
-      .slice(0, 5)
-  })
-
-  const serverDotClass = createMemo(() => {
-    const healthy = global.servers.health[server.key]?.healthy
-    if (healthy === true) return "bg-icon-success-base"
-    if (healthy === false) return "bg-icon-critical-base"
-    return "bg-border-weak-base"
-  })
-
-  function openProject(server: ServerConnection.Any, directory: string) {
-    const serverCtx = global.ensureServerCtx(server)
-    serverCtx.projects.open(directory)
-    serverCtx.projects.touch(directory)
-    navigate(`/${base64Encode(directory)}`)
-  }
-
-  function chooseProject() {
-    if (serverUnreachable()) return
-    const s = server.current
-    if (!s) return
-
-    const resolve = (result: string | string[] | null) => {
-      if (Array.isArray(result)) {
-        for (const directory of result) {
-          openProject(s, directory)
-        }
-      } else if (result) {
-        openProject(s, result)
-      }
-    }
-
-    pickDirectory({
-      server: s,
-      title: language.t("command.project.open"),
-      multiple: true,
-      onSelect: resolve,
-    })
-  }
-
-  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>
-      <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"
-                disabled={serverUnreachable()}
-                onClick={chooseProject}
-              >
-                {language.t("command.project.open")}
-              </Button>
-            </div>
-            <ul class="flex flex-col gap-2">
-              <For each={recent()}>
-                {(project) => (
-                  <Button
-                    size="large"
-                    variant="ghost"
-                    class="text-14-mono text-left justify-between px-3"
-                    onClick={() => openProject(server.current!, project.worktree)}
-                  >
-                    {project.worktree.replace(homedir(), "~")}
-                    <div class="text-14-regular text-text-weak">
-                      {DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
-                    </div>
-                  </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" disabled={serverUnreachable()} onClick={chooseProject}>
-              {language.t("command.project.open")}
-            </Button>
-          </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>
-            <Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
-              {language.t("command.project.open")}
-            </Button>
-          </div>
-        </Match>
-      </Switch>
-    </div>
-  )
-}

+ 108 - 0
packages/app/src/pages/home/home-controller.ts

@@ -0,0 +1,108 @@
+import { useGlobal } from "@/context/global"
+import { type HomeProjectSelection, useLayout } from "@/context/layout"
+import { ServerConnection, useServer } from "@/context/server"
+import { useServerSync } from "@/context/server-sync"
+import { useTabs } from "@/context/tabs"
+import { toggleHomeProjectSelection } from "@/pages/layout/helpers"
+import { createEffect, createMemo } from "solid-js"
+
+export function createHomeController() {
+  const sync = useServerSync()
+  const layout = useLayout()
+  const server = useServer()
+  const global = useGlobal()
+  const tabs = useTabs()
+  const selection = layout.home.selection
+  const focusedServer = createMemo(
+    () => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current,
+  )
+  const focusedServerCtx = createMemo(() => {
+    const conn = focusedServer()
+    if (!conn) return undefined
+    return global.ensureServerCtx(conn)
+  })
+  const focusedSync = () => focusedServerCtx()?.sync ?? sync()
+  const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
+  const recentlyClosed = createMemo(
+    () => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
+  )
+  const homedir = createMemo(() => focusedSync().data.path.home ?? "")
+  const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
+  const newSessionProject = createMemo(
+    () =>
+      selectedProject() ??
+      projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ??
+      projects()[0],
+  )
+
+  createEffect(() => {
+    const list = global.servers.list()
+    if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
+    const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
+    if (conn) setSelection({ server: ServerConnection.key(conn) })
+  })
+
+  function setSelection(next: HomeProjectSelection) {
+    layout.home.setSelection(next)
+  }
+
+  function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
+    const ctx = global.ensureServerCtx(conn)
+    ctx.projects.open(directory)
+    ctx.projects.touch(directory)
+    void tabs.newDraft({ server: ServerConnection.key(conn), directory })
+  }
+
+  return {
+    selection: {
+      value: selection,
+      set: setSelection,
+      focusServer: (conn: ServerConnection.Any) => setSelection({ server: ServerConnection.key(conn) }),
+    },
+    server: {
+      list: global.servers.list,
+      health: (conn: ServerConnection.Any) => global.servers.health[ServerConnection.key(conn)],
+      context: (conn: ServerConnection.Any) => global.ensureServerCtx(conn),
+      focused: focusedServer,
+      focusedContext: focusedServerCtx,
+      focusedSync,
+    },
+    project: {
+      list: projects,
+      recentlyClosed,
+      homedir,
+      selected: selectedProject,
+      newSession: newSessionProject,
+      forServer: (conn: ServerConnection.Any) => global.ensureServerCtx(conn).projects.list(),
+      select: (conn: ServerConnection.Any, directory: string) => {
+        const key = ServerConnection.key(conn)
+        if (global.servers.health[key]?.healthy === false) return
+        if (
+          !global
+            .ensureServerCtx(conn)
+            .projects.list()
+            .some((project) => project.worktree === directory)
+        )
+          return
+        setSelection(toggleHomeProjectSelection(selection(), key, directory))
+      },
+      add: (conn: ServerConnection.Any, directories: string[]) => {
+        const directory = directories[0]
+        if (!directory) return
+        const ctx = global.ensureServerCtx(conn)
+        directories.forEach((item) => ctx.projects.open(item))
+        ctx.projects.touch(directory)
+        setSelection({ server: ServerConnection.key(conn), directory })
+      },
+      openNewSession: () => {
+        const conn = focusedServer()
+        const project = newSessionProject()
+        if (!conn || !project) return
+        openProjectNewSession(conn, project.worktree)
+      },
+      openProjectNewSession,
+    },
+  }
+}
+
+export type HomeController = ReturnType<typeof createHomeController>

+ 128 - 0
packages/app/src/pages/home/home-projects-controller.tsx

@@ -0,0 +1,128 @@
+import { useDirectoryPicker } from "@/components/directory-picker"
+import { useServerManagementController } from "@/components/dialog-select-server"
+import { useSettingsCommand } from "@/components/settings-dialog"
+import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
+import { type LocalProject } from "@/context/layout"
+import { useLanguage } from "@/context/language"
+import { useNotification } from "@/context/notification"
+import { usePlatform } from "@/context/platform"
+import { ServerConnection } from "@/context/server"
+import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/pages/layout/helpers"
+import { Persist, persisted } from "@/utils/persist"
+import { showToast } from "@/utils/toast"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { createResource } from "solid-js"
+import { createStore } from "solid-js/store"
+import type { HomeController } from "./home-controller"
+
+export function createHomeProjectsController(home: HomeController) {
+  const platform = usePlatform()
+  const pickDirectory = useDirectoryPicker()
+  const dialog = useDialog()
+  const language = useLanguage()
+  const notification = useNotification()
+  const openSettings = useSettingsCommand()
+  const serverManagement = useServerManagementController({ navigateOnAdd: false })
+  const [_state, setState, _, ready] = persisted(
+    Persist.global("home.servers", ["home.servers.v1"]),
+    createStore({ collapsed: {} as Record<string, boolean> }),
+  )
+  const [state] = createResource(
+    () => ready.promise ?? Promise.resolve(),
+    (promise) => promise.then(() => _state),
+    { initialValue: _state },
+  )
+  function directories(project: LocalProject) {
+    return [project.worktree, ...(project.sandboxes ?? [])]
+  }
+
+  function canRevealProject(conn: ServerConnection.Any) {
+    return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn)
+  }
+
+  return {
+    copy: {
+      language,
+    },
+    selection: {
+      value: home.selection.value,
+    },
+    server: {
+      list: home.server.list,
+      health: home.server.health,
+      projects: home.project.forServer,
+      collapsed: (conn: ServerConnection.Any) => state().collapsed[ServerConnection.key(conn)] ?? false,
+      toggleCollapsed: (conn: ServerConnection.Any) => {
+        const key = ServerConnection.key(conn)
+        setState("collapsed", key, !state().collapsed[key])
+      },
+      canDefault: serverManagement.canDefault,
+      defaultKey: serverManagement.defaultKey,
+      setDefault: (conn: ServerConnection.Any | undefined) =>
+        serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
+      remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
+      edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
+      focus: home.selection.focusServer,
+    },
+    project: {
+      list: home.project.list,
+      recentlyClosed: home.project.recentlyClosed,
+      homedir: home.project.homedir,
+      select: home.project.select,
+      add: home.project.add,
+      openNewSession: home.project.openProjectNewSession,
+      edit: (conn: ServerConnection.Any, project: LocalProject) => {
+        void import("@/components/dialog-edit-project-v2").then(({ DialogEditProjectV2 }) => {
+          void dialog.show(() => <DialogEditProjectV2 server={conn} project={project} />)
+        })
+      },
+      unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
+        const state = notification.ensureServerState(ServerConnection.key(conn))
+        return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
+      },
+      clearNotifications: (conn: ServerConnection.Any, project: LocalProject) => {
+        const state = notification.ensureServerState(ServerConnection.key(conn))
+        directories(project)
+          .filter((directory) => state.project.unseenCount(directory) > 0)
+          .forEach((directory) => state.project.markViewed(directory))
+      },
+      choose: (conn: ServerConnection.Any) => {
+        if (home.server.health(conn)?.healthy === false) return
+        pickDirectory({
+          server: conn,
+          title: language.t("command.project.open"),
+          multiple: true,
+          onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)),
+        })
+      },
+      close: (conn: ServerConnection.Any, directory: string) => {
+        const next = closeHomeProject(
+          home.selection.value(),
+          ServerConnection.key(conn),
+          home.server.context(conn).projects,
+          directory,
+        )
+        if (next) home.selection.set(next)
+      },
+      move: (conn: ServerConnection.Any, worktree: string, index: number) => {
+        home.server.context(conn).projects.move(worktree, index)
+      },
+      canReveal: canRevealProject,
+      reveal: (conn: ServerConnection.Any, project: LocalProject) => {
+        if (!platform.openPath || !canRevealProject(conn)) return
+        platform.openPath(project.worktree).catch((cause: unknown) =>
+          showToast({
+            title: language.t("common.requestFailed"),
+            description: errorMessage(cause, language.t("common.requestFailed")),
+          }),
+        )
+      },
+    },
+    utility: {
+      settings: openSettings,
+      help: () => platform.openLink("https://opencode.ai/desktop-feedback"),
+    },
+  }
+}
+
+export type HomeProjectsController = ReturnType<typeof createHomeProjectsController>

+ 608 - 0
packages/app/src/pages/home/home-projects-view.tsx

@@ -0,0 +1,608 @@
+import { type Accessor, createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
+import { createStore } from "solid-js/store"
+import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
+import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
+import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
+import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
+import { RestrictToElement } from "@dnd-kit/dom/modifiers"
+import { ScrollView } from "@opencode-ai/ui/scroll-view"
+import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
+import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/context/layout"
+import { ServerConnection } from "@/context/server"
+import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
+import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
+import { ServerRowMenuView, serverMenuLabels } from "@/components/server/server-row-menu"
+import { ServerHealthIndicator } from "@/components/server/server-row"
+import { type ServerHealth } from "@/utils/server-health"
+import { fileManagerApp } from "@/utils/file-manager"
+
+const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
+
+const serverContextMenuID = (server: ServerConnection.Any) => `server:${ServerConnection.key(server)}`
+const projectContextMenuID = (server: ServerConnection.Any, directory: string) =>
+  `project:${ServerConnection.key(server)}:${directory}`
+
+export type HomeProjectsViewProps = {
+  language: ReturnType<typeof useLanguage>
+  servers: Accessor<ServerConnection.Any[]>
+  projects: Accessor<LocalProject[]>
+  recentlyClosed: Accessor<LocalProject[]>
+  selection: Accessor<HomeProjectSelection>
+  homedir: Accessor<string>
+  serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
+  projectsForServer: (server: ServerConnection.Any) => LocalProject[]
+  collapsed: (server: ServerConnection.Any) => boolean
+  canDefaultServer: Accessor<boolean>
+  defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
+  canRevealProject: (server: ServerConnection.Any) => boolean
+  unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
+  onWheel: (event: WheelEvent) => void
+  onChooseProject: (server: ServerConnection.Any) => void
+  onFocusServer: (server: ServerConnection.Any) => void
+  onToggleCollapsed: (server: ServerConnection.Any) => void
+  onEditServer: (server: ServerConnection.Http) => void
+  onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
+  onRemoveServer: (server: ServerConnection.Any) => void
+  onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void
+  onSelectProject: (server: ServerConnection.Any, directory: string) => void
+  onAddProjects: (server: ServerConnection.Any, directories: string[]) => void
+  onOpenProjectNewSession: (server: ServerConnection.Any, directory: string) => void
+  onEditProject: (server: ServerConnection.Any, project: LocalProject) => void
+  onRevealProject: (server: ServerConnection.Any, project: LocalProject) => void
+  onClearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
+  onCloseProject: (server: ServerConnection.Any, directory: string) => void
+  onOpenSettings: () => void
+  onOpenHelp: () => void
+}
+
+export function HomeProjectsView(props: HomeProjectsViewProps) {
+  const [contextMenu, setContextMenu] = createStore({ open: undefined as string | undefined })
+  const contextMenuProps = {
+    contextMenuOpen: (id: string) => contextMenu.open === id,
+    onSetContextMenuOpen: (id: string, open: boolean) => setContextMenu("open", open ? id : undefined),
+  }
+  return (
+    <aside
+      class={`
+        mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden
+        lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]
+      `}
+      aria-label={props.language.t("home.projects")}
+      onWheel={(event) => {
+        if (event.target === event.currentTarget) return
+        props.onWheel(event)
+      }}
+    >
+      <div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
+        <div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
+        <Show
+          when={props.servers().length === 1 && !(props.projects().length === 0 && props.recentlyClosed().length > 0)}
+        >
+          <TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
+            <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" />}
+              disabled={props.serverHealth(props.servers()[0])?.healthy === false}
+              onClick={() => props.onChooseProject(props.servers()[0])}
+              aria-label={props.language.t("home.project.add")}
+            />
+          </TooltipV2>
+        </Show>
+      </div>
+      <ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
+        <Show
+          when={props.servers().length > 1}
+          fallback={
+            <div class="pr-3">
+              <Show
+                when={props.projects().length > 0}
+                fallback={<HomeProjectEmpty {...props} server={props.servers()[0]} items={props.recentlyClosed()} />}
+              >
+                <HomeProjectList {...props} {...contextMenuProps} server={props.servers()[0]} items={props.projects()} />
+              </Show>
+            </div>
+          }
+        >
+          <div class="flex min-w-0 flex-col gap-4 pr-3">
+            <For each={props.servers()}>
+              {(item) => {
+                const projects = () => props.projectsForServer(item)
+                const healthy = () => !!props.serverHealth(item)?.healthy
+                const hasProjects = () => projects().length > 0
+                const collapsed = () => props.collapsed(item)
+                return (
+                  <div class="flex min-w-0 flex-col gap-1">
+                    <HomeServerRow
+                      server={item}
+                      {...props}
+                      {...contextMenuProps}
+                      selected={props.selection().server === ServerConnection.key(item) && !props.selection().directory}
+                      collapsed={collapsed()}
+                      health={props.serverHealth(item)}
+                    />
+                    <Show when={healthy() && hasProjects() && !collapsed()}>
+                      <div class="mx-3 h-px bg-v2-border-border-base" />
+                      <HomeProjectList {...props} {...contextMenuProps} server={item} items={projects()} />
+                    </Show>
+                  </div>
+                )
+              }}
+            </For>
+          </div>
+        </Show>
+      </ScrollView>
+      <HomeUtilityNav
+        class="mb-8 mt-4 hidden shrink-0 lg:flex"
+        onOpenSettings={props.onOpenSettings}
+        onOpenHelp={props.onOpenHelp}
+        language={props.language}
+      />
+    </aside>
+  )
+}
+
+export function HomeUtilityNav(props: {
+  class?: string
+  onOpenSettings: () => void
+  onOpenHelp: () => void
+  language: ReturnType<typeof useLanguage>
+}) {
+  return (
+    <div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
+      <HomeProjectNavButton
+        type="button"
+        class="text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
+        onClick={props.onOpenSettings}
+      >
+        <IconV2 name="settings-gear" size="small" />
+        <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
+      </HomeProjectNavButton>
+      <HomeProjectNavButton
+        type="button"
+        class="text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
+        onClick={props.onOpenHelp}
+      >
+        <IconV2 name="help" size="small" />
+        <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
+      </HomeProjectNavButton>
+    </div>
+  )
+}
+
+function HomeServerRow(props: {
+  language: HomeProjectsViewProps["language"]
+  projectsForServer: HomeProjectsViewProps["projectsForServer"]
+  contextMenuOpen: HomeProjectsContextMenuProps["contextMenuOpen"]
+  canDefaultServer: HomeProjectsViewProps["canDefaultServer"]
+  defaultServerKey: HomeProjectsViewProps["defaultServerKey"]
+  onFocusServer: HomeProjectsViewProps["onFocusServer"]
+  onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"]
+  onEditServer: HomeProjectsViewProps["onEditServer"]
+  onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
+  onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
+  onSetContextMenuOpen: HomeProjectsContextMenuProps["onSetContextMenuOpen"]
+  onChooseProject: HomeProjectsViewProps["onChooseProject"]
+  server: ServerConnection.Any
+  selected: boolean
+  collapsed: boolean
+  health: ServerHealth | undefined
+}) {
+  const healthy = () => !!props.health?.healthy
+  const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
+  const contextMenuID = () => serverContextMenuID(props.server)
+  onCleanup(() => {
+    const id = contextMenuID()
+    if (props.contextMenuOpen(id)) props.onSetContextMenuOpen(id, false)
+  })
+  return (
+    <div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
+      <HomeProjectNavButton
+        type="button"
+        class="pr-16 disabled:opacity-60"
+        data-selected={props.selected ? "" : undefined}
+        disabled={!healthy()}
+        onClick={() => props.onFocusServer(props.server)}
+      >
+        <span
+          data-action="home-server-collapse"
+          class={`
+            -ml-0.5 -mr-1.5 inline-flex size-5 shrink-0 items-center justify-center
+            rounded-[4px] text-v2-icon-icon-muted
+          `}
+          classList={{
+            "hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
+            "cursor-default opacity-40": !canToggle(),
+          }}
+          aria-label={
+            props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
+          }
+          aria-disabled={!canToggle()}
+          aria-expanded={canToggle() ? !props.collapsed : undefined}
+          onClick={(event) => {
+            event.preventDefault()
+            event.stopPropagation()
+            if (!canToggle()) return
+            props.onToggleCollapsed(props.server)
+          }}
+          onPointerDown={(event) => event.preventDefault()}
+        >
+          <IconV2
+            name="chevron-down"
+            size="small"
+            class="transition-transform duration-150 ease-in-out"
+            style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
+          />
+        </span>
+        <div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
+          <ServerHealthIndicator health={props.health} />
+        </div>
+        <span class="flex min-w-0 items-center gap-1">
+          <span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
+          <Show when={props.server.label}>
+            {(label) => (
+              <span
+                class={`
+                  shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5
+                  text-[9px] leading-none text-v2-text-text-muted
+                `}
+              >
+                {label()}
+              </span>
+            )}
+          </Show>
+        </span>
+      </HomeProjectNavButton>
+      <div
+        class={`
+          hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1
+          group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100
+        `}
+        data-menu={props.contextMenuOpen(contextMenuID())}
+      >
+        <ServerRowMenuView
+          server={props.server}
+          labels={serverMenuLabels(props.language)}
+          canDefault={props.canDefaultServer()}
+          isDefault={props.defaultServerKey() === ServerConnection.key(props.server)}
+          onEdit={props.onEditServer}
+          onSetDefault={() => props.onSetDefaultServer(props.server)}
+          onRemoveDefault={() => props.onSetDefaultServer(undefined)}
+          onRemove={() => props.onRemoveServer(props.server)}
+          open={props.contextMenuOpen(contextMenuID())}
+          onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
+        />
+        <TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
+          <IconButtonV2
+            data-action="home-add-project"
+            variant="ghost-muted"
+            size="small"
+            icon={<IconV2 name="folder-add-left" />}
+            aria-label={props.language.t("home.project.add")}
+            disabled={props.health?.healthy === false}
+            onClick={() => props.onChooseProject(props.server)}
+          />
+        </TooltipV2>
+      </div>
+    </div>
+  )
+}
+
+type HomeProjectsContextMenuProps = {
+  contextMenuOpen: (id: string) => boolean
+  onSetContextMenuOpen: (id: string, open: boolean) => void
+}
+
+type HomeProjectListProps = HomeProjectsViewProps & HomeProjectsContextMenuProps & {
+  server: ServerConnection.Any
+  items: LocalProject[]
+}
+
+function HomeProjectList(props: HomeProjectListProps) {
+  let listRef!: HTMLDivElement
+
+  return (
+    <DragDropProvider
+      sensors={(defaults) => [
+        ...defaults.filter((sensor) => sensor !== PointerSensor),
+        PointerSensor.configure({
+          activationConstraints: (event) =>
+            event.pointerType === "touch"
+              ? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })]
+              : [new PointerActivationConstraints.Distance({ value: 4 })],
+          preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"),
+        }),
+      ]}
+      modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
+      plugins={(defaults) => [
+        ...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
+        AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
+        Feedback.configure({ dropAnimation: null }),
+      ]}
+      onDragEnd={(event) => {
+        const source = event.operation.source
+        if (event.canceled || !isSortable(source)) return
+        if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index)
+        if (props.selection().server !== ServerConnection.key(props.server))
+          props.onSelectProject(props.server, source.id.toString())
+      }}
+    >
+      <div class="flex min-w-0 flex-col gap-1" ref={listRef}>
+        {/* Keyed on worktree strings: the enriched project objects are
+            recreated on every store or sync update, so iterating them directly
+            remounts all rows — killing any in-flight drag activation (the
+            row's sortable unregisters on unmount) and discarding animations.
+            String keys keep row elements alive and move them on reorder. */}
+        <For each={props.items.map((project) => project.worktree)}>
+          {(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
+        </For>
+      </div>
+    </DragDropProvider>
+  )
+}
+
+function HomeProjectSlot(
+  props: HomeProjectListProps & {
+    worktree: string
+    index: () => number
+  },
+) {
+  const project = createMemo(() => props.items.find((item) => item.worktree === props.worktree))
+
+  return (
+    <Show when={project()}>
+      {(item) => (
+        <HomeProjectRow
+          {...props}
+          project={item()}
+          server={props.server}
+          index={props.index}
+          serverSelected={props.selection().server === ServerConnection.key(props.server)}
+          selected={
+            props.selection().server === ServerConnection.key(props.server) &&
+            props.selection().directory === props.worktree
+          }
+          unseen={props.unseenCount(props.server, item())}
+        />
+      )}
+    </Show>
+  )
+}
+
+function HomeProjectEmpty(
+  props: HomeProjectsViewProps & {
+    server: ServerConnection.Any
+    items: LocalProject[]
+  },
+) {
+  const unreachable = () => props.serverHealth(props.server)?.healthy === false
+  return (
+    <div class="flex min-w-0 flex-col gap-1">
+      <HomeProjectNavButton
+        type="button"
+        data-action="home-add-project-row"
+        class="disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
+        disabled={unreachable()}
+        onClick={() => props.onChooseProject(props.server)}
+      >
+        <IconV2 name="folder-add-left" size="small" />
+        <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
+      </HomeProjectNavButton>
+      <Show when={props.items.length > 0}>
+        <div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
+          <div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
+        </div>
+        <For each={props.items}>
+          {(project) => <HomeRecentlyClosedRow {...props} project={project} server={props.server} />}
+        </For>
+      </Show>
+    </div>
+  )
+}
+
+function HomeRecentlyClosedRow(
+  props: HomeProjectsViewProps & {
+    project: LocalProject
+    server: ServerConnection.Any
+  },
+) {
+  const unreachable = () => props.serverHealth(props.server)?.healthy === false
+  const path = () => {
+    const home = props.homedir()
+    const worktree = props.project.worktree
+    if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
+    return worktree
+  }
+  return (
+    <TooltipV2 placement="right" value={path()}>
+      <HomeProjectNavButton
+        type="button"
+        data-component="home-recently-closed-row"
+        class="disabled:opacity-60"
+        disabled={unreachable()}
+        onClick={() => props.onAddProjects(props.server, [props.project.worktree])}
+      >
+        <HomeProjectAvatar project={props.project} outline />
+        <span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
+      </HomeProjectNavButton>
+    </TooltipV2>
+  )
+}
+
+function HomeProjectRow(
+  props: HomeProjectsViewProps & HomeProjectsContextMenuProps & {
+    project: LocalProject
+    server: ServerConnection.Any
+    index: () => number
+    serverSelected: boolean
+    selected: boolean
+    unseen: number
+  },
+) {
+  const platform = usePlatform()
+  const serverUnreachable = () => props.serverHealth(props.server)?.healthy === false
+  const sortable = useSortable({
+    get id() {
+      return props.project.worktree
+    },
+    get index() {
+      return props.index()
+    },
+  })
+  let pointerDownSelected: boolean | undefined
+  const contextMenuID = () => projectContextMenuID(props.server, props.project.worktree)
+  onCleanup(() => {
+    const id = contextMenuID()
+    if (props.contextMenuOpen(id)) props.onSetContextMenuOpen(id, false)
+  })
+  return (
+    <div
+      ref={sortable.ref}
+      class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
+      classList={{ "z-10": sortable.isDragSource() }}
+    >
+      <HomeProjectNavButton
+        type="button"
+        data-component="home-project-row"
+        class="pr-16 disabled:opacity-60"
+        classList={{
+          "bg-v2-background-bg-layer-01 text-v2-text-text-base": sortable.isDragSource(),
+          "[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]": sortable.isDragSource(),
+        }}
+        data-selected={props.selected ? "" : undefined}
+        aria-current={props.selected ? "page" : undefined}
+        disabled={serverUnreachable()}
+        onPointerDown={(event) => {
+          // Same-server mouse selection happens on pointerdown (like tabs),
+          // but only ever selects; selectProject toggles, and deselecting here
+          // would fire on every drag before the threshold is met. Cross-server
+          // selection waits for click so reordering a remote server's projects
+          // does not focus that server and load its session index. Touch is
+          // excluded so flick-scrolling the list cannot select rows.
+          pointerDownSelected = undefined
+          if (event.button !== 0 || event.pointerType === "touch") return
+          if (!props.serverSelected) return
+          pointerDownSelected = props.selected
+          if (!props.selected) props.onSelectProject(props.server, props.project.worktree)
+        }}
+        onClick={(event) => {
+          // The drag sensor calls preventDefault on post-drag clicks; never
+          // toggle selection as part of a reorder.
+          if (event.defaultPrevented) return
+          // Keyboard activation and touch taps keep the original toggle.
+          if (event.detail === 0 || pointerDownSelected === undefined) {
+            props.onSelectProject(props.server, props.project.worktree)
+            return
+          }
+          // Mouse: pointerdown already selected unselected rows; a plain click
+          // on an already-selected row toggles it off.
+          if (pointerDownSelected) props.onSelectProject(props.server, props.project.worktree)
+          pointerDownSelected = undefined
+        }}
+      >
+        <HomeProjectAvatar project={props.project} />
+        <span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
+      </HomeProjectNavButton>
+      <div
+        class={`
+          hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1
+          group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100
+        `}
+        data-menu={props.contextMenuOpen(contextMenuID())}
+      >
+        <MenuV2
+          gutter={6}
+          modal={false}
+          placement="bottom-end"
+          open={props.contextMenuOpen(contextMenuID())}
+          onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
+        >
+          <MenuV2.Trigger
+            as={IconButtonV2}
+            data-action="home-project-menu"
+            variant="ghost-muted"
+            size="small"
+            icon={<IconV2 name="outline-dots" />}
+            aria-label={props.language.t("common.moreOptions")}
+          />
+          <MenuV2.Portal>
+            <MenuV2.Content>
+              <MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
+                {props.language.t("command.session.new")}
+              </MenuV2.Item>
+              <MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
+                {props.language.t("dialog.project.edit.title")}
+              </MenuV2.Item>
+              <Show when={props.canRevealProject(props.server)}>
+                <MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
+                  {props.language.t(
+                    fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown")
+                      .actionLabel,
+                  )}
+                </MenuV2.Item>
+              </Show>
+              <MenuV2.Item
+                disabled={props.unseen === 0}
+                onSelect={() => props.onClearNotifications(props.server, props.project)}
+              >
+                {props.language.t("sidebar.project.clearNotifications")}
+              </MenuV2.Item>
+              <MenuV2.Separator />
+              <MenuV2.Item onSelect={() => props.onCloseProject(props.server, props.project.worktree)}>
+                {props.language.t("common.close")}
+              </MenuV2.Item>
+            </MenuV2.Content>
+          </MenuV2.Portal>
+        </MenuV2>
+        <IconButtonV2
+          data-action="home-project-new-session"
+          variant="ghost-muted"
+          size="small"
+          icon={<IconV2 name="edit" />}
+          aria-label={props.language.t("command.session.new")}
+          onClick={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}
+        />
+      </div>
+    </div>
+  )
+}
+
+function HomeProjectNavButton(props: JSX.ButtonHTMLAttributes<HTMLButtonElement>) {
+  const [local, rest] = splitProps(props, ["class", "classList", "children"])
+  return (
+    <button
+      {...rest}
+      class={`
+        flex h-7 min-w-0 w-full shrink-0 cursor-default items-center gap-2 rounded-[6px] bg-transparent px-1.5 text-left
+        text-v2-text-text-muted [font-weight:440] transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
+        hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]
+        data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base
+        data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03
+        focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:outline-none
+        focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]
+        ${local.class ?? ""}
+      `}
+      classList={local.classList}
+    >
+      {local.children}
+    </button>
+  )
+}
+
+function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
+  const name = createMemo(() => displayName(props.project))
+  return (
+    <ProjectAvatar
+      fallback={name()}
+      src={props.outline ? undefined : getProjectAvatarSource(props.project.id, props.project.icon)}
+      variant={props.outline ? "outline" : getProjectAvatarVariant(props.project.icon?.color)}
+    />
+  )
+}

+ 40 - 0
packages/app/src/pages/home/home-projects.tsx

@@ -0,0 +1,40 @@
+import type { HomeProjectsController } from "./home-projects-controller"
+import { HomeProjectsView } from "./home-projects-view"
+import type { HomeScrollController } from "./home-scroll-controller"
+
+export function HomeProjects(props: { projects: HomeProjectsController; scroll: HomeScrollController }) {
+  return (
+    <HomeProjectsView
+      language={props.projects.copy.language}
+      servers={props.projects.server.list}
+      projects={props.projects.project.list}
+      recentlyClosed={props.projects.project.recentlyClosed}
+      selection={props.projects.selection.value}
+      homedir={props.projects.project.homedir}
+      serverHealth={props.projects.server.health}
+      projectsForServer={props.projects.server.projects}
+      collapsed={props.projects.server.collapsed}
+      canDefaultServer={props.projects.server.canDefault}
+      defaultServerKey={props.projects.server.defaultKey}
+      canRevealProject={props.projects.project.canReveal}
+      unseenCount={props.projects.project.unseenCount}
+      onWheel={props.scroll.viewport.containWheel}
+      onChooseProject={props.projects.project.choose}
+      onFocusServer={props.projects.server.focus}
+      onToggleCollapsed={props.projects.server.toggleCollapsed}
+      onEditServer={props.projects.server.edit}
+      onSetDefaultServer={props.projects.server.setDefault}
+      onRemoveServer={props.projects.server.remove}
+      onMoveProject={props.projects.project.move}
+      onSelectProject={props.projects.project.select}
+      onAddProjects={props.projects.project.add}
+      onOpenProjectNewSession={props.projects.project.openNewSession}
+      onEditProject={props.projects.project.edit}
+      onRevealProject={props.projects.project.reveal}
+      onClearNotifications={props.projects.project.clearNotifications}
+      onCloseProject={props.projects.project.close}
+      onOpenSettings={props.projects.utility.settings}
+      onOpenHelp={props.projects.utility.help}
+    />
+  )
+}

+ 145 - 0
packages/app/src/pages/home/home-scroll-controller.ts

@@ -0,0 +1,145 @@
+import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
+import { createStore } from "solid-js/store"
+import type { HomeSessionGroup } from "./home-sessions-controller"
+
+const HOME_SESSION_HEADER_STICKY_TOP = 12
+const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
+const HOME_SESSION_HEADER_FADE_DISTANCE = 16
+
+export function createHomeScrollController(groups: Accessor<HomeSessionGroup[]>) {
+  const [thumbTrack, setThumbTrack] = createSignal<HTMLDivElement>()
+  const [hoverTarget, setHoverTarget] = createSignal<HTMLElement>()
+  const [state, setState] = createStore({
+    titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
+  })
+  const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
+  const headerOffsets = new Map<HomeSessionGroup["id"], number>()
+  let viewport: HTMLDivElement | undefined
+  let content: HTMLDivElement | undefined
+  let positionFrame: number | undefined
+  let resizeObserver: ResizeObserver | undefined
+  let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
+
+  createEffect(() => {
+    const items = groups()
+    const ids = new Set(items.map((group) => group.id))
+    headerRefs.forEach((_, id) => {
+      if (!ids.has(id)) headerRefs.delete(id)
+    })
+    headerOffsets.forEach((_, id) => {
+      if (!ids.has(id)) headerOffsets.delete(id)
+    })
+    if (items.length === 0) {
+      content = undefined
+      bindResizeObserver()
+    }
+    queuePositionUpdate()
+  })
+
+  onCleanup(() => {
+    if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
+    resizeObserver?.disconnect()
+  })
+
+  function queuePositionUpdate() {
+    if (typeof requestAnimationFrame === "undefined") {
+      updatePositionCache()
+      return
+    }
+    if (positionFrame !== undefined) return
+    positionFrame = requestAnimationFrame(() => {
+      positionFrame = undefined
+      updatePositionCache()
+    })
+  }
+
+  function updatePositionCache() {
+    if (!viewport) return
+    const header = groups()
+      .map((group) => headerRefs.get(group.id))
+      .find((element) => element !== undefined)
+    if (header && typeof getComputedStyle === "function") {
+      const top = Number.parseFloat(getComputedStyle(header).top)
+      if (Number.isFinite(top)) stickyTop = top
+    }
+    groups().forEach((group) => {
+      const element = headerRefs.get(group.id)
+      if (element) headerOffsets.set(group.id, element.offsetTop)
+    })
+    update(viewport.scrollTop)
+  }
+
+  function update(scrollTop: number) {
+    const items = groups()
+    items.forEach((group, index) => {
+      const nextOffset = items
+        .slice(index + 1)
+        .map((item) => headerOffsets.get(item.id))
+        .find((offset) => offset !== undefined)
+      const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
+      const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
+      const opacity =
+        nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
+      setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
+    })
+  }
+
+  function bindResizeObserver() {
+    resizeObserver?.disconnect()
+    if (typeof ResizeObserver === "undefined") return
+    resizeObserver = new ResizeObserver(queuePositionUpdate)
+    if (viewport) resizeObserver.observe(viewport)
+    if (content) resizeObserver.observe(content)
+  }
+
+  function containWheel(event: WheelEvent) {
+    if (!viewport) return
+    if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
+    if (!(event.target instanceof Element)) return
+    const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
+    if (
+      scrollable !== viewport &&
+      scrollable &&
+      (event.deltaY < 0
+        ? scrollable.scrollTop > 0
+        : scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
+    )
+      return
+    event.preventDefault()
+  }
+
+  return {
+    viewport: {
+      thumbTrack,
+      hoverTarget,
+      setThumbTrack,
+      setHoverTarget,
+      setViewport: (element: HTMLDivElement) => {
+        viewport = element
+        bindResizeObserver()
+        queuePositionUpdate()
+      },
+      update,
+      containWheel,
+      containOuterWheel: (event: WheelEvent) => {
+        if (!viewport) return
+        if (event.target instanceof Node && viewport.contains(event.target)) return
+        containWheel(event)
+      },
+    },
+    header: {
+      setContent: (element: HTMLDivElement) => {
+        content = element
+        bindResizeObserver()
+        queuePositionUpdate()
+      },
+      setHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => {
+        headerRefs.set(id, element)
+        queuePositionUpdate()
+      },
+      titleOpacity: (id: HomeSessionGroup["id"]) => state.titleOpacity[id] ?? 1,
+    },
+  }
+}
+
+export type HomeScrollController = ReturnType<typeof createHomeScrollController>

+ 114 - 0
packages/app/src/pages/home/home-session-search-controller.ts

@@ -0,0 +1,114 @@
+import { useCommand } from "@/context/command"
+import { useLanguage } from "@/context/language"
+import { serverName } from "@/context/server"
+import { displayName } from "@/pages/layout/helpers"
+import { makeEventListener } from "@solid-primitives/event-listener"
+import { createMemo, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import type { HomeController } from "./home-controller"
+import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./home-sessions-controller"
+
+type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
+
+export function createHomeSessionSearchController(home: HomeController, sessions: HomeSessionSearchSource) {
+  const command = useCommand()
+  const language = useLanguage()
+  const [state, setState] = createStore({ value: "", focused: false, highlighted: "" })
+  let root: HTMLDivElement | undefined
+  let input: HTMLInputElement | undefined
+  let list: HTMLDivElement | undefined
+  const query = createMemo(() => state.value.trim())
+  const results = createMemo(() => {
+    const value = query().toLowerCase()
+    if (!value) return []
+    return sessions.data
+      .searchRecords()
+      .filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
+  })
+  const active = createMemo(() => {
+    const records = results()
+    if (records.some((record) => homeSessionSearchKey(record) === state.highlighted)) return state.highlighted
+    return records[0] ? homeSessionSearchKey(records[0]) : ""
+  })
+  const open = createMemo(() => state.focused && query().length > 0)
+  const placeholder = createMemo(() => {
+    const project = home.project.selected()
+    if (project) return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) })
+    if (home.server.list().length > 1) {
+      const conn = home.server.focused()
+      if (conn) return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) })
+    }
+    return language.t("home.sessions.search.placeholder")
+  })
+
+  onCleanup(
+    makeEventListener(document, "pointerdown", (event) => {
+      if (!open()) return
+      const target = event.target
+      if (!(target instanceof Node) || root?.contains(target)) return
+      close()
+    }),
+  )
+
+  command.register("home.search", () => [
+    {
+      id: "home.sessions.search.focus",
+      title: placeholder(),
+      keybind: "mod+f",
+      hidden: true,
+      onSelect: focus,
+    },
+  ])
+
+  function focus() {
+    input?.focus()
+    setState("focused", true)
+  }
+
+  function close() {
+    setState({ value: "", focused: false })
+  }
+
+  function select(record: HomeSessionRecord, options?: { background?: boolean }) {
+    sessions.session.open(record.session, options)
+    if (!options?.background) close()
+  }
+
+  return {
+    query: {
+      value: () => state.value,
+      placeholder,
+      open,
+      focus,
+      input: (value: string) => setState({ value, highlighted: "" }),
+      close,
+    },
+    result: {
+      loading: sessions.data.loading,
+      list: results,
+      active,
+      noResultsLabel: () => language.t("home.sessions.search.noResults", { query: query() }),
+      highlight: (record: HomeSessionRecord) => setState("highlighted", homeSessionSearchKey(record)),
+      move: (delta: number) => {
+        const records = results()
+        if (records.length === 0) return
+        const index = records.findIndex((record) => homeSessionSearchKey(record) === active())
+        const next = ((index === -1 ? 0 : index) + delta + records.length) % records.length
+        setState("highlighted", homeSessionSearchKey(records[next]))
+        list?.querySelector<HTMLElement>(`[data-key="${state.highlighted}"]`)?.scrollIntoView({ block: "nearest" })
+      },
+      select,
+      selectActive: () => {
+        const record = results().find((item) => homeSessionSearchKey(item) === active())
+        if (record) select(record)
+      },
+    },
+    element: {
+      setRoot: (element: HTMLDivElement) => (root = element),
+      setInput: (element: HTMLInputElement) => (input = element),
+      setList: (element: HTMLDivElement) => (list = element),
+    },
+  }
+}
+
+export type HomeSessionSearchController = ReturnType<typeof createHomeSessionSearchController>

+ 314 - 0
packages/app/src/pages/home/home-sessions-controller.tsx

@@ -0,0 +1,314 @@
+import type { Session } from "@opencode-ai/sdk/v2/client"
+import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useMarked } from "@opencode-ai/ui/context/marked"
+import { useQuery } from "@tanstack/solid-query"
+import { DateTime } from "luxon"
+import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js"
+import { produce } from "solid-js/store"
+import { useCommand } from "@/context/command"
+import {
+  loadHomeSessionIndex,
+  retainHomeSessions,
+  type HomeSessionEvents,
+} from "@/context/global-sync/home-session-index"
+import type { LocalProject } from "@/context/layout"
+import { useLanguage } from "@/context/language"
+import { ServerConnection } from "@/context/server"
+import { sessionHasOpenTab, useTabs } from "@/context/tabs"
+import { displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
+import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
+import { pathKey } from "@/utils/path-key"
+import { showToast } from "@/utils/toast"
+import { Binary } from "@opencode-ai/core/util/binary"
+import { archiveHomeSession } from "../home-session-archive"
+import type { HomeController } from "./home-controller"
+
+const HOME_SESSION_LIMIT = 64
+export type HomeSessionRecord = {
+  session: Session
+  project: LocalProject
+  projectName: string
+}
+
+export type HomeSessionGroup = {
+  id: "today" | "yesterday" | "older"
+  title: string
+  sessions: HomeSessionRecord[]
+}
+
+export type OpenSessionOptions = { background?: boolean }
+
+export function createHomeSessionsController(home: HomeController) {
+  const tabs = useTabs()
+  const command = useCommand()
+  const dialog = useDialog()
+  const language = useLanguage()
+  const marked = useMarked()
+  const projectDirectories = createMemo(() => {
+    const project = home.project.selected()
+    if (!project) return home.project.list().flatMap(directories)
+    return directories(project)
+  })
+  const projectByID = createMemo(
+    () => new Map(home.project.list().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
+  )
+  const homeSessions = () => home.server.focusedSync().homeSessions
+  const sessionEventLoad = useQuery(() => ({
+    queryKey: homeSessions().eventsKey,
+    queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
+    initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
+    enabled: false,
+  }))
+  const sessionLoad = useQuery(() => ({
+    queryKey: homeSessions().indexKey,
+    enabled: !!home.server.focusedContext(),
+    queryFn: async ({ signal }) => {
+      const ctx = home.server.focusedContext()
+      if (!ctx) return { sessions: [], eventSequence: 0 }
+      const cache = homeSessions()
+      const eventSequence = cache.eventSequence()
+      const index = await loadHomeSessionIndex(
+        (input, options) => ctx.sdk.client.v2.session.list(input, options),
+        eventSequence,
+        signal,
+      )
+      cache.complete(eventSequence)
+      return index
+    },
+    retry: false,
+    staleTime: 30_000,
+    refetchOnMount: true,
+    refetchOnReconnect: true,
+  }))
+  const indexedSessions = createMemo(() =>
+    retainHomeSessions(
+      homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
+      HOME_SESSION_LIMIT,
+      Date.now(),
+    ),
+  )
+  const allRecords = createMemo(() =>
+    buildHomeSessionRecords({
+      sessions: indexedSessions,
+      projectDirectories,
+      projects: home.project.list,
+      projectByID,
+    }),
+  )
+  const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
+  const groups = createMemo(() => groupSessions(records(), language))
+  const prefetched = new Set<string>()
+
+  createEffect(() => {
+    const ctx = home.server.focusedContext()
+    const conn = home.server.focused()
+    if (!ctx || !conn) return
+    records()
+      .slice(0, 2)
+      .forEach((record) => {
+        const key = `${ServerConnection.key(conn)}\0${record.session.id}`
+        if (prefetched.has(key)) return
+        prefetched.add(key)
+        createRoot((dispose) => {
+          try {
+            void ctx.sync.session
+              .sync(record.session.id)
+              .then(() =>
+                Promise.all(
+                  (ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) =>
+                    (ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => {
+                      if (part.type !== "text" || !part.text) return []
+                      return preloadMarkdown(part.text, part.id, marked)
+                    }),
+                  ),
+                ),
+              )
+              .catch(() => {})
+              .finally(dispose)
+          } catch {
+            dispose()
+          }
+        })
+      })
+  })
+
+  command.register("home.palette", () => [
+    {
+      id: "command.palette",
+      title: language.t("command.palette"),
+      hidden: true,
+      onSelect: async () => {
+        const conn = home.server.focused()
+        if (!conn) return
+        const ctx = home.server.focusedContext()
+        if (!ctx) return
+        const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
+        void dialog.show(() => (
+          <DialogHomeCommandPaletteV2
+            server={conn}
+            onSelectSession={(entry) => {
+              if (!entry.sessionID || !entry.directory || !entry.server) return
+              const sessionID = entry.sessionID
+              const server = entry.server
+              const directory = entry.project?.worktree ?? entry.directory
+              ctx.projects.open(directory)
+              ctx.projects.touch(directory)
+              void startTransition(() => {
+                const tab = tabs.addSessionTab({ server, sessionId: sessionID })
+                tabs.select(tab)
+              })
+            }}
+          />
+        ))
+      },
+    },
+  ])
+
+  return {
+    copy: {
+      language,
+    },
+    data: {
+      records,
+      groups,
+      loading: () => sessionLoad.isLoading,
+      searchRecords: allRecords,
+    },
+    session: {
+      showProjectName: () => !home.project.selected(),
+      server: () => home.selection.value().server,
+      canCreate: () => !!home.project.newSession(),
+      create: home.project.openNewSession,
+      open: (session: Session, options?: OpenSessionOptions) => {
+        const directoryKey = pathKey(session.directory)
+        const project =
+          home.project
+            .list()
+            .find(
+              (item) =>
+                pathKey(item.worktree) === directoryKey ||
+                item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
+            ) ?? projectForSession(session, home.project.list(), projectByID())
+        const conn = home.server.focused()
+        if (!conn) return
+        const directory = project?.worktree ?? session.directory
+        const ctx = home.server.focusedContext()
+        if (!ctx) return
+        ctx.projects.open(directory)
+        if (options?.background) {
+          tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
+          return
+        }
+        ctx.projects.touch(directory)
+        void startTransition(() => {
+          const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
+          tabs.select(tab)
+        })
+      },
+      archive: async (session: Session) => {
+        const conn = home.server.focused()
+        const ctx = home.server.focusedContext()
+        if (!conn || !ctx) return
+        const [, setStore] = ctx.sync.child(session.directory)
+        await archiveHomeSession({
+          server: ServerConnection.key(conn),
+          session,
+          archive: (sessionID) => ctx.sdk.api.session.archive({ sessionID, directory: session.directory }),
+          remove: () =>
+            setStore(
+              produce((draft) => {
+                const match = Binary.search(draft.session, session.id, (item) => item.id)
+                if (match.found) draft.session.splice(match.index, 1)
+              }),
+            ),
+          onError: (cause) =>
+            showToast({
+              title: language.t("common.requestFailed"),
+              description: errorMessage(cause, language.t("common.requestFailed")),
+            }),
+        })
+      },
+    },
+    tab: {
+      isOpen: (record: HomeSessionRecord) =>
+        sessionHasOpenTab(tabs.store, home.selection.value().server, record.session),
+    },
+  }
+}
+
+function directories(project: LocalProject) {
+  return [project.worktree, ...(project.sandboxes ?? [])]
+}
+
+function buildHomeSessionRecords(input: {
+  sessions: () => Session[]
+  projectDirectories: () => string[]
+  projects: () => LocalProject[]
+  projectByID: () => Map<string, LocalProject>
+}) {
+  const directories = new Set(input.projectDirectories().map(pathKey))
+  const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
+  return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
+    .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
+    .flatMap((session) => {
+      const directory = pathKey(session.directory)
+      const project =
+        input
+          .projects()
+          .find(
+            (item) =>
+              pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
+          ) ?? projectForSession(session, input.projects(), input.projectByID())
+      if (!project) return []
+      return { session, project, projectName: displayName(project) }
+    })
+}
+
+export function homeSessionSearchKey(record: HomeSessionRecord) {
+  return `${pathKey(record.session.directory)}:${record.session.id}`
+}
+
+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)
+}
+
+export type HomeSessionsController = ReturnType<typeof createHomeSessionsController>
+
+export function HomeSessionStatusController(props: {
+  server: Accessor<ServerConnection.Key>
+  record: HomeSessionRecord
+  isOpenTab: (record: HomeSessionRecord) => boolean
+  render: (state: { unread: Accessor<boolean>; loading: Accessor<boolean>; open: Accessor<boolean> }) => JSX.Element
+}) {
+  const avatar = useSessionTabAvatarState(
+    props.server,
+    () => props.record.session.directory,
+    () => props.record.session.id,
+  )
+  return props.render({
+    unread: avatar.unread,
+    loading: avatar.loading,
+    open: () => props.isOpenTab(props.record),
+  })
+}

+ 550 - 0
packages/app/src/pages/home/home-sessions-view.tsx

@@ -0,0 +1,550 @@
+import type { Session } from "@opencode-ai/sdk/v2/client"
+import { type Accessor, createMemo, For, Show } from "solid-js"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { ScrollView } from "@opencode-ai/ui/scroll-view"
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { useLanguage } from "@/context/language"
+import { ServerConnection } from "@/context/server"
+import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
+import { sessionTitle } from "@/utils/session-title"
+import { shouldOpenSessionInBackground } from "../home-session-open"
+import {
+  HomeSessionStatusController,
+  homeSessionSearchKey,
+  type HomeSessionGroup,
+  type HomeSessionRecord,
+  type OpenSessionOptions,
+} from "./home-sessions-controller"
+
+const SHOW_HOME_SESSION_ARCHIVE = false
+const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
+const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
+
+// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
+// tab in the background without navigating, matching browser conventions.
+function isBackgroundOpen(event: MouseEvent) {
+  return shouldOpenSessionInBackground({
+    button: event.button,
+    mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
+    meta: event.metaKey,
+    ctrl: event.ctrlKey,
+    shift: event.shiftKey,
+    alt: event.altKey,
+  })
+}
+
+export type HomeSessionsViewProps = {
+  language: ReturnType<typeof useLanguage>
+  groups: Accessor<HomeSessionGroup[]>
+  loading: Accessor<boolean>
+  showProjectName: Accessor<boolean>
+  server: Accessor<ServerConnection.Key>
+  canCreateSession: Accessor<boolean>
+  searchValue: Accessor<string>
+  searchPlaceholder: Accessor<string>
+  searchOpen: Accessor<boolean>
+  searchLoading: Accessor<boolean>
+  searchResults: Accessor<HomeSessionRecord[]>
+  searchActive: Accessor<string>
+  searchNoResultsLabel: Accessor<string>
+  titleOpacity: (id: HomeSessionGroup["id"]) => number
+  isOpenTab: (record: HomeSessionRecord) => boolean
+  onCreateSession: () => void
+  onOpenSession: (session: Session, options?: OpenSessionOptions) => void
+  onArchiveSession: (session: Session) => Promise<void>
+  onSetHoverTarget: (element: HTMLElement) => void
+  onSetThumbTrack: (element: HTMLDivElement) => void
+  onSetContent: (element: HTMLDivElement) => void
+  onSetHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => void
+  onWheel: (event: WheelEvent) => void
+  onSetSearchRoot: (element: HTMLDivElement) => void
+  onSetSearchInput: (element: HTMLInputElement) => void
+  onSetSearchList: (element: HTMLDivElement) => void
+  onSearchFocus: () => void
+  onSearchInput: (value: string) => void
+  onSearchClose: () => void
+  onSearchMove: (delta: number) => void
+  onSearchSelectActive: () => void
+  onSearchHighlight: (record: HomeSessionRecord) => void
+  onSearchSelect: (record: HomeSessionRecord, options?: OpenSessionOptions) => void
+}
+
+export function HomeSessionsView(props: HomeSessionsViewProps) {
+  return (
+    <section
+      ref={props.onSetHoverTarget}
+      class="min-h-0 min-w-0 flex-1 flex flex-col"
+      aria-label={props.language.t("sidebar.project.recentSessions")}
+    >
+      <div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
+        <HomeSessionSearch {...props} />
+        <Show when={props.groups().length > 0 && props.canCreateSession()}>
+          <div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
+            <ButtonV2
+              data-action="home-new-session"
+              variant="ghost-muted"
+              size="normal"
+              icon="edit"
+              class="pointer-events-auto h-7 px-2 [font-weight:530]"
+              onClick={props.onCreateSession}
+            >
+              {props.language.t("command.session.new")}
+            </ButtonV2>
+          </div>
+        </Show>
+      </div>
+      <div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
+        <div
+          ref={props.onSetThumbTrack}
+          data-component="home-session-scroll-track"
+          class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
+        />
+      </div>
+      <div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
+        <Show
+          when={!props.loading()}
+          fallback={
+            <div class="pt-3">
+              <HomeSessionSkeleton label={props.language.t("common.loading")} />
+            </div>
+          }
+        >
+          <Show
+            when={props.groups().length > 0}
+            fallback={
+              <HomeSessionsEmpty
+                onNewSession={props.canCreateSession() ? props.onCreateSession : undefined}
+                language={props.language}
+              />
+            }
+          >
+            <div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
+              <For each={props.groups()}>
+                {(group, index) => (
+                  <>
+                    <HomeSessionGroupHeader
+                      title={group.title}
+                      titleOpacity={props.titleOpacity(group.id)}
+                      onSetRef={(element) => props.onSetHeader(group.id, element)}
+                      elevated={index() === 0}
+                    />
+                    <div
+                      class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups().length - 1 ? "" : "mb-6"}`}
+                    >
+                      <For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
+                    </div>
+                  </>
+                )}
+              </For>
+            </div>
+          </Show>
+        </Show>
+      </div>
+    </section>
+  )
+}
+
+function HomeSessionLeadingController(props: {
+  server: HomeSessionsViewProps["server"]
+  isOpenTab: HomeSessionsViewProps["isOpenTab"]
+  record: HomeSessionRecord
+  revealProjectOnHover: boolean
+}) {
+  return (
+    <HomeSessionStatusController
+      server={props.server}
+      record={props.record}
+      isOpenTab={props.isOpenTab}
+      render={(state) => (
+        <HomeSessionLeading
+          record={props.record}
+          revealProjectOnHover={props.revealProjectOnHover}
+          open={state.open()}
+          unread={state.unread()}
+          loading={state.loading()}
+        />
+      )}
+    />
+  )
+}
+
+function HomeSessionLeading(props: {
+  record: HomeSessionRecord
+  revealProjectOnHover: boolean
+  open: boolean
+  unread: boolean
+  loading: boolean
+}) {
+  return (
+    <div class="relative shrink-0">
+      <Show when={props.open}>
+        <span
+          aria-hidden="true"
+          class={`
+            pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2
+            rounded-[2px] bg-v2-background-bg-layer-04
+          `}
+          style={{ right: "calc(100% + 4px)" }}
+        />
+      </Show>
+      <SessionTabAvatarView
+        project={props.record.project}
+        directory={props.record.session.directory}
+        revealProjectOnHover={props.revealProjectOnHover}
+        unread={props.unread}
+        loading={props.loading}
+      />
+    </div>
+  )
+}
+
+function HomeSessionSearch(props: HomeSessionsViewProps) {
+  return (
+    <div class="w-full">
+      <div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full">
+        <Show when={props.searchOpen()}>
+          <div
+            data-component="home-session-search-panel"
+            class={`
+              absolute flex flex-col overflow-hidden rounded-[12px]
+              bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]
+            `}
+            style={{ top: "-6px", left: "-6px", width: "calc(100% + 12px)" }}
+          >
+            <div class="flex flex-col pt-9">
+              <div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
+                <Show
+                  when={!props.searchLoading()}
+                  fallback={
+                    <div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
+                      <Spinner class="size-4" />
+                    </div>
+                  }
+                >
+                  <Show
+                    when={props.searchResults().length > 0}
+                    fallback={
+                      <p
+                        class={`
+                          my-1.5 px-4 pb-2 text-[13px] leading-4 tracking-[-0.04px]
+                          text-v2-text-text-muted [font-weight:440]
+                        `}
+                      >
+                        {props.searchNoResultsLabel()}
+                      </p>
+                    }
+                  >
+                    <div class="flex flex-col">
+                      <p
+                        class={`
+                          my-1.5 pl-[18px] pr-6 text-[13px] leading-4 tracking-[-0.04px]
+                          text-v2-text-text-muted [font-weight:440]
+                        `}
+                      >
+                        {props.language.t("home.sessions.search.sessions")}
+                      </p>
+                      <ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
+                        <div class="flex flex-col gap-px pb-2">
+                          <For each={props.searchResults()}>
+                            {(record) => (
+                              <HomeSessionSearchResultRow
+                                {...props}
+                                record={record}
+                                selected={props.searchActive() === homeSessionSearchKey(record)}
+                              />
+                            )}
+                          </For>
+                        </div>
+                      </ScrollView>
+                    </div>
+                  </Show>
+                </Show>
+              </div>
+            </div>
+          </div>
+        </Show>
+        <label
+          class={`
+            relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2
+            bg-v2-background-bg-layer-02/60 text-v2-icon-icon-muted transition-[background-color,box-shadow]
+            duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02
+          `}
+        >
+          <IconV2 name="magnifying-glass" />
+          <input
+            ref={props.onSetSearchInput}
+            class={`
+              relative z-20 min-w-0 flex-1 border-0 bg-transparent outline-0
+              text-v2-text-text-base [font-weight:440] placeholder:text-v2-text-text-faint
+            `}
+            value={props.searchValue()}
+            placeholder={props.searchPlaceholder()}
+            aria-label={props.searchPlaceholder()}
+            aria-expanded={props.searchOpen()}
+            aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
+            aria-autocomplete="list"
+            aria-activedescendant={
+              props.searchActive() && props.searchOpen()
+                ? `home-session-search-option-${props.searchActive()}`
+                : undefined
+            }
+            onFocus={props.onSearchFocus}
+            onInput={(event) => props.onSearchInput(event.currentTarget.value)}
+            onKeyDown={(event) => {
+              if (event.key === "Escape") {
+                event.preventDefault()
+                props.onSearchClose()
+                event.currentTarget.blur()
+                return
+              }
+              if (!props.searchOpen() || props.searchResults().length === 0) return
+              if (event.altKey || event.metaKey) return
+              if (event.key === "ArrowDown") {
+                event.preventDefault()
+                props.onSearchMove(1)
+                return
+              }
+              if (event.key === "ArrowUp") {
+                event.preventDefault()
+                props.onSearchMove(-1)
+                return
+              }
+              if (event.key === "Enter" && !event.isComposing) {
+                event.preventDefault()
+                props.onSearchSelectActive()
+              }
+            }}
+          />
+          <Show when={props.searchValue()}>
+            <IconButtonV2
+              type="button"
+              variant="ghost-muted"
+              size="small"
+              class="relative z-20 shrink-0"
+              icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
+              aria-label={props.searchPlaceholder()}
+              onClick={() => {
+                props.onSearchClose()
+                props.onSearchFocus()
+              }}
+            />
+          </Show>
+        </label>
+      </div>
+    </div>
+  )
+}
+
+function HomeSessionSearchResultRow(
+  props: HomeSessionsViewProps & {
+    record: HomeSessionRecord
+    selected: boolean
+  },
+) {
+  const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
+  const showProjectName = () => props.showProjectName() && props.record.projectName
+  const key = () => homeSessionSearchKey(props.record)
+
+  return (
+    <button
+      type="button"
+      id={`home-session-search-option-${key()}`}
+      data-key={key()}
+      data-component="home-session-search-row"
+      role="option"
+      aria-selected={props.selected}
+      class={`
+        flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-[18px] pr-6 text-left
+        transition-[background-color] duration-[120ms] ease-in-out
+        hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
+      `}
+      classList={{
+        "bg-v2-overlay-simple-overlay-hover": props.selected,
+        group: !!showProjectName(),
+      }}
+      onMouseEnter={() => props.onSearchHighlight(props.record)}
+      onMouseDown={(event) => {
+        if (event.button === 1) event.preventDefault()
+      }}
+      onClick={(event) => props.onSearchSelect(props.record, { background: isBackgroundOpen(event) })}
+      onAuxClick={(event) => {
+        if (!isBackgroundOpen(event)) return
+        event.preventDefault()
+        props.onSearchSelect(props.record, { background: true })
+      }}
+    >
+      <HomeSessionLeadingController
+        server={props.server}
+        isOpenTab={props.isOpenTab}
+        record={props.record}
+        revealProjectOnHover={!!showProjectName()}
+      />
+      <div class="flex min-w-0 flex-1 items-center gap-1.5">
+        <HomeSessionTitle title={title()} showProjectName={!!showProjectName()} search />
+        <Show when={showProjectName()}>
+          <HomeSessionProjectName name={props.record.projectName} search />
+        </Show>
+      </div>
+    </button>
+  )
+}
+
+function HomeSessionGroupHeader(props: {
+  title: string
+  titleOpacity: number
+  onSetRef: (element: HTMLDivElement) => void
+  elevated?: boolean
+}) {
+  return (
+    <div
+      ref={props.onSetRef}
+      class={`
+        pointer-events-none sticky top-[84px] flex h-7 min-w-0 items-center justify-between
+        bg-v2-background-bg-base pl-3 lg:top-[108px]
+      `}
+      classList={{ "home-session-group-header z-[5]": !!props.elevated, "z-10": !props.elevated }}
+    >
+      <div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
+        {props.title}
+      </div>
+    </div>
+  )
+}
+
+function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
+  const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
+  const showProjectName = () => props.showProjectName() && props.record.projectName
+
+  return (
+    <div
+      class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
+      classList={{ group: !!showProjectName() }}
+    >
+      <button
+        type="button"
+        data-component="home-session-row"
+        class={`
+          flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
+          bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
+          transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
+          hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
+        `}
+        onMouseDown={(event) => {
+          if (event.button === 1) event.preventDefault()
+        }}
+        onClick={(event) => props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })}
+        onAuxClick={(event) => {
+          if (!isBackgroundOpen(event)) return
+          event.preventDefault()
+          props.onOpenSession(props.record.session, { background: true })
+        }}
+      >
+        <HomeSessionLeadingController
+          server={props.server}
+          isOpenTab={props.isOpenTab}
+          record={props.record}
+          revealProjectOnHover={!!showProjectName()}
+        />
+        <HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
+        <Show when={showProjectName()}>
+          <HomeSessionProjectName name={props.record.projectName} />
+        </Show>
+      </button>
+      <Show when={SHOW_HOME_SESSION_ARCHIVE}>
+        <div
+          class={`
+            hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1
+            group-hover/session:opacity-100 focus-within:opacity-100
+          `}
+        >
+          <TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
+            <IconButtonV2
+              data-action="home-session-archive"
+              variant="ghost-muted"
+              size="large"
+              icon={<IconV2 name="archive" />}
+              aria-label={props.language.t("common.archive")}
+              onClick={(event) => {
+                event.preventDefault()
+                event.stopPropagation()
+                void props.onArchiveSession(props.record.session)
+              }}
+            />
+          </TooltipV2>
+        </div>
+      </Show>
+    </div>
+  )
+}
+
+function HomeSessionTitle(props: { title: string; showProjectName: boolean; search?: boolean }) {
+  return (
+    <span
+      class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530]"
+      classList={{
+        "text-[13px] leading-4 tracking-[-0.04px]": !!props.search,
+        "max-w-[min(70%,480px)] flex-[0_1_auto]": props.showProjectName,
+        "flex-[1_1_auto]": !props.showProjectName,
+      }}
+    >
+      {props.title}
+    </span>
+  )
+}
+
+function HomeSessionProjectName(props: { name: string; search?: boolean }) {
+  return (
+    <span
+      class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]"
+      classList={{ "text-[13px] leading-4 tracking-[-0.04px]": !!props.search }}
+    >
+      {props.name}
+    </span>
+  )
+}
+
+function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnType<typeof useLanguage> }) {
+  return (
+    <div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
+      <div
+        class={`
+          shrink-0 text-[13px] leading-[13px] tracking-[-0.04px]
+          text-v2-text-text-base [font-weight:530]
+        `}
+      >
+        {props.language.t("home.sessions.empty")}
+      </div>
+      <p
+        class={`
+          mb-1 text-center text-[13px] leading-5 tracking-[-0.04px]
+          text-v2-text-text-muted [font-weight:440]
+        `}
+      >
+        {props.language.t("home.sessions.empty.description")}
+      </p>
+      <Show when={props.onNewSession}>
+        {(onNewSession) => (
+          <ButtonV2 data-action="home-new-session" variant="neutral" size="normal" icon="edit" onClick={onNewSession()}>
+            {props.language.t("command.session.new")}
+          </ButtonV2>
+        )}
+      </Show>
+    </div>
+  )
+}
+
+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>
+  )
+}

+ 48 - 0
packages/app/src/pages/home/home-sessions.tsx

@@ -0,0 +1,48 @@
+import type { HomeScrollController } from "./home-scroll-controller"
+import type { HomeSessionSearchController } from "./home-session-search-controller"
+import type { HomeSessionsController } from "./home-sessions-controller"
+import { HomeSessionsView } from "./home-sessions-view"
+
+export function HomeSessions(props: {
+  sessions: HomeSessionsController
+  search: HomeSessionSearchController
+  scroll: HomeScrollController
+}) {
+  return (
+    <HomeSessionsView
+      language={props.sessions.copy.language}
+      groups={props.sessions.data.groups}
+      loading={props.sessions.data.loading}
+      showProjectName={props.sessions.session.showProjectName}
+      server={props.sessions.session.server}
+      canCreateSession={props.sessions.session.canCreate}
+      searchValue={props.search.query.value}
+      searchPlaceholder={props.search.query.placeholder}
+      searchOpen={props.search.query.open}
+      searchLoading={props.search.result.loading}
+      searchResults={props.search.result.list}
+      searchActive={props.search.result.active}
+      searchNoResultsLabel={props.search.result.noResultsLabel}
+      titleOpacity={props.scroll.header.titleOpacity}
+      isOpenTab={props.sessions.tab.isOpen}
+      onCreateSession={props.sessions.session.create}
+      onOpenSession={props.sessions.session.open}
+      onArchiveSession={props.sessions.session.archive}
+      onSetHoverTarget={props.scroll.viewport.setHoverTarget}
+      onSetThumbTrack={props.scroll.viewport.setThumbTrack}
+      onSetContent={props.scroll.header.setContent}
+      onSetHeader={props.scroll.header.setHeader}
+      onWheel={props.scroll.viewport.containWheel}
+      onSetSearchRoot={props.search.element.setRoot}
+      onSetSearchInput={props.search.element.setInput}
+      onSetSearchList={props.search.element.setList}
+      onSearchFocus={props.search.query.focus}
+      onSearchInput={props.search.query.input}
+      onSearchClose={props.search.query.close}
+      onSearchMove={props.search.result.move}
+      onSearchSelectActive={props.search.result.selectActive}
+      onSearchHighlight={props.search.result.highlight}
+      onSearchSelect={props.search.result.select}
+    />
+  )
+}

+ 142 - 0
packages/app/src/pages/home/legacy-home.tsx

@@ -0,0 +1,142 @@
+import { DialogSelectServer } from "@/components/dialog-select-server"
+import { useDirectoryPicker } from "@/components/directory-picker"
+import { useGlobal } from "@/context/global"
+import { useLanguage } from "@/context/language"
+import { type ServerConnection, useServer } from "@/context/server"
+import { useServerSync } from "@/context/server-sync"
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Logo } from "@opencode-ai/ui/logo"
+import { useNavigate } from "@solidjs/router"
+import { DateTime } from "luxon"
+import { createMemo, For, Match, Switch } from "solid-js"
+
+export function LegacyHome() {
+  const sync = useServerSync()
+  const pickDirectory = useDirectoryPicker()
+  const dialog = useDialog()
+  const navigate = useNavigate()
+  const global = useGlobal()
+  const server = useServer()
+  const language = useLanguage()
+  const homedir = createMemo(() => sync().data.path.home)
+  const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
+  const recent = createMemo(() => {
+    return sync()
+      .data.project.slice()
+      .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
+      .slice(0, 5)
+  })
+
+  const serverDotClass = createMemo(() => {
+    const healthy = global.servers.health[server.key]?.healthy
+    if (healthy === true) return "bg-icon-success-base"
+    if (healthy === false) return "bg-icon-critical-base"
+    return "bg-border-weak-base"
+  })
+
+  function openProject(conn: ServerConnection.Any, directory: string) {
+    const serverCtx = global.ensureServerCtx(conn)
+    serverCtx.projects.open(directory)
+    serverCtx.projects.touch(directory)
+    navigate(`/${base64Encode(directory)}`)
+  }
+
+  function chooseProject() {
+    if (serverUnreachable()) return
+    const conn = server.current
+    if (!conn) return
+
+    const resolve = (result: string | string[] | null) => {
+      if (Array.isArray(result)) {
+        result.forEach((directory) => openProject(conn, directory))
+        return
+      }
+      if (result) openProject(conn, result)
+    }
+
+    pickDirectory({
+      server: conn,
+      title: language.t("command.project.open"),
+      multiple: true,
+      onSelect: resolve,
+    })
+  }
+
+  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>
+      <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"
+                disabled={serverUnreachable()}
+                onClick={chooseProject}
+              >
+                {language.t("command.project.open")}
+              </Button>
+            </div>
+            <ul class="flex flex-col gap-2">
+              <For each={recent()}>
+                {(project) => (
+                  <Button
+                    size="large"
+                    variant="ghost"
+                    class="text-14-mono text-left justify-between px-3"
+                    onClick={() => openProject(server.current!, project.worktree)}
+                  >
+                    {project.worktree.replace(homedir(), "~")}
+                    <div class="text-14-regular text-text-weak">
+                      {DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
+                    </div>
+                  </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" disabled={serverUnreachable()} onClick={chooseProject}>
+              {language.t("command.project.open")}
+            </Button>
+          </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>
+            <Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
+              {language.t("command.project.open")}
+            </Button>
+          </div>
+        </Match>
+      </Switch>
+    </div>
+  )
+}

+ 20 - 2
packages/app/src/pages/layout/session-tab-avatar.tsx

@@ -19,16 +19,34 @@ export function SessionTabAvatar(props: {
     () => props.directory,
     () => props.sessionId,
   )
+  return (
+    <SessionTabAvatarView
+      project={props.project}
+      directory={props.directory}
+      revealProjectOnHover={props.revealProjectOnHover}
+      unread={state.unread()}
+      loading={state.loading()}
+    />
+  )
+}
+
+export function SessionTabAvatarView(props: {
+  project?: LocalProject
+  directory: string
+  revealProjectOnHover?: boolean
+  unread: boolean
+  loading: boolean
+}) {
   const projectAvatar = () => (
     <ProjectAvatar
       fallback={displayName(props.project ?? { worktree: props.directory })}
       src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
       variant={getProjectAvatarVariant(props.project?.icon?.color)}
-      unread={state.unread()}
+      unread={props.unread}
     />
   )
   return (
-    <Show when={state.loading()} fallback={projectAvatar()}>
+    <Show when={props.loading} fallback={projectAvatar()}>
       <span class="relative block size-4 shrink-0">
         <SessionProgressIndicatorV2
           class={`absolute inset-0 ${props.revealProjectOnHover === false ? "" : "group-hover:invisible"}`}