import type { Session } from "@opencode-ai/sdk/v2/client" import { createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import { createStore } from "solid-js/store" import { useQuery } from "@tanstack/solid-query" import { Button } from "@opencode-ai/ui/button" import { Logo } from "@opencode-ai/ui/logo" import { Spinner } from "@opencode-ai/ui/spinner" 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 { TabStateIndicator } from "@opencode-ai/ui/v2/tab-state-indicator" import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout" import { useNavigate } from "@solidjs/router" import { base64Encode } from "@opencode-ai/core/util/encode" import { Icon } from "@opencode-ai/ui/icon" import { usePlatform } from "@/context/platform" import { DateTime } from "luxon" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { DialogSelectServer } from "@/components/dialog-select-server" import { ServerConnection, useServer } from "@/context/server" import { useServerSync } from "@/context/server-sync" import { useServers } from "@/context/servers" import { useLanguage } from "@/context/language" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { displayName, getProjectAvatarSource, projectForSession, sortedRootSessions } from "@/pages/layout/helpers" import { sessionTitle } from "@/utils/session-title" import { pathKey } from "@/utils/path-key" import { messageAgentColor } from "@/utils/agent" import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree" import { useCommand } from "@/context/command" import { useSettings } from "@/context/settings" import { ServerHealthIndicator } from "@/components/server/server-row" const HOME_SESSION_LIMIT = 15 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-02 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-02 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]` const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]" type HomeSessionRecord = { session: Session project: LocalProject projectName: string } type HomeSessionGroup = { id: "today" | "yesterday" | "older" title: string sessions: HomeSessionRecord[] } const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results" const HOME_SEARCH_RESULT_ROW = "flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-4 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]" function buildHomeSessionRecords(input: { sync: ReturnType projectDirectories: () => string[] projects: () => LocalProject[] projectByID: () => Map }) { return [ ...new Map( input .projectDirectories() .flatMap((directory) => sortedRootSessions(input.sync.child(directory, { bootstrap: false })[0], Date.now())) .map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const), ).values(), ] .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) .flatMap((session) => { const project = projectForSession(session, input.projects(), input.projectByID()) if (!project) return [] return { session, project, projectName: displayName(project), } }) } function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) { return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query) } function homeSessionSearchKey(record: HomeSessionRecord) { return `${pathKey(record.session.directory)}:${record.session.id}` } export default function Home() { const settings = useSettings() return ( }> ) } function HomeDesign() { const sync = useServerSync() const layout = useLayout() const platform = usePlatform() const dialog = useDialog() const navigate = useNavigate() const server = useServer() const language = useLanguage() const command = useCommand() const notification = useNotification() let focusSessionSearch: (() => void) | undefined const [state, setState] = createStore({ search: "", project: undefined as string | undefined, searchFocused: false, }) const projects = createMemo(() => layout.projects.list()) const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.project)) const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])] const projectDirectories = createMemo(() => { const project = selectedProject() if (!project) return projects().flatMap(directories) return directories(project) }) const search = createMemo(() => state.search.trim()) const sessionLoad = useQuery(() => ({ queryKey: ["home", "sessions", ...projectDirectories()] as const, queryFn: async () => { await Promise.all(projectDirectories().map((directory) => sync.project.loadSessions(directory))) return null }, })) const projectByID = createMemo( () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), ) const allRecords = createMemo(() => buildHomeSessionRecords({ sync, projectDirectories, projects, projectByID, }), ) const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT)) const searchResults = createMemo(() => { const query = search().toLowerCase() if (!query) return [] return allRecords().filter((record) => matchesHomeSessionSearch(record, query)) }) const searchOpen = createMemo(() => state.searchFocused && search().length > 0) const groups = createMemo(() => groupSessions(records(), language)) function closeSearch() { setState("search", "") setState("searchFocused", false) } function selectSearchSession(session: Session) { openSession(session) closeSearch() } command.register("home", () => [ { id: "home.sessions.search.focus", title: language.t("home.sessions.search.placeholder"), keybind: "mod+f", hidden: true, onSelect: () => focusSessionSearch?.(), }, ]) function selectProject(directory: string) { if (!projects().some((project) => project.worktree === directory)) return setState("project", state.project === directory ? undefined : directory) } function addProject(directory: string) { layout.projects.open(directory) server.projects.touch(directory) setState("project", directory) } function openNewSession() { const project = selectedProject() if (!project) { void chooseProject() return } layout.projects.open(project.worktree) server.projects.touch(project.worktree) navigate(`/${base64Encode(project.worktree)}/session`) } function openProjectNewSession(directory: string) { layout.projects.open(directory) server.projects.touch(directory) navigate(`/${base64Encode(directory)}/session`) } function editProject(project: LocalProject) { void import("@/components/dialog-edit-project").then((x) => { dialog.show(() => ) }) } function unseenCount(project: LocalProject) { return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0) } function clearNotifications(project: LocalProject) { directories(project) .filter((directory) => notification.project.unseenCount(directory) > 0) .forEach((directory) => notification.project.markViewed(directory)) } function openSession(session: Session) { const project = projectForSession(session, projects(), projectByID()) layout.projects.open(project?.worktree ?? session.directory) server.projects.touch(project?.worktree ?? session.directory) navigate(`/${base64Encode(session.directory)}/session/${session.id}`) } async function chooseProject() { function resolve(result: string | string[] | null) { if (Array.isArray(result)) { result.forEach(addProject) if (result[0]) setState("project", result[0]) return } if (result) addProject(result) } if (platform.openDirectoryPickerDialog && server.isLocal()) { const result = await platform.openDirectoryPickerDialog?.({ title: language.t("command.project.open"), multiple: true, }) resolve(result) return } dialog.show( () => , () => resolve(null), ) } function openSettings() { void import("@/components/settings-v2").then((x) => { dialog.show(() => ) }) } return (
void chooseProject()} editProject={editProject} closeProject={(directory) => { layout.projects.close(directory) if (state.project === directory) setState("project", undefined) }} clearNotifications={clearNotifications} unseenCount={unseenCount} openSettings={openSettings} openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} language={language} />
{ focusSessionSearch = focus }} onInput={(value) => setState("search", value)} onFocus={() => setState("searchFocused", true)} onClose={closeSearch} onSelect={selectSearchSession} />
}> 0} fallback={
} > {(group, index) => (
{(record) => }
)}
) } function HomeProjectColumn(props: { projects: LocalProject[] selected?: string selectProject: (directory: string) => void openNewSession: (directory: string) => void chooseProject: () => void editProject: (project: LocalProject) => void closeProject: (directory: string) => void clearNotifications: (project: LocalProject) => void unseenCount: (project: LocalProject) => number openSettings: () => void openHelp: () => void language: ReturnType }) { const servers = useServers() return ( ) } function HomeProjectList(props: { projects: LocalProject[] selected?: string selectProject: (directory: string) => void openNewSession: (directory: string) => void chooseProject: () => void editProject: (project: LocalProject) => void closeProject: (directory: string) => void clearNotifications: (project: LocalProject) => void unseenCount: (project: LocalProject) => number language: ReturnType }) { return ( 0} fallback={ } > {(project) => ( )} ) } function HomeProjectRow(props: { project: LocalProject selected: boolean unseenCount: number selectProject: (directory: string) => void openNewSession: (directory: string) => void editProject: (project: LocalProject) => void closeProject: (directory: string) => void clearNotifications: (project: LocalProject) => void language: ReturnType }) { const [state, setState] = createStore({ menuOpen: false }) return (
setState("menuOpen", open)} > } aria-label={props.language.t("common.moreOptions")} /> props.openNewSession(props.project.worktree)}> {props.language.t("command.session.new")} props.editProject(props.project)}> {props.language.t("common.edit")} props.clearNotifications(props.project)}> {props.language.t("sidebar.project.clearNotifications")} props.closeProject(props.project.worktree)}> {props.language.t("common.close")} } aria-label={props.language.t("command.session.new")} onClick={() => props.openNewSession(props.project.worktree)} />
) } function HomeProjectAvatar(props: { project: LocalProject }) { const name = createMemo(() => displayName(props.project)) return ( ) } function HomeSessionSearch(props: { value: string placeholder: string open: boolean loading: boolean results: HomeSessionRecord[] noResultsLabel: string bindFocus: (focus: () => void) => void onInput: (value: string) => void onFocus: () => void onClose: () => void onSelect: (session: Session) => void }) { const language = useLanguage() const [store, setStore] = createStore({ active: "" }) let root: HTMLDivElement | undefined let input: HTMLInputElement | undefined let listRef: HTMLDivElement | undefined const focusInput = () => { input?.focus() props.onFocus() } onMount(() => { props.bindFocus(focusInput) }) const syncActive = (results: HomeSessionRecord[]) => { if (results.length === 0) { setStore("active", "") return } if (!results.some((record) => homeSessionSearchKey(record) === store.active)) { setStore("active", homeSessionSearchKey(results[0])) } } createEffect(() => syncActive(props.results)) createEffect( on( () => props.value, () => syncActive(props.results), ), ) const scrollActiveIntoView = () => { const key = store.active if (!key || !listRef) return const element = listRef.querySelector(`[data-key="${key}"]`) element?.scrollIntoView({ block: "nearest" }) } const moveActive = (delta: number) => { const results = props.results if (results.length === 0) return const index = results.findIndex((record) => homeSessionSearchKey(record) === store.active) const start = index === -1 ? 0 : index const next = (start + delta + results.length) % results.length setStore("active", homeSessionSearchKey(results[next])) scrollActiveIntoView() } const selectActive = () => { const record = props.results.find((item) => homeSessionSearchKey(item) === store.active) if (!record) return props.onSelect(record.session) } onCleanup( makeEventListener(document, "pointerdown", (event) => { if (!props.open) return const target = event.target if (!(target instanceof Node)) return if (root?.contains(target)) return props.onClose() }), ) return (
} > 0} fallback={

{props.noResultsLabel}

} >

{language.t("home.sessions.search.sessions")}

{(record) => ( setStore("active", homeSessionSearchKey(record))} onSelect={(session) => props.onSelect(session)} /> )}
) } function HomeSessionSearchResultRow(props: { record: HomeSessionRecord selected: boolean onHighlight: () => void onSelect: (session: Session) => void }) { const globalSync = useServerSync() const notification = useNotification() const permission = usePermission() const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false }) const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id)) const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id)) const hasPermissions = createMemo( () => !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => { return !permission.autoResponds(item, props.record.session.directory) }), ) const isWorking = createMemo(() => { if (hasPermissions()) return false return sessionStore.session_working(props.record.session.id) }) const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent)) const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0) const key = () => homeSessionSearchKey(props.record) return ( ) } function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) { const language = useLanguage() return (
{(onNewSession) => ( {language.t("command.session.new")} )}
) } function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (session: Session) => void }) { const globalSync = useServerSync() const notification = useNotification() const permission = usePermission() const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false }) const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id)) const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id)) const hasPermissions = createMemo( () => !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => { return !permission.autoResponds(item, props.record.session.directory) }), ) const isWorking = createMemo(() => { if (hasPermissions()) return false return sessionStore.session_working(props.record.session.id) }) const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent)) const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0) return ( ) } function HomeSessionSkeleton(props: { label: string }) { return (
) } function groupSessions(records: HomeSessionRecord[], language: ReturnType): HomeSessionGroup[] { const now = DateTime.local() const yesterday = now.minus({ days: 1 }) const todaySessions = records.filter((record) => DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"), ) const yesterdaySessions = records.filter((record) => DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"), ) const olderSessions = records.filter((record) => { const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created) return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day") }) const olderTitle = todaySessions.length === 0 && yesterdaySessions.length === 0 ? language.t("sidebar.project.recentSessions") : language.t("home.sessions.group.older") return [ { id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions }, { id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions }, { id: "older" as const, title: olderTitle, sessions: olderSessions }, ].filter((group) => group.sessions.length > 0) } function LegacyHome() { const sync = useServerSync() const layout = useLayout() const platform = usePlatform() const dialog = useDialog() const navigate = useNavigate() const server = useServer() const servers = useServers() const language = useLanguage() const homedir = createMemo(() => sync.data.path.home) const recent = createMemo(() => { return sync.data.project .slice() .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) .slice(0, 5) }) const serverDotClass = createMemo(() => { const healthy = servers.health[server.key]?.healthy if (healthy === true) return "bg-icon-success-base" if (healthy === false) return "bg-icon-critical-base" return "bg-border-weak-base" }) function openProject(directory: string) { layout.projects.open(directory) server.projects.touch(directory) navigate(`/${base64Encode(directory)}`) } async function chooseProject() { function resolve(result: string | string[] | null) { if (Array.isArray(result)) { for (const directory of result) { openProject(directory) } } else if (result) { openProject(result) } } if (platform.openDirectoryPickerDialog && server.isLocal()) { const result = await platform.openDirectoryPickerDialog?.({ title: language.t("command.project.open"), multiple: true, }) resolve(result) } else { dialog.show( () => , () => resolve(null), ) } } return (
0}>
{language.t("home.recentProjects")}
    {(project) => ( )}
{language.t("common.loading")}
{language.t("home.empty.title")}
{language.t("home.empty.description")}
) }