layout.tsx 82 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342
  1. import {
  2. batch,
  3. createEffect,
  4. createMemo,
  5. createSignal,
  6. For,
  7. on,
  8. onCleanup,
  9. onMount,
  10. ParentProps,
  11. Show,
  12. untrack,
  13. } from "solid-js"
  14. import { useNavigate, useParams } from "@solidjs/router"
  15. import { useLayout, LocalProject } from "@/context/layout"
  16. import { useGlobalSync } from "@/context/global-sync"
  17. import { Persist, persisted } from "@/utils/persist"
  18. import { base64Encode } from "@opencode-ai/util/encode"
  19. import { decode64 } from "@/utils/base64"
  20. import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
  21. import { Button } from "@opencode-ai/ui/button"
  22. import { IconButton } from "@opencode-ai/ui/icon-button"
  23. import { Tooltip } from "@opencode-ai/ui/tooltip"
  24. import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
  25. import { Dialog } from "@opencode-ai/ui/dialog"
  26. import { getFilename } from "@opencode-ai/util/path"
  27. import { Session, type Message } from "@opencode-ai/sdk/v2/client"
  28. import { usePlatform } from "@/context/platform"
  29. import { useSettings } from "@/context/settings"
  30. import { createStore, produce, reconcile } from "solid-js/store"
  31. import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
  32. import type { DragEvent } from "@thisbeyond/solid-dnd"
  33. import { useProviders } from "@/hooks/use-providers"
  34. import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
  35. import { useGlobalSDK } from "@/context/global-sdk"
  36. import { clearWorkspaceTerminals } from "@/context/terminal"
  37. import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
  38. import { useNotification } from "@/context/notification"
  39. import { usePermission } from "@/context/permission"
  40. import { Binary } from "@opencode-ai/util/binary"
  41. import { retry } from "@opencode-ai/util/retry"
  42. import { playSound, soundSrc } from "@/utils/sound"
  43. import { createAim } from "@/utils/aim"
  44. import { setNavigate } from "@/utils/notification-click"
  45. import { Worktree as WorktreeState } from "@/utils/worktree"
  46. import { setSessionHandoff } from "@/pages/session/handoff"
  47. import { useDialog } from "@opencode-ai/ui/context/dialog"
  48. import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
  49. import { DialogSelectProvider } from "@/components/dialog-select-provider"
  50. import { DialogSelectServer } from "@/components/dialog-select-server"
  51. import { DialogSettings } from "@/components/dialog-settings"
  52. import { useCommand, type CommandOption } from "@/context/command"
  53. import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
  54. import { DialogSelectDirectory } from "@/components/dialog-select-directory"
  55. import { DialogEditProject } from "@/components/dialog-edit-project"
  56. import { DebugBar } from "@/components/debug-bar"
  57. import { Titlebar } from "@/components/titlebar"
  58. import { useServer } from "@/context/server"
  59. import { useLanguage, type Locale } from "@/context/language"
  60. import {
  61. displayName,
  62. effectiveWorkspaceOrder,
  63. errorMessage,
  64. latestRootSession,
  65. sortedRootSessions,
  66. workspaceKey,
  67. } from "./layout/helpers"
  68. import {
  69. collectNewSessionDeepLinks,
  70. collectOpenProjectDeepLinks,
  71. deepLinkEvent,
  72. drainPendingDeepLinks,
  73. } from "./layout/deep-links"
  74. import { createInlineEditorController } from "./layout/inline-editor"
  75. import {
  76. LocalWorkspace,
  77. SortableWorkspace,
  78. WorkspaceDragOverlay,
  79. type WorkspaceSidebarContext,
  80. } from "./layout/sidebar-workspace"
  81. import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
  82. import { SidebarContent } from "./layout/sidebar-shell"
  83. export default function Layout(props: ParentProps) {
  84. const [store, setStore, , ready] = persisted(
  85. Persist.global("layout.page", ["layout.page.v1"]),
  86. createStore({
  87. lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
  88. activeProject: undefined as string | undefined,
  89. activeWorkspace: undefined as string | undefined,
  90. workspaceOrder: {} as Record<string, string[]>,
  91. workspaceName: {} as Record<string, string>,
  92. workspaceBranchName: {} as Record<string, Record<string, string>>,
  93. workspaceExpanded: {} as Record<string, boolean>,
  94. gettingStartedDismissed: false,
  95. }),
  96. )
  97. const pageReady = createMemo(() => ready())
  98. let scrollContainerRef: HTMLDivElement | undefined
  99. const params = useParams()
  100. const globalSDK = useGlobalSDK()
  101. const globalSync = useGlobalSync()
  102. const layout = useLayout()
  103. const layoutReady = createMemo(() => layout.ready())
  104. const platform = usePlatform()
  105. const settings = useSettings()
  106. const server = useServer()
  107. const notification = useNotification()
  108. const permission = usePermission()
  109. const navigate = useNavigate()
  110. setNavigate(navigate)
  111. const providers = useProviders()
  112. const dialog = useDialog()
  113. const command = useCommand()
  114. const theme = useTheme()
  115. const language = useLanguage()
  116. const initialDirectory = decode64(params.dir)
  117. const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
  118. const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
  119. const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
  120. system: "theme.scheme.system",
  121. light: "theme.scheme.light",
  122. dark: "theme.scheme.dark",
  123. }
  124. const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
  125. const currentDir = createMemo(() => decode64(params.dir) ?? "")
  126. const [state, setState] = createStore({
  127. autoselect: !initialDirectory,
  128. busyWorkspaces: {} as Record<string, boolean>,
  129. hoverSession: undefined as string | undefined,
  130. hoverProject: undefined as string | undefined,
  131. scrollSessionKey: undefined as string | undefined,
  132. nav: undefined as HTMLElement | undefined,
  133. })
  134. const editor = createInlineEditorController()
  135. const setBusy = (directory: string, value: boolean) => {
  136. const key = workspaceKey(directory)
  137. if (value) {
  138. setState("busyWorkspaces", key, true)
  139. return
  140. }
  141. setState(
  142. "busyWorkspaces",
  143. produce((draft) => {
  144. delete draft[key]
  145. }),
  146. )
  147. }
  148. const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
  149. const navLeave = { current: undefined as number | undefined }
  150. const [sortNow, setSortNow] = createSignal(Date.now())
  151. const [sizing, setSizing] = createSignal(false)
  152. let sizet: number | undefined
  153. let sortNowInterval: ReturnType<typeof setInterval> | undefined
  154. const sortNowTimeout = setTimeout(
  155. () => {
  156. setSortNow(Date.now())
  157. sortNowInterval = setInterval(() => setSortNow(Date.now()), 60_000)
  158. },
  159. 60_000 - (Date.now() % 60_000),
  160. )
  161. const aim = createAim({
  162. enabled: () => !layout.sidebar.opened(),
  163. active: () => state.hoverProject,
  164. el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
  165. onActivate: (directory) => {
  166. globalSync.child(directory)
  167. setState("hoverProject", directory)
  168. setState("hoverSession", undefined)
  169. },
  170. })
  171. onCleanup(() => {
  172. if (navLeave.current !== undefined) clearTimeout(navLeave.current)
  173. clearTimeout(sortNowTimeout)
  174. if (sortNowInterval) clearInterval(sortNowInterval)
  175. if (sizet !== undefined) clearTimeout(sizet)
  176. if (peekt !== undefined) clearTimeout(peekt)
  177. aim.reset()
  178. })
  179. onMount(() => {
  180. const stop = () => setSizing(false)
  181. window.addEventListener("pointerup", stop)
  182. window.addEventListener("pointercancel", stop)
  183. window.addEventListener("blur", stop)
  184. onCleanup(() => {
  185. window.removeEventListener("pointerup", stop)
  186. window.removeEventListener("pointercancel", stop)
  187. window.removeEventListener("blur", stop)
  188. })
  189. })
  190. const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
  191. const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
  192. const setHoverProject = (value: string | undefined) => {
  193. setState("hoverProject", value)
  194. if (value !== undefined) return
  195. aim.reset()
  196. }
  197. const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
  198. const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
  199. const disarm = () => {
  200. if (navLeave.current === undefined) return
  201. clearTimeout(navLeave.current)
  202. navLeave.current = undefined
  203. }
  204. const arm = () => {
  205. if (layout.sidebar.opened()) return
  206. if (state.hoverProject === undefined) return
  207. disarm()
  208. navLeave.current = window.setTimeout(() => {
  209. navLeave.current = undefined
  210. setHoverProject(undefined)
  211. setState("hoverSession", undefined)
  212. }, 300)
  213. }
  214. const [peek, setPeek] = createSignal<LocalProject | undefined>(undefined)
  215. const [peeked, setPeeked] = createSignal(false)
  216. let peekt: number | undefined
  217. const hoverProjectData = createMemo(() => {
  218. const id = state.hoverProject
  219. if (!id) return
  220. return layout.projects.list().find((project) => project.worktree === id)
  221. })
  222. createEffect(() => {
  223. const p = hoverProjectData()
  224. if (p) {
  225. if (peekt !== undefined) {
  226. clearTimeout(peekt)
  227. peekt = undefined
  228. }
  229. setPeek(p)
  230. setPeeked(true)
  231. return
  232. }
  233. setPeeked(false)
  234. if (peek() === undefined) return
  235. if (peekt !== undefined) clearTimeout(peekt)
  236. peekt = window.setTimeout(() => {
  237. peekt = undefined
  238. setPeek(undefined)
  239. }, 180)
  240. })
  241. createEffect(() => {
  242. if (!layout.sidebar.opened()) return
  243. setHoverProject(undefined)
  244. })
  245. const autoselecting = createMemo(() => {
  246. if (params.dir) return false
  247. if (!state.autoselect) return false
  248. if (!pageReady()) return true
  249. if (!layoutReady()) return true
  250. const list = layout.projects.list()
  251. if (list.length > 0) return true
  252. return !!server.projects.last()
  253. })
  254. createEffect(() => {
  255. if (!state.autoselect) return
  256. const dir = params.dir
  257. if (!dir) return
  258. const directory = decode64(dir)
  259. if (!directory) return
  260. setState("autoselect", false)
  261. })
  262. const editorOpen = editor.editorOpen
  263. const openEditor = editor.openEditor
  264. const closeEditor = editor.closeEditor
  265. const setEditor = editor.setEditor
  266. const InlineEditor = editor.InlineEditor
  267. const clearSidebarHoverState = () => {
  268. if (layout.sidebar.opened()) return
  269. setState("hoverSession", undefined)
  270. setHoverProject(undefined)
  271. }
  272. const navigateWithSidebarReset = (href: string) => {
  273. clearSidebarHoverState()
  274. navigate(href)
  275. layout.mobileSidebar.hide()
  276. }
  277. function cycleTheme(direction = 1) {
  278. const ids = availableThemeEntries().map(([id]) => id)
  279. if (ids.length === 0) return
  280. const currentIndex = ids.indexOf(theme.themeId())
  281. const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
  282. const nextThemeId = ids[nextIndex]
  283. theme.setTheme(nextThemeId)
  284. const nextTheme = theme.themes()[nextThemeId]
  285. showToast({
  286. title: language.t("toast.theme.title"),
  287. description: nextTheme?.name ?? nextThemeId,
  288. })
  289. }
  290. function cycleColorScheme(direction = 1) {
  291. const current = theme.colorScheme()
  292. const currentIndex = colorSchemeOrder.indexOf(current)
  293. const nextIndex =
  294. currentIndex === -1 ? 0 : (currentIndex + direction + colorSchemeOrder.length) % colorSchemeOrder.length
  295. const next = colorSchemeOrder[nextIndex]
  296. theme.setColorScheme(next)
  297. showToast({
  298. title: language.t("toast.scheme.title"),
  299. description: colorSchemeLabel(next),
  300. })
  301. }
  302. function setLocale(next: Locale) {
  303. if (next === language.locale()) return
  304. language.setLocale(next)
  305. showToast({
  306. title: language.t("toast.language.title"),
  307. description: language.t("toast.language.description", { language: language.label(next) }),
  308. })
  309. }
  310. function cycleLanguage(direction = 1) {
  311. const locales = language.locales
  312. const currentIndex = locales.indexOf(language.locale())
  313. const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + locales.length) % locales.length
  314. const next = locales[nextIndex]
  315. if (!next) return
  316. setLocale(next)
  317. }
  318. const useUpdatePolling = () =>
  319. onMount(() => {
  320. if (!platform.checkUpdate || !platform.update || !platform.restart) return
  321. let toastId: number | undefined
  322. let interval: ReturnType<typeof setInterval> | undefined
  323. const pollUpdate = () =>
  324. platform.checkUpdate!().then(({ updateAvailable, version }) => {
  325. if (!updateAvailable) return
  326. if (toastId !== undefined) return
  327. toastId = showToast({
  328. persistent: true,
  329. icon: "download",
  330. title: language.t("toast.update.title"),
  331. description: language.t("toast.update.description", { version: version ?? "" }),
  332. actions: [
  333. {
  334. label: language.t("toast.update.action.installRestart"),
  335. onClick: async () => {
  336. await platform.update!()
  337. await platform.restart!()
  338. },
  339. },
  340. {
  341. label: language.t("toast.update.action.notYet"),
  342. onClick: "dismiss",
  343. },
  344. ],
  345. })
  346. })
  347. createEffect(() => {
  348. if (!settings.ready()) return
  349. if (!settings.updates.startup()) {
  350. if (interval === undefined) return
  351. clearInterval(interval)
  352. interval = undefined
  353. return
  354. }
  355. if (interval !== undefined) return
  356. void pollUpdate()
  357. interval = setInterval(pollUpdate, 10 * 60 * 1000)
  358. })
  359. onCleanup(() => {
  360. if (interval === undefined) return
  361. clearInterval(interval)
  362. })
  363. })
  364. const useSDKNotificationToasts = () =>
  365. onMount(() => {
  366. const toastBySession = new Map<string, number>()
  367. const alertedAtBySession = new Map<string, number>()
  368. const cooldownMs = 5000
  369. const dismissSessionAlert = (sessionKey: string) => {
  370. const toastId = toastBySession.get(sessionKey)
  371. if (toastId === undefined) return
  372. toaster.dismiss(toastId)
  373. toastBySession.delete(sessionKey)
  374. alertedAtBySession.delete(sessionKey)
  375. }
  376. const unsub = globalSDK.event.listen((e) => {
  377. if (e.details?.type === "worktree.ready") {
  378. setBusy(e.name, false)
  379. WorktreeState.ready(e.name)
  380. return
  381. }
  382. if (e.details?.type === "worktree.failed") {
  383. setBusy(e.name, false)
  384. WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed"))
  385. return
  386. }
  387. if (
  388. e.details?.type === "question.replied" ||
  389. e.details?.type === "question.rejected" ||
  390. e.details?.type === "permission.replied"
  391. ) {
  392. const props = e.details.properties as { sessionID: string }
  393. const sessionKey = `${e.name}:${props.sessionID}`
  394. dismissSessionAlert(sessionKey)
  395. return
  396. }
  397. if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
  398. const title =
  399. e.details.type === "permission.asked"
  400. ? language.t("notification.permission.title")
  401. : language.t("notification.question.title")
  402. const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const)
  403. const directory = e.name
  404. const props = e.details.properties
  405. if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
  406. const [store] = globalSync.child(directory, { bootstrap: false })
  407. const session = store.session.find((s) => s.id === props.sessionID)
  408. const sessionKey = `${directory}:${props.sessionID}`
  409. const sessionTitle = session?.title ?? language.t("command.session.new")
  410. const projectName = getFilename(directory)
  411. const description =
  412. e.details.type === "permission.asked"
  413. ? language.t("notification.permission.description", { sessionTitle, projectName })
  414. : language.t("notification.question.description", { sessionTitle, projectName })
  415. const href = `/${base64Encode(directory)}/session/${props.sessionID}`
  416. const now = Date.now()
  417. const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
  418. if (now - lastAlerted < cooldownMs) return
  419. alertedAtBySession.set(sessionKey, now)
  420. if (e.details.type === "permission.asked") {
  421. if (settings.sounds.permissionsEnabled()) {
  422. playSound(soundSrc(settings.sounds.permissions()))
  423. }
  424. if (settings.notifications.permissions()) {
  425. void platform.notify(title, description, href)
  426. }
  427. }
  428. if (e.details.type === "question.asked") {
  429. if (settings.notifications.agent()) {
  430. void platform.notify(title, description, href)
  431. }
  432. }
  433. const currentSession = params.id
  434. if (directory === currentDir() && props.sessionID === currentSession) return
  435. if (directory === currentDir() && session?.parentID === currentSession) return
  436. dismissSessionAlert(sessionKey)
  437. const toastId = showToast({
  438. persistent: true,
  439. icon,
  440. title,
  441. description,
  442. actions: [
  443. {
  444. label: language.t("notification.action.goToSession"),
  445. onClick: () => navigate(href),
  446. },
  447. {
  448. label: language.t("common.dismiss"),
  449. onClick: "dismiss",
  450. },
  451. ],
  452. })
  453. toastBySession.set(sessionKey, toastId)
  454. })
  455. onCleanup(unsub)
  456. createEffect(() => {
  457. const currentSession = params.id
  458. if (!currentDir() || !currentSession) return
  459. const sessionKey = `${currentDir()}:${currentSession}`
  460. dismissSessionAlert(sessionKey)
  461. const [store] = globalSync.child(currentDir(), { bootstrap: false })
  462. const childSessions = store.session.filter((s) => s.parentID === currentSession)
  463. for (const child of childSessions) {
  464. dismissSessionAlert(`${currentDir()}:${child.id}`)
  465. }
  466. })
  467. })
  468. useUpdatePolling()
  469. useSDKNotificationToasts()
  470. function scrollToSession(sessionId: string, sessionKey: string) {
  471. if (!scrollContainerRef) return
  472. if (state.scrollSessionKey === sessionKey) return
  473. const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
  474. if (!element) return
  475. const containerRect = scrollContainerRef.getBoundingClientRect()
  476. const elementRect = element.getBoundingClientRect()
  477. if (elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom) {
  478. setState("scrollSessionKey", sessionKey)
  479. return
  480. }
  481. setState("scrollSessionKey", sessionKey)
  482. element.scrollIntoView({ block: "nearest", behavior: "smooth" })
  483. }
  484. const currentProject = createMemo(() => {
  485. const directory = currentDir()
  486. if (!directory) return
  487. const projects = layout.projects.list()
  488. const sandbox = projects.find((p) => p.sandboxes?.includes(directory))
  489. if (sandbox) return sandbox
  490. const direct = projects.find((p) => p.worktree === directory)
  491. if (direct) return direct
  492. const [child] = globalSync.child(directory, { bootstrap: false })
  493. const id = child.project
  494. if (!id) return
  495. const meta = globalSync.data.project.find((p) => p.id === id)
  496. const root = meta?.worktree
  497. if (!root) return
  498. return projects.find((p) => p.worktree === root)
  499. })
  500. createEffect(
  501. on(
  502. () => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
  503. (value) => {
  504. if (!value.ready) return
  505. if (!value.layoutReady) return
  506. if (!state.autoselect) return
  507. if (value.dir) return
  508. const last = server.projects.last()
  509. if (value.list.length === 0) {
  510. if (!last) return
  511. setState("autoselect", false)
  512. openProject(last, false)
  513. navigateToProject(last)
  514. return
  515. }
  516. const next = value.list.find((project) => project.worktree === last) ?? value.list[0]
  517. if (!next) return
  518. setState("autoselect", false)
  519. openProject(next.worktree, false)
  520. navigateToProject(next.worktree)
  521. },
  522. ),
  523. )
  524. const workspaceName = (directory: string, projectId?: string, branch?: string) => {
  525. const key = workspaceKey(directory)
  526. const direct = store.workspaceName[key] ?? store.workspaceName[directory]
  527. if (direct) return direct
  528. if (!projectId) return
  529. if (!branch) return
  530. return store.workspaceBranchName[projectId]?.[branch]
  531. }
  532. const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => {
  533. const key = workspaceKey(directory)
  534. setStore("workspaceName", key, next)
  535. if (!projectId) return
  536. if (!branch) return
  537. if (!store.workspaceBranchName[projectId]) {
  538. setStore("workspaceBranchName", projectId, {})
  539. }
  540. setStore("workspaceBranchName", projectId, branch, next)
  541. }
  542. const workspaceLabel = (directory: string, branch?: string, projectId?: string) =>
  543. workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
  544. const workspaceSetting = createMemo(() => {
  545. const project = currentProject()
  546. if (!project) return false
  547. if (project.vcs !== "git") return false
  548. return layout.sidebar.workspaces(project.worktree)()
  549. })
  550. const visibleSessionDirs = createMemo(() => {
  551. const project = currentProject()
  552. if (!project) return [] as string[]
  553. if (!workspaceSetting()) return [project.worktree]
  554. const activeDir = currentDir()
  555. return workspaceIds(project).filter((directory) => {
  556. const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
  557. const active = directory === activeDir
  558. return expanded || active
  559. })
  560. })
  561. createEffect(() => {
  562. if (!pageReady()) return
  563. if (!layoutReady()) return
  564. const projects = layout.projects.list()
  565. for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
  566. if (!expanded) continue
  567. const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
  568. if (!project) continue
  569. if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
  570. setStore("workspaceExpanded", directory, false)
  571. }
  572. })
  573. const currentSessions = createMemo(() => {
  574. const now = Date.now()
  575. const dirs = visibleSessionDirs()
  576. if (dirs.length === 0) return [] as Session[]
  577. const result: Session[] = []
  578. for (const dir of dirs) {
  579. const [dirStore] = globalSync.child(dir, { bootstrap: true })
  580. const dirSessions = sortedRootSessions(dirStore, now)
  581. result.push(...dirSessions)
  582. }
  583. return result
  584. })
  585. type PrefetchQueue = {
  586. inflight: Set<string>
  587. pending: string[]
  588. pendingSet: Set<string>
  589. running: number
  590. }
  591. const prefetchChunk = 200
  592. const prefetchConcurrency = 1
  593. const prefetchPendingLimit = 6
  594. const prefetchToken = { value: 0 }
  595. const prefetchQueues = new Map<string, PrefetchQueue>()
  596. const PREFETCH_MAX_SESSIONS_PER_DIR = 10
  597. const prefetchedByDir = new Map<string, Set<string>>()
  598. const lruFor = (directory: string) => {
  599. const existing = prefetchedByDir.get(directory)
  600. if (existing) return existing
  601. const created = new Set<string>()
  602. prefetchedByDir.set(directory, created)
  603. return created
  604. }
  605. const markPrefetched = (directory: string, sessionID: string) => {
  606. const lru = lruFor(directory)
  607. return pickSessionCacheEvictions({
  608. seen: lru,
  609. keep: sessionID,
  610. limit: PREFETCH_MAX_SESSIONS_PER_DIR,
  611. preserve: directory === params.dir && params.id ? [params.id] : undefined,
  612. })
  613. }
  614. createEffect(() => {
  615. params.dir
  616. globalSDK.url
  617. prefetchToken.value += 1
  618. for (const q of prefetchQueues.values()) {
  619. q.pending.length = 0
  620. q.pendingSet.clear()
  621. }
  622. })
  623. const queueFor = (directory: string) => {
  624. const existing = prefetchQueues.get(directory)
  625. if (existing) return existing
  626. const created: PrefetchQueue = {
  627. inflight: new Set(),
  628. pending: [],
  629. pendingSet: new Set(),
  630. running: 0,
  631. }
  632. prefetchQueues.set(directory, created)
  633. return created
  634. }
  635. const mergeByID = <T extends { id: string }>(current: T[], incoming: T[]) => {
  636. if (current.length === 0) {
  637. return incoming.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
  638. }
  639. const map = new Map<string, T>()
  640. for (const item of current) {
  641. map.set(item.id, item)
  642. }
  643. for (const item of incoming) {
  644. map.set(item.id, item)
  645. }
  646. return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
  647. }
  648. async function prefetchMessages(directory: string, sessionID: string, token: number) {
  649. const [store, setStore] = globalSync.child(directory, { bootstrap: false })
  650. return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
  651. .then((messages) => {
  652. if (prefetchToken.value !== token) return
  653. if (!lruFor(directory).has(sessionID)) return
  654. const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
  655. const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
  656. const sorted = mergeByID([], next)
  657. const current = store.message[sessionID] ?? []
  658. const merged = mergeByID(
  659. current.filter((item): item is Message => !!item?.id),
  660. sorted,
  661. )
  662. batch(() => {
  663. setStore("message", sessionID, reconcile(merged, { key: "id" }))
  664. for (const message of items) {
  665. const currentParts = store.part[message.info.id] ?? []
  666. const mergedParts = mergeByID(
  667. currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id),
  668. message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id),
  669. )
  670. setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
  671. }
  672. })
  673. })
  674. .catch(() => undefined)
  675. }
  676. const pumpPrefetch = (directory: string) => {
  677. const q = queueFor(directory)
  678. if (q.running >= prefetchConcurrency) return
  679. const sessionID = q.pending.shift()
  680. if (!sessionID) return
  681. q.pendingSet.delete(sessionID)
  682. q.inflight.add(sessionID)
  683. q.running += 1
  684. const token = prefetchToken.value
  685. void prefetchMessages(directory, sessionID, token).finally(() => {
  686. q.running -= 1
  687. q.inflight.delete(sessionID)
  688. pumpPrefetch(directory)
  689. })
  690. }
  691. const prefetchSession = (session: Session, priority: "high" | "low" = "low") => {
  692. const directory = session.directory
  693. if (!directory) return
  694. const [store] = globalSync.child(directory, { bootstrap: false })
  695. const cached = untrack(() => store.message[session.id] !== undefined)
  696. if (cached) return
  697. const q = queueFor(directory)
  698. if (q.inflight.has(session.id)) return
  699. if (q.pendingSet.has(session.id)) return
  700. const lru = lruFor(directory)
  701. const known = lru.has(session.id)
  702. if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
  703. const stale = markPrefetched(directory, session.id)
  704. if (stale.length > 0) {
  705. const [, setStore] = globalSync.child(directory, { bootstrap: false })
  706. for (const id of stale) {
  707. globalSync.todo.set(id, undefined)
  708. }
  709. setStore(
  710. produce((draft) => {
  711. dropSessionCaches(draft, stale)
  712. }),
  713. )
  714. }
  715. if (priority === "high") q.pending.unshift(session.id)
  716. if (priority !== "high") q.pending.push(session.id)
  717. q.pendingSet.add(session.id)
  718. while (q.pending.length > prefetchPendingLimit) {
  719. const dropped = q.pending.pop()
  720. if (!dropped) continue
  721. q.pendingSet.delete(dropped)
  722. }
  723. pumpPrefetch(directory)
  724. }
  725. createEffect(() => {
  726. const sessions = currentSessions()
  727. const id = params.id
  728. if (!id) {
  729. const first = sessions[0]
  730. if (first) prefetchSession(first)
  731. const second = sessions[1]
  732. if (second) prefetchSession(second)
  733. return
  734. }
  735. const index = sessions.findIndex((s) => s.id === id)
  736. if (index === -1) return
  737. const next = sessions[index + 1]
  738. if (next) prefetchSession(next)
  739. const prev = sessions[index - 1]
  740. if (prev) prefetchSession(prev)
  741. })
  742. function navigateSessionByOffset(offset: number) {
  743. const sessions = currentSessions()
  744. if (sessions.length === 0) return
  745. const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
  746. let targetIndex: number
  747. if (sessionIndex === -1) {
  748. targetIndex = offset > 0 ? 0 : sessions.length - 1
  749. } else {
  750. targetIndex = (sessionIndex + offset + sessions.length) % sessions.length
  751. }
  752. const session = sessions[targetIndex]
  753. if (!session) return
  754. const next = sessions[(targetIndex + 1) % sessions.length]
  755. const prev = sessions[(targetIndex - 1 + sessions.length) % sessions.length]
  756. if (offset > 0) {
  757. if (next) prefetchSession(next, "high")
  758. if (prev) prefetchSession(prev)
  759. }
  760. if (offset < 0) {
  761. if (prev) prefetchSession(prev, "high")
  762. if (next) prefetchSession(next)
  763. }
  764. navigateToSession(session)
  765. }
  766. function navigateSessionByUnseen(offset: number) {
  767. const sessions = currentSessions()
  768. if (sessions.length === 0) return
  769. const hasUnseen = sessions.some((session) => notification.session.unseenCount(session.id) > 0)
  770. if (!hasUnseen) return
  771. const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
  772. const start = activeIndex === -1 ? (offset > 0 ? -1 : 0) : activeIndex
  773. for (let i = 1; i <= sessions.length; i++) {
  774. const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length
  775. const session = sessions[index]
  776. if (!session) continue
  777. if (notification.session.unseenCount(session.id) === 0) continue
  778. prefetchSession(session, "high")
  779. const next = sessions[(index + 1) % sessions.length]
  780. const prev = sessions[(index - 1 + sessions.length) % sessions.length]
  781. if (offset > 0) {
  782. if (next) prefetchSession(next, "high")
  783. if (prev) prefetchSession(prev)
  784. }
  785. if (offset < 0) {
  786. if (prev) prefetchSession(prev, "high")
  787. if (next) prefetchSession(next)
  788. }
  789. navigateToSession(session)
  790. return
  791. }
  792. }
  793. async function archiveSession(session: Session) {
  794. const [store, setStore] = globalSync.child(session.directory)
  795. const sessions = store.session ?? []
  796. const index = sessions.findIndex((s) => s.id === session.id)
  797. const nextSession = sessions[index + 1] ?? sessions[index - 1]
  798. await globalSDK.client.session.update({
  799. directory: session.directory,
  800. sessionID: session.id,
  801. time: { archived: Date.now() },
  802. })
  803. setStore(
  804. produce((draft) => {
  805. const match = Binary.search(draft.session, session.id, (s) => s.id)
  806. if (match.found) draft.session.splice(match.index, 1)
  807. }),
  808. )
  809. if (session.id === params.id) {
  810. if (nextSession) {
  811. navigate(`/${params.dir}/session/${nextSession.id}`)
  812. } else {
  813. navigate(`/${params.dir}/session`)
  814. }
  815. }
  816. }
  817. command.register("layout", () => {
  818. const commands: CommandOption[] = [
  819. {
  820. id: "sidebar.toggle",
  821. title: language.t("command.sidebar.toggle"),
  822. category: language.t("command.category.view"),
  823. keybind: "mod+b",
  824. onSelect: () => layout.sidebar.toggle(),
  825. },
  826. {
  827. id: "project.open",
  828. title: language.t("command.project.open"),
  829. category: language.t("command.category.project"),
  830. keybind: "mod+o",
  831. onSelect: () => chooseProject(),
  832. },
  833. {
  834. id: "provider.connect",
  835. title: language.t("command.provider.connect"),
  836. category: language.t("command.category.provider"),
  837. onSelect: () => connectProvider(),
  838. },
  839. {
  840. id: "server.switch",
  841. title: language.t("command.server.switch"),
  842. category: language.t("command.category.server"),
  843. onSelect: () => openServer(),
  844. },
  845. {
  846. id: "settings.open",
  847. title: language.t("command.settings.open"),
  848. category: language.t("command.category.settings"),
  849. keybind: "mod+comma",
  850. onSelect: () => openSettings(),
  851. },
  852. {
  853. id: "session.previous",
  854. title: language.t("command.session.previous"),
  855. category: language.t("command.category.session"),
  856. keybind: "alt+arrowup",
  857. onSelect: () => navigateSessionByOffset(-1),
  858. },
  859. {
  860. id: "session.next",
  861. title: language.t("command.session.next"),
  862. category: language.t("command.category.session"),
  863. keybind: "alt+arrowdown",
  864. onSelect: () => navigateSessionByOffset(1),
  865. },
  866. {
  867. id: "session.previous.unseen",
  868. title: language.t("command.session.previous.unseen"),
  869. category: language.t("command.category.session"),
  870. keybind: "shift+alt+arrowup",
  871. onSelect: () => navigateSessionByUnseen(-1),
  872. },
  873. {
  874. id: "session.next.unseen",
  875. title: language.t("command.session.next.unseen"),
  876. category: language.t("command.category.session"),
  877. keybind: "shift+alt+arrowdown",
  878. onSelect: () => navigateSessionByUnseen(1),
  879. },
  880. {
  881. id: "session.archive",
  882. title: language.t("command.session.archive"),
  883. category: language.t("command.category.session"),
  884. keybind: "mod+shift+backspace",
  885. disabled: !params.dir || !params.id,
  886. onSelect: () => {
  887. const session = currentSessions().find((s) => s.id === params.id)
  888. if (session) archiveSession(session)
  889. },
  890. },
  891. {
  892. id: "workspace.new",
  893. title: language.t("workspace.new"),
  894. category: language.t("command.category.workspace"),
  895. keybind: "mod+shift+w",
  896. disabled: !workspaceSetting(),
  897. onSelect: () => {
  898. const project = currentProject()
  899. if (!project) return
  900. return createWorkspace(project)
  901. },
  902. },
  903. {
  904. id: "workspace.toggle",
  905. title: language.t("command.workspace.toggle"),
  906. description: language.t("command.workspace.toggle.description"),
  907. category: language.t("command.category.workspace"),
  908. slash: "workspace",
  909. disabled: !currentProject() || currentProject()?.vcs !== "git",
  910. onSelect: () => {
  911. const project = currentProject()
  912. if (!project) return
  913. if (project.vcs !== "git") return
  914. const wasEnabled = layout.sidebar.workspaces(project.worktree)()
  915. layout.sidebar.toggleWorkspaces(project.worktree)
  916. showToast({
  917. title: wasEnabled
  918. ? language.t("toast.workspace.disabled.title")
  919. : language.t("toast.workspace.enabled.title"),
  920. description: wasEnabled
  921. ? language.t("toast.workspace.disabled.description")
  922. : language.t("toast.workspace.enabled.description"),
  923. })
  924. },
  925. },
  926. {
  927. id: "theme.cycle",
  928. title: language.t("command.theme.cycle"),
  929. category: language.t("command.category.theme"),
  930. keybind: "mod+shift+t",
  931. onSelect: () => cycleTheme(1),
  932. },
  933. ]
  934. for (const [id, definition] of availableThemeEntries()) {
  935. commands.push({
  936. id: `theme.set.${id}`,
  937. title: language.t("command.theme.set", { theme: definition.name ?? id }),
  938. category: language.t("command.category.theme"),
  939. onSelect: () => theme.commitPreview(),
  940. onHighlight: () => {
  941. theme.previewTheme(id)
  942. return () => theme.cancelPreview()
  943. },
  944. })
  945. }
  946. commands.push({
  947. id: "theme.scheme.cycle",
  948. title: language.t("command.theme.scheme.cycle"),
  949. category: language.t("command.category.theme"),
  950. keybind: "mod+shift+s",
  951. onSelect: () => cycleColorScheme(1),
  952. })
  953. for (const scheme of colorSchemeOrder) {
  954. commands.push({
  955. id: `theme.scheme.${scheme}`,
  956. title: language.t("command.theme.scheme.set", { scheme: colorSchemeLabel(scheme) }),
  957. category: language.t("command.category.theme"),
  958. onSelect: () => theme.commitPreview(),
  959. onHighlight: () => {
  960. theme.previewColorScheme(scheme)
  961. return () => theme.cancelPreview()
  962. },
  963. })
  964. }
  965. commands.push({
  966. id: "language.cycle",
  967. title: language.t("command.language.cycle"),
  968. category: language.t("command.category.language"),
  969. onSelect: () => cycleLanguage(1),
  970. })
  971. for (const locale of language.locales) {
  972. commands.push({
  973. id: `language.set.${locale}`,
  974. title: language.t("command.language.set", { language: language.label(locale) }),
  975. category: language.t("command.category.language"),
  976. onSelect: () => setLocale(locale),
  977. })
  978. }
  979. return commands
  980. })
  981. function connectProvider() {
  982. dialog.show(() => <DialogSelectProvider />)
  983. }
  984. function openServer() {
  985. dialog.show(() => <DialogSelectServer />)
  986. }
  987. function openSettings() {
  988. dialog.show(() => <DialogSettings />)
  989. }
  990. function projectRoot(directory: string) {
  991. const project = layout.projects
  992. .list()
  993. .find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
  994. if (project) return project.worktree
  995. const known = Object.entries(store.workspaceOrder).find(
  996. ([root, dirs]) => root === directory || dirs.includes(directory),
  997. )
  998. if (known) return known[0]
  999. const [child] = globalSync.child(directory, { bootstrap: false })
  1000. const id = child.project
  1001. if (!id) return directory
  1002. const meta = globalSync.data.project.find((item) => item.id === id)
  1003. return meta?.worktree ?? directory
  1004. }
  1005. function activeProjectRoot(directory: string) {
  1006. return currentProject()?.worktree ?? projectRoot(directory)
  1007. }
  1008. function touchProjectRoute() {
  1009. const root = currentProject()?.worktree
  1010. if (!root) return
  1011. if (server.projects.last() !== root) server.projects.touch(root)
  1012. return root
  1013. }
  1014. function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
  1015. setStore("lastProjectSession", root, { directory, id, at: Date.now() })
  1016. return root
  1017. }
  1018. function clearLastProjectSession(root: string) {
  1019. if (!store.lastProjectSession[root]) return
  1020. setStore(
  1021. "lastProjectSession",
  1022. produce((draft) => {
  1023. delete draft[root]
  1024. }),
  1025. )
  1026. }
  1027. function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
  1028. rememberSessionRoute(directory, id, root)
  1029. notification.session.markViewed(id)
  1030. const expanded = untrack(() => store.workspaceExpanded[directory])
  1031. if (expanded === false) {
  1032. setStore("workspaceExpanded", directory, true)
  1033. }
  1034. requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
  1035. return root
  1036. }
  1037. async function navigateToProject(directory: string | undefined) {
  1038. if (!directory) return
  1039. const root = projectRoot(directory)
  1040. server.projects.touch(root)
  1041. const project = layout.projects.list().find((item) => item.worktree === root)
  1042. let dirs = project
  1043. ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
  1044. : [root]
  1045. const canOpen = (value: string | undefined) => {
  1046. if (!value) return false
  1047. return dirs.some((item) => workspaceKey(item) === workspaceKey(value))
  1048. }
  1049. const refreshDirs = async (target?: string) => {
  1050. if (!target || target === root || canOpen(target)) return canOpen(target)
  1051. const listed = await globalSDK.client.worktree
  1052. .list({ directory: root })
  1053. .then((x) => x.data ?? [])
  1054. .catch(() => [] as string[])
  1055. dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
  1056. return canOpen(target)
  1057. }
  1058. const openSession = async (target: { directory: string; id: string }) => {
  1059. if (!canOpen(target.directory)) return false
  1060. const [data] = globalSync.child(target.directory, { bootstrap: false })
  1061. if (data.session.some((item) => item.id === target.id)) {
  1062. setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
  1063. navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
  1064. return true
  1065. }
  1066. const resolved = await globalSDK.client.session
  1067. .get({ sessionID: target.id })
  1068. .then((x) => x.data)
  1069. .catch(() => undefined)
  1070. if (!resolved?.directory) return false
  1071. if (!canOpen(resolved.directory)) return false
  1072. setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
  1073. navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
  1074. return true
  1075. }
  1076. const projectSession = store.lastProjectSession[root]
  1077. if (projectSession?.id) {
  1078. await refreshDirs(projectSession.directory)
  1079. const opened = await openSession(projectSession)
  1080. if (opened) return
  1081. clearLastProjectSession(root)
  1082. }
  1083. const latest = latestRootSession(
  1084. dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
  1085. Date.now(),
  1086. )
  1087. if (latest && (await openSession(latest))) {
  1088. return
  1089. }
  1090. const fetched = latestRootSession(
  1091. await Promise.all(
  1092. dirs.map(async (item) => ({
  1093. path: { directory: item },
  1094. session: await globalSDK.client.session
  1095. .list({ directory: item })
  1096. .then((x) => x.data ?? [])
  1097. .catch(() => []),
  1098. })),
  1099. ),
  1100. Date.now(),
  1101. )
  1102. if (fetched && (await openSession(fetched))) {
  1103. return
  1104. }
  1105. navigateWithSidebarReset(`/${base64Encode(root)}/session`)
  1106. }
  1107. function navigateToSession(session: Session | undefined) {
  1108. if (!session) return
  1109. navigateWithSidebarReset(`/${base64Encode(session.directory)}/session/${session.id}`)
  1110. }
  1111. function openProject(directory: string, navigate = true) {
  1112. layout.projects.open(directory)
  1113. if (navigate) navigateToProject(directory)
  1114. }
  1115. const handleDeepLinks = (urls: string[]) => {
  1116. if (!server.isLocal()) return
  1117. for (const directory of collectOpenProjectDeepLinks(urls)) {
  1118. openProject(directory)
  1119. }
  1120. for (const link of collectNewSessionDeepLinks(urls)) {
  1121. openProject(link.directory, false)
  1122. const slug = base64Encode(link.directory)
  1123. if (link.prompt) {
  1124. setSessionHandoff(slug, { prompt: link.prompt })
  1125. }
  1126. const href = link.prompt ? `/${slug}/session?prompt=${encodeURIComponent(link.prompt)}` : `/${slug}/session`
  1127. navigateWithSidebarReset(href)
  1128. }
  1129. }
  1130. onMount(() => {
  1131. const handler = (event: Event) => {
  1132. const detail = (event as CustomEvent<{ urls: string[] }>).detail
  1133. const urls = detail?.urls ?? []
  1134. if (urls.length === 0) return
  1135. handleDeepLinks(urls)
  1136. }
  1137. handleDeepLinks(drainPendingDeepLinks(window))
  1138. window.addEventListener(deepLinkEvent, handler as EventListener)
  1139. onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener))
  1140. })
  1141. async function renameProject(project: LocalProject, next: string) {
  1142. const current = displayName(project)
  1143. if (next === current) return
  1144. const name = next === getFilename(project.worktree) ? "" : next
  1145. if (project.id && project.id !== "global") {
  1146. await globalSDK.client.project.update({ projectID: project.id, directory: project.worktree, name })
  1147. return
  1148. }
  1149. globalSync.project.meta(project.worktree, { name })
  1150. }
  1151. const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
  1152. const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
  1153. if (current === next) return
  1154. setWorkspaceName(directory, next, projectId, branch)
  1155. }
  1156. function closeProject(directory: string) {
  1157. const list = layout.projects.list()
  1158. const index = list.findIndex((x) => x.worktree === directory)
  1159. const active = currentProject()?.worktree === directory
  1160. if (index === -1) return
  1161. const next = list[index + 1]
  1162. if (!active) {
  1163. layout.projects.close(directory)
  1164. return
  1165. }
  1166. if (!next) {
  1167. layout.projects.close(directory)
  1168. navigate("/")
  1169. return
  1170. }
  1171. navigateWithSidebarReset(`/${base64Encode(next.worktree)}/session`)
  1172. layout.projects.close(directory)
  1173. queueMicrotask(() => {
  1174. void navigateToProject(next.worktree)
  1175. })
  1176. }
  1177. function toggleProjectWorkspaces(project: LocalProject) {
  1178. const enabled = layout.sidebar.workspaces(project.worktree)()
  1179. if (enabled) {
  1180. layout.sidebar.toggleWorkspaces(project.worktree)
  1181. return
  1182. }
  1183. if (project.vcs !== "git") return
  1184. layout.sidebar.toggleWorkspaces(project.worktree)
  1185. }
  1186. const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)
  1187. async function chooseProject() {
  1188. function resolve(result: string | string[] | null) {
  1189. if (Array.isArray(result)) {
  1190. for (const directory of result) {
  1191. openProject(directory, false)
  1192. }
  1193. navigateToProject(result[0])
  1194. } else if (result) {
  1195. openProject(result)
  1196. }
  1197. }
  1198. if (platform.openDirectoryPickerDialog && server.isLocal()) {
  1199. const result = await platform.openDirectoryPickerDialog?.({
  1200. title: language.t("command.project.open"),
  1201. multiple: true,
  1202. })
  1203. resolve(result)
  1204. } else {
  1205. dialog.show(
  1206. () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
  1207. () => resolve(null),
  1208. )
  1209. }
  1210. }
  1211. const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
  1212. if (directory === root) return
  1213. const current = currentDir()
  1214. const currentKey = workspaceKey(current)
  1215. const deletedKey = workspaceKey(directory)
  1216. const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
  1217. if (!leaveDeletedWorkspace && shouldLeave) {
  1218. navigateWithSidebarReset(`/${base64Encode(root)}/session`)
  1219. }
  1220. setBusy(directory, true)
  1221. const result = await globalSDK.client.worktree
  1222. .remove({ directory: root, worktreeRemoveInput: { directory } })
  1223. .then((x) => x.data)
  1224. .catch((err) => {
  1225. showToast({
  1226. title: language.t("workspace.delete.failed.title"),
  1227. description: errorMessage(err, language.t("common.requestFailed")),
  1228. })
  1229. return false
  1230. })
  1231. setBusy(directory, false)
  1232. if (!result) return
  1233. if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
  1234. clearLastProjectSession(root)
  1235. }
  1236. globalSync.set(
  1237. "project",
  1238. produce((draft) => {
  1239. const project = draft.find((item) => item.worktree === root)
  1240. if (!project) return
  1241. project.sandboxes = (project.sandboxes ?? []).filter((sandbox) => sandbox !== directory)
  1242. }),
  1243. )
  1244. setStore("workspaceOrder", root, (order) => (order ?? []).filter((workspace) => workspace !== directory))
  1245. layout.projects.close(directory)
  1246. layout.projects.open(root)
  1247. if (shouldLeave) return
  1248. const nextCurrent = currentDir()
  1249. const nextKey = workspaceKey(nextCurrent)
  1250. const project = layout.projects.list().find((item) => item.worktree === root)
  1251. const dirs = project
  1252. ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
  1253. : [root]
  1254. const valid = dirs.some((item) => workspaceKey(item) === nextKey)
  1255. if (params.dir && projectRoot(nextCurrent) === root && !valid) {
  1256. navigateWithSidebarReset(`/${base64Encode(root)}/session`)
  1257. }
  1258. }
  1259. const resetWorkspace = async (root: string, directory: string) => {
  1260. if (directory === root) return
  1261. setBusy(directory, true)
  1262. const progress = showToast({
  1263. persistent: true,
  1264. title: language.t("workspace.resetting.title"),
  1265. description: language.t("workspace.resetting.description"),
  1266. })
  1267. const dismiss = () => toaster.dismiss(progress)
  1268. const sessions: Session[] = await globalSDK.client.session
  1269. .list({ directory })
  1270. .then((x) => x.data ?? [])
  1271. .catch(() => [])
  1272. clearWorkspaceTerminals(
  1273. directory,
  1274. sessions.map((s) => s.id),
  1275. platform,
  1276. )
  1277. await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
  1278. const result = await globalSDK.client.worktree
  1279. .reset({ directory: root, worktreeResetInput: { directory } })
  1280. .then((x) => x.data)
  1281. .catch((err) => {
  1282. showToast({
  1283. title: language.t("workspace.reset.failed.title"),
  1284. description: errorMessage(err, language.t("common.requestFailed")),
  1285. })
  1286. return false
  1287. })
  1288. if (!result) {
  1289. setBusy(directory, false)
  1290. dismiss()
  1291. return
  1292. }
  1293. const archivedAt = Date.now()
  1294. await Promise.all(
  1295. sessions
  1296. .filter((session) => session.time.archived === undefined)
  1297. .map((session) =>
  1298. globalSDK.client.session
  1299. .update({
  1300. sessionID: session.id,
  1301. directory: session.directory,
  1302. time: { archived: archivedAt },
  1303. })
  1304. .catch(() => undefined),
  1305. ),
  1306. )
  1307. setBusy(directory, false)
  1308. dismiss()
  1309. showToast({
  1310. title: language.t("workspace.reset.success.title"),
  1311. description: language.t("workspace.reset.success.description"),
  1312. actions: [
  1313. {
  1314. label: language.t("command.session.new"),
  1315. onClick: () => {
  1316. const href = `/${base64Encode(directory)}/session`
  1317. navigate(href)
  1318. layout.mobileSidebar.hide()
  1319. },
  1320. },
  1321. {
  1322. label: language.t("common.dismiss"),
  1323. onClick: "dismiss",
  1324. },
  1325. ],
  1326. })
  1327. }
  1328. function DialogDeleteWorkspace(props: { root: string; directory: string }) {
  1329. const name = createMemo(() => getFilename(props.directory))
  1330. const [data, setData] = createStore({
  1331. status: "loading" as "loading" | "ready" | "error",
  1332. dirty: false,
  1333. })
  1334. onMount(() => {
  1335. globalSDK.client.file
  1336. .status({ directory: props.directory })
  1337. .then((x) => {
  1338. const files = x.data ?? []
  1339. const dirty = files.length > 0
  1340. setData({ status: "ready", dirty })
  1341. })
  1342. .catch(() => {
  1343. setData({ status: "error", dirty: false })
  1344. })
  1345. })
  1346. const handleDelete = () => {
  1347. const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory)
  1348. if (leaveDeletedWorkspace) {
  1349. navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
  1350. }
  1351. dialog.close()
  1352. void deleteWorkspace(props.root, props.directory, leaveDeletedWorkspace)
  1353. }
  1354. const description = () => {
  1355. if (data.status === "loading") return language.t("workspace.status.checking")
  1356. if (data.status === "error") return language.t("workspace.status.error")
  1357. if (!data.dirty) return language.t("workspace.status.clean")
  1358. return language.t("workspace.status.dirty")
  1359. }
  1360. return (
  1361. <Dialog title={language.t("workspace.delete.title")} fit>
  1362. <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
  1363. <div class="flex flex-col gap-1">
  1364. <span class="text-14-regular text-text-strong">
  1365. {language.t("workspace.delete.confirm", { name: name() })}
  1366. </span>
  1367. <span class="text-12-regular text-text-weak">{description()}</span>
  1368. </div>
  1369. <div class="flex justify-end gap-2">
  1370. <Button variant="ghost" size="large" onClick={() => dialog.close()}>
  1371. {language.t("common.cancel")}
  1372. </Button>
  1373. <Button variant="primary" size="large" disabled={data.status === "loading"} onClick={handleDelete}>
  1374. {language.t("workspace.delete.button")}
  1375. </Button>
  1376. </div>
  1377. </div>
  1378. </Dialog>
  1379. )
  1380. }
  1381. function DialogResetWorkspace(props: { root: string; directory: string }) {
  1382. const name = createMemo(() => getFilename(props.directory))
  1383. const [state, setState] = createStore({
  1384. status: "loading" as "loading" | "ready" | "error",
  1385. dirty: false,
  1386. sessions: [] as Session[],
  1387. })
  1388. const refresh = async () => {
  1389. const sessions = await globalSDK.client.session
  1390. .list({ directory: props.directory })
  1391. .then((x) => x.data ?? [])
  1392. .catch(() => [])
  1393. const active = sessions.filter((session) => session.time.archived === undefined)
  1394. setState({ sessions: active })
  1395. }
  1396. onMount(() => {
  1397. globalSDK.client.file
  1398. .status({ directory: props.directory })
  1399. .then((x) => {
  1400. const files = x.data ?? []
  1401. const dirty = files.length > 0
  1402. setState({ status: "ready", dirty })
  1403. void refresh()
  1404. })
  1405. .catch(() => {
  1406. setState({ status: "error", dirty: false })
  1407. })
  1408. })
  1409. const handleReset = () => {
  1410. dialog.close()
  1411. void resetWorkspace(props.root, props.directory)
  1412. }
  1413. const archivedCount = () => state.sessions.length
  1414. const description = () => {
  1415. if (state.status === "loading") return language.t("workspace.status.checking")
  1416. if (state.status === "error") return language.t("workspace.status.error")
  1417. if (!state.dirty) return language.t("workspace.status.clean")
  1418. return language.t("workspace.status.dirty")
  1419. }
  1420. const archivedLabel = () => {
  1421. const count = archivedCount()
  1422. if (count === 0) return language.t("workspace.reset.archived.none")
  1423. if (count === 1) return language.t("workspace.reset.archived.one")
  1424. return language.t("workspace.reset.archived.many", { count })
  1425. }
  1426. return (
  1427. <Dialog title={language.t("workspace.reset.title")} fit>
  1428. <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
  1429. <div class="flex flex-col gap-1">
  1430. <span class="text-14-regular text-text-strong">
  1431. {language.t("workspace.reset.confirm", { name: name() })}
  1432. </span>
  1433. <span class="text-12-regular text-text-weak">
  1434. {description()} {archivedLabel()} {language.t("workspace.reset.note")}
  1435. </span>
  1436. </div>
  1437. <div class="flex justify-end gap-2">
  1438. <Button variant="ghost" size="large" onClick={() => dialog.close()}>
  1439. {language.t("common.cancel")}
  1440. </Button>
  1441. <Button variant="primary" size="large" disabled={state.status === "loading"} onClick={handleReset}>
  1442. {language.t("workspace.reset.button")}
  1443. </Button>
  1444. </div>
  1445. </div>
  1446. </Dialog>
  1447. )
  1448. }
  1449. const activeRoute = {
  1450. session: "",
  1451. sessionProject: "",
  1452. }
  1453. createEffect(
  1454. on(
  1455. () => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const,
  1456. ([ready, dir, id]) => {
  1457. if (!ready || !dir) {
  1458. activeRoute.session = ""
  1459. activeRoute.sessionProject = ""
  1460. return
  1461. }
  1462. const directory = decode64(dir)
  1463. if (!directory) return
  1464. const root = touchProjectRoute() ?? activeProjectRoot(directory)
  1465. if (!id) {
  1466. activeRoute.session = ""
  1467. activeRoute.sessionProject = ""
  1468. return
  1469. }
  1470. const session = `${dir}/${id}`
  1471. if (session !== activeRoute.session) {
  1472. activeRoute.session = session
  1473. activeRoute.sessionProject = syncSessionRoute(directory, id, root)
  1474. return
  1475. }
  1476. if (root === activeRoute.sessionProject) return
  1477. activeRoute.sessionProject = rememberSessionRoute(directory, id, root)
  1478. },
  1479. ),
  1480. )
  1481. createEffect(() => {
  1482. const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
  1483. document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
  1484. })
  1485. const loadedSessionDirs = new Set<string>()
  1486. createEffect(
  1487. on(
  1488. visibleSessionDirs,
  1489. (dirs) => {
  1490. if (dirs.length === 0) {
  1491. loadedSessionDirs.clear()
  1492. return
  1493. }
  1494. const next = new Set(dirs)
  1495. for (const directory of next) {
  1496. if (loadedSessionDirs.has(directory)) continue
  1497. globalSync.project.loadSessions(directory)
  1498. }
  1499. loadedSessionDirs.clear()
  1500. for (const directory of next) {
  1501. loadedSessionDirs.add(directory)
  1502. }
  1503. },
  1504. { defer: true },
  1505. ),
  1506. )
  1507. function handleDragStart(event: unknown) {
  1508. const id = getDraggableId(event)
  1509. if (!id) return
  1510. setHoverProject(undefined)
  1511. setStore("activeProject", id)
  1512. }
  1513. function handleDragOver(event: DragEvent) {
  1514. const { draggable, droppable } = event
  1515. if (draggable && droppable) {
  1516. const projects = layout.projects.list()
  1517. const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString())
  1518. const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString())
  1519. if (fromIndex !== toIndex && toIndex !== -1) {
  1520. layout.projects.move(draggable.id.toString(), toIndex)
  1521. }
  1522. }
  1523. }
  1524. function handleDragEnd() {
  1525. setStore("activeProject", undefined)
  1526. }
  1527. function workspaceIds(project: LocalProject | undefined) {
  1528. if (!project) return []
  1529. const local = project.worktree
  1530. const dirs = [local, ...(project.sandboxes ?? [])]
  1531. const active = currentProject()
  1532. const directory = active?.worktree === project.worktree ? currentDir() : undefined
  1533. const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
  1534. const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
  1535. const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
  1536. if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
  1537. if (!extra) return ordered
  1538. if (pending) return ordered
  1539. return [...ordered, extra]
  1540. }
  1541. const sidebarProject = createMemo(() => {
  1542. if (layout.sidebar.opened()) return currentProject()
  1543. const hovered = hoverProjectData()
  1544. if (hovered) return hovered
  1545. return currentProject()
  1546. })
  1547. function handleWorkspaceDragStart(event: unknown) {
  1548. const id = getDraggableId(event)
  1549. if (!id) return
  1550. setStore("activeWorkspace", id)
  1551. }
  1552. function handleWorkspaceDragOver(event: DragEvent) {
  1553. const { draggable, droppable } = event
  1554. if (!draggable || !droppable) return
  1555. const project = sidebarProject()
  1556. if (!project) return
  1557. const ids = workspaceIds(project)
  1558. const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString())
  1559. const toIndex = ids.findIndex((dir) => dir === droppable.id.toString())
  1560. if (fromIndex === -1 || toIndex === -1) return
  1561. if (fromIndex === toIndex) return
  1562. const result = ids.slice()
  1563. const [item] = result.splice(fromIndex, 1)
  1564. if (!item) return
  1565. result.splice(toIndex, 0, item)
  1566. setStore(
  1567. "workspaceOrder",
  1568. project.worktree,
  1569. result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)),
  1570. )
  1571. }
  1572. function handleWorkspaceDragEnd() {
  1573. setStore("activeWorkspace", undefined)
  1574. }
  1575. const createWorkspace = async (project: LocalProject) => {
  1576. clearSidebarHoverState()
  1577. const created = await globalSDK.client.worktree
  1578. .create({ directory: project.worktree })
  1579. .then((x) => x.data)
  1580. .catch((err) => {
  1581. showToast({
  1582. title: language.t("workspace.create.failed.title"),
  1583. description: errorMessage(err, language.t("common.requestFailed")),
  1584. })
  1585. return undefined
  1586. })
  1587. if (!created?.directory) return
  1588. setWorkspaceName(created.directory, created.branch, project.id, created.branch)
  1589. const local = project.worktree
  1590. const key = workspaceKey(created.directory)
  1591. const root = workspaceKey(local)
  1592. setBusy(created.directory, true)
  1593. WorktreeState.pending(created.directory)
  1594. setStore("workspaceExpanded", key, true)
  1595. if (key !== created.directory) {
  1596. setStore("workspaceExpanded", created.directory, true)
  1597. }
  1598. setStore("workspaceOrder", project.worktree, (prev) => {
  1599. const existing = prev ?? []
  1600. const next = existing.filter((item) => {
  1601. const id = workspaceKey(item)
  1602. return id !== root && id !== key
  1603. })
  1604. return [created.directory, ...next]
  1605. })
  1606. globalSync.child(created.directory)
  1607. navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
  1608. }
  1609. const workspaceSidebarCtx: WorkspaceSidebarContext = {
  1610. currentDir,
  1611. sidebarExpanded,
  1612. sidebarHovering,
  1613. nav: () => state.nav,
  1614. hoverSession: () => state.hoverSession,
  1615. setHoverSession,
  1616. clearHoverProjectSoon,
  1617. prefetchSession,
  1618. archiveSession,
  1619. workspaceName,
  1620. renameWorkspace,
  1621. editorOpen,
  1622. openEditor,
  1623. closeEditor,
  1624. setEditor,
  1625. InlineEditor,
  1626. isBusy,
  1627. workspaceExpanded: (directory, local) => store.workspaceExpanded[directory] ?? local,
  1628. setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value),
  1629. showResetWorkspaceDialog: (root, directory) =>
  1630. dialog.show(() => <DialogResetWorkspace root={root} directory={directory} />),
  1631. showDeleteWorkspaceDialog: (root, directory) =>
  1632. dialog.show(() => <DialogDeleteWorkspace root={root} directory={directory} />),
  1633. setScrollContainerRef: (el, mobile) => {
  1634. if (!mobile) scrollContainerRef = el
  1635. },
  1636. }
  1637. const projectSidebarCtx: ProjectSidebarContext = {
  1638. currentDir,
  1639. sidebarOpened: () => layout.sidebar.opened(),
  1640. sidebarHovering,
  1641. hoverProject: () => state.hoverProject,
  1642. nav: () => state.nav,
  1643. onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
  1644. onProjectMouseLeave: (worktree) => aim.leave(worktree),
  1645. onProjectFocus: (worktree) => aim.activate(worktree),
  1646. navigateToProject,
  1647. openSidebar: () => layout.sidebar.open(),
  1648. closeProject,
  1649. showEditProjectDialog,
  1650. toggleProjectWorkspaces,
  1651. workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
  1652. workspaceIds,
  1653. workspaceLabel,
  1654. sessionProps: {
  1655. sidebarExpanded,
  1656. sidebarHovering,
  1657. nav: () => state.nav,
  1658. hoverSession: () => state.hoverSession,
  1659. setHoverSession,
  1660. clearHoverProjectSoon,
  1661. prefetchSession,
  1662. archiveSession,
  1663. },
  1664. setHoverSession,
  1665. }
  1666. const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
  1667. const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
  1668. const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
  1669. const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
  1670. const projectName = createMemo(() => {
  1671. const project = panelProps.project
  1672. if (!project) return ""
  1673. return project.name || getFilename(project.worktree)
  1674. })
  1675. const projectId = createMemo(() => panelProps.project?.id ?? "")
  1676. const workspaces = createMemo(() => workspaceIds(panelProps.project))
  1677. const unseenCount = createMemo(() =>
  1678. workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
  1679. )
  1680. const clearNotifications = () =>
  1681. workspaces()
  1682. .filter((directory) => notification.project.unseenCount(directory) > 0)
  1683. .forEach((directory) => notification.project.markViewed(directory))
  1684. const workspacesEnabled = createMemo(() => {
  1685. const project = panelProps.project
  1686. if (!project) return false
  1687. if (project.vcs !== "git") return false
  1688. return layout.sidebar.workspaces(project.worktree)()
  1689. })
  1690. const homedir = createMemo(() => globalSync.data.path.home)
  1691. return (
  1692. <div
  1693. classList={{
  1694. "flex flex-col min-h-0 min-w-0 box-border rounded-tl-[12px] px-2": true,
  1695. "border border-b-0 border-border-weak-base": !merged(),
  1696. "border-l border-t border-border-weaker-base": merged(),
  1697. "bg-background-base": merged() || hover(),
  1698. "bg-background-stronger": !merged() && !hover(),
  1699. "flex-1 min-w-0": panelProps.mobile,
  1700. "max-w-full overflow-hidden": panelProps.mobile,
  1701. }}
  1702. style={{
  1703. width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
  1704. }}
  1705. >
  1706. <Show when={panelProps.project}>
  1707. {(p) => (
  1708. <>
  1709. <div class="shrink-0 px-2 py-1">
  1710. <div class="group/project flex items-start justify-between gap-2 p-2 pr-1">
  1711. <div class="flex flex-col min-w-0">
  1712. <InlineEditor
  1713. id={`project:${projectId()}`}
  1714. value={projectName}
  1715. onSave={(next) => renameProject(p(), next)}
  1716. class="text-14-medium text-text-strong truncate"
  1717. displayClass="text-14-medium text-text-strong truncate"
  1718. stopPropagation
  1719. />
  1720. <Tooltip
  1721. placement="bottom"
  1722. gutter={2}
  1723. value={p().worktree}
  1724. class="shrink-0"
  1725. contentStyle={{
  1726. "max-width": "640px",
  1727. transform: "translate3d(52px, 0, 0)",
  1728. }}
  1729. >
  1730. <span class="text-12-regular text-text-base truncate select-text">
  1731. {p().worktree.replace(homedir(), "~")}
  1732. </span>
  1733. </Tooltip>
  1734. </div>
  1735. <DropdownMenu modal={!sidebarHovering()}>
  1736. <DropdownMenu.Trigger
  1737. as={IconButton}
  1738. icon="dot-grid"
  1739. variant="ghost"
  1740. data-action="project-menu"
  1741. data-project={base64Encode(p().worktree)}
  1742. class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
  1743. classList={{
  1744. "opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
  1745. }}
  1746. aria-label={language.t("common.moreOptions")}
  1747. />
  1748. <DropdownMenu.Portal>
  1749. <DropdownMenu.Content class="mt-1">
  1750. <DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
  1751. <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
  1752. </DropdownMenu.Item>
  1753. <DropdownMenu.Item
  1754. data-action="project-workspaces-toggle"
  1755. data-project={base64Encode(p().worktree)}
  1756. disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
  1757. onSelect={() => toggleProjectWorkspaces(p())}
  1758. >
  1759. <DropdownMenu.ItemLabel>
  1760. {layout.sidebar.workspaces(p().worktree)()
  1761. ? language.t("sidebar.workspaces.disable")
  1762. : language.t("sidebar.workspaces.enable")}
  1763. </DropdownMenu.ItemLabel>
  1764. </DropdownMenu.Item>
  1765. <DropdownMenu.Item
  1766. data-action="project-clear-notifications"
  1767. data-project={base64Encode(p().worktree)}
  1768. disabled={unseenCount() === 0}
  1769. onSelect={clearNotifications}
  1770. >
  1771. <DropdownMenu.ItemLabel>
  1772. {language.t("sidebar.project.clearNotifications")}
  1773. </DropdownMenu.ItemLabel>
  1774. </DropdownMenu.Item>
  1775. <DropdownMenu.Separator />
  1776. <DropdownMenu.Item
  1777. data-action="project-close-menu"
  1778. data-project={base64Encode(p().worktree)}
  1779. onSelect={() => closeProject(p().worktree)}
  1780. >
  1781. <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
  1782. </DropdownMenu.Item>
  1783. </DropdownMenu.Content>
  1784. </DropdownMenu.Portal>
  1785. </DropdownMenu>
  1786. </div>
  1787. </div>
  1788. <div class="flex-1 min-h-0 flex flex-col">
  1789. <Show
  1790. when={workspacesEnabled()}
  1791. fallback={
  1792. <>
  1793. <div class="shrink-0 py-4 px-3">
  1794. <Button
  1795. size="large"
  1796. icon="plus-small"
  1797. class="w-full"
  1798. onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
  1799. >
  1800. {language.t("command.session.new")}
  1801. </Button>
  1802. </div>
  1803. <div class="flex-1 min-h-0">
  1804. <LocalWorkspace
  1805. ctx={workspaceSidebarCtx}
  1806. project={p()}
  1807. sortNow={sortNow}
  1808. mobile={panelProps.mobile}
  1809. popover={popover()}
  1810. />
  1811. </div>
  1812. </>
  1813. }
  1814. >
  1815. <>
  1816. <div class="shrink-0 py-4 px-3">
  1817. <Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
  1818. {language.t("workspace.new")}
  1819. </Button>
  1820. </div>
  1821. <div class="relative flex-1 min-h-0">
  1822. <DragDropProvider
  1823. onDragStart={handleWorkspaceDragStart}
  1824. onDragEnd={handleWorkspaceDragEnd}
  1825. onDragOver={handleWorkspaceDragOver}
  1826. collisionDetector={closestCenter}
  1827. >
  1828. <DragDropSensors />
  1829. <ConstrainDragXAxis />
  1830. <div
  1831. ref={(el) => {
  1832. if (!panelProps.mobile) scrollContainerRef = el
  1833. }}
  1834. class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
  1835. >
  1836. <SortableProvider ids={workspaces()}>
  1837. <For each={workspaces()}>
  1838. {(directory) => (
  1839. <SortableWorkspace
  1840. ctx={workspaceSidebarCtx}
  1841. directory={directory}
  1842. project={p()}
  1843. sortNow={sortNow}
  1844. mobile={panelProps.mobile}
  1845. popover={popover()}
  1846. />
  1847. )}
  1848. </For>
  1849. </SortableProvider>
  1850. </div>
  1851. <DragOverlay>
  1852. <WorkspaceDragOverlay
  1853. sidebarProject={sidebarProject}
  1854. activeWorkspace={() => store.activeWorkspace}
  1855. workspaceLabel={workspaceLabel}
  1856. />
  1857. </DragOverlay>
  1858. </DragDropProvider>
  1859. </div>
  1860. </>
  1861. </Show>
  1862. </div>
  1863. </>
  1864. )}
  1865. </Show>
  1866. <div
  1867. class="shrink-0 px-3 py-3"
  1868. classList={{
  1869. hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0),
  1870. }}
  1871. >
  1872. <div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
  1873. <div class="p-3 flex flex-col gap-6">
  1874. <div class="flex flex-col gap-2">
  1875. <div class="text-14-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
  1876. <div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
  1877. {language.t("sidebar.gettingStarted.line1")}
  1878. </div>
  1879. <div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
  1880. {language.t("sidebar.gettingStarted.line2")}
  1881. </div>
  1882. </div>
  1883. <div data-component="getting-started-actions">
  1884. <Button size="large" icon="plus-small" onClick={connectProvider}>
  1885. {language.t("command.provider.connect")}
  1886. </Button>
  1887. <Button size="large" variant="ghost" onClick={() => setStore("gettingStartedDismissed", true)}>
  1888. Not yet
  1889. </Button>
  1890. </div>
  1891. </div>
  1892. </div>
  1893. </div>
  1894. </div>
  1895. )
  1896. }
  1897. return (
  1898. <div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
  1899. <Titlebar />
  1900. <div class="flex-1 min-h-0 min-w-0 flex">
  1901. <div class="flex-1 min-h-0 relative">
  1902. <div class="size-full relative overflow-x-hidden">
  1903. <nav
  1904. aria-label={language.t("sidebar.nav.projectsAndSessions")}
  1905. data-component="sidebar-nav-desktop"
  1906. classList={{
  1907. "hidden xl:block": true,
  1908. "absolute inset-y-0 left-0": true,
  1909. "z-10": true,
  1910. }}
  1911. style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
  1912. ref={(el) => {
  1913. setState("nav", el)
  1914. }}
  1915. onMouseEnter={() => {
  1916. disarm()
  1917. }}
  1918. onMouseLeave={() => {
  1919. aim.reset()
  1920. if (!sidebarHovering()) return
  1921. arm()
  1922. }}
  1923. >
  1924. <div class="@container w-full h-full contain-strict">
  1925. <SidebarContent
  1926. opened={() => layout.sidebar.opened()}
  1927. aimMove={aim.move}
  1928. projects={() => layout.projects.list()}
  1929. renderProject={(project) => (
  1930. <SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
  1931. )}
  1932. handleDragStart={handleDragStart}
  1933. handleDragEnd={handleDragEnd}
  1934. handleDragOver={handleDragOver}
  1935. openProjectLabel={language.t("command.project.open")}
  1936. openProjectKeybind={() => command.keybind("project.open")}
  1937. onOpenProject={chooseProject}
  1938. renderProjectOverlay={() => (
  1939. <ProjectDragOverlay
  1940. projects={() => layout.projects.list()}
  1941. activeProject={() => store.activeProject}
  1942. />
  1943. )}
  1944. settingsLabel={() => language.t("sidebar.settings")}
  1945. settingsKeybind={() => command.keybind("settings.open")}
  1946. onOpenSettings={openSettings}
  1947. helpLabel={() => language.t("sidebar.help")}
  1948. onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
  1949. renderPanel={() => (
  1950. <Show when={currentProject()} keyed>
  1951. {(project) => <SidebarPanel project={project} merged />}
  1952. </Show>
  1953. )}
  1954. />
  1955. </div>
  1956. <Show when={layout.sidebar.opened()}>
  1957. <div onPointerDown={() => setSizing(true)}>
  1958. <ResizeHandle
  1959. direction="horizontal"
  1960. size={layout.sidebar.width()}
  1961. min={244}
  1962. max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
  1963. collapseThreshold={244}
  1964. onResize={(w) => {
  1965. setSizing(true)
  1966. if (sizet !== undefined) clearTimeout(sizet)
  1967. sizet = window.setTimeout(() => setSizing(false), 120)
  1968. layout.sidebar.resize(w)
  1969. }}
  1970. onCollapse={layout.sidebar.close}
  1971. />
  1972. </div>
  1973. </Show>
  1974. </nav>
  1975. <div
  1976. class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
  1977. style={{ left: "calc(4rem + 12px)" }}
  1978. />
  1979. <div class="xl:hidden">
  1980. <div
  1981. classList={{
  1982. "fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
  1983. "opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
  1984. "opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
  1985. }}
  1986. onClick={(e) => {
  1987. if (e.target === e.currentTarget) layout.mobileSidebar.hide()
  1988. }}
  1989. />
  1990. <nav
  1991. aria-label={language.t("sidebar.nav.projectsAndSessions")}
  1992. data-component="sidebar-nav-mobile"
  1993. classList={{
  1994. "@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
  1995. "translate-x-0": layout.mobileSidebar.opened(),
  1996. "-translate-x-full": !layout.mobileSidebar.opened(),
  1997. }}
  1998. onClick={(e) => e.stopPropagation()}
  1999. >
  2000. <SidebarContent
  2001. mobile
  2002. opened={() => layout.sidebar.opened()}
  2003. aimMove={aim.move}
  2004. projects={() => layout.projects.list()}
  2005. renderProject={(project) => (
  2006. <SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile />
  2007. )}
  2008. handleDragStart={handleDragStart}
  2009. handleDragEnd={handleDragEnd}
  2010. handleDragOver={handleDragOver}
  2011. openProjectLabel={language.t("command.project.open")}
  2012. openProjectKeybind={() => command.keybind("project.open")}
  2013. onOpenProject={chooseProject}
  2014. renderProjectOverlay={() => (
  2015. <ProjectDragOverlay
  2016. projects={() => layout.projects.list()}
  2017. activeProject={() => store.activeProject}
  2018. />
  2019. )}
  2020. settingsLabel={() => language.t("sidebar.settings")}
  2021. settingsKeybind={() => command.keybind("settings.open")}
  2022. onOpenSettings={openSettings}
  2023. helpLabel={() => language.t("sidebar.help")}
  2024. onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
  2025. renderPanel={() => <SidebarPanel project={currentProject()} mobile />}
  2026. />
  2027. </nav>
  2028. </div>
  2029. <div
  2030. classList={{
  2031. "absolute inset-0": true,
  2032. "xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
  2033. "z-20": true,
  2034. "transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
  2035. !sizing(),
  2036. }}
  2037. style={{
  2038. "--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
  2039. }}
  2040. >
  2041. <main
  2042. classList={{
  2043. "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
  2044. }}
  2045. >
  2046. <Show when={!autoselecting()} fallback={<div class="size-full" />}>
  2047. {props.children}
  2048. </Show>
  2049. </main>
  2050. </div>
  2051. <div
  2052. classList={{
  2053. "hidden xl:flex absolute inset-y-0 left-16 z-30": true,
  2054. "opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(),
  2055. "opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
  2056. "transition-[opacity,transform] motion-reduce:transition-none": true,
  2057. "duration-180 ease-out": peeked() && !layout.sidebar.opened(),
  2058. "duration-120 ease-in": !peeked() || layout.sidebar.opened(),
  2059. }}
  2060. onMouseMove={disarm}
  2061. onMouseEnter={() => {
  2062. disarm()
  2063. aim.reset()
  2064. }}
  2065. onPointerDown={disarm}
  2066. onMouseLeave={() => {
  2067. arm()
  2068. }}
  2069. >
  2070. <Show when={peek()} keyed>
  2071. {(project) => <SidebarPanel project={project} merged={false} />}
  2072. </Show>
  2073. </div>
  2074. <div
  2075. classList={{
  2076. "hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
  2077. "opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(),
  2078. "opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
  2079. "transition-[opacity,transform] motion-reduce:transition-none": true,
  2080. "duration-180 ease-out": peeked() && !layout.sidebar.opened(),
  2081. "duration-120 ease-in": !peeked() || layout.sidebar.opened(),
  2082. }}
  2083. style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
  2084. >
  2085. <div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
  2086. </div>
  2087. </div>
  2088. </div>
  2089. {import.meta.env.DEV && <DebugBar />}
  2090. </div>
  2091. <Toast.Region />
  2092. </div>
  2093. )
  2094. }