layout.tsx 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126
  1. import {
  2. createEffect,
  3. createMemo,
  4. createSignal,
  5. For,
  6. Match,
  7. onCleanup,
  8. onMount,
  9. ParentProps,
  10. Show,
  11. Switch,
  12. untrack,
  13. type JSX,
  14. } from "solid-js"
  15. import { DateTime } from "luxon"
  16. import { A, useNavigate, useParams } from "@solidjs/router"
  17. import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
  18. import { useGlobalSync } from "@/context/global-sync"
  19. import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
  20. import { Avatar } from "@opencode-ai/ui/avatar"
  21. import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
  22. import { Button } from "@opencode-ai/ui/button"
  23. import { Icon } from "@opencode-ai/ui/icon"
  24. import { IconButton } from "@opencode-ai/ui/icon-button"
  25. import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
  26. import { Collapsible } from "@opencode-ai/ui/collapsible"
  27. import { DiffChanges } from "@opencode-ai/ui/diff-changes"
  28. import { Spinner } from "@opencode-ai/ui/spinner"
  29. import { Mark } from "@opencode-ai/ui/logo"
  30. import { getFilename } from "@opencode-ai/util/path"
  31. import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
  32. import { Session } from "@opencode-ai/sdk/v2/client"
  33. import { usePlatform } from "@/context/platform"
  34. import { createStore, produce } from "solid-js/store"
  35. import {
  36. DragDropProvider,
  37. DragDropSensors,
  38. DragOverlay,
  39. SortableProvider,
  40. closestCenter,
  41. createSortable,
  42. } from "@thisbeyond/solid-dnd"
  43. import type { DragEvent } from "@thisbeyond/solid-dnd"
  44. import { useProviders } from "@/hooks/use-providers"
  45. import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
  46. import { useGlobalSDK } from "@/context/global-sdk"
  47. import { useNotification } from "@/context/notification"
  48. import { usePermission } from "@/context/permission"
  49. import { Binary } from "@opencode-ai/util/binary"
  50. import { useDialog } from "@opencode-ai/ui/context/dialog"
  51. import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
  52. import { DialogSelectProvider } from "@/components/dialog-select-provider"
  53. import { DialogEditProject } from "@/components/dialog-edit-project"
  54. import { DialogSelectServer } from "@/components/dialog-select-server"
  55. import { useCommand, type CommandOption } from "@/context/command"
  56. import { ConstrainDragXAxis } from "@/utils/solid-dnd"
  57. import { DialogSelectDirectory } from "@/components/dialog-select-directory"
  58. import { useServer } from "@/context/server"
  59. export default function Layout(props: ParentProps) {
  60. const [store, setStore] = createStore({
  61. lastSession: {} as { [directory: string]: string },
  62. activeDraggable: undefined as string | undefined,
  63. mobileProjectsExpanded: {} as Record<string, boolean>,
  64. })
  65. const mobileProjects = {
  66. expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true,
  67. expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true),
  68. collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false),
  69. }
  70. let scrollContainerRef: HTMLDivElement | undefined
  71. const xlQuery = window.matchMedia("(min-width: 1280px)")
  72. const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
  73. const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
  74. xlQuery.addEventListener("change", handleViewportChange)
  75. onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
  76. const params = useParams()
  77. const globalSDK = useGlobalSDK()
  78. const globalSync = useGlobalSync()
  79. const layout = useLayout()
  80. const platform = usePlatform()
  81. const server = useServer()
  82. const notification = useNotification()
  83. const permission = usePermission()
  84. const navigate = useNavigate()
  85. const providers = useProviders()
  86. const dialog = useDialog()
  87. const command = useCommand()
  88. const theme = useTheme()
  89. const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
  90. const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
  91. const colorSchemeLabel: Record<ColorScheme, string> = {
  92. system: "System",
  93. light: "Light",
  94. dark: "Dark",
  95. }
  96. function cycleTheme(direction = 1) {
  97. const ids = availableThemeEntries().map(([id]) => id)
  98. if (ids.length === 0) return
  99. const currentIndex = ids.indexOf(theme.themeId())
  100. const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
  101. const nextThemeId = ids[nextIndex]
  102. theme.setTheme(nextThemeId)
  103. const nextTheme = theme.themes()[nextThemeId]
  104. showToast({
  105. title: "Theme switched",
  106. description: nextTheme?.name ?? nextThemeId,
  107. })
  108. }
  109. function cycleColorScheme(direction = 1) {
  110. const current = theme.colorScheme()
  111. const currentIndex = colorSchemeOrder.indexOf(current)
  112. const nextIndex =
  113. currentIndex === -1 ? 0 : (currentIndex + direction + colorSchemeOrder.length) % colorSchemeOrder.length
  114. const next = colorSchemeOrder[nextIndex]
  115. theme.setColorScheme(next)
  116. showToast({
  117. title: "Color scheme",
  118. description: colorSchemeLabel[next],
  119. })
  120. }
  121. onMount(() => {
  122. if (!platform.checkUpdate || !platform.update || !platform.restart) return
  123. let toastId: number | undefined
  124. async function pollUpdate() {
  125. const { updateAvailable, version } = await platform.checkUpdate!()
  126. if (updateAvailable && toastId === undefined) {
  127. toastId = showToast({
  128. persistent: true,
  129. icon: "download",
  130. title: "Update available",
  131. description: `A new version of OpenCode (${version}) is now available to install.`,
  132. actions: [
  133. {
  134. label: "Install and restart",
  135. onClick: async () => {
  136. await platform.update!()
  137. await platform.restart!()
  138. },
  139. },
  140. {
  141. label: "Not yet",
  142. onClick: "dismiss",
  143. },
  144. ],
  145. })
  146. }
  147. }
  148. pollUpdate()
  149. const interval = setInterval(pollUpdate, 10 * 60 * 1000)
  150. onCleanup(() => clearInterval(interval))
  151. })
  152. onMount(() => {
  153. const toastBySession = new Map<string, number>()
  154. const alertedAtBySession = new Map<string, number>()
  155. const permissionAlertCooldownMs = 5000
  156. const unsub = globalSDK.event.listen((e) => {
  157. if (e.details?.type !== "permission.asked") return
  158. const directory = e.name
  159. const perm = e.details.properties
  160. if (permission.autoResponds(perm, directory)) return
  161. const [store] = globalSync.child(directory)
  162. const session = store.session.find((s) => s.id === perm.sessionID)
  163. const sessionKey = `${directory}:${perm.sessionID}`
  164. const sessionTitle = session?.title ?? "New session"
  165. const projectName = getFilename(directory)
  166. const description = `${sessionTitle} in ${projectName} needs permission`
  167. const href = `/${base64Encode(directory)}/session/${perm.sessionID}`
  168. const now = Date.now()
  169. const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
  170. if (now - lastAlerted < permissionAlertCooldownMs) return
  171. alertedAtBySession.set(sessionKey, now)
  172. void platform.notify("Permission required", description, href)
  173. const currentDir = params.dir ? base64Decode(params.dir) : undefined
  174. const currentSession = params.id
  175. if (directory === currentDir && perm.sessionID === currentSession) return
  176. if (directory === currentDir && session?.parentID === currentSession) return
  177. const existingToastId = toastBySession.get(sessionKey)
  178. if (existingToastId !== undefined) {
  179. toaster.dismiss(existingToastId)
  180. }
  181. const toastId = showToast({
  182. persistent: true,
  183. icon: "checklist",
  184. title: "Permission required",
  185. description,
  186. actions: [
  187. {
  188. label: "Go to session",
  189. onClick: () => {
  190. navigate(href)
  191. },
  192. },
  193. {
  194. label: "Dismiss",
  195. onClick: "dismiss",
  196. },
  197. ],
  198. })
  199. toastBySession.set(sessionKey, toastId)
  200. })
  201. onCleanup(unsub)
  202. createEffect(() => {
  203. const currentDir = params.dir ? base64Decode(params.dir) : undefined
  204. const currentSession = params.id
  205. if (!currentDir || !currentSession) return
  206. const sessionKey = `${currentDir}:${currentSession}`
  207. const toastId = toastBySession.get(sessionKey)
  208. if (toastId !== undefined) {
  209. toaster.dismiss(toastId)
  210. toastBySession.delete(sessionKey)
  211. alertedAtBySession.delete(sessionKey)
  212. }
  213. const [store] = globalSync.child(currentDir)
  214. const childSessions = store.session.filter((s) => s.parentID === currentSession)
  215. for (const child of childSessions) {
  216. const childKey = `${currentDir}:${child.id}`
  217. const childToastId = toastBySession.get(childKey)
  218. if (childToastId !== undefined) {
  219. toaster.dismiss(childToastId)
  220. toastBySession.delete(childKey)
  221. alertedAtBySession.delete(childKey)
  222. }
  223. }
  224. })
  225. })
  226. function sortSessions(a: Session, b: Session) {
  227. const now = Date.now()
  228. const oneMinuteAgo = now - 60 * 1000
  229. const aUpdated = a.time.updated ?? a.time.created
  230. const bUpdated = b.time.updated ?? b.time.created
  231. const aRecent = aUpdated > oneMinuteAgo
  232. const bRecent = bUpdated > oneMinuteAgo
  233. if (aRecent && bRecent) return a.id.localeCompare(b.id)
  234. if (aRecent && !bRecent) return -1
  235. if (!aRecent && bRecent) return 1
  236. return bUpdated - aUpdated
  237. }
  238. function scrollToSession(sessionId: string) {
  239. if (!scrollContainerRef) return
  240. const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
  241. if (element) {
  242. element.scrollIntoView({ block: "nearest", behavior: "smooth" })
  243. }
  244. }
  245. const currentProject = createMemo(() => {
  246. const directory = params.dir ? base64Decode(params.dir) : undefined
  247. if (!directory) return
  248. return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
  249. })
  250. function projectSessions(project: LocalProject | undefined) {
  251. if (!project) return []
  252. const dirs = [project.worktree, ...(project.sandboxes ?? [])]
  253. const stores = dirs.map((dir) => globalSync.child(dir)[0])
  254. const sessions = stores
  255. .flatMap((store) => store.session.filter((session) => session.directory === store.path.directory))
  256. .toSorted(sortSessions)
  257. return sessions.filter((s) => !s.parentID)
  258. }
  259. const currentSessions = createMemo(() => projectSessions(currentProject()))
  260. function navigateSessionByOffset(offset: number) {
  261. const projects = layout.projects.list()
  262. if (projects.length === 0) return
  263. const project = currentProject()
  264. const projectIndex = project ? projects.findIndex((p) => p.worktree === project.worktree) : -1
  265. if (projectIndex === -1) {
  266. const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1]
  267. if (targetProject) navigateToProject(targetProject.worktree)
  268. return
  269. }
  270. const sessions = currentSessions()
  271. const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
  272. let targetIndex: number
  273. if (sessionIndex === -1) {
  274. targetIndex = offset > 0 ? 0 : sessions.length - 1
  275. } else {
  276. targetIndex = sessionIndex + offset
  277. }
  278. if (targetIndex >= 0 && targetIndex < sessions.length) {
  279. const session = sessions[targetIndex]
  280. navigateToSession(session)
  281. queueMicrotask(() => scrollToSession(session.id))
  282. return
  283. }
  284. const nextProjectIndex = projectIndex + (offset > 0 ? 1 : -1)
  285. const nextProject = projects[nextProjectIndex]
  286. if (!nextProject) return
  287. const nextProjectSessions = projectSessions(nextProject)
  288. if (nextProjectSessions.length === 0) {
  289. navigateToProject(nextProject.worktree)
  290. return
  291. }
  292. const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
  293. navigateToSession(targetSession)
  294. queueMicrotask(() => scrollToSession(targetSession.id))
  295. }
  296. async function archiveSession(session: Session) {
  297. const [store, setStore] = globalSync.child(session.directory)
  298. const sessions = store.session ?? []
  299. const index = sessions.findIndex((s) => s.id === session.id)
  300. const nextSession = sessions[index + 1] ?? sessions[index - 1]
  301. await globalSDK.client.session.update({
  302. directory: session.directory,
  303. sessionID: session.id,
  304. time: { archived: Date.now() },
  305. })
  306. setStore(
  307. produce((draft) => {
  308. const match = Binary.search(draft.session, session.id, (s) => s.id)
  309. if (match.found) draft.session.splice(match.index, 1)
  310. }),
  311. )
  312. if (session.id === params.id) {
  313. if (nextSession) {
  314. navigate(`/${params.dir}/session/${nextSession.id}`)
  315. } else {
  316. navigate(`/${params.dir}/session`)
  317. }
  318. }
  319. }
  320. command.register(() => {
  321. const commands: CommandOption[] = [
  322. {
  323. id: "sidebar.toggle",
  324. title: "Toggle sidebar",
  325. category: "View",
  326. keybind: "mod+b",
  327. onSelect: () => layout.sidebar.toggle(),
  328. },
  329. {
  330. id: "project.open",
  331. title: "Open project",
  332. category: "Project",
  333. keybind: "mod+o",
  334. onSelect: () => chooseProject(),
  335. },
  336. {
  337. id: "provider.connect",
  338. title: "Connect provider",
  339. category: "Provider",
  340. onSelect: () => connectProvider(),
  341. },
  342. {
  343. id: "server.switch",
  344. title: "Switch server",
  345. category: "Server",
  346. onSelect: () => openServer(),
  347. },
  348. {
  349. id: "session.previous",
  350. title: "Previous session",
  351. category: "Session",
  352. keybind: "alt+arrowup",
  353. onSelect: () => navigateSessionByOffset(-1),
  354. },
  355. {
  356. id: "session.next",
  357. title: "Next session",
  358. category: "Session",
  359. keybind: "alt+arrowdown",
  360. onSelect: () => navigateSessionByOffset(1),
  361. },
  362. {
  363. id: "session.archive",
  364. title: "Archive session",
  365. category: "Session",
  366. keybind: "mod+shift+backspace",
  367. disabled: !params.dir || !params.id,
  368. onSelect: () => {
  369. const session = currentSessions().find((s) => s.id === params.id)
  370. if (session) archiveSession(session)
  371. },
  372. },
  373. {
  374. id: "theme.cycle",
  375. title: "Cycle theme",
  376. category: "Theme",
  377. keybind: "mod+shift+t",
  378. onSelect: () => cycleTheme(1),
  379. },
  380. ]
  381. for (const [id, definition] of availableThemeEntries()) {
  382. commands.push({
  383. id: `theme.set.${id}`,
  384. title: `Use theme: ${definition.name ?? id}`,
  385. category: "Theme",
  386. onSelect: () => theme.commitPreview(),
  387. onHighlight: () => {
  388. theme.previewTheme(id)
  389. return () => theme.cancelPreview()
  390. },
  391. })
  392. }
  393. commands.push({
  394. id: "theme.scheme.cycle",
  395. title: "Cycle color scheme",
  396. category: "Theme",
  397. keybind: "mod+shift+s",
  398. onSelect: () => cycleColorScheme(1),
  399. })
  400. for (const scheme of colorSchemeOrder) {
  401. commands.push({
  402. id: `theme.scheme.${scheme}`,
  403. title: `Use color scheme: ${colorSchemeLabel[scheme]}`,
  404. category: "Theme",
  405. onSelect: () => theme.commitPreview(),
  406. onHighlight: () => {
  407. theme.previewColorScheme(scheme)
  408. return () => theme.cancelPreview()
  409. },
  410. })
  411. }
  412. return commands
  413. })
  414. function connectProvider() {
  415. dialog.show(() => <DialogSelectProvider />)
  416. }
  417. function openServer() {
  418. dialog.show(() => <DialogSelectServer />)
  419. }
  420. function navigateToProject(directory: string | undefined) {
  421. if (!directory) return
  422. const lastSession = store.lastSession[directory]
  423. navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
  424. layout.mobileSidebar.hide()
  425. }
  426. function navigateToSession(session: Session | undefined) {
  427. if (!session) return
  428. navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
  429. layout.mobileSidebar.hide()
  430. }
  431. function openProject(directory: string, navigate = true) {
  432. layout.projects.open(directory)
  433. if (navigate) navigateToProject(directory)
  434. }
  435. function closeProject(directory: string) {
  436. const index = layout.projects.list().findIndex((x) => x.worktree === directory)
  437. const next = layout.projects.list()[index + 1]
  438. layout.projects.close(directory)
  439. if (next) navigateToProject(next.worktree)
  440. else navigate("/")
  441. }
  442. async function chooseProject() {
  443. function resolve(result: string | string[] | null) {
  444. if (Array.isArray(result)) {
  445. for (const directory of result) {
  446. openProject(directory, false)
  447. }
  448. navigateToProject(result[0])
  449. } else if (result) {
  450. openProject(result)
  451. }
  452. }
  453. if (platform.openDirectoryPickerDialog && server.isLocal()) {
  454. const result = await platform.openDirectoryPickerDialog?.({
  455. title: "Open project",
  456. multiple: true,
  457. })
  458. resolve(result)
  459. } else {
  460. dialog.show(
  461. () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
  462. () => resolve(null),
  463. )
  464. }
  465. }
  466. createEffect(() => {
  467. if (!params.dir || !params.id) return
  468. const directory = base64Decode(params.dir)
  469. const id = params.id
  470. setStore("lastSession", directory, id)
  471. notification.session.markViewed(id)
  472. const project = currentProject()
  473. untrack(() => layout.projects.expand(project?.worktree ?? directory))
  474. requestAnimationFrame(() => scrollToSession(id))
  475. })
  476. createEffect(() => {
  477. if (isLargeViewport()) {
  478. const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
  479. document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
  480. } else {
  481. document.documentElement.style.setProperty("--dialog-left-margin", "0px")
  482. }
  483. })
  484. function getDraggableId(event: unknown): string | undefined {
  485. if (typeof event !== "object" || event === null) return undefined
  486. if (!("draggable" in event)) return undefined
  487. const draggable = (event as { draggable?: { id?: unknown } }).draggable
  488. if (!draggable) return undefined
  489. return typeof draggable.id === "string" ? draggable.id : undefined
  490. }
  491. function handleDragStart(event: unknown) {
  492. const id = getDraggableId(event)
  493. if (!id) return
  494. setStore("activeDraggable", id)
  495. }
  496. function handleDragOver(event: DragEvent) {
  497. const { draggable, droppable } = event
  498. if (draggable && droppable) {
  499. const projects = layout.projects.list()
  500. const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString())
  501. const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString())
  502. if (fromIndex !== toIndex && toIndex !== -1) {
  503. layout.projects.move(draggable.id.toString(), toIndex)
  504. }
  505. }
  506. }
  507. function handleDragEnd() {
  508. setStore("activeDraggable", undefined)
  509. }
  510. const ProjectAvatar = (props: {
  511. project: LocalProject
  512. class?: string
  513. expandable?: boolean
  514. notify?: boolean
  515. }): JSX.Element => {
  516. const notification = useNotification()
  517. const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
  518. const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
  519. const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
  520. const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
  521. const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
  522. return (
  523. <div class="relative size-5 shrink-0 rounded-sm">
  524. <Avatar
  525. fallback={name()}
  526. src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
  527. {...getAvatarColors(props.project.icon?.color)}
  528. class={`size-full ${props.class ?? ""}`}
  529. style={
  530. notifications().length > 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined
  531. }
  532. />
  533. <Show when={props.expandable}>
  534. <Icon
  535. name="chevron-right"
  536. size="normal"
  537. 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"
  538. />
  539. </Show>
  540. <Show when={notifications().length > 0 && props.notify}>
  541. <div
  542. classList={{
  543. "absolute -top-0.5 -right-0.5 size-1.5 rounded-full": true,
  544. "bg-icon-critical-base": hasError(),
  545. "bg-text-interactive-base": !hasError(),
  546. }}
  547. />
  548. </Show>
  549. </div>
  550. )
  551. }
  552. const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => {
  553. const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
  554. const current = createMemo(() => base64Decode(params.dir ?? ""))
  555. return (
  556. <Switch>
  557. <Match when={layout.sidebar.opened()}>
  558. <Button
  559. as={"div"}
  560. variant="ghost"
  561. data-active
  562. class="flex items-center justify-between gap-3 w-full px-1 self-stretch h-8 border-none rounded-lg"
  563. >
  564. <div class="flex items-center gap-3 p-0 text-left min-w-0 grow">
  565. <ProjectAvatar project={props.project} />
  566. <span class="truncate text-14-medium text-text-strong">{name()}</span>
  567. </div>
  568. </Button>
  569. </Match>
  570. <Match when={true}>
  571. <Button
  572. variant="ghost"
  573. size="large"
  574. class="flex items-center justify-center p-0 aspect-square border-none rounded-lg"
  575. data-selected={props.project.worktree === current()}
  576. onClick={() => navigateToProject(props.project.worktree)}
  577. >
  578. <ProjectAvatar project={props.project} notify />
  579. </Button>
  580. </Match>
  581. </Switch>
  582. )
  583. }
  584. const SessionItem = (props: {
  585. session: Session
  586. slug: string
  587. project: LocalProject
  588. mobile?: boolean
  589. }): JSX.Element => {
  590. const notification = useNotification()
  591. const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
  592. const notifications = createMemo(() => notification.session.unseen(props.session.id))
  593. const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
  594. const [sessionStore] = globalSync.child(props.session.directory)
  595. const hasPermissions = createMemo(() => {
  596. const permissions = sessionStore.permission?.[props.session.id] ?? []
  597. if (permissions.length > 0) return true
  598. const childSessions = sessionStore.session.filter((s) => s.parentID === props.session.id)
  599. for (const child of childSessions) {
  600. const childPermissions = sessionStore.permission?.[child.id] ?? []
  601. if (childPermissions.length > 0) return true
  602. }
  603. return false
  604. })
  605. const isWorking = createMemo(() => {
  606. if (props.session.id === params.id) return false
  607. if (hasPermissions()) return false
  608. const status = sessionStore.session_status[props.session.id]
  609. return status?.type === "busy" || status?.type === "retry"
  610. })
  611. return (
  612. <>
  613. <div
  614. data-session-id={props.session.id}
  615. class="group/session relative w-full rounded-md cursor-default transition-colors
  616. hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
  617. >
  618. <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
  619. <A
  620. href={`${props.slug}/session/${props.session.id}`}
  621. class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
  622. >
  623. <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">
  624. <span
  625. classList={{
  626. "text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true,
  627. "animate-pulse": isWorking(),
  628. }}
  629. >
  630. {props.session.title}
  631. </span>
  632. <div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
  633. <Switch>
  634. <Match when={isWorking()}>
  635. <Spinner class="size-2.5 mr-0.5" />
  636. </Match>
  637. <Match when={hasPermissions()}>
  638. <div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" />
  639. </Match>
  640. <Match when={hasError()}>
  641. <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
  642. </Match>
  643. <Match when={notifications().length > 0}>
  644. <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" />
  645. </Match>
  646. <Match when={true}>
  647. <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
  648. {Math.abs(updated().diffNow().as("seconds")) < 60
  649. ? "Now"
  650. : updated()
  651. .toRelative({
  652. style: "short",
  653. unit: ["days", "hours", "minutes"],
  654. })
  655. ?.replace(" ago", "")
  656. ?.replace(/ days?/, "d")
  657. ?.replace(" min.", "m")
  658. ?.replace(" hr.", "h")}
  659. </span>
  660. </Match>
  661. </Switch>
  662. </div>
  663. </div>
  664. <Show when={props.session.summary?.files}>
  665. <div class="flex justify-between items-center self-stretch">
  666. <span class="text-12-regular text-text-weak">{`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`}</span>
  667. <Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
  668. </div>
  669. </Show>
  670. </A>
  671. </Tooltip>
  672. <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">
  673. <TooltipKeybind
  674. placement={props.mobile ? "bottom" : "right"}
  675. title="Archive session"
  676. keybind={command.keybind("session.archive")}
  677. >
  678. <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
  679. </TooltipKeybind>
  680. </div>
  681. </div>
  682. </>
  683. )
  684. }
  685. const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
  686. const sortable = createSortable(props.project.worktree)
  687. const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
  688. const defaultWorktree = createMemo(() => base64Encode(props.project.worktree))
  689. const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
  690. const [store, setProjectStore] = globalSync.child(props.project.worktree)
  691. const stores = createMemo(() =>
  692. [props.project.worktree, ...(props.project.sandboxes ?? [])].map((dir) => globalSync.child(dir)[0]),
  693. )
  694. const sessions = createMemo(() =>
  695. stores()
  696. .flatMap((store) => store.session.filter((session) => session.directory === store.path.directory))
  697. .toSorted(sortSessions),
  698. )
  699. const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
  700. const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
  701. const loadMoreSessions = async () => {
  702. setProjectStore("limit", (limit) => limit + 5)
  703. await globalSync.project.loadSessions(props.project.worktree)
  704. }
  705. const isExpanded = createMemo(() =>
  706. props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
  707. )
  708. const isActive = createMemo(() => {
  709. const current = params.dir ? base64Decode(params.dir) : ""
  710. return props.project.worktree === current || props.project.sandboxes?.includes(current)
  711. })
  712. const handleOpenChange = (open: boolean) => {
  713. if (props.mobile) {
  714. if (open) mobileProjects.expand(props.project.worktree)
  715. else mobileProjects.collapse(props.project.worktree)
  716. } else {
  717. if (open) layout.projects.expand(props.project.worktree)
  718. else layout.projects.collapse(props.project.worktree)
  719. }
  720. }
  721. return (
  722. // @ts-ignore
  723. <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
  724. <Switch>
  725. <Match when={showExpanded()}>
  726. <Collapsible variant="ghost" open={isExpanded()} class="gap-2 shrink-0" onOpenChange={handleOpenChange}>
  727. <Button
  728. as={"div"}
  729. variant="ghost"
  730. classList={{
  731. "group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg": true,
  732. "bg-surface-raised-base-hover": isActive() && !isExpanded(),
  733. }}
  734. >
  735. <Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
  736. <ProjectAvatar
  737. project={props.project}
  738. class="group-hover/session:hidden"
  739. expandable
  740. notify={!isExpanded()}
  741. />
  742. <span class="truncate text-14-medium text-text-strong">{name()}</span>
  743. </Collapsible.Trigger>
  744. <div class="flex invisible gap-1 items-center group-hover/session:visible has-[[data-expanded]]:visible">
  745. <DropdownMenu>
  746. <DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
  747. <DropdownMenu.Portal>
  748. <DropdownMenu.Content>
  749. <DropdownMenu.Item
  750. onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}
  751. >
  752. <DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel>
  753. </DropdownMenu.Item>
  754. <DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
  755. <DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
  756. </DropdownMenu.Item>
  757. </DropdownMenu.Content>
  758. </DropdownMenu.Portal>
  759. </DropdownMenu>
  760. <TooltipKeybind placement="top" title="New session" keybind={command.keybind("session.new")}>
  761. <IconButton as={A} href={`${defaultWorktree()}/session`} icon="plus-small" variant="ghost" />
  762. </TooltipKeybind>
  763. </div>
  764. </Button>
  765. <Collapsible.Content>
  766. <nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
  767. <For each={rootSessions()}>
  768. {(session) => (
  769. <SessionItem
  770. session={session}
  771. slug={base64Encode(session.directory)}
  772. project={props.project}
  773. mobile={props.mobile}
  774. />
  775. )}
  776. </For>
  777. <Show when={rootSessions().length === 0}>
  778. <div
  779. class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors
  780. hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
  781. >
  782. <div class="flex items-center self-stretch w-full">
  783. <div class="flex-1 min-w-0">
  784. <Tooltip placement={props.mobile ? "bottom" : "right"} value="New session">
  785. <A
  786. href={`${defaultWorktree()}/session`}
  787. class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
  788. >
  789. <div class="flex items-center self-stretch gap-6 justify-between">
  790. <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
  791. New session
  792. </span>
  793. </div>
  794. </A>
  795. </Tooltip>
  796. </div>
  797. </div>
  798. </div>
  799. </Show>
  800. <Show when={hasMoreSessions()}>
  801. <div class="relative w-full py-1">
  802. <Button
  803. variant="ghost"
  804. class="flex w-full text-left justify-start text-12-medium opacity-50 px-3.5"
  805. size="large"
  806. onClick={loadMoreSessions}
  807. >
  808. Load more
  809. </Button>
  810. </div>
  811. </Show>
  812. </nav>
  813. </Collapsible.Content>
  814. </Collapsible>
  815. </Match>
  816. <Match when={true}>
  817. <Tooltip placement="right" value={getFilename(props.project.worktree)}>
  818. <ProjectVisual project={props.project} />
  819. </Tooltip>
  820. </Match>
  821. </Switch>
  822. </div>
  823. )
  824. }
  825. const ProjectDragOverlay = (): JSX.Element => {
  826. const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeDraggable))
  827. return (
  828. <Show when={project()}>
  829. {(p) => (
  830. <div class="bg-background-base rounded-md">
  831. <ProjectVisual project={p()} />
  832. </div>
  833. )}
  834. </Show>
  835. )
  836. }
  837. const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
  838. const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
  839. return (
  840. <div class="flex flex-col self-stretch h-full items-center justify-between overflow-hidden min-h-0">
  841. <div class="flex flex-col items-start self-stretch gap-4 min-h-0">
  842. <Show when={!sidebarProps.mobile}>
  843. <div
  844. classList={{
  845. "border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0": true,
  846. "justify-start": expanded(),
  847. }}
  848. >
  849. <A href="/" class="shrink-0 h-8 flex items-center justify-start px-2 w-full" data-tauri-drag-region>
  850. <Mark class="shrink-0" />
  851. </A>
  852. </div>
  853. </Show>
  854. <div class="flex flex-col items-start self-stretch gap-4 px-2 overflow-hidden min-h-0">
  855. <Show when={!sidebarProps.mobile}>
  856. <TooltipKeybind
  857. class="shrink-0"
  858. placement="right"
  859. title="Toggle sidebar"
  860. keybind={command.keybind("sidebar.toggle")}
  861. inactive={expanded()}
  862. >
  863. <Button
  864. variant="ghost"
  865. size="large"
  866. class="group/sidebar-toggle shrink-0 w-full text-left justify-start rounded-lg px-2"
  867. onClick={layout.sidebar.toggle}
  868. >
  869. <div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
  870. <Icon
  871. name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
  872. size="small"
  873. class="group-hover/sidebar-toggle:hidden"
  874. />
  875. <Icon
  876. name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
  877. size="small"
  878. class="hidden group-hover/sidebar-toggle:inline-block"
  879. />
  880. <Icon
  881. name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
  882. size="small"
  883. class="hidden group-active/sidebar-toggle:inline-block"
  884. />
  885. </div>
  886. <Show when={layout.sidebar.opened()}>
  887. <div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
  888. Toggle sidebar
  889. </div>
  890. </Show>
  891. </Button>
  892. </TooltipKeybind>
  893. </Show>
  894. <DragDropProvider
  895. onDragStart={handleDragStart}
  896. onDragEnd={handleDragEnd}
  897. onDragOver={handleDragOver}
  898. collisionDetector={closestCenter}
  899. >
  900. <DragDropSensors />
  901. <ConstrainDragXAxis />
  902. <div
  903. ref={(el) => {
  904. if (!sidebarProps.mobile) scrollContainerRef = el
  905. }}
  906. class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
  907. >
  908. <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
  909. <For each={layout.projects.list()}>
  910. {(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
  911. </For>
  912. </SortableProvider>
  913. </div>
  914. <DragOverlay>
  915. <ProjectDragOverlay />
  916. </DragOverlay>
  917. </DragDropProvider>
  918. </div>
  919. </div>
  920. <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
  921. <Switch>
  922. <Match when={providers.all().length > 0 && !providers.paid().length && expanded()}>
  923. <div class="rounded-md bg-background-stronger shadow-xs-border-base">
  924. <div class="p-3 flex flex-col gap-2">
  925. <div class="text-12-medium text-text-strong">Getting started</div>
  926. <div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
  927. <div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
  928. </div>
  929. <Tooltip placement="right" value="Connect provider" inactive={expanded()}>
  930. <Button
  931. 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"
  932. size="large"
  933. icon="plus"
  934. onClick={connectProvider}
  935. >
  936. Connect provider
  937. </Button>
  938. </Tooltip>
  939. </div>
  940. </Match>
  941. <Match when={providers.all().length > 0}>
  942. <Tooltip placement="right" value="Connect provider" inactive={expanded()}>
  943. <Button
  944. class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
  945. variant="ghost"
  946. size="large"
  947. icon="plus"
  948. onClick={connectProvider}
  949. >
  950. <Show when={expanded()}>Connect provider</Show>
  951. </Button>
  952. </Tooltip>
  953. </Match>
  954. </Switch>
  955. <Tooltip
  956. placement="right"
  957. value={
  958. <div class="flex items-center gap-2">
  959. <span>Open project</span>
  960. <Show when={!sidebarProps.mobile}>
  961. <span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
  962. </Show>
  963. </div>
  964. }
  965. inactive={expanded()}
  966. >
  967. <Button
  968. class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
  969. variant="ghost"
  970. size="large"
  971. icon="folder-add-left"
  972. onClick={chooseProject}
  973. >
  974. <Show when={expanded()}>Open project</Show>
  975. </Button>
  976. </Tooltip>
  977. <Tooltip placement="right" value="Share feedback" inactive={expanded()}>
  978. <Button
  979. as={"a"}
  980. href="https://opencode.ai/desktop-feedback"
  981. target="_blank"
  982. class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
  983. variant="ghost"
  984. size="large"
  985. icon="bubble-5"
  986. >
  987. <Show when={expanded()}>Share feedback</Show>
  988. </Button>
  989. </Tooltip>
  990. </div>
  991. </div>
  992. )
  993. }
  994. return (
  995. <div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
  996. <div class="flex-1 min-h-0 flex">
  997. <div
  998. classList={{
  999. "hidden xl:block": true,
  1000. "relative shrink-0": true,
  1001. }}
  1002. style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : "48px" }}
  1003. >
  1004. <div
  1005. classList={{
  1006. "@container w-full h-full pb-5 bg-background-base": true,
  1007. "flex flex-col gap-5.5 items-start self-stretch justify-between": true,
  1008. "border-r border-border-weak-base contain-strict": true,
  1009. }}
  1010. >
  1011. <SidebarContent />
  1012. </div>
  1013. <Show when={layout.sidebar.opened()}>
  1014. <ResizeHandle
  1015. direction="horizontal"
  1016. size={layout.sidebar.width()}
  1017. min={150}
  1018. max={window.innerWidth * 0.3}
  1019. collapseThreshold={80}
  1020. onResize={layout.sidebar.resize}
  1021. onCollapse={layout.sidebar.close}
  1022. />
  1023. </Show>
  1024. </div>
  1025. <div class="xl:hidden">
  1026. <div
  1027. classList={{
  1028. "fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
  1029. "opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
  1030. "opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
  1031. }}
  1032. onClick={(e) => {
  1033. if (e.target === e.currentTarget) layout.mobileSidebar.hide()
  1034. }}
  1035. />
  1036. <div
  1037. classList={{
  1038. "@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,
  1039. "translate-x-0": layout.mobileSidebar.opened(),
  1040. "-translate-x-full": !layout.mobileSidebar.opened(),
  1041. }}
  1042. onClick={(e) => e.stopPropagation()}
  1043. >
  1044. <div class="border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0">
  1045. <A
  1046. href="/"
  1047. class="shrink-0 h-8 flex items-center justify-start px-2 w-full"
  1048. onClick={() => layout.mobileSidebar.hide()}
  1049. >
  1050. <Mark class="shrink-0" />
  1051. </A>
  1052. </div>
  1053. <SidebarContent mobile />
  1054. </div>
  1055. </div>
  1056. <main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main>
  1057. </div>
  1058. <Toast.Region />
  1059. </div>
  1060. )
  1061. }