@jonsoc/app 1.1.34

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