Переглянути джерело

refactor(app): split home section views

Brendan Allan 3 тижнів тому
батько
коміт
1612baf254

+ 5 - 978
packages/app/src/pages/home.tsx

@@ -1,65 +1,8 @@
-import { type ComponentProps, createMemo, For, Show } from "solid-js"
-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 { 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, type LocalProject } from "@/context/layout"
-import { ServerConnection } from "@/context/server"
-import { useLanguage } from "@/context/language"
-import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
-import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
-import { sessionTitle } from "@/utils/session-title"
-import { ServerRowMenuView, serverMenuLabels } from "@/components/server/server-row-menu"
-import { ServerHealthIndicator } from "@/components/server/server-row"
-import { type ServerHealth } from "@/utils/server-health"
-import { shouldOpenSessionInBackground } from "./home-session-open"
-import { createHomeProjectsController, type HomeProjectsController } from "./home/home-projects-controller"
-import {
-  createHomeSessionsController,
-  HomeSessionStatusController,
-  homeSessionSearchKey,
-  type HomeSessionRecord,
-  type HomeSessionsController,
-} from "./home/home-sessions-controller"
-
-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]"
-
-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]"
-
-// 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,
-  })
-}
+import { createHomeProjectsController } from "./home/home-projects-controller"
+import { HomeProjectsView, HomeUtilityNav } from "./home/home-projects-view"
+import { createHomeSessionsController } from "./home/home-sessions-controller"
+import { HomeSessionsView } from "./home/home-sessions-view"
 
 export function NewHome() {
   const projects = createHomeProjectsController()
@@ -75,7 +18,7 @@ export function NewHome() {
         onWheel={sessions.scroll.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 controller={projects} onWheel={sessions.scroll.containWheel} />
+          <HomeProjectsView controller={projects} onWheel={sessions.scroll.containWheel} />
           <HomeSessionsView controller={sessions} />
           <HomeUtilityNav
             class="flex lg:hidden"
@@ -88,919 +31,3 @@ export function NewHome() {
     </div>
   )
 }
-
-function HomeSessionsView(props: { controller: HomeSessionsController }) {
-  return (
-    <section
-      ref={props.controller.scroll.setHoverTarget}
-      class="min-h-0 min-w-0 flex-1 flex flex-col"
-      aria-label={props.controller.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.controller.scroll.containWheel}
-      >
-        <HomeSessionSearch controller={props.controller} />
-        <Show when={props.controller.groups().length > 0 && props.controller.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.controller.createSession}
-            >
-              {props.controller.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.controller.scroll.setThumbTrack}
-          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.controller.loading()}
-          fallback={
-            <div class="pt-3">
-              <HomeSessionSkeleton label={props.controller.language.t("common.loading")} />
-            </div>
-          }
-        >
-          <Show
-            when={props.controller.groups().length > 0}
-            fallback={
-              <HomeSessionsEmpty
-                onNewSession={props.controller.canCreateSession() ? props.controller.createSession : undefined}
-                language={props.controller.language}
-              />
-            }
-          >
-            <div ref={props.controller.header.setContent} class="flex flex-col pt-3 pr-3 pb-16">
-              <For each={props.controller.groups()}>
-                {(group, index) => (
-                  <>
-                    <HomeSessionGroupHeader
-                      title={group.title}
-                      titleOpacity={props.controller.header.titleOpacity(group.id)}
-                      ref={(element) => props.controller.header.setHeader(group.id, element)}
-                      elevated={index() === 0}
-                    />
-                    <div
-                      class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.controller.groups().length - 1 ? "" : "mb-6"}`}
-                    >
-                      <For each={group.sessions}>
-                        {(record) => <HomeSessionRow controller={props.controller} record={record} />}
-                      </For>
-                    </div>
-                  </>
-                )}
-              </For>
-            </div>
-          </Show>
-        </Show>
-      </div>
-    </section>
-  )
-}
-
-function HomeProjectColumn(props: { controller: HomeProjectsController; onWheel: (event: WheelEvent) => void }) {
-  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.controller.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.controller.language.t("home.projects")}</div>
-        <Show
-          when={
-            props.controller.servers().length === 1 &&
-            !(props.controller.projects().length === 0 && props.controller.recentlyClosed().length > 0)
-          }
-        >
-          <TooltipV2 placement="bottom" value={props.controller.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.controller.serverHealth(props.controller.servers()[0])?.healthy === false}
-              onClick={() => props.controller.chooseProject(props.controller.servers()[0])}
-              aria-label={props.controller.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.controller.servers().length > 1}
-          fallback={
-            <div class="pr-3">
-              <Show
-                when={props.controller.projects().length > 0}
-                fallback={
-                  <HomeProjectEmpty
-                    controller={props.controller}
-                    server={props.controller.servers()[0]}
-                    recentlyClosed={props.controller.recentlyClosed()}
-                  />
-                }
-              >
-                <HomeProjectList
-                  controller={props.controller}
-                  server={props.controller.servers()[0]}
-                  projects={props.controller.projects()}
-                />
-              </Show>
-            </div>
-          }
-        >
-          <div class="flex min-w-0 flex-col gap-4 pr-3">
-            <For each={props.controller.servers()}>
-              {(item) => {
-                const projects = () => props.controller.projectsForServer(item)
-                const healthy = () => !!props.controller.serverHealth(item)?.healthy
-                const hasProjects = () => projects().length > 0
-                const collapsed = () => props.controller.collapsed(item)
-                return (
-                  <div class="flex min-w-0 flex-col gap-1">
-                    <HomeServerRow
-                      server={item}
-                      controller={props.controller}
-                      selected={
-                        props.controller.selection().server === ServerConnection.key(item) &&
-                        !props.controller.selection().directory
-                      }
-                      collapsed={collapsed()}
-                      health={props.controller.serverHealth(item)}
-                    />
-                    <Show when={healthy() && hasProjects() && !collapsed()}>
-                      <div class="mx-3 h-px bg-v2-border-border-base" />
-                      <HomeProjectList controller={props.controller} server={item} projects={projects()} />
-                    </Show>
-                  </div>
-                )
-              }}
-            </For>
-          </div>
-        </Show>
-      </ScrollView>
-      <HomeUtilityNav
-        class="mb-8 mt-4 hidden shrink-0 lg:flex"
-        openSettings={props.controller.openSettings}
-        openHelp={props.controller.openHelp}
-        language={props.controller.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: {
-  controller: HomeProjectsController
-  server: ServerConnection.Any
-  selected: boolean
-  collapsed: boolean
-  health: ServerHealth | undefined
-}) {
-  const healthy = () => !!props.health?.healthy
-  const canToggle = () => healthy() && props.controller.projectsForServer(props.server).length > 0
-  const menuID = () => props.controller.serverMenuID(props.server)
-  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.controller.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.controller.language.t("home.server.expand")
-              : props.controller.language.t("home.server.collapse")
-          }
-          aria-disabled={!canToggle()}
-          aria-expanded={canToggle() ? !props.collapsed : undefined}
-          onClick={(event) => {
-            event.preventDefault()
-            event.stopPropagation()
-            if (!canToggle()) return
-            props.controller.toggleCollapsed(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>
-      </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={props.controller.menuOpen(menuID())}
-      >
-        <ServerRowMenuView
-          server={props.server}
-          labels={serverMenuLabels(props.controller.language)}
-          canDefault={props.controller.canDefaultServer()}
-          isDefault={props.controller.isDefaultServer(props.server)}
-          onEdit={props.controller.openEditServer}
-          onSetDefault={() => props.controller.setDefaultServer(props.server)}
-          onRemoveDefault={() => props.controller.setDefaultServer(undefined)}
-          onRemove={() => props.controller.removeServer(props.server)}
-          open={props.controller.menuOpen(menuID())}
-          onOpenChange={(open) => props.controller.setMenuOpen(menuID(), open)}
-        />
-        <TooltipV2
-          class="flex shrink-0 items-center"
-          placement="bottom"
-          value={props.controller.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.controller.language.t("home.project.add")}
-            disabled={props.health?.healthy === false}
-            onClick={() => props.controller.chooseProject(props.server)}
-          />
-        </TooltipV2>
-      </div>
-    </div>
-  )
-}
-
-type HomeProjectListProps = {
-  controller: HomeProjectsController
-  server: ServerConnection.Any
-  projects: 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.controller.moveProject(props.server, source.id.toString(), source.index)
-        if (props.controller.selection().server !== ServerConnection.key(props.server))
-          props.controller.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
-          controller={props.controller}
-          project={item()}
-          server={props.server}
-          index={props.index}
-          serverSelected={props.controller.selection().server === ServerConnection.key(props.server)}
-          selected={
-            props.controller.selection().server === ServerConnection.key(props.server) &&
-            props.controller.selection().directory === props.worktree
-          }
-          unseenCount={props.controller.unseenCount(props.server, item())}
-        />
-      )}
-    </Show>
-  )
-}
-
-function HomeProjectEmpty(props: {
-  controller: HomeProjectsController
-  server: ServerConnection.Any
-  recentlyClosed: LocalProject[]
-}) {
-  const unreachable = () => props.controller.serverHealth(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.controller.chooseProject(props.server)}
-      >
-        <IconV2 name="folder-add-left" size="small" />
-        <span class={HOME_PROJECT_NAV_LABEL}>{props.controller.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.controller.language.t("home.recentlyClosed")}
-          </div>
-        </div>
-        <For each={props.recentlyClosed}>
-          {(project) => <HomeRecentlyClosedRow project={project} controller={props.controller} server={props.server} />}
-        </For>
-      </Show>
-    </div>
-  )
-}
-
-function HomeRecentlyClosedRow(props: {
-  controller: HomeProjectsController
-  project: LocalProject
-  server: ServerConnection.Any
-}) {
-  const unreachable = () => props.controller.serverHealth(props.server)?.healthy === false
-  const path = () => {
-    const home = props.controller.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.controller.addProjects(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: {
-  controller: HomeProjectsController
-  project: LocalProject
-  server: ServerConnection.Any
-  index: () => number
-  serverSelected: boolean
-  selected: boolean
-  unseenCount: number
-}) {
-  const serverUnreachable = () => props.controller.serverHealth(props.server)?.healthy === false
-  const sortable = useSortable({
-    get id() {
-      return props.project.worktree
-    },
-    get index() {
-      return props.index()
-    },
-  })
-  let pointerDownSelected: boolean | undefined
-  const menuID = () => props.controller.projectMenuID(props.server, props.project.worktree)
-  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.controller.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.controller.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.controller.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={props.controller.menuOpen(menuID())}
-      >
-        <MenuV2
-          gutter={6}
-          modal={false}
-          placement="bottom-end"
-          open={props.controller.menuOpen(menuID())}
-          onOpenChange={(open) => props.controller.setMenuOpen(menuID(), open)}
-        >
-          <MenuV2.Trigger
-            as={IconButtonV2}
-            data-action="home-project-menu"
-            variant="ghost-muted"
-            size="small"
-            icon={<IconV2 name="outline-dots" />}
-            aria-label={props.controller.language.t("common.moreOptions")}
-          />
-          <MenuV2.Portal>
-            <MenuV2.Content>
-              <MenuV2.Item
-                onSelect={() => props.controller.openProjectNewSession(props.server, props.project.worktree)}
-              >
-                {props.controller.language.t("command.session.new")}
-              </MenuV2.Item>
-              <MenuV2.Item onSelect={() => props.controller.editProject(props.server, props.project)}>
-                {props.controller.language.t("dialog.project.edit.title")}
-              </MenuV2.Item>
-              <Show when={props.controller.canRevealProject(props.server)}>
-                <MenuV2.Item onSelect={() => props.controller.revealProject(props.server, props.project)}>
-                  {props.controller.fileManagerActionLabel()}
-                </MenuV2.Item>
-              </Show>
-              <MenuV2.Item
-                disabled={props.unseenCount === 0}
-                onSelect={() => props.controller.clearNotifications(props.server, props.project)}
-              >
-                {props.controller.language.t("sidebar.project.clearNotifications")}
-              </MenuV2.Item>
-              <MenuV2.Separator />
-              <MenuV2.Item onSelect={() => props.controller.closeProject(props.server, props.project.worktree)}>
-                {props.controller.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.controller.language.t("command.session.new")}
-          onClick={() => props.controller.openProjectNewSession(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 HomeSessionLeadingController(props: {
-  controller: HomeSessionsController
-  record: HomeSessionRecord
-  revealProjectOnHover: boolean
-}) {
-  return (
-    <HomeSessionStatusController
-      controller={props.controller}
-      record={props.record}
-      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: { controller: HomeSessionsController }) {
-  return (
-    <div class="w-full">
-      <div ref={props.controller.search.setRoot} data-component="home-session-search" class="relative z-30 w-full">
-        <Show when={props.controller.search.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.controller.search.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.controller.search.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.controller.search.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]">
-                        {props.controller.language.t("home.sessions.search.sessions")}
-                      </p>
-                      <ScrollView class="max-h-80" viewportRef={props.controller.search.setList}>
-                        <div class="flex flex-col gap-px pb-2">
-                          <For each={props.controller.search.results()}>
-                            {(record) => (
-                              <HomeSessionSearchResultRow
-                                controller={props.controller}
-                                record={record}
-                                selected={props.controller.search.active() === 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] 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={props.controller.search.setInput}
-            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.controller.search.value()}
-            placeholder={props.controller.search.placeholder()}
-            aria-label={props.controller.search.placeholder()}
-            aria-expanded={props.controller.search.open()}
-            aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
-            aria-autocomplete="list"
-            aria-activedescendant={
-              props.controller.search.active() && props.controller.search.open()
-                ? `home-session-search-option-${props.controller.search.active()}`
-                : undefined
-            }
-            onFocus={props.controller.search.focus}
-            onInput={(event) => props.controller.search.input(event.currentTarget.value)}
-            onKeyDown={(event) => {
-              if (event.key === "Escape") {
-                event.preventDefault()
-                props.controller.search.close()
-                event.currentTarget.blur()
-                return
-              }
-              if (!props.controller.search.open() || props.controller.search.results().length === 0) return
-              if (event.altKey || event.metaKey) return
-              if (event.key === "ArrowDown") {
-                event.preventDefault()
-                props.controller.search.move(1)
-                return
-              }
-              if (event.key === "ArrowUp") {
-                event.preventDefault()
-                props.controller.search.move(-1)
-                return
-              }
-              if (event.key === "Enter" && !event.isComposing) {
-                event.preventDefault()
-                props.controller.search.selectActive()
-              }
-            }}
-          />
-          <Show when={props.controller.search.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.controller.search.placeholder()}
-              onClick={() => {
-                props.controller.search.close()
-                props.controller.search.focus()
-              }}
-            />
-          </Show>
-        </label>
-      </div>
-    </div>
-  )
-}
-
-function HomeSessionSearchResultRow(props: {
-  controller: HomeSessionsController
-  record: HomeSessionRecord
-  selected: boolean
-}) {
-  const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
-  const showProjectName = () => props.controller.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.controller.search.highlight(props.record)}
-      onMouseDown={(event) => {
-        if (event.button === 1) event.preventDefault()
-      }}
-      onClick={(event) => props.controller.search.select(props.record, { background: isBackgroundOpen(event) })}
-      onAuxClick={(event) => {
-        if (!isBackgroundOpen(event)) return
-        event.preventDefault()
-        props.controller.search.select(props.record, { background: true })
-      }}
-    >
-      <HomeSessionLeadingController
-        controller={props.controller}
-        record={props.record}
-        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: { controller: HomeSessionsController; record: HomeSessionRecord }) {
-  const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
-  const showProjectName = () => props.controller.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.controller.openSession(props.record.session, { background: isBackgroundOpen(event) })}
-        onAuxClick={(event) => {
-          if (!isBackgroundOpen(event)) return
-          event.preventDefault()
-          props.controller.openSession(props.record.session, { background: true })
-        }}
-      >
-        <HomeSessionLeadingController
-          controller={props.controller}
-          record={props.record}
-          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={props.controller.language.t("common.archive")}
-          >
-            <IconButtonV2
-              data-action="home-session-archive"
-              variant="ghost-muted"
-              size="large"
-              icon={<IconV2 name="archive" />}
-              aria-label={props.controller.language.t("common.archive")}
-              onClick={(event) => {
-                event.preventDefault()
-                event.stopPropagation()
-                void props.controller.archiveSession(props.record.session)
-              }}
-            />
-          </TooltipV2>
-        </div>
-      </Show>
-    </div>
-  )
-}
-
-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>
-  )
-}

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

@@ -0,0 +1,524 @@
+import { createMemo, For, Show } from "solid-js"
+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 LocalProject } from "@/context/layout"
+import { ServerConnection } from "@/context/server"
+import { useLanguage } from "@/context/language"
+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 { type HomeProjectsController } from "./home-projects-controller"
+
+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_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)]`
+
+export function HomeProjectsView(props: { controller: HomeProjectsController; onWheel: (event: WheelEvent) => void }) {
+  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.controller.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.controller.language.t("home.projects")}</div>
+        <Show
+          when={
+            props.controller.servers().length === 1 &&
+            !(props.controller.projects().length === 0 && props.controller.recentlyClosed().length > 0)
+          }
+        >
+          <TooltipV2 placement="bottom" value={props.controller.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.controller.serverHealth(props.controller.servers()[0])?.healthy === false}
+              onClick={() => props.controller.chooseProject(props.controller.servers()[0])}
+              aria-label={props.controller.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.controller.servers().length > 1}
+          fallback={
+            <div class="pr-3">
+              <Show
+                when={props.controller.projects().length > 0}
+                fallback={
+                  <HomeProjectEmpty
+                    controller={props.controller}
+                    server={props.controller.servers()[0]}
+                    recentlyClosed={props.controller.recentlyClosed()}
+                  />
+                }
+              >
+                <HomeProjectList
+                  controller={props.controller}
+                  server={props.controller.servers()[0]}
+                  projects={props.controller.projects()}
+                />
+              </Show>
+            </div>
+          }
+        >
+          <div class="flex min-w-0 flex-col gap-4 pr-3">
+            <For each={props.controller.servers()}>
+              {(item) => {
+                const projects = () => props.controller.projectsForServer(item)
+                const healthy = () => !!props.controller.serverHealth(item)?.healthy
+                const hasProjects = () => projects().length > 0
+                const collapsed = () => props.controller.collapsed(item)
+                return (
+                  <div class="flex min-w-0 flex-col gap-1">
+                    <HomeServerRow
+                      server={item}
+                      controller={props.controller}
+                      selected={
+                        props.controller.selection().server === ServerConnection.key(item) &&
+                        !props.controller.selection().directory
+                      }
+                      collapsed={collapsed()}
+                      health={props.controller.serverHealth(item)}
+                    />
+                    <Show when={healthy() && hasProjects() && !collapsed()}>
+                      <div class="mx-3 h-px bg-v2-border-border-base" />
+                      <HomeProjectList controller={props.controller} server={item} projects={projects()} />
+                    </Show>
+                  </div>
+                )
+              }}
+            </For>
+          </div>
+        </Show>
+      </ScrollView>
+      <HomeUtilityNav
+        class="mb-8 mt-4 hidden shrink-0 lg:flex"
+        openSettings={props.controller.openSettings}
+        openHelp={props.controller.openHelp}
+        language={props.controller.language}
+      />
+    </aside>
+  )
+}
+
+export 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: {
+  controller: HomeProjectsController
+  server: ServerConnection.Any
+  selected: boolean
+  collapsed: boolean
+  health: ServerHealth | undefined
+}) {
+  const healthy = () => !!props.health?.healthy
+  const canToggle = () => healthy() && props.controller.projectsForServer(props.server).length > 0
+  const menuID = () => props.controller.serverMenuID(props.server)
+  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.controller.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.controller.language.t("home.server.expand")
+              : props.controller.language.t("home.server.collapse")
+          }
+          aria-disabled={!canToggle()}
+          aria-expanded={canToggle() ? !props.collapsed : undefined}
+          onClick={(event) => {
+            event.preventDefault()
+            event.stopPropagation()
+            if (!canToggle()) return
+            props.controller.toggleCollapsed(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>
+      </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={props.controller.menuOpen(menuID())}
+      >
+        <ServerRowMenuView
+          server={props.server}
+          labels={serverMenuLabels(props.controller.language)}
+          canDefault={props.controller.canDefaultServer()}
+          isDefault={props.controller.isDefaultServer(props.server)}
+          onEdit={props.controller.openEditServer}
+          onSetDefault={() => props.controller.setDefaultServer(props.server)}
+          onRemoveDefault={() => props.controller.setDefaultServer(undefined)}
+          onRemove={() => props.controller.removeServer(props.server)}
+          open={props.controller.menuOpen(menuID())}
+          onOpenChange={(open) => props.controller.setMenuOpen(menuID(), open)}
+        />
+        <TooltipV2
+          class="flex shrink-0 items-center"
+          placement="bottom"
+          value={props.controller.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.controller.language.t("home.project.add")}
+            disabled={props.health?.healthy === false}
+            onClick={() => props.controller.chooseProject(props.server)}
+          />
+        </TooltipV2>
+      </div>
+    </div>
+  )
+}
+
+type HomeProjectListProps = {
+  controller: HomeProjectsController
+  server: ServerConnection.Any
+  projects: 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.controller.moveProject(props.server, source.id.toString(), source.index)
+        if (props.controller.selection().server !== ServerConnection.key(props.server))
+          props.controller.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
+          controller={props.controller}
+          project={item()}
+          server={props.server}
+          index={props.index}
+          serverSelected={props.controller.selection().server === ServerConnection.key(props.server)}
+          selected={
+            props.controller.selection().server === ServerConnection.key(props.server) &&
+            props.controller.selection().directory === props.worktree
+          }
+          unseenCount={props.controller.unseenCount(props.server, item())}
+        />
+      )}
+    </Show>
+  )
+}
+
+function HomeProjectEmpty(props: {
+  controller: HomeProjectsController
+  server: ServerConnection.Any
+  recentlyClosed: LocalProject[]
+}) {
+  const unreachable = () => props.controller.serverHealth(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.controller.chooseProject(props.server)}
+      >
+        <IconV2 name="folder-add-left" size="small" />
+        <span class={HOME_PROJECT_NAV_LABEL}>{props.controller.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.controller.language.t("home.recentlyClosed")}
+          </div>
+        </div>
+        <For each={props.recentlyClosed}>
+          {(project) => <HomeRecentlyClosedRow project={project} controller={props.controller} server={props.server} />}
+        </For>
+      </Show>
+    </div>
+  )
+}
+
+function HomeRecentlyClosedRow(props: {
+  controller: HomeProjectsController
+  project: LocalProject
+  server: ServerConnection.Any
+}) {
+  const unreachable = () => props.controller.serverHealth(props.server)?.healthy === false
+  const path = () => {
+    const home = props.controller.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.controller.addProjects(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: {
+  controller: HomeProjectsController
+  project: LocalProject
+  server: ServerConnection.Any
+  index: () => number
+  serverSelected: boolean
+  selected: boolean
+  unseenCount: number
+}) {
+  const serverUnreachable = () => props.controller.serverHealth(props.server)?.healthy === false
+  const sortable = useSortable({
+    get id() {
+      return props.project.worktree
+    },
+    get index() {
+      return props.index()
+    },
+  })
+  let pointerDownSelected: boolean | undefined
+  const menuID = () => props.controller.projectMenuID(props.server, props.project.worktree)
+  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.controller.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.controller.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.controller.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={props.controller.menuOpen(menuID())}
+      >
+        <MenuV2
+          gutter={6}
+          modal={false}
+          placement="bottom-end"
+          open={props.controller.menuOpen(menuID())}
+          onOpenChange={(open) => props.controller.setMenuOpen(menuID(), open)}
+        >
+          <MenuV2.Trigger
+            as={IconButtonV2}
+            data-action="home-project-menu"
+            variant="ghost-muted"
+            size="small"
+            icon={<IconV2 name="outline-dots" />}
+            aria-label={props.controller.language.t("common.moreOptions")}
+          />
+          <MenuV2.Portal>
+            <MenuV2.Content>
+              <MenuV2.Item
+                onSelect={() => props.controller.openProjectNewSession(props.server, props.project.worktree)}
+              >
+                {props.controller.language.t("command.session.new")}
+              </MenuV2.Item>
+              <MenuV2.Item onSelect={() => props.controller.editProject(props.server, props.project)}>
+                {props.controller.language.t("dialog.project.edit.title")}
+              </MenuV2.Item>
+              <Show when={props.controller.canRevealProject(props.server)}>
+                <MenuV2.Item onSelect={() => props.controller.revealProject(props.server, props.project)}>
+                  {props.controller.fileManagerActionLabel()}
+                </MenuV2.Item>
+              </Show>
+              <MenuV2.Item
+                disabled={props.unseenCount === 0}
+                onSelect={() => props.controller.clearNotifications(props.server, props.project)}
+              >
+                {props.controller.language.t("sidebar.project.clearNotifications")}
+              </MenuV2.Item>
+              <MenuV2.Separator />
+              <MenuV2.Item onSelect={() => props.controller.closeProject(props.server, props.project.worktree)}>
+                {props.controller.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.controller.language.t("command.session.new")}
+          onClick={() => props.controller.openProjectNewSession(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)}
+    />
+  )
+}

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

@@ -0,0 +1,456 @@
+import { type ComponentProps, 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 { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
+import { sessionTitle } from "@/utils/session-title"
+import { shouldOpenSessionInBackground } from "../home-session-open"
+import {
+  HomeSessionStatusController,
+  homeSessionSearchKey,
+  type HomeSessionRecord,
+  type HomeSessionsController,
+} from "./home-sessions-controller"
+
+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_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
+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]"
+
+// 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 function HomeSessionsView(props: { controller: HomeSessionsController }) {
+  return (
+    <section
+      ref={props.controller.scroll.setHoverTarget}
+      class="min-h-0 min-w-0 flex-1 flex flex-col"
+      aria-label={props.controller.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.controller.scroll.containWheel}
+      >
+        <HomeSessionSearch controller={props.controller} />
+        <Show when={props.controller.groups().length > 0 && props.controller.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.controller.createSession}
+            >
+              {props.controller.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.controller.scroll.setThumbTrack}
+          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.controller.loading()}
+          fallback={
+            <div class="pt-3">
+              <HomeSessionSkeleton label={props.controller.language.t("common.loading")} />
+            </div>
+          }
+        >
+          <Show
+            when={props.controller.groups().length > 0}
+            fallback={
+              <HomeSessionsEmpty
+                onNewSession={props.controller.canCreateSession() ? props.controller.createSession : undefined}
+                language={props.controller.language}
+              />
+            }
+          >
+            <div ref={props.controller.header.setContent} class="flex flex-col pt-3 pr-3 pb-16">
+              <For each={props.controller.groups()}>
+                {(group, index) => (
+                  <>
+                    <HomeSessionGroupHeader
+                      title={group.title}
+                      titleOpacity={props.controller.header.titleOpacity(group.id)}
+                      ref={(element) => props.controller.header.setHeader(group.id, element)}
+                      elevated={index() === 0}
+                    />
+                    <div
+                      class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.controller.groups().length - 1 ? "" : "mb-6"}`}
+                    >
+                      <For each={group.sessions}>
+                        {(record) => <HomeSessionRow controller={props.controller} record={record} />}
+                      </For>
+                    </div>
+                  </>
+                )}
+              </For>
+            </div>
+          </Show>
+        </Show>
+      </div>
+    </section>
+  )
+}
+
+function HomeSessionLeadingController(props: {
+  controller: HomeSessionsController
+  record: HomeSessionRecord
+  revealProjectOnHover: boolean
+}) {
+  return (
+    <HomeSessionStatusController
+      controller={props.controller}
+      record={props.record}
+      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: { controller: HomeSessionsController }) {
+  return (
+    <div class="w-full">
+      <div ref={props.controller.search.setRoot} data-component="home-session-search" class="relative z-30 w-full">
+        <Show when={props.controller.search.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.controller.search.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.controller.search.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.controller.search.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]">
+                        {props.controller.language.t("home.sessions.search.sessions")}
+                      </p>
+                      <ScrollView class="max-h-80" viewportRef={props.controller.search.setList}>
+                        <div class="flex flex-col gap-px pb-2">
+                          <For each={props.controller.search.results()}>
+                            {(record) => (
+                              <HomeSessionSearchResultRow
+                                controller={props.controller}
+                                record={record}
+                                selected={props.controller.search.active() === 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] 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={props.controller.search.setInput}
+            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.controller.search.value()}
+            placeholder={props.controller.search.placeholder()}
+            aria-label={props.controller.search.placeholder()}
+            aria-expanded={props.controller.search.open()}
+            aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
+            aria-autocomplete="list"
+            aria-activedescendant={
+              props.controller.search.active() && props.controller.search.open()
+                ? `home-session-search-option-${props.controller.search.active()}`
+                : undefined
+            }
+            onFocus={props.controller.search.focus}
+            onInput={(event) => props.controller.search.input(event.currentTarget.value)}
+            onKeyDown={(event) => {
+              if (event.key === "Escape") {
+                event.preventDefault()
+                props.controller.search.close()
+                event.currentTarget.blur()
+                return
+              }
+              if (!props.controller.search.open() || props.controller.search.results().length === 0) return
+              if (event.altKey || event.metaKey) return
+              if (event.key === "ArrowDown") {
+                event.preventDefault()
+                props.controller.search.move(1)
+                return
+              }
+              if (event.key === "ArrowUp") {
+                event.preventDefault()
+                props.controller.search.move(-1)
+                return
+              }
+              if (event.key === "Enter" && !event.isComposing) {
+                event.preventDefault()
+                props.controller.search.selectActive()
+              }
+            }}
+          />
+          <Show when={props.controller.search.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.controller.search.placeholder()}
+              onClick={() => {
+                props.controller.search.close()
+                props.controller.search.focus()
+              }}
+            />
+          </Show>
+        </label>
+      </div>
+    </div>
+  )
+}
+
+function HomeSessionSearchResultRow(props: {
+  controller: HomeSessionsController
+  record: HomeSessionRecord
+  selected: boolean
+}) {
+  const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
+  const showProjectName = () => props.controller.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.controller.search.highlight(props.record)}
+      onMouseDown={(event) => {
+        if (event.button === 1) event.preventDefault()
+      }}
+      onClick={(event) => props.controller.search.select(props.record, { background: isBackgroundOpen(event) })}
+      onAuxClick={(event) => {
+        if (!isBackgroundOpen(event)) return
+        event.preventDefault()
+        props.controller.search.select(props.record, { background: true })
+      }}
+    >
+      <HomeSessionLeadingController
+        controller={props.controller}
+        record={props.record}
+        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: { controller: HomeSessionsController; record: HomeSessionRecord }) {
+  const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
+  const showProjectName = () => props.controller.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.controller.openSession(props.record.session, { background: isBackgroundOpen(event) })}
+        onAuxClick={(event) => {
+          if (!isBackgroundOpen(event)) return
+          event.preventDefault()
+          props.controller.openSession(props.record.session, { background: true })
+        }}
+      >
+        <HomeSessionLeadingController
+          controller={props.controller}
+          record={props.record}
+          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={props.controller.language.t("common.archive")}
+          >
+            <IconButtonV2
+              data-action="home-session-archive"
+              variant="ghost-muted"
+              size="large"
+              icon={<IconV2 name="archive" />}
+              aria-label={props.controller.language.t("common.archive")}
+              onClick={(event) => {
+                event.preventDefault()
+                event.stopPropagation()
+                void props.controller.archiveSession(props.record.session)
+              }}
+            />
+          </TooltipV2>
+        </div>
+      </Show>
+    </div>
+  )
+}
+
+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>
+  )
+}