| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126 |
- import {
- 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 } 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 { 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 { 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<string, boolean>,
- })
- 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<ColorScheme, string> = {
- 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<string, number>()
- const alertedAtBySession = new Map<string, number>()
- 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()))
- 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]
- 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 targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
- 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(() => <DialogSelectProvider />)
- }
- function openServer() {
- dialog.show(() => <DialogSelectServer />)
- }
- 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(
- () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
- () => 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 (
- <div class="relative size-5 shrink-0 rounded-sm">
- <Avatar
- fallback={name()}
- src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
- {...getAvatarColors(props.project.icon?.color)}
- class={`size-full ${props.class ?? ""}`}
- style={
- notifications().length > 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined
- }
- />
- <Show when={props.expandable}>
- <Icon
- name="chevron-right"
- size="normal"
- class="hidden size-full items-center justify-center text-text-subtle group-hover/session:flex group-data-[expanded]/trigger:rotate-90 transition-transform duration-50"
- />
- </Show>
- <Show when={notifications().length > 0 && props.notify}>
- <div
- classList={{
- "absolute -top-0.5 -right-0.5 size-1.5 rounded-full": true,
- "bg-icon-critical-base": hasError(),
- "bg-text-interactive-base": !hasError(),
- }}
- />
- </Show>
- </div>
- )
- }
- 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 (
- <Switch>
- <Match when={layout.sidebar.opened()}>
- <Button
- as={"div"}
- variant="ghost"
- data-active
- class="flex items-center justify-between gap-3 w-full px-1 self-stretch h-8 border-none rounded-lg"
- >
- <div class="flex items-center gap-3 p-0 text-left min-w-0 grow">
- <ProjectAvatar project={props.project} />
- <span class="truncate text-14-medium text-text-strong">{name()}</span>
- </div>
- </Button>
- </Match>
- <Match when={true}>
- <Button
- variant="ghost"
- size="large"
- class="flex items-center justify-center p-0 aspect-square border-none rounded-lg"
- data-selected={props.project.worktree === current()}
- onClick={() => navigateToProject(props.project.worktree)}
- >
- <ProjectAvatar project={props.project} notify />
- </Button>
- </Match>
- </Switch>
- )
- }
- 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 (
- <>
- <div
- data-session-id={props.session.id}
- class="group/session relative w-full rounded-md cursor-default transition-colors
- hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
- >
- <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
- <A
- href={`${props.slug}/session/${props.session.id}`}
- class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
- >
- <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
- <span
- classList={{
- "text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true,
- "animate-pulse": isWorking(),
- }}
- >
- {props.session.title}
- </span>
- <div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
- <Switch>
- <Match when={isWorking()}>
- <Spinner class="size-2.5 mr-0.5" />
- </Match>
- <Match when={hasPermissions()}>
- <div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" />
- </Match>
- <Match when={hasError()}>
- <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
- </Match>
- <Match when={notifications().length > 0}>
- <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" />
- </Match>
- <Match when={true}>
- <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
- {Math.abs(updated().diffNow().as("seconds")) < 60
- ? "Now"
- : updated()
- .toRelative({
- style: "short",
- unit: ["days", "hours", "minutes"],
- })
- ?.replace(" ago", "")
- ?.replace(/ days?/, "d")
- ?.replace(" min.", "m")
- ?.replace(" hr.", "h")}
- </span>
- </Match>
- </Switch>
- </div>
- </div>
- <Show when={props.session.summary?.files}>
- <div class="flex justify-between items-center self-stretch">
- <span class="text-12-regular text-text-weak">{`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`}</span>
- <Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
- </div>
- </Show>
- </A>
- </Tooltip>
- <div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
- <TooltipKeybind
- placement={props.mobile ? "bottom" : "right"}
- title="Archive session"
- keybind={command.keybind("session.archive")}
- >
- <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
- </TooltipKeybind>
- </div>
- </div>
- </>
- )
- }
- 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
- <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
- <Switch>
- <Match when={showExpanded()}>
- <Collapsible variant="ghost" open={isExpanded()} class="gap-2 shrink-0" onOpenChange={handleOpenChange}>
- <Button
- as={"div"}
- variant="ghost"
- classList={{
- "group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg": true,
- "bg-surface-raised-base-hover": isActive() && !isExpanded(),
- }}
- >
- <Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
- <ProjectAvatar
- project={props.project}
- class="group-hover/session:hidden"
- expandable
- notify={!isExpanded()}
- />
- <span class="truncate text-14-medium text-text-strong">{name()}</span>
- </Collapsible.Trigger>
- <div class="flex invisible gap-1 items-center group-hover/session:visible has-[[data-expanded]]:visible">
- <DropdownMenu>
- <DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
- <DropdownMenu.Portal>
- <DropdownMenu.Content>
- <DropdownMenu.Item
- onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}
- >
- <DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
- <DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- </DropdownMenu.Content>
- </DropdownMenu.Portal>
- </DropdownMenu>
- <TooltipKeybind placement="top" title="New session" keybind={command.keybind("session.new")}>
- <IconButton as={A} href={`${defaultWorktree()}/session`} icon="plus-small" variant="ghost" />
- </TooltipKeybind>
- </div>
- </Button>
- <Collapsible.Content>
- <nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
- <For each={rootSessions()}>
- {(session) => (
- <SessionItem
- session={session}
- slug={base64Encode(session.directory)}
- project={props.project}
- mobile={props.mobile}
- />
- )}
- </For>
- <Show when={rootSessions().length === 0}>
- <div
- class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors
- hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
- >
- <div class="flex items-center self-stretch w-full">
- <div class="flex-1 min-w-0">
- <Tooltip placement={props.mobile ? "bottom" : "right"} value="New session">
- <A
- href={`${defaultWorktree()}/session`}
- class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
- >
- <div class="flex items-center self-stretch gap-6 justify-between">
- <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
- New session
- </span>
- </div>
- </A>
- </Tooltip>
- </div>
- </div>
- </div>
- </Show>
- <Show when={hasMoreSessions()}>
- <div class="relative w-full py-1">
- <Button
- variant="ghost"
- class="flex w-full text-left justify-start text-12-medium opacity-50 px-3.5"
- size="large"
- onClick={loadMoreSessions}
- >
- Load more
- </Button>
- </div>
- </Show>
- </nav>
- </Collapsible.Content>
- </Collapsible>
- </Match>
- <Match when={true}>
- <Tooltip placement="right" value={getFilename(props.project.worktree)}>
- <ProjectVisual project={props.project} />
- </Tooltip>
- </Match>
- </Switch>
- </div>
- )
- }
- const ProjectDragOverlay = (): JSX.Element => {
- const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeDraggable))
- return (
- <Show when={project()}>
- {(p) => (
- <div class="bg-background-base rounded-md">
- <ProjectVisual project={p()} />
- </div>
- )}
- </Show>
- )
- }
- const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
- const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
- return (
- <div class="flex flex-col self-stretch h-full items-center justify-between overflow-hidden min-h-0">
- <div class="flex flex-col items-start self-stretch gap-4 min-h-0">
- <Show when={!sidebarProps.mobile}>
- <div
- classList={{
- "border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0": true,
- "justify-start": expanded(),
- }}
- >
- <A href="/" class="shrink-0 h-8 flex items-center justify-start px-2 w-full" data-tauri-drag-region>
- <Mark class="shrink-0" />
- </A>
- </div>
- </Show>
- <div class="flex flex-col items-start self-stretch gap-4 px-2 overflow-hidden min-h-0">
- <Show when={!sidebarProps.mobile}>
- <TooltipKeybind
- class="shrink-0"
- placement="right"
- title="Toggle sidebar"
- keybind={command.keybind("sidebar.toggle")}
- inactive={expanded()}
- >
- <Button
- variant="ghost"
- size="large"
- class="group/sidebar-toggle shrink-0 w-full text-left justify-start rounded-lg px-2"
- onClick={layout.sidebar.toggle}
- >
- <div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
- <Icon
- name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
- size="small"
- class="group-hover/sidebar-toggle:hidden"
- />
- <Icon
- name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
- size="small"
- class="hidden group-hover/sidebar-toggle:inline-block"
- />
- <Icon
- name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
- size="small"
- class="hidden group-active/sidebar-toggle:inline-block"
- />
- </div>
- <Show when={layout.sidebar.opened()}>
- <div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
- Toggle sidebar
- </div>
- </Show>
- </Button>
- </TooltipKeybind>
- </Show>
- <DragDropProvider
- onDragStart={handleDragStart}
- onDragEnd={handleDragEnd}
- onDragOver={handleDragOver}
- collisionDetector={closestCenter}
- >
- <DragDropSensors />
- <ConstrainDragXAxis />
- <div
- ref={(el) => {
- if (!sidebarProps.mobile) scrollContainerRef = el
- }}
- class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
- >
- <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
- <For each={layout.projects.list()}>
- {(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
- </For>
- </SortableProvider>
- </div>
- <DragOverlay>
- <ProjectDragOverlay />
- </DragOverlay>
- </DragDropProvider>
- </div>
- </div>
- <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
- <Switch>
- <Match when={providers.all().length > 0 && !providers.paid().length && expanded()}>
- <div class="rounded-md bg-background-stronger shadow-xs-border-base">
- <div class="p-3 flex flex-col gap-2">
- <div class="text-12-medium text-text-strong">Getting started</div>
- <div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
- <div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
- </div>
- <Tooltip placement="right" value="Connect provider" inactive={expanded()}>
- <Button
- class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
- size="large"
- icon="plus"
- onClick={connectProvider}
- >
- Connect provider
- </Button>
- </Tooltip>
- </div>
- </Match>
- <Match when={providers.all().length > 0}>
- <Tooltip placement="right" value="Connect provider" inactive={expanded()}>
- <Button
- class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
- variant="ghost"
- size="large"
- icon="plus"
- onClick={connectProvider}
- >
- <Show when={expanded()}>Connect provider</Show>
- </Button>
- </Tooltip>
- </Match>
- </Switch>
- <Tooltip
- placement="right"
- value={
- <div class="flex items-center gap-2">
- <span>Open project</span>
- <Show when={!sidebarProps.mobile}>
- <span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
- </Show>
- </div>
- }
- inactive={expanded()}
- >
- <Button
- class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
- variant="ghost"
- size="large"
- icon="folder-add-left"
- onClick={chooseProject}
- >
- <Show when={expanded()}>Open project</Show>
- </Button>
- </Tooltip>
- <Tooltip placement="right" value="Share feedback" inactive={expanded()}>
- <Button
- as={"a"}
- href="https://opencode.ai/desktop-feedback"
- target="_blank"
- class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
- variant="ghost"
- size="large"
- icon="bubble-5"
- >
- <Show when={expanded()}>Share feedback</Show>
- </Button>
- </Tooltip>
- </div>
- </div>
- )
- }
- return (
- <div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
- <div class="flex-1 min-h-0 flex">
- <div
- classList={{
- "hidden xl:block": true,
- "relative shrink-0": true,
- }}
- style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : "48px" }}
- >
- <div
- classList={{
- "@container w-full h-full pb-5 bg-background-base": true,
- "flex flex-col gap-5.5 items-start self-stretch justify-between": true,
- "border-r border-border-weak-base contain-strict": true,
- }}
- >
- <SidebarContent />
- </div>
- <Show when={layout.sidebar.opened()}>
- <ResizeHandle
- direction="horizontal"
- size={layout.sidebar.width()}
- min={150}
- max={window.innerWidth * 0.3}
- collapseThreshold={80}
- onResize={layout.sidebar.resize}
- onCollapse={layout.sidebar.close}
- />
- </Show>
- </div>
- <div class="xl:hidden">
- <div
- classList={{
- "fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
- "opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
- "opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
- }}
- onClick={(e) => {
- if (e.target === e.currentTarget) layout.mobileSidebar.hide()
- }}
- />
- <div
- classList={{
- "@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pb-5 transition-transform duration-200 ease-out": true,
- "translate-x-0": layout.mobileSidebar.opened(),
- "-translate-x-full": !layout.mobileSidebar.opened(),
- }}
- onClick={(e) => e.stopPropagation()}
- >
- <div class="border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0">
- <A
- href="/"
- class="shrink-0 h-8 flex items-center justify-start px-2 w-full"
- onClick={() => layout.mobileSidebar.hide()}
- >
- <Mark class="shrink-0" />
- </A>
- </div>
- <SidebarContent mobile />
- </div>
- </div>
- <main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main>
- </div>
- <Toast.Region />
- </div>
- )
- }
|