diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx
index 7112018371..6b5a38ab7e 100644
--- a/packages/app/src/pages/home.tsx
+++ b/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}
>
-
+
)
}
-
-function HomeSessionsView(props: { controller: HomeSessionsController }) {
- return (
-
-
-
-
0 && props.controller.canCreateSession()}>
-
-
- {props.controller.language.t("command.session.new")}
-
-
-
-
-
-
-
-
-
- }
- >
- 0}
- fallback={
-
- }
- >
-
-
- {(group, index) => (
- <>
- props.controller.header.setHeader(group.id, element)}
- elevated={index() === 0}
- />
-
-
- {(record) => }
-
-
- >
- )}
-
-
-
-
-
-
- )
-}
-
-function HomeProjectColumn(props: { controller: HomeProjectsController; onWheel: (event: WheelEvent) => void }) {
- return (
-
- )
-}
-
-function HomeUtilityNav(props: {
- class?: string
- openSettings: () => void
- openHelp: () => void
- language: ReturnType
-}) {
- return (
-
- [data-slot=icon-svg]]:text-v2-icon-icon-muted`}
- onClick={props.openSettings}
- >
-
- {props.language.t("sidebar.settings")}
-
- [data-slot=icon-svg]]:text-v2-icon-icon-muted`}
- onClick={props.openHelp}
- >
-
- {props.language.t("sidebar.help")}
-
-
- )
-}
-
-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 (
-
-
props.controller.focusServer(props.server)}
- >
- {
- event.preventDefault()
- event.stopPropagation()
- if (!canToggle()) return
- props.controller.toggleCollapsed(props.server)
- }}
- onPointerDown={(event) => event.preventDefault()}
- >
-
-
-
-
-
-
- {props.server.displayName ?? new URL(props.server.http.url).host}
-
- {(label) => (
-
- {label()}
-
- )}
-
-
-
-
-
- )
-}
-
-type HomeProjectListProps = {
- controller: HomeProjectsController
- server: ServerConnection.Any
- projects: LocalProject[]
-}
-
-function HomeProjectList(props: HomeProjectListProps) {
- let listRef!: HTMLDivElement
-
- return (
- [
- ...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())
- }}
- >
-
- {/* 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. */}
- project.worktree)}>
- {(worktree, index) => }
-
-
-
- )
-}
-
-function HomeProjectSlot(
- props: HomeProjectListProps & {
- worktree: string
- index: () => number
- },
-) {
- const project = createMemo(() => props.projects.find((item) => item.worktree === props.worktree))
-
- return (
-
- {(item) => (
-
- )}
-
- )
-}
-
-function HomeProjectEmpty(props: {
- controller: HomeProjectsController
- server: ServerConnection.Any
- recentlyClosed: LocalProject[]
-}) {
- const unreachable = () => props.controller.serverHealth(props.server)?.healthy === false
- return (
-
-
[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
- disabled={unreachable()}
- onClick={() => props.controller.chooseProject(props.server)}
- >
-
- {props.controller.language.t("home.project.add")}
-
-
0}>
-
-
- {props.controller.language.t("home.recentlyClosed")}
-
-
-
- {(project) => }
-
-
-
- )
-}
-
-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 (
-
- props.controller.addProjects(props.server, [props.project.worktree])}
- >
-
- {displayName(props.project)}
-
-
- )
-}
-
-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 (
-
- {
- // 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
- }}
- >
-
- {displayName(props.project)}
-
-
-
- )
-}
-
-function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
- const name = createMemo(() => displayName(props.project))
- return (
-
- )
-}
-
-function HomeSessionLeadingController(props: {
- controller: HomeSessionsController
- record: HomeSessionRecord
- revealProjectOnHover: boolean
-}) {
- return (
- (
-
- )}
- />
- )
-}
-
-function HomeSessionLeading(props: {
- record: HomeSessionRecord
- revealProjectOnHover: boolean
- open: boolean
- unread: boolean
- loading: boolean
-}) {
- return (
-
-
-
-
-
-
- )
-}
-
-function HomeSessionSearch(props: { controller: HomeSessionsController }) {
- return (
-
-
-
-
-
-
-
-
-
- }
- >
-
0}
- fallback={
-
- {props.controller.search.noResultsLabel()}
-
- }
- >
-
-
- {props.controller.language.t("home.sessions.search.sessions")}
-
-
-
-
- {(record) => (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
- 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()
- }
- }}
- />
-
- }
- aria-label={props.controller.search.placeholder()}
- onClick={() => {
- props.controller.search.close()
- props.controller.search.focus()
- }}
- />
-
-
-
-
- )
-}
-
-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 (
- 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 })
- }}
- >
-
-
-
- {title()}
-
-
- {props.record.projectName}
-
-
-
- )
-}
-
-function HomeSessionGroupHeader(props: {
- title: string
- titleOpacity: number
- ref: ComponentProps<"div">["ref"]
- elevated?: boolean
-}) {
- return (
-
- )
-}
-
-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 (
-
-
{
- 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 })
- }}
- >
-
-
- {title()}
-
-
-
- {props.record.projectName}
-
-
-
-
-
-
- }
- aria-label={props.controller.language.t("common.archive")}
- onClick={(event) => {
- event.preventDefault()
- event.stopPropagation()
- void props.controller.archiveSession(props.record.session)
- }}
- />
-
-
-
-
- )
-}
-
-function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnType }) {
- return (
-
-
- {props.language.t("home.sessions.empty")}
-
-
- {props.language.t("home.sessions.empty.description")}
-
-
- {(onNewSession) => (
-
- {props.language.t("command.session.new")}
-
- )}
-
-
- )
-}
-
-function HomeSessionSkeleton(props: { label: string }) {
- return (
-
- )
-}
diff --git a/packages/app/src/pages/home/home-projects-view.tsx b/packages/app/src/pages/home/home-projects-view.tsx
new file mode 100644
index 0000000000..cc8be8e75f
--- /dev/null
+++ b/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 (
+
+ )
+}
+
+export function HomeUtilityNav(props: {
+ class?: string
+ openSettings: () => void
+ openHelp: () => void
+ language: ReturnType
+}) {
+ return (
+
+ [data-slot=icon-svg]]:text-v2-icon-icon-muted`}
+ onClick={props.openSettings}
+ >
+
+ {props.language.t("sidebar.settings")}
+
+ [data-slot=icon-svg]]:text-v2-icon-icon-muted`}
+ onClick={props.openHelp}
+ >
+
+ {props.language.t("sidebar.help")}
+
+
+ )
+}
+
+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 (
+
+
props.controller.focusServer(props.server)}
+ >
+ {
+ event.preventDefault()
+ event.stopPropagation()
+ if (!canToggle()) return
+ props.controller.toggleCollapsed(props.server)
+ }}
+ onPointerDown={(event) => event.preventDefault()}
+ >
+
+
+
+
+
+
+ {props.server.displayName ?? new URL(props.server.http.url).host}
+
+ {(label) => (
+
+ {label()}
+
+ )}
+
+
+
+
+
+ )
+}
+
+type HomeProjectListProps = {
+ controller: HomeProjectsController
+ server: ServerConnection.Any
+ projects: LocalProject[]
+}
+
+function HomeProjectList(props: HomeProjectListProps) {
+ let listRef!: HTMLDivElement
+
+ return (
+ [
+ ...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())
+ }}
+ >
+
+ {/* 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. */}
+ project.worktree)}>
+ {(worktree, index) => }
+
+
+
+ )
+}
+
+function HomeProjectSlot(
+ props: HomeProjectListProps & {
+ worktree: string
+ index: () => number
+ },
+) {
+ const project = createMemo(() => props.projects.find((item) => item.worktree === props.worktree))
+
+ return (
+
+ {(item) => (
+
+ )}
+
+ )
+}
+
+function HomeProjectEmpty(props: {
+ controller: HomeProjectsController
+ server: ServerConnection.Any
+ recentlyClosed: LocalProject[]
+}) {
+ const unreachable = () => props.controller.serverHealth(props.server)?.healthy === false
+ return (
+
+
[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
+ disabled={unreachable()}
+ onClick={() => props.controller.chooseProject(props.server)}
+ >
+
+ {props.controller.language.t("home.project.add")}
+
+
0}>
+
+
+ {props.controller.language.t("home.recentlyClosed")}
+
+
+
+ {(project) => }
+
+
+
+ )
+}
+
+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 (
+
+ props.controller.addProjects(props.server, [props.project.worktree])}
+ >
+
+ {displayName(props.project)}
+
+
+ )
+}
+
+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 (
+
+ {
+ // 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
+ }}
+ >
+
+ {displayName(props.project)}
+
+
+
+ )
+}
+
+function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
+ const name = createMemo(() => displayName(props.project))
+ return (
+
+ )
+}
diff --git a/packages/app/src/pages/home/home-sessions-view.tsx b/packages/app/src/pages/home/home-sessions-view.tsx
new file mode 100644
index 0000000000..12b383c4a9
--- /dev/null
+++ b/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 (
+
+
+
+
0 && props.controller.canCreateSession()}>
+
+
+ {props.controller.language.t("command.session.new")}
+
+
+
+
+
+
+
+
+
+ }
+ >
+ 0}
+ fallback={
+
+ }
+ >
+
+
+ {(group, index) => (
+ <>
+ props.controller.header.setHeader(group.id, element)}
+ elevated={index() === 0}
+ />
+
+
+ {(record) => }
+
+
+ >
+ )}
+
+
+
+
+
+
+ )
+}
+
+function HomeSessionLeadingController(props: {
+ controller: HomeSessionsController
+ record: HomeSessionRecord
+ revealProjectOnHover: boolean
+}) {
+ return (
+ (
+
+ )}
+ />
+ )
+}
+
+function HomeSessionLeading(props: {
+ record: HomeSessionRecord
+ revealProjectOnHover: boolean
+ open: boolean
+ unread: boolean
+ loading: boolean
+}) {
+ return (
+
+
+
+
+
+
+ )
+}
+
+function HomeSessionSearch(props: { controller: HomeSessionsController }) {
+ return (
+
+
+
+
+
+
+
+
+
+ }
+ >
+
0}
+ fallback={
+
+ {props.controller.search.noResultsLabel()}
+
+ }
+ >
+
+
+ {props.controller.language.t("home.sessions.search.sessions")}
+
+
+
+
+ {(record) => (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ 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()
+ }
+ }}
+ />
+
+ }
+ aria-label={props.controller.search.placeholder()}
+ onClick={() => {
+ props.controller.search.close()
+ props.controller.search.focus()
+ }}
+ />
+
+
+
+
+ )
+}
+
+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 (
+ 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 })
+ }}
+ >
+
+
+
+ {title()}
+
+
+ {props.record.projectName}
+
+
+
+ )
+}
+
+function HomeSessionGroupHeader(props: {
+ title: string
+ titleOpacity: number
+ ref: ComponentProps<"div">["ref"]
+ elevated?: boolean
+}) {
+ return (
+
+ )
+}
+
+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 (
+
+
{
+ 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 })
+ }}
+ >
+
+
+ {title()}
+
+
+
+ {props.record.projectName}
+
+
+
+
+
+
+ }
+ aria-label={props.controller.language.t("common.archive")}
+ onClick={(event) => {
+ event.preventDefault()
+ event.stopPropagation()
+ void props.controller.archiveSession(props.record.session)
+ }}
+ />
+
+
+
+
+ )
+}
+
+function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnType }) {
+ return (
+
+
+ {props.language.t("home.sessions.empty")}
+
+
+ {props.language.t("home.sessions.empty.description")}
+
+
+ {(onNewSession) => (
+
+ {props.language.t("command.session.new")}
+
+ )}
+
+
+ )
+}
+
+function HomeSessionSkeleton(props: { label: string }) {
+ return (
+
+ )
+}