Sfoglia il codice sorgente

refactor(app): detach home views from controllers

Brendan Allan 3 settimane fa
parent
commit
b751e26565

+ 74 - 4
packages/app/src/pages/home.tsx

@@ -18,12 +18,82 @@ 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">
-          <HomeProjectsView controller={projects} onWheel={sessions.scroll.containWheel} />
-          <HomeSessionsView controller={sessions} />
+          <HomeProjectsView
+            language={projects.language}
+            servers={projects.servers}
+            projects={projects.projects}
+            recentlyClosed={projects.recentlyClosed}
+            selection={projects.selection}
+            homedir={projects.homedir}
+            serverHealth={projects.serverHealth}
+            projectsForServer={projects.projectsForServer}
+            collapsed={projects.collapsed}
+            menuOpen={projects.menuOpen}
+            canDefaultServer={projects.canDefaultServer}
+            isDefaultServer={projects.isDefaultServer}
+            canRevealProject={projects.canRevealProject}
+            fileManagerActionLabel={projects.fileManagerActionLabel}
+            unseenCount={projects.unseenCount}
+            serverMenuID={projects.serverMenuID}
+            projectMenuID={projects.projectMenuID}
+            onWheel={sessions.scroll.containWheel}
+            onChooseProject={projects.chooseProject}
+            onFocusServer={projects.focusServer}
+            onToggleCollapsed={projects.toggleCollapsed}
+            onEditServer={projects.openEditServer}
+            onSetDefaultServer={projects.setDefaultServer}
+            onRemoveServer={projects.removeServer}
+            onSetMenuOpen={projects.setMenuOpen}
+            onMoveProject={projects.moveProject}
+            onSelectProject={projects.selectProject}
+            onAddProjects={projects.addProjects}
+            onOpenProjectNewSession={projects.openProjectNewSession}
+            onEditProject={projects.editProject}
+            onRevealProject={projects.revealProject}
+            onClearNotifications={projects.clearNotifications}
+            onCloseProject={projects.closeProject}
+            onOpenSettings={projects.openSettings}
+            onOpenHelp={projects.openHelp}
+          />
+          <HomeSessionsView
+            language={sessions.language}
+            groups={sessions.groups}
+            loading={sessions.loading}
+            showProjectName={sessions.showProjectName}
+            server={sessions.server}
+            canCreateSession={sessions.canCreateSession}
+            searchValue={sessions.search.value}
+            searchPlaceholder={sessions.search.placeholder}
+            searchOpen={sessions.search.open}
+            searchLoading={sessions.search.loading}
+            searchResults={sessions.search.results}
+            searchActive={sessions.search.active}
+            searchNoResultsLabel={sessions.search.noResultsLabel}
+            titleOpacity={sessions.header.titleOpacity}
+            isOpenTab={sessions.hasOpenTab}
+            onCreateSession={sessions.createSession}
+            onOpenSession={sessions.openSession}
+            onArchiveSession={sessions.archiveSession}
+            onSetHoverTarget={sessions.scroll.setHoverTarget}
+            onSetThumbTrack={sessions.scroll.setThumbTrack}
+            onSetContent={sessions.header.setContent}
+            onSetHeader={sessions.header.setHeader}
+            onWheel={sessions.scroll.containWheel}
+            onSetSearchRoot={sessions.search.setRoot}
+            onSetSearchInput={sessions.search.setInput}
+            onSetSearchList={sessions.search.setList}
+            onSearchFocus={sessions.search.focus}
+            onSearchInput={sessions.search.input}
+            onSearchClose={sessions.search.close}
+            onSearchMove={sessions.search.move}
+            onSearchSelectActive={sessions.search.selectActive}
+            onSearchHighlight={sessions.search.highlight}
+            onSearchSelect={sessions.search.select}
+          />
           <HomeUtilityNav
             class="flex lg:hidden"
-            openSettings={projects.openSettings}
-            openHelp={projects.openHelp}
+            onOpenSettings={projects.openSettings}
+            onOpenHelp={projects.openHelp}
             language={projects.language}
           />
         </div>

+ 165 - 141
packages/app/src/pages/home/home-projects-view.tsx

@@ -1,4 +1,4 @@
-import { createMemo, For, Show } from "solid-js"
+import { type Accessor, 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"
@@ -10,98 +10,119 @@ 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 { getProjectAvatarVariant, type HomeProjectSelection, 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 }) {
+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
+  menuOpen: (id: string) => boolean
+  canDefaultServer: Accessor<boolean>
+  isDefaultServer: (server: ServerConnection.Any) => boolean
+  canRevealProject: (server: ServerConnection.Any) => boolean
+  fileManagerActionLabel: Accessor<string>
+  unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
+  serverMenuID: (server: ServerConnection.Any) => string
+  projectMenuID: (server: ServerConnection.Any, directory: string) => string
+  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
+  onSetMenuOpen: (id: string, open: boolean) => 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) {
   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")}
+      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.controller.language.t("home.projects")}</div>
+        <div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
         <Show
-          when={
-            props.controller.servers().length === 1 &&
-            !(props.controller.projects().length === 0 && props.controller.recentlyClosed().length > 0)
-          }
+          when={props.servers().length === 1 && !(props.projects().length === 0 && props.recentlyClosed().length > 0)}
         >
-          <TooltipV2 placement="bottom" value={props.controller.language.t("home.project.add")}>
+          <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.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")}
+              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.controller.servers().length > 1}
+          when={props.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()}
-                  />
-                }
+                when={props.projects().length > 0}
+                fallback={<HomeProjectEmpty {...props} server={props.servers()[0]} items={props.recentlyClosed()} />}
               >
-                <HomeProjectList
-                  controller={props.controller}
-                  server={props.controller.servers()[0]}
-                  projects={props.controller.projects()}
-                />
+                <HomeProjectList {...props} server={props.servers()[0]} items={props.projects()} />
               </Show>
             </div>
           }
         >
           <div class="flex min-w-0 flex-col gap-4 pr-3">
-            <For each={props.controller.servers()}>
+            <For each={props.servers()}>
               {(item) => {
-                const projects = () => props.controller.projectsForServer(item)
-                const healthy = () => !!props.controller.serverHealth(item)?.healthy
+                const projects = () => props.projectsForServer(item)
+                const healthy = () => !!props.serverHealth(item)?.healthy
                 const hasProjects = () => projects().length > 0
-                const collapsed = () => props.controller.collapsed(item)
+                const collapsed = () => props.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
-                      }
+                      {...props}
+                      selected={props.selection().server === ServerConnection.key(item) && !props.selection().directory}
                       collapsed={collapsed()}
-                      health={props.controller.serverHealth(item)}
+                      health={props.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()} />
+                      <HomeProjectList {...props} server={item} items={projects()} />
                     </Show>
                   </div>
                 )
@@ -112,9 +133,9 @@ export function HomeProjectsView(props: { controller: HomeProjectsController; on
       </ScrollView>
       <HomeUtilityNav
         class="mb-8 mt-4 hidden shrink-0 lg:flex"
-        openSettings={props.controller.openSettings}
-        openHelp={props.controller.openHelp}
-        language={props.controller.language}
+        onOpenSettings={props.onOpenSettings}
+        onOpenHelp={props.onOpenHelp}
+        language={props.language}
       />
     </aside>
   )
@@ -122,8 +143,8 @@ export function HomeProjectsView(props: { controller: HomeProjectsController; on
 
 export function HomeUtilityNav(props: {
   class?: string
-  openSettings: () => void
-  openHelp: () => void
+  onOpenSettings: () => void
+  onOpenHelp: () => void
   language: ReturnType<typeof useLanguage>
 }) {
   return (
@@ -131,7 +152,7 @@ export function HomeUtilityNav(props: {
       <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}
+        onClick={props.onOpenSettings}
       >
         <IconV2 name="settings-gear" size="small" />
         <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
@@ -139,7 +160,7 @@ export function HomeUtilityNav(props: {
       <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}
+        onClick={props.onOpenHelp}
       >
         <IconV2 name="help" size="small" />
         <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
@@ -149,15 +170,27 @@ export function HomeUtilityNav(props: {
 }
 
 function HomeServerRow(props: {
-  controller: HomeProjectsController
+  language: HomeProjectsViewProps["language"]
+  projectsForServer: HomeProjectsViewProps["projectsForServer"]
+  menuOpen: HomeProjectsViewProps["menuOpen"]
+  canDefaultServer: HomeProjectsViewProps["canDefaultServer"]
+  isDefaultServer: HomeProjectsViewProps["isDefaultServer"]
+  serverMenuID: HomeProjectsViewProps["serverMenuID"]
+  onFocusServer: HomeProjectsViewProps["onFocusServer"]
+  onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"]
+  onEditServer: HomeProjectsViewProps["onEditServer"]
+  onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
+  onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
+  onSetMenuOpen: HomeProjectsViewProps["onSetMenuOpen"]
+  onChooseProject: HomeProjectsViewProps["onChooseProject"]
   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)
+  const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
+  const menuID = () => props.serverMenuID(props.server)
   return (
     <div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
       <button
@@ -165,7 +198,7 @@ function HomeServerRow(props: {
         class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
         data-selected={props.selected ? "" : undefined}
         disabled={!healthy()}
-        onClick={() => props.controller.focusServer(props.server)}
+        onClick={() => props.onFocusServer(props.server)}
       >
         <span
           data-action="home-server-collapse"
@@ -175,9 +208,7 @@ function HomeServerRow(props: {
             "cursor-default opacity-40": !canToggle(),
           }}
           aria-label={
-            props.collapsed
-              ? props.controller.language.t("home.server.expand")
-              : props.controller.language.t("home.server.collapse")
+            props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
           }
           aria-disabled={!canToggle()}
           aria-expanded={canToggle() ? !props.collapsed : undefined}
@@ -185,7 +216,7 @@ function HomeServerRow(props: {
             event.preventDefault()
             event.stopPropagation()
             if (!canToggle()) return
-            props.controller.toggleCollapsed(props.server)
+            props.onToggleCollapsed(props.server)
           }}
           onPointerDown={(event) => event.preventDefault()}
         >
@@ -212,33 +243,29 @@ function HomeServerRow(props: {
       </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())}
+        data-menu={props.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)}
+          labels={serverMenuLabels(props.language)}
+          canDefault={props.canDefaultServer()}
+          isDefault={props.isDefaultServer(props.server)}
+          onEdit={props.onEditServer}
+          onSetDefault={() => props.onSetDefaultServer(props.server)}
+          onRemoveDefault={() => props.onSetDefaultServer(undefined)}
+          onRemove={() => props.onRemoveServer(props.server)}
+          open={props.menuOpen(menuID())}
+          onOpenChange={(open) => props.onSetMenuOpen(menuID(), open)}
         />
-        <TooltipV2
-          class="flex shrink-0 items-center"
-          placement="bottom"
-          value={props.controller.language.t("home.project.add")}
-        >
+        <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.controller.language.t("home.project.add")}
+            aria-label={props.language.t("home.project.add")}
             disabled={props.health?.healthy === false}
-            onClick={() => props.controller.chooseProject(props.server)}
+            onClick={() => props.onChooseProject(props.server)}
           />
         </TooltipV2>
       </div>
@@ -246,10 +273,9 @@ function HomeServerRow(props: {
   )
 }
 
-type HomeProjectListProps = {
-  controller: HomeProjectsController
+type HomeProjectListProps = HomeProjectsViewProps & {
   server: ServerConnection.Any
-  projects: LocalProject[]
+  items: LocalProject[]
 }
 
 function HomeProjectList(props: HomeProjectListProps) {
@@ -276,10 +302,9 @@ function HomeProjectList(props: HomeProjectListProps) {
       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())
+        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}>
@@ -288,7 +313,7 @@ function HomeProjectList(props: HomeProjectListProps) {
             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)}>
+        <For each={props.items.map((project) => project.worktree)}>
           {(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
         </For>
       </div>
@@ -302,34 +327,35 @@ function HomeProjectSlot(
     index: () => number
   },
 ) {
-  const project = createMemo(() => props.projects.find((item) => item.worktree === props.worktree))
+  const project = createMemo(() => props.items.find((item) => item.worktree === props.worktree))
 
   return (
     <Show when={project()}>
       {(item) => (
         <HomeProjectRow
-          controller={props.controller}
+          {...props}
           project={item()}
           server={props.server}
           index={props.index}
-          serverSelected={props.controller.selection().server === ServerConnection.key(props.server)}
+          serverSelected={props.selection().server === ServerConnection.key(props.server)}
           selected={
-            props.controller.selection().server === ServerConnection.key(props.server) &&
-            props.controller.selection().directory === props.worktree
+            props.selection().server === ServerConnection.key(props.server) &&
+            props.selection().directory === props.worktree
           }
-          unseenCount={props.controller.unseenCount(props.server, item())}
+          unseen={props.unseenCount(props.server, item())}
         />
       )}
     </Show>
   )
 }
 
-function HomeProjectEmpty(props: {
-  controller: HomeProjectsController
-  server: ServerConnection.Any
-  recentlyClosed: LocalProject[]
-}) {
-  const unreachable = () => props.controller.serverHealth(props.server)?.healthy === false
+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">
       <button
@@ -337,33 +363,32 @@ function HomeProjectEmpty(props: {
         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)}
+        onClick={() => props.onChooseProject(props.server)}
       >
         <IconV2 name="folder-add-left" size="small" />
-        <span class={HOME_PROJECT_NAV_LABEL}>{props.controller.language.t("home.project.add")}</span>
+        <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
       </button>
-      <Show when={props.recentlyClosed.length > 0}>
+      <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.controller.language.t("home.recentlyClosed")}
-          </div>
+          <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} controller={props.controller} server={props.server} />}
+        <For each={props.items}>
+          {(project) => <HomeRecentlyClosedRow {...props} project={project} 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
+function HomeRecentlyClosedRow(
+  props: HomeProjectsViewProps & {
+    project: LocalProject
+    server: ServerConnection.Any
+  },
+) {
+  const unreachable = () => props.serverHealth(props.server)?.healthy === false
   const path = () => {
-    const home = props.controller.homedir()
+    const home = props.homedir()
     const worktree = props.project.worktree
     if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
     return worktree
@@ -375,7 +400,7 @@ function HomeRecentlyClosedRow(props: {
         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])}
+        onClick={() => props.onAddProjects(props.server, [props.project.worktree])}
       >
         <HomeProjectAvatar project={props.project} outline />
         <span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
@@ -384,16 +409,17 @@ function HomeRecentlyClosedRow(props: {
   )
 }
 
-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
+function HomeProjectRow(
+  props: HomeProjectsViewProps & {
+    project: LocalProject
+    server: ServerConnection.Any
+    index: () => number
+    serverSelected: boolean
+    selected: boolean
+    unseen: number
+  },
+) {
+  const serverUnreachable = () => props.serverHealth(props.server)?.healthy === false
   const sortable = useSortable({
     get id() {
       return props.project.worktree
@@ -403,7 +429,7 @@ function HomeProjectRow(props: {
     },
   })
   let pointerDownSelected: boolean | undefined
-  const menuID = () => props.controller.projectMenuID(props.server, props.project.worktree)
+  const menuID = () => props.projectMenuID(props.server, props.project.worktree)
   return (
     <div
       ref={sortable.ref}
@@ -432,7 +458,7 @@ function HomeProjectRow(props: {
           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)
+          if (!props.selected) props.onSelectProject(props.server, props.project.worktree)
         }}
         onClick={(event) => {
           // The drag sensor calls preventDefault on post-drag clicks; never
@@ -440,12 +466,12 @@ function HomeProjectRow(props: {
           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)
+            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.controller.selectProject(props.server, props.project.worktree)
+          if (pointerDownSelected) props.onSelectProject(props.server, props.project.worktree)
           pointerDownSelected = undefined
         }}
       >
@@ -454,14 +480,14 @@ function HomeProjectRow(props: {
       </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())}
+        data-menu={props.menuOpen(menuID())}
       >
         <MenuV2
           gutter={6}
           modal={false}
           placement="bottom-end"
-          open={props.controller.menuOpen(menuID())}
-          onOpenChange={(open) => props.controller.setMenuOpen(menuID(), open)}
+          open={props.menuOpen(menuID())}
+          onOpenChange={(open) => props.onSetMenuOpen(menuID(), open)}
         >
           <MenuV2.Trigger
             as={IconButtonV2}
@@ -469,32 +495,30 @@ function HomeProjectRow(props: {
             variant="ghost-muted"
             size="small"
             icon={<IconV2 name="outline-dots" />}
-            aria-label={props.controller.language.t("common.moreOptions")}
+            aria-label={props.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 onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
+                {props.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 onSelect={() => props.onEditProject(props.server, props.project)}>
+                {props.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()}
+              <Show when={props.canRevealProject(props.server)}>
+                <MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
+                  {props.fileManagerActionLabel()}
                 </MenuV2.Item>
               </Show>
               <MenuV2.Item
-                disabled={props.unseenCount === 0}
-                onSelect={() => props.controller.clearNotifications(props.server, props.project)}
+                disabled={props.unseen === 0}
+                onSelect={() => props.onClearNotifications(props.server, props.project)}
               >
-                {props.controller.language.t("sidebar.project.clearNotifications")}
+                {props.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 onSelect={() => props.onCloseProject(props.server, props.project.worktree)}>
+                {props.language.t("common.close")}
               </MenuV2.Item>
             </MenuV2.Content>
           </MenuV2.Portal>
@@ -504,8 +528,8 @@ function HomeProjectRow(props: {
           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)}
+          aria-label={props.language.t("command.session.new")}
+          onClick={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}
         />
       </div>
     </div>

+ 4 - 3
packages/app/src/pages/home/home-sessions-controller.tsx

@@ -560,18 +560,19 @@ function createHomeSessionHeaderController(groups: () => HomeSessionGroup[]) {
 export type HomeSessionsController = ReturnType<typeof createHomeSessionsController>
 
 export function HomeSessionStatusController(props: {
-  controller: HomeSessionsController
+  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.controller.server,
+    props.server,
     () => props.record.session.directory,
     () => props.record.session.id,
   )
   return props.render({
     unread: avatar.unread,
     loading: avatar.loading,
-    open: () => props.controller.hasOpenTab(props.record),
+    open: () => props.isOpenTab(props.record),
   })
 }

+ 117 - 82
packages/app/src/pages/home/home-sessions-view.tsx

@@ -1,4 +1,5 @@
-import { type ComponentProps, createMemo, For, Show } from "solid-js"
+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"
@@ -6,14 +7,16 @@ 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 HomeSessionsController,
+  type OpenSessionOptions,
 } from "./home-sessions-controller"
 
 const SHOW_HOME_SESSION_ARCHIVE = false
@@ -43,19 +46,52 @@ function isBackgroundOpen(event: MouseEvent) {
   })
 }
 
-export function HomeSessionsView(props: { controller: HomeSessionsController }) {
+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.controller.scroll.setHoverTarget}
+      ref={props.onSetHoverTarget}
       class="min-h-0 min-w-0 flex-1 flex flex-col"
-      aria-label={props.controller.language.t("sidebar.project.recentSessions")}
+      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.controller.scroll.containWheel}
-      >
-        <HomeSessionSearch controller={props.controller} />
-        <Show when={props.controller.groups().length > 0 && props.controller.canCreateSession()}>
+      <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"
@@ -63,54 +99,52 @@ export function HomeSessionsView(props: { controller: HomeSessionsController })
               size="normal"
               icon="edit"
               class="pointer-events-auto h-7 px-2 [font-weight:530]"
-              onClick={props.controller.createSession}
+              onClick={props.onCreateSession}
             >
-              {props.controller.language.t("command.session.new")}
+              {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.controller.scroll.setThumbTrack}
+          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.controller.loading()}
+          when={!props.loading()}
           fallback={
             <div class="pt-3">
-              <HomeSessionSkeleton label={props.controller.language.t("common.loading")} />
+              <HomeSessionSkeleton label={props.language.t("common.loading")} />
             </div>
           }
         >
           <Show
-            when={props.controller.groups().length > 0}
+            when={props.groups().length > 0}
             fallback={
               <HomeSessionsEmpty
-                onNewSession={props.controller.canCreateSession() ? props.controller.createSession : undefined}
-                language={props.controller.language}
+                onNewSession={props.canCreateSession() ? props.onCreateSession : undefined}
+                language={props.language}
               />
             }
           >
-            <div ref={props.controller.header.setContent} class="flex flex-col pt-3 pr-3 pb-16">
-              <For each={props.controller.groups()}>
+            <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.controller.header.titleOpacity(group.id)}
-                      ref={(element) => props.controller.header.setHeader(group.id, element)}
+                      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.controller.groups().length - 1 ? "" : "mb-6"}`}
+                      class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups().length - 1 ? "" : "mb-6"}`}
                     >
-                      <For each={group.sessions}>
-                        {(record) => <HomeSessionRow controller={props.controller} record={record} />}
-                      </For>
+                      <For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
                     </div>
                   </>
                 )}
@@ -124,14 +158,16 @@ export function HomeSessionsView(props: { controller: HomeSessionsController })
 }
 
 function HomeSessionLeadingController(props: {
-  controller: HomeSessionsController
+  server: HomeSessionsViewProps["server"]
+  isOpenTab: HomeSessionsViewProps["isOpenTab"]
   record: HomeSessionRecord
   revealProjectOnHover: boolean
 }) {
   return (
     <HomeSessionStatusController
-      controller={props.controller}
+      server={props.server}
       record={props.record}
+      isOpenTab={props.isOpenTab}
       render={(state) => (
         <HomeSessionLeading
           record={props.record}
@@ -172,11 +208,11 @@ function HomeSessionLeading(props: {
   )
 }
 
-function HomeSessionSearch(props: { controller: HomeSessionsController }) {
+function HomeSessionSearch(props: HomeSessionsViewProps) {
   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 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)]"
@@ -185,7 +221,7 @@ function HomeSessionSearch(props: { controller: HomeSessionsController }) {
             <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()}
+                  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" />
@@ -193,25 +229,25 @@ function HomeSessionSearch(props: { controller: HomeSessionsController }) {
                   }
                 >
                   <Show
-                    when={props.controller.search.results().length > 0}
+                    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.controller.search.noResultsLabel()}
+                        {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.controller.language.t("home.sessions.search.sessions")}
+                        {props.language.t("home.sessions.search.sessions")}
                       </p>
-                      <ScrollView class="max-h-80" viewportRef={props.controller.search.setList}>
+                      <ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
                         <div class="flex flex-col gap-px pb-2">
-                          <For each={props.controller.search.results()}>
+                          <For each={props.searchResults()}>
                             {(record) => (
                               <HomeSessionSearchResultRow
-                                controller={props.controller}
+                                {...props}
                                 record={record}
-                                selected={props.controller.search.active() === homeSessionSearchKey(record)}
+                                selected={props.searchActive() === homeSessionSearchKey(record)}
                               />
                             )}
                           </For>
@@ -227,57 +263,57 @@ function HomeSessionSearch(props: { controller: HomeSessionsController }) {
         <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}
+            ref={props.onSetSearchInput}
             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()}
+            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.controller.search.active() && props.controller.search.open()
-                ? `home-session-search-option-${props.controller.search.active()}`
+              props.searchActive() && props.searchOpen()
+                ? `home-session-search-option-${props.searchActive()}`
                 : undefined
             }
-            onFocus={props.controller.search.focus}
-            onInput={(event) => props.controller.search.input(event.currentTarget.value)}
+            onFocus={props.onSearchFocus}
+            onInput={(event) => props.onSearchInput(event.currentTarget.value)}
             onKeyDown={(event) => {
               if (event.key === "Escape") {
                 event.preventDefault()
-                props.controller.search.close()
+                props.onSearchClose()
                 event.currentTarget.blur()
                 return
               }
-              if (!props.controller.search.open() || props.controller.search.results().length === 0) return
+              if (!props.searchOpen() || props.searchResults().length === 0) return
               if (event.altKey || event.metaKey) return
               if (event.key === "ArrowDown") {
                 event.preventDefault()
-                props.controller.search.move(1)
+                props.onSearchMove(1)
                 return
               }
               if (event.key === "ArrowUp") {
                 event.preventDefault()
-                props.controller.search.move(-1)
+                props.onSearchMove(-1)
                 return
               }
               if (event.key === "Enter" && !event.isComposing) {
                 event.preventDefault()
-                props.controller.search.selectActive()
+                props.onSearchSelectActive()
               }
             }}
           />
-          <Show when={props.controller.search.value()}>
+          <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.controller.search.placeholder()}
+              aria-label={props.searchPlaceholder()}
               onClick={() => {
-                props.controller.search.close()
-                props.controller.search.focus()
+                props.onSearchClose()
+                props.onSearchFocus()
               }}
             />
           </Show>
@@ -287,13 +323,14 @@ function HomeSessionSearch(props: { controller: HomeSessionsController }) {
   )
 }
 
-function HomeSessionSearchResultRow(props: {
-  controller: HomeSessionsController
-  record: HomeSessionRecord
-  selected: boolean
-}) {
+function HomeSessionSearchResultRow(
+  props: HomeSessionsViewProps & {
+    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 showProjectName = () => props.showProjectName() && props.record.projectName
   const key = () => homeSessionSearchKey(props.record)
 
   return (
@@ -309,19 +346,20 @@ function HomeSessionSearchResultRow(props: {
         "bg-v2-overlay-simple-overlay-hover": props.selected,
         group: !!showProjectName(),
       }}
-      onMouseEnter={() => props.controller.search.highlight(props.record)}
+      onMouseEnter={() => props.onSearchHighlight(props.record)}
       onMouseDown={(event) => {
         if (event.button === 1) event.preventDefault()
       }}
-      onClick={(event) => props.controller.search.select(props.record, { background: isBackgroundOpen(event) })}
+      onClick={(event) => props.onSearchSelect(props.record, { background: isBackgroundOpen(event) })}
       onAuxClick={(event) => {
         if (!isBackgroundOpen(event)) return
         event.preventDefault()
-        props.controller.search.select(props.record, { background: true })
+        props.onSearchSelect(props.record, { background: true })
       }}
     >
       <HomeSessionLeadingController
-        controller={props.controller}
+        server={props.server}
+        isOpenTab={props.isOpenTab}
         record={props.record}
         revealProjectOnHover={!!showProjectName()}
       />
@@ -342,12 +380,12 @@ function HomeSessionSearchResultRow(props: {
 function HomeSessionGroupHeader(props: {
   title: string
   titleOpacity: number
-  ref: ComponentProps<"div">["ref"]
+  onSetRef: (element: HTMLDivElement) => void
   elevated?: boolean
 }) {
   return (
     <div
-      ref={props.ref}
+      ref={props.onSetRef}
       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 }}>
@@ -357,9 +395,9 @@ function HomeSessionGroupHeader(props: {
   )
 }
 
-function HomeSessionRow(props: { controller: HomeSessionsController; record: HomeSessionRecord }) {
+function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
   const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
-  const showProjectName = () => props.controller.showProjectName() && props.record.projectName
+  const showProjectName = () => props.showProjectName() && props.record.projectName
 
   return (
     <div
@@ -373,15 +411,16 @@ function HomeSessionRow(props: { controller: HomeSessionsController; record: Hom
         onMouseDown={(event) => {
           if (event.button === 1) event.preventDefault()
         }}
-        onClick={(event) => props.controller.openSession(props.record.session, { background: isBackgroundOpen(event) })}
+        onClick={(event) => props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })}
         onAuxClick={(event) => {
           if (!isBackgroundOpen(event)) return
           event.preventDefault()
-          props.controller.openSession(props.record.session, { background: true })
+          props.onOpenSession(props.record.session, { background: true })
         }}
       >
         <HomeSessionLeadingController
-          controller={props.controller}
+          server={props.server}
+          isOpenTab={props.isOpenTab}
           record={props.record}
           revealProjectOnHover={!!showProjectName()}
         />
@@ -398,21 +437,17 @@ function HomeSessionRow(props: { controller: HomeSessionsController; record: Hom
       </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")}
-          >
+          <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.controller.language.t("common.archive")}
+              aria-label={props.language.t("common.archive")}
               onClick={(event) => {
                 event.preventDefault()
                 event.stopPropagation()
-                void props.controller.archiveSession(props.record.session)
+                void props.onArchiveSession(props.record.session)
               }}
             />
           </TooltipV2>