layout.tsx 87 KB

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