import { batch, createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, ParentProps, Show, Switch, untrack, type JSX, } from "solid-js" import { DateTime } from "luxon" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, getAvatarColors, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { base64Decode, base64Encode } from "@opencode-ai/util/encode" import { Avatar } from "@opencode-ai/ui/avatar" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" import { Mark } from "@opencode-ai/ui/logo" import { getFilename } from "@opencode-ai/util/path" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Session } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { createStore, produce, reconcile } from "solid-js/store" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter, createSortable, } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { useProviders } from "@/hooks/use-providers" import { showToast, Toast, toaster } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { DialogSelectProvider } from "@/components/dialog-select-provider" import { DialogEditProject } from "@/components/dialog-edit-project" import { DialogSelectServer } from "@/components/dialog-select-server" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { navStart } from "@/utils/perf" import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { useServer } from "@/context/server" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ lastSession: {} as { [directory: string]: string }, activeDraggable: undefined as string | undefined, mobileProjectsExpanded: {} as Record, }) const mobileProjects = { expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true, expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true), collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false), } let scrollContainerRef: HTMLDivElement | undefined const xlQuery = window.matchMedia("(min-width: 1280px)") const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches) const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches) xlQuery.addEventListener("change", handleViewportChange) onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange)) const params = useParams() const globalSDK = useGlobalSDK() const globalSync = useGlobalSync() const layout = useLayout() const platform = usePlatform() const server = useServer() const notification = useNotification() const permission = usePermission() const navigate = useNavigate() const providers = useProviders() const dialog = useDialog() const command = useCommand() const theme = useTheme() const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeLabel: Record = { system: "System", light: "Light", dark: "Dark", } function cycleTheme(direction = 1) { const ids = availableThemeEntries().map(([id]) => id) if (ids.length === 0) return const currentIndex = ids.indexOf(theme.themeId()) const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length const nextThemeId = ids[nextIndex] theme.setTheme(nextThemeId) const nextTheme = theme.themes()[nextThemeId] showToast({ title: "Theme switched", description: nextTheme?.name ?? nextThemeId, }) } function cycleColorScheme(direction = 1) { const current = theme.colorScheme() const currentIndex = colorSchemeOrder.indexOf(current) const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + colorSchemeOrder.length) % colorSchemeOrder.length const next = colorSchemeOrder[nextIndex] theme.setColorScheme(next) showToast({ title: "Color scheme", description: colorSchemeLabel[next], }) } onMount(() => { if (!platform.checkUpdate || !platform.update || !platform.restart) return let toastId: number | undefined async function pollUpdate() { const { updateAvailable, version } = await platform.checkUpdate!() if (updateAvailable && toastId === undefined) { toastId = showToast({ persistent: true, icon: "download", title: "Update available", description: `A new version of OpenCode (${version}) is now available to install.`, actions: [ { label: "Install and restart", onClick: async () => { await platform.update!() await platform.restart!() }, }, { label: "Not yet", onClick: "dismiss", }, ], }) } } pollUpdate() const interval = setInterval(pollUpdate, 10 * 60 * 1000) onCleanup(() => clearInterval(interval)) }) onMount(() => { const toastBySession = new Map() const alertedAtBySession = new Map() const permissionAlertCooldownMs = 5000 const unsub = globalSDK.event.listen((e) => { if (e.details?.type !== "permission.asked") return const directory = e.name const perm = e.details.properties if (permission.autoResponds(perm, directory)) return const [store] = globalSync.child(directory) const session = store.session.find((s) => s.id === perm.sessionID) const sessionKey = `${directory}:${perm.sessionID}` const sessionTitle = session?.title ?? "New session" const projectName = getFilename(directory) const description = `${sessionTitle} in ${projectName} needs permission` const href = `/${base64Encode(directory)}/session/${perm.sessionID}` const now = Date.now() const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0 if (now - lastAlerted < permissionAlertCooldownMs) return alertedAtBySession.set(sessionKey, now) void platform.notify("Permission required", description, href) const currentDir = params.dir ? base64Decode(params.dir) : undefined const currentSession = params.id if (directory === currentDir && perm.sessionID === currentSession) return if (directory === currentDir && session?.parentID === currentSession) return const existingToastId = toastBySession.get(sessionKey) if (existingToastId !== undefined) { toaster.dismiss(existingToastId) } const toastId = showToast({ persistent: true, icon: "checklist", title: "Permission required", description, actions: [ { label: "Go to session", onClick: () => { navigate(href) }, }, { label: "Dismiss", onClick: "dismiss", }, ], }) toastBySession.set(sessionKey, toastId) }) onCleanup(unsub) createEffect(() => { const currentDir = params.dir ? base64Decode(params.dir) : undefined const currentSession = params.id if (!currentDir || !currentSession) return const sessionKey = `${currentDir}:${currentSession}` const toastId = toastBySession.get(sessionKey) if (toastId !== undefined) { toaster.dismiss(toastId) toastBySession.delete(sessionKey) alertedAtBySession.delete(sessionKey) } const [store] = globalSync.child(currentDir) const childSessions = store.session.filter((s) => s.parentID === currentSession) for (const child of childSessions) { const childKey = `${currentDir}:${child.id}` const childToastId = toastBySession.get(childKey) if (childToastId !== undefined) { toaster.dismiss(childToastId) toastBySession.delete(childKey) alertedAtBySession.delete(childKey) } } }) }) function sortSessions(a: Session, b: Session) { const now = Date.now() const oneMinuteAgo = now - 60 * 1000 const aUpdated = a.time.updated ?? a.time.created const bUpdated = b.time.updated ?? b.time.created const aRecent = aUpdated > oneMinuteAgo const bRecent = bUpdated > oneMinuteAgo if (aRecent && bRecent) return a.id.localeCompare(b.id) if (aRecent && !bRecent) return -1 if (!aRecent && bRecent) return 1 return bUpdated - aUpdated } function scrollToSession(sessionId: string) { if (!scrollContainerRef) return const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`) if (element) { element.scrollIntoView({ block: "nearest", behavior: "smooth" }) } } const currentProject = createMemo(() => { const directory = params.dir ? base64Decode(params.dir) : undefined if (!directory) return return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) }) function projectSessions(project: LocalProject | undefined) { if (!project) return [] const dirs = [project.worktree, ...(project.sandboxes ?? [])] const stores = dirs.map((dir) => globalSync.child(dir)[0]) const sessions = stores .flatMap((store) => store.session.filter((session) => session.directory === store.path.directory)) .toSorted(sortSessions) return sessions.filter((s) => !s.parentID) } const currentSessions = createMemo(() => projectSessions(currentProject())) type PrefetchQueue = { inflight: Set pending: string[] pendingSet: Set running: number } const prefetchChunk = 200 const prefetchConcurrency = 1 const prefetchPendingLimit = 6 const prefetchToken = { value: 0 } const prefetchQueues = new Map() createEffect(() => { params.dir globalSDK.url prefetchToken.value += 1 for (const q of prefetchQueues.values()) { q.pending.length = 0 q.pendingSet.clear() } }) const queueFor = (directory: string) => { const existing = prefetchQueues.get(directory) if (existing) return existing const created: PrefetchQueue = { inflight: new Set(), pending: [], pendingSet: new Set(), running: 0, } prefetchQueues.set(directory, created) return created } const prefetchMessages = (directory: string, sessionID: string, token: number) => { const [, setStore] = globalSync.child(directory) return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) .then((messages) => { if (prefetchToken.value !== token) return const items = (messages.data ?? []).filter((x) => !!x?.info?.id) const next = items .map((x) => x.info) .filter((m) => !!m?.id) .slice() .sort((a, b) => a.id.localeCompare(b.id)) batch(() => { setStore("message", sessionID, reconcile(next, { key: "id" })) for (const message of items) { setStore( "part", message.info.id, reconcile( message.parts .filter((p) => !!p?.id) .slice() .sort((a, b) => a.id.localeCompare(b.id)), { key: "id" }, ), ) } }) }) .catch(() => undefined) } const pumpPrefetch = (directory: string) => { const q = queueFor(directory) if (q.running >= prefetchConcurrency) return const sessionID = q.pending.shift() if (!sessionID) return q.pendingSet.delete(sessionID) q.inflight.add(sessionID) q.running += 1 const token = prefetchToken.value void prefetchMessages(directory, sessionID, token).finally(() => { q.running -= 1 q.inflight.delete(sessionID) pumpPrefetch(directory) }) } const prefetchSession = (session: Session, priority: "high" | "low" = "low") => { const directory = session.directory if (!directory) return const [store] = globalSync.child(directory) if (store.message[session.id] !== undefined) return const q = queueFor(directory) if (q.inflight.has(session.id)) return if (q.pendingSet.has(session.id)) return if (priority === "high") q.pending.unshift(session.id) if (priority !== "high") q.pending.push(session.id) q.pendingSet.add(session.id) while (q.pending.length > prefetchPendingLimit) { const dropped = q.pending.pop() if (!dropped) continue q.pendingSet.delete(dropped) } pumpPrefetch(directory) } createEffect(() => { const sessions = currentSessions() const id = params.id if (!id) { const first = sessions[0] if (first) prefetchSession(first) const second = sessions[1] if (second) prefetchSession(second) return } const index = sessions.findIndex((s) => s.id === id) if (index === -1) return const next = sessions[index + 1] if (next) prefetchSession(next) const prev = sessions[index - 1] if (prev) prefetchSession(prev) }) function navigateSessionByOffset(offset: number) { const projects = layout.projects.list() if (projects.length === 0) return const project = currentProject() const projectIndex = project ? projects.findIndex((p) => p.worktree === project.worktree) : -1 if (projectIndex === -1) { const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1] if (targetProject) navigateToProject(targetProject.worktree) return } const sessions = currentSessions() const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 let targetIndex: number if (sessionIndex === -1) { targetIndex = offset > 0 ? 0 : sessions.length - 1 } else { targetIndex = sessionIndex + offset } if (targetIndex >= 0 && targetIndex < sessions.length) { const session = sessions[targetIndex] const next = sessions[targetIndex + 1] const prev = sessions[targetIndex - 1] if (offset > 0) { if (next) prefetchSession(next, "high") if (prev) prefetchSession(prev) } if (offset < 0) { if (prev) prefetchSession(prev, "high") if (next) prefetchSession(next) } if (import.meta.env.DEV) { navStart({ dir: base64Encode(session.directory), from: params.id, to: session.id, trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup", }) } navigateToSession(session) queueMicrotask(() => scrollToSession(session.id)) return } const nextProjectIndex = projectIndex + (offset > 0 ? 1 : -1) const nextProject = projects[nextProjectIndex] if (!nextProject) return const nextProjectSessions = projectSessions(nextProject) if (nextProjectSessions.length === 0) { navigateToProject(nextProject.worktree) return } const index = offset > 0 ? 0 : nextProjectSessions.length - 1 const targetSession = nextProjectSessions[index] const nextSession = nextProjectSessions[index + 1] const prevSession = nextProjectSessions[index - 1] if (offset > 0) { if (nextSession) prefetchSession(nextSession, "high") } if (offset < 0) { if (prevSession) prefetchSession(prevSession, "high") } if (import.meta.env.DEV) { navStart({ dir: base64Encode(targetSession.directory), from: params.id, to: targetSession.id, trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup", }) } navigateToSession(targetSession) queueMicrotask(() => scrollToSession(targetSession.id)) } async function archiveSession(session: Session) { const [store, setStore] = globalSync.child(session.directory) const sessions = store.session ?? [] const index = sessions.findIndex((s) => s.id === session.id) const nextSession = sessions[index + 1] ?? sessions[index - 1] await globalSDK.client.session.update({ directory: session.directory, sessionID: session.id, time: { archived: Date.now() }, }) setStore( produce((draft) => { const match = Binary.search(draft.session, session.id, (s) => s.id) if (match.found) draft.session.splice(match.index, 1) }), ) if (session.id === params.id) { if (nextSession) { navigate(`/${params.dir}/session/${nextSession.id}`) } else { navigate(`/${params.dir}/session`) } } } command.register(() => { const commands: CommandOption[] = [ { id: "sidebar.toggle", title: "Toggle sidebar", category: "View", keybind: "mod+b", onSelect: () => layout.sidebar.toggle(), }, { id: "project.open", title: "Open project", category: "Project", keybind: "mod+o", onSelect: () => chooseProject(), }, { id: "provider.connect", title: "Connect provider", category: "Provider", onSelect: () => connectProvider(), }, { id: "server.switch", title: "Switch server", category: "Server", onSelect: () => openServer(), }, { id: "session.previous", title: "Previous session", category: "Session", keybind: "alt+arrowup", onSelect: () => navigateSessionByOffset(-1), }, { id: "session.next", title: "Next session", category: "Session", keybind: "alt+arrowdown", onSelect: () => navigateSessionByOffset(1), }, { id: "session.archive", title: "Archive session", category: "Session", keybind: "mod+shift+backspace", disabled: !params.dir || !params.id, onSelect: () => { const session = currentSessions().find((s) => s.id === params.id) if (session) archiveSession(session) }, }, { id: "theme.cycle", title: "Cycle theme", category: "Theme", keybind: "mod+shift+t", onSelect: () => cycleTheme(1), }, ] for (const [id, definition] of availableThemeEntries()) { commands.push({ id: `theme.set.${id}`, title: `Use theme: ${definition.name ?? id}`, category: "Theme", onSelect: () => theme.commitPreview(), onHighlight: () => { theme.previewTheme(id) return () => theme.cancelPreview() }, }) } commands.push({ id: "theme.scheme.cycle", title: "Cycle color scheme", category: "Theme", keybind: "mod+shift+s", onSelect: () => cycleColorScheme(1), }) for (const scheme of colorSchemeOrder) { commands.push({ id: `theme.scheme.${scheme}`, title: `Use color scheme: ${colorSchemeLabel[scheme]}`, category: "Theme", onSelect: () => theme.commitPreview(), onHighlight: () => { theme.previewColorScheme(scheme) return () => theme.cancelPreview() }, }) } return commands }) function connectProvider() { dialog.show(() => ) } function openServer() { dialog.show(() => ) } function navigateToProject(directory: string | undefined) { if (!directory) return const lastSession = store.lastSession[directory] navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`) layout.mobileSidebar.hide() } function navigateToSession(session: Session | undefined) { if (!session) return navigate(`/${base64Encode(session.directory)}/session/${session.id}`) layout.mobileSidebar.hide() } function openProject(directory: string, navigate = true) { layout.projects.open(directory) if (navigate) navigateToProject(directory) } function closeProject(directory: string) { const index = layout.projects.list().findIndex((x) => x.worktree === directory) const next = layout.projects.list()[index + 1] layout.projects.close(directory) if (next) navigateToProject(next.worktree) else navigate("/") } async function chooseProject() { function resolve(result: string | string[] | null) { if (Array.isArray(result)) { for (const directory of result) { openProject(directory, false) } navigateToProject(result[0]) } else if (result) { openProject(result) } } if (platform.openDirectoryPickerDialog && server.isLocal()) { const result = await platform.openDirectoryPickerDialog?.({ title: "Open project", multiple: true, }) resolve(result) } else { dialog.show( () => , () => resolve(null), ) } } createEffect(() => { if (!params.dir || !params.id) return const directory = base64Decode(params.dir) const id = params.id setStore("lastSession", directory, id) notification.session.markViewed(id) const project = currentProject() untrack(() => layout.projects.expand(project?.worktree ?? directory)) requestAnimationFrame(() => scrollToSession(id)) }) createEffect(() => { if (isLargeViewport()) { const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48 document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) } else { document.documentElement.style.setProperty("--dialog-left-margin", "0px") } }) function getDraggableId(event: unknown): string | undefined { if (typeof event !== "object" || event === null) return undefined if (!("draggable" in event)) return undefined const draggable = (event as { draggable?: { id?: unknown } }).draggable if (!draggable) return undefined return typeof draggable.id === "string" ? draggable.id : undefined } function handleDragStart(event: unknown) { const id = getDraggableId(event) if (!id) return setStore("activeDraggable", id) } function handleDragOver(event: DragEvent) { const { draggable, droppable } = event if (draggable && droppable) { const projects = layout.projects.list() const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString()) const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString()) if (fromIndex !== toIndex && toIndex !== -1) { layout.projects.move(draggable.id.toString(), toIndex) } } } function handleDragEnd() { setStore("activeDraggable", undefined) } const ProjectAvatar = (props: { project: LocalProject class?: string expandable?: boolean notify?: boolean }): JSX.Element => { const notification = useNotification() const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" return (
0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined } /> 0 && props.notify}>
) } const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => { const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const current = createMemo(() => base64Decode(params.dir ?? "")) return ( ) } const SessionItem = (props: { session: Session slug: string project: LocalProject mobile?: boolean }): JSX.Element => { const notification = useNotification() const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) const notifications = createMemo(() => notification.session.unseen(props.session.id)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const [sessionStore] = globalSync.child(props.session.directory) const hasPermissions = createMemo(() => { const permissions = sessionStore.permission?.[props.session.id] ?? [] if (permissions.length > 0) return true const childSessions = sessionStore.session.filter((s) => s.parentID === props.session.id) for (const child of childSessions) { const childPermissions = sessionStore.permission?.[child.id] ?? [] if (childPermissions.length > 0) return true } return false }) const isWorking = createMemo(() => { if (props.session.id === params.id) return false if (hasPermissions()) return false const status = sessionStore.session_status[props.session.id] return status?.type === "busy" || status?.type === "retry" }) return ( <>
prefetchSession(props.session, "high")} onFocus={() => prefetchSession(props.session, "high")} >
{props.session.title}
) } const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(props.project.worktree) const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened()) const defaultWorktree = createMemo(() => base64Encode(props.project.worktree)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const [store, setProjectStore] = globalSync.child(props.project.worktree) const stores = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])].map((dir) => globalSync.child(dir)[0]), ) const sessions = createMemo(() => stores() .flatMap((store) => store.session.filter((session) => session.directory === store.path.directory)) .toSorted(sortSessions), ) const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) const hasMoreSessions = createMemo(() => store.session.length >= store.limit) const loadMoreSessions = async () => { setProjectStore("limit", (limit) => limit + 5) await globalSync.project.loadSessions(props.project.worktree) } const isExpanded = createMemo(() => props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded, ) const isActive = createMemo(() => { const current = params.dir ? base64Decode(params.dir) : "" return props.project.worktree === current || props.project.sandboxes?.includes(current) }) const handleOpenChange = (open: boolean) => { if (props.mobile) { if (open) mobileProjects.expand(props.project.worktree) else mobileProjects.collapse(props.project.worktree) } else { if (open) layout.projects.expand(props.project.worktree) else layout.projects.collapse(props.project.worktree) } } return ( // @ts-ignore
) } const ProjectDragOverlay = (): JSX.Element => { const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeDraggable)) return ( {(p) => (
)}
) } const SidebarContent = (sidebarProps: { mobile?: boolean }) => { const expanded = () => sidebarProps.mobile || layout.sidebar.opened() return (
{ if (!sidebarProps.mobile) scrollContainerRef = el }} class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar" > p.worktree)}> {(project) => }
0 && !providers.paid().length && expanded()}>
Getting started
OpenCode includes free models so you can start immediately.
Connect any provider to use models, inc. Claude, GPT, Gemini etc.
0}>
Open project {command.keybind("project.open")}
} inactive={expanded()} >
) } return (
{ if (e.target === e.currentTarget) layout.mobileSidebar.hide() }} />
e.stopPropagation()} >
{props.children}
) }