import { batch, createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, ParentProps, Show, Switch, untrack, type JSX, } from "solid-js" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, getAvatarColors, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { Persist, persisted } from "@/utils/persist" 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 { HoverCard } from "@opencode-ai/ui/hover-card" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" import { getFilename } from "@opencode-ai/util/path" 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 { 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 { DialogEditProject } from "@/components/dialog-edit-project" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( Persist.global("layout.page", ["layout.page.v1"]), createStore({ lastSession: {} as { [directory: string]: string }, activeProject: undefined as string | undefined, activeWorkspace: undefined as string | undefined, workspaceOrder: {} as Record, workspaceExpanded: {} as Record, }), ) const pageReady = createMemo(() => ready()) 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 layoutReady = createMemo(() => layout.ready()) 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 alerts = { "permission.asked": { title: "Permission required", icon: "checklist" as const, description: (sessionTitle: string, projectName: string) => `${sessionTitle} in ${projectName} needs permission`, }, "question.asked": { title: "Question", icon: "bubble-5" as const, description: (sessionTitle: string, projectName: string) => `${sessionTitle} in ${projectName} has a question`, }, } const toastBySession = new Map() const alertedAtBySession = new Map() const cooldownMs = 5000 const unsub = globalSDK.event.listen((e) => { if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return const config = alerts[e.details.type] const directory = e.name const props = e.details.properties if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return const [store] = globalSync.child(directory) const session = store.session.find((s) => s.id === props.sessionID) const sessionKey = `${directory}:${props.sessionID}` const sessionTitle = session?.title ?? "New session" const projectName = getFilename(directory) const description = config.description(sessionTitle, projectName) const href = `/${base64Encode(directory)}/session/${props.sessionID}` const now = Date.now() const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0 if (now - lastAlerted < cooldownMs) return alertedAtBySession.set(sessionKey, now) void platform.notify(config.title, description, href) const currentDir = params.dir ? base64Decode(params.dir) : undefined const currentSession = params.id if (directory === currentDir && props.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: config.icon, title: config.title, 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)) }) const workspaceSetting = createMemo(() => { const project = currentProject() if (!project) return false return layout.sidebar.workspaces(project.worktree)() }) createEffect(() => { if (!pageReady()) return if (!layoutReady()) return const project = currentProject() if (!project) return const dirs = [project.worktree, ...(project.sandboxes ?? [])] const existing = store.workspaceOrder[project.worktree] if (!existing) { setStore("workspaceOrder", project.worktree, dirs) return } const keep = existing.filter((d) => dirs.includes(d)) const missing = dirs.filter((d) => !existing.includes(d)) const merged = [...keep, ...missing] if (merged.length !== existing.length) { setStore("workspaceOrder", project.worktree, merged) return } if (merged.some((d, i) => d !== existing[i])) { setStore("workspaceOrder", project.worktree, merged) } }) createEffect(() => { if (!pageReady()) return if (!layoutReady()) return for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) { if (layout.sidebar.workspaces(directory)()) continue if (!expanded) continue setStore("workspaceExpanded", directory, false) } }) const currentSessions = createMemo(() => { const project = currentProject() if (!project) return [] as Session[] if (workspaceSetting()) { const dirs = workspaceIds(project) const result: Session[] = [] for (const dir of dirs) { const [dirStore] = globalSync.child(dir) const dirSessions = dirStore.session .filter((session) => session.directory === dirStore.path.directory) .filter((session) => !session.parentID) .toSorted(sortSessions) result.push(...dirSessions) } return result } const [projectStore] = globalSync.child(project.worktree) return projectStore.session .filter((session) => session.directory === projectStore.path.directory) .filter((session) => !session.parentID) .toSorted(sortSessions) }) 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 } async function 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 sessions = currentSessions() if (sessions.length === 0) return 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 + sessions.length) % sessions.length } const session = sessions[targetIndex] if (!session) return const next = sessions[(targetIndex + 1) % sessions.length] const prev = sessions[(targetIndex - 1 + sessions.length) % sessions.length] 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)) } 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 (!pageReady()) return if (!params.dir || !params.id) return const directory = base64Decode(params.dir) const id = params.id setStore("lastSession", directory, id) notification.session.markViewed(id) untrack(() => setStore("workspaceExpanded", directory, (value) => value ?? true)) requestAnimationFrame(() => scrollToSession(id)) }) createEffect(() => { const project = currentProject() if (!project) return if (workspaceSetting()) { const dirs = [project.worktree, ...(project.sandboxes ?? [])] for (const directory of dirs) { globalSync.project.loadSessions(directory) } return } globalSync.project.loadSessions(project.worktree) }) createEffect(() => { if (isLargeViewport()) { const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 64 document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) return } 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("activeProject", 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("activeProject", undefined) } function workspaceIds(project: LocalProject | undefined) { if (!project) return [] const dirs = [project.worktree, ...(project.sandboxes ?? [])] const existing = store.workspaceOrder[project.worktree] if (!existing) return dirs const keep = existing.filter((d) => dirs.includes(d)) const missing = dirs.filter((d) => !existing.includes(d)) return [...keep, ...missing] } function handleWorkspaceDragStart(event: unknown) { const id = getDraggableId(event) if (!id) return setStore("activeWorkspace", id) } function handleWorkspaceDragOver(event: DragEvent) { const { draggable, droppable } = event if (!draggable || !droppable) return const project = currentProject() if (!project) return const ids = workspaceIds(project) const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString()) const toIndex = ids.findIndex((dir) => dir === droppable.id.toString()) if (fromIndex === -1 || toIndex === -1) return if (fromIndex === toIndex) return const result = ids.slice() const [item] = result.splice(fromIndex, 1) if (!item) return result.splice(toIndex, 0, item) setStore("workspaceOrder", project.worktree, result) } function handleWorkspaceDragEnd() { setStore("activeWorkspace", undefined) } const ProjectIcon = (props: { project: LocalProject; class?: string; 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 6px at calc(100% - 3px) 3px, transparent 6px, black 6.5px)" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" return (
0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined } />
0 && props.notify}>
) } const SessionItem = (props: { session: Session; slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => { const notification = useNotification() 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 (hasPermissions()) return false const status = sessionStore.session_status[props.session.id] return status?.type === "busy" || status?.type === "retry" }) const tint = createMemo(() => { const messages = sessionStore.message[props.session.id] if (!messages) return undefined const user = messages .slice() .reverse() .find((m) => m.role === "user") if (!user?.agent) return undefined const agent = sessionStore.agent.find((a) => a.name === user.agent) return agent?.color }) return (
prefetchSession(props.session, "high")} onFocus={() => prefetchSession(props.session, "high")} >
}>
0}>
{props.session.title} {(summary) => (
)}
archiveSession(props.session)} />
) } const SessionSkeleton = (props: { count?: number }): JSX.Element => { const items = Array.from({ length: props.count ?? 4 }, (_, index) => index) return (
{() =>
}
) } const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(props.project.worktree) const selected = createMemo(() => { const current = params.dir ? base64Decode(params.dir) : "" return props.project.worktree === current || props.project.sandboxes?.includes(current) }) const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2)) const workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)()) const label = (directory: string) => { const [data] = globalSync.child(directory) const kind = directory === props.project.worktree ? "local" : "sandbox" const name = data.vcs?.branch ?? getFilename(directory) return `${kind} : ${name}` } const sessions = (directory: string) => { const [data] = globalSync.child(directory) return data.session .filter((session) => session.directory === data.path.directory) .filter((session) => !session.parentID) .toSorted(sortSessions) .slice(0, 2) } const projectSessions = () => { const [data] = globalSync.child(props.project.worktree) return data.session .filter((session) => session.directory === data.path.directory) .filter((session) => !session.parentID) .toSorted(sortSessions) .slice(0, 2) } const trigger = ( ) return ( // @ts-ignore
Recent sessions
{(session) => ( )} } > {(directory) => (
{label(directory)}
{(session) => ( )}
)}
) } const ProjectDragOverlay = (): JSX.Element => { const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject)) return ( {(p) => (
)}
) } const WorkspaceDragOverlay = (): JSX.Element => { const label = createMemo(() => { const project = currentProject() if (!project) return const directory = store.activeWorkspace if (!directory) return const [workspaceStore] = globalSync.child(directory) const kind = directory === project.worktree ? "local" : "sandbox" const name = workspaceStore.vcs?.branch ?? getFilename(directory) return `${kind} : ${name}` }) return ( {(value) => (
{value()}
)}
) } const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(props.directory) const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory) const slug = createMemo(() => base64Encode(props.directory)) const sessions = createMemo(() => workspaceStore.session .filter((session) => session.directory === workspaceStore.path.directory) .filter((session) => !session.parentID) .toSorted(sortSessions), ) const local = createMemo(() => props.directory === props.project.worktree) const title = createMemo(() => { const kind = local() ? "local" : "sandbox" const name = workspaceStore.vcs?.branch ?? getFilename(props.directory) return `${kind} : ${name}` }) const open = createMemo(() => store.workspaceExpanded[props.directory] ?? true) const loading = createMemo(() => open() && workspaceStore.status !== "complete" && sessions().length === 0) const hasMore = createMemo(() => local() && workspaceStore.sessionTotal > workspaceStore.session.length) const loadMore = async () => { if (!local()) return setWorkspaceStore("limit", (limit) => limit + 5) await globalSync.project.loadSessions(props.directory) } return ( // @ts-ignore
setStore("workspaceExpanded", props.directory, value)} >
{title()}
) } const LocalWorkspace = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const [workspaceStore, setWorkspaceStore] = globalSync.child(props.project.worktree) const slug = createMemo(() => base64Encode(props.project.worktree)) const sessions = createMemo(() => workspaceStore.session .filter((session) => session.directory === workspaceStore.path.directory) .filter((session) => !session.parentID) .toSorted(sortSessions), ) const loading = createMemo(() => workspaceStore.status !== "complete" && sessions().length === 0) const hasMore = createMemo(() => workspaceStore.sessionTotal > workspaceStore.session.length) const loadMore = async () => { setWorkspaceStore("limit", (limit) => limit + 5) await globalSync.project.loadSessions(props.project.worktree) } return (
{ if (!props.mobile) scrollContainerRef = el }} class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar" >
) } const SidebarContent = (sidebarProps: { mobile?: boolean }) => { const expanded = () => sidebarProps.mobile || layout.sidebar.opened() const sync = useGlobalSync() const project = createMemo(() => currentProject()) const projectName = createMemo(() => { const current = project() if (!current) return "" return current.name || getFilename(current.worktree) }) const workspaces = createMemo(() => workspaceIds(project())) const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "data" in err) { const data = (err as { data?: { message?: string } }).data if (data?.message) return data.message } if (err instanceof Error) return err.message return "Request failed" } const createWorkspace = async () => { const current = project() if (!current) return const created = await globalSDK.client.worktree .create({ directory: current.worktree }) .then((x) => x.data) .catch((err) => { showToast({ title: "Failed to create workspace", description: errorMessage(err), }) return undefined }) if (!created?.directory) return globalSync.child(created.directory) navigate(`/${base64Encode(created.directory)}/session`) } const homedir = createMemo(() => sync.data.path.home) return (
p.worktree)}> {(project) => } Open project {command.keybind("project.open")}
} >
platform.openLink("https://opencode.ai/desktop-feedback")} />
{(p) => ( <>
{projectName()} {project()?.worktree.replace(homedir(), "~")}
dialog.show(() => )}> Edit layout.sidebar.toggleWorkspaces(p.worktree)}> {layout.sidebar.workspaces(p.worktree)() ? "Disable workspaces" : "Enable workspaces"} closeProject(p.worktree)}> Close
} > <>
{ if (!sidebarProps.mobile) scrollContainerRef = el }} class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar" > {(directory) => ( )}
)}
0 && providers.paid().length === 0}>
Getting started
OpenCode includes free models so you can start immediately.
Connect any provider to use models, inc. Claude, GPT, Gemini etc.
) } return (
{ if (e.target === e.currentTarget) layout.mobileSidebar.hide() }} />
e.stopPropagation()} >
{props.children}
) }