@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,2505 @@
1
+ import {
2
+ For,
3
+ Index,
4
+ onCleanup,
5
+ onMount,
6
+ Show,
7
+ Match,
8
+ Switch,
9
+ createMemo,
10
+ createEffect,
11
+ on,
12
+ createSignal,
13
+ } from "solid-js"
14
+ import { createMediaQuery } from "@solid-primitives/media"
15
+ import { createResizeObserver } from "@solid-primitives/resize-observer"
16
+ import { Dynamic } from "solid-js/web"
17
+ import { useLocal } from "@/context/local"
18
+ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
19
+ import { createStore } from "solid-js/store"
20
+ import { PromptInput } from "@/components/prompt-input"
21
+ import { SessionContextUsage } from "@/components/session-context-usage"
22
+ import { IconButton } from "@jonsoc/ui/icon-button"
23
+ import { Button } from "@jonsoc/ui/button"
24
+ import { Icon } from "@jonsoc/ui/icon"
25
+ import { Tooltip, TooltipKeybind } from "@jonsoc/ui/tooltip"
26
+ import { DiffChanges } from "@jonsoc/ui/diff-changes"
27
+ import { ResizeHandle } from "@jonsoc/ui/resize-handle"
28
+ import { Tabs } from "@jonsoc/ui/tabs"
29
+ import { useCodeComponent } from "@jonsoc/ui/context/code"
30
+ import { SessionTurn } from "@jonsoc/ui/session-turn"
31
+ import { createAutoScroll } from "@jonsoc/ui/hooks"
32
+ import { SessionReview } from "@jonsoc/ui/session-review"
33
+ import { Mark } from "@jonsoc/ui/logo"
34
+ import { getFiletypeFromFileName } from "@pierre/diffs"
35
+
36
+ import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
37
+ import type { DragEvent } from "@thisbeyond/solid-dnd"
38
+ import { useSync } from "@/context/sync"
39
+ import { useTerminal, type LocalPTY } from "@/context/terminal"
40
+ import { useLayout } from "@/context/layout"
41
+ import { Terminal } from "@/components/terminal"
42
+ import { checksum, base64Encode, base64Decode } from "@jonsoc/util/encode"
43
+ import { getFilename } from "@jonsoc/util/path"
44
+ import { useDialog } from "@jonsoc/ui/context/dialog"
45
+ import { DialogSelectFile } from "@/components/dialog-select-file"
46
+ import { DialogSelectModel } from "@/components/dialog-select-model"
47
+ import { DialogSelectMcp } from "@/components/dialog-select-mcp"
48
+ import { DialogFork } from "@/components/dialog-fork"
49
+ import { useCommand } from "@/context/command"
50
+ import { useLanguage } from "@/context/language"
51
+ import { useNavigate, useParams } from "@solidjs/router"
52
+ import { UserMessage } from "@jonsoc/sdk/v2"
53
+ import type { FileDiff } from "@jonsoc/sdk/v2/client"
54
+ import { useSDK } from "@/context/sdk"
55
+ import { usePrompt } from "@/context/prompt"
56
+ import { useComments, type LineComment } from "@/context/comments"
57
+ import { extractPromptFromParts } from "@/utils/prompt"
58
+ import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
59
+ import { usePermission } from "@/context/permission"
60
+ import { showToast } from "@jonsoc/ui/toast"
61
+ import {
62
+ SessionHeader,
63
+ SessionContextTab,
64
+ SortableTab,
65
+ FileVisual,
66
+ SortableTerminalTab,
67
+ NewSessionView,
68
+ } from "@/components/session"
69
+ import { usePlatform } from "@/context/platform"
70
+ import { navMark, navParams } from "@/utils/perf"
71
+ import { same } from "@/utils/same"
72
+
73
+ type DiffStyle = "unified" | "split"
74
+
75
+ const handoff = {
76
+ prompt: "",
77
+ terminals: [] as string[],
78
+ files: {} as Record<string, SelectedLineRange | null>,
79
+ }
80
+
81
+ interface SessionReviewTabProps {
82
+ diffs: () => FileDiff[]
83
+ view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
84
+ diffStyle: DiffStyle
85
+ onDiffStyleChange?: (style: DiffStyle) => void
86
+ onViewFile?: (file: string) => void
87
+ onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
88
+ comments?: LineComment[]
89
+ focusedComment?: { file: string; id: string } | null
90
+ onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
91
+ classes?: {
92
+ root?: string
93
+ header?: string
94
+ container?: string
95
+ }
96
+ }
97
+
98
+ function SessionReviewTab(props: SessionReviewTabProps) {
99
+ let scroll: HTMLDivElement | undefined
100
+ let frame: number | undefined
101
+ let pending: { x: number; y: number } | undefined
102
+
103
+ const sdk = useSDK()
104
+
105
+ const readFile = (path: string) => {
106
+ return sdk.client.file
107
+ .read({ path })
108
+ .then((x) => x.data)
109
+ .catch(() => undefined)
110
+ }
111
+
112
+ const restoreScroll = () => {
113
+ const el = scroll
114
+ if (!el) return
115
+
116
+ const s = props.view().scroll("review")
117
+ if (!s) return
118
+
119
+ if (el.scrollTop !== s.y) el.scrollTop = s.y
120
+ if (el.scrollLeft !== s.x) el.scrollLeft = s.x
121
+ }
122
+
123
+ const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
124
+ pending = {
125
+ x: event.currentTarget.scrollLeft,
126
+ y: event.currentTarget.scrollTop,
127
+ }
128
+ if (frame !== undefined) return
129
+
130
+ frame = requestAnimationFrame(() => {
131
+ frame = undefined
132
+
133
+ const next = pending
134
+ pending = undefined
135
+ if (!next) return
136
+
137
+ props.view().setScroll("review", next)
138
+ })
139
+ }
140
+
141
+ createEffect(
142
+ on(
143
+ () => props.diffs().length,
144
+ () => {
145
+ requestAnimationFrame(restoreScroll)
146
+ },
147
+ { defer: true },
148
+ ),
149
+ )
150
+
151
+ onCleanup(() => {
152
+ if (frame === undefined) return
153
+ cancelAnimationFrame(frame)
154
+ })
155
+
156
+ return (
157
+ <SessionReview
158
+ scrollRef={(el) => {
159
+ scroll = el
160
+ restoreScroll()
161
+ }}
162
+ onScroll={handleScroll}
163
+ onDiffRendered={() => requestAnimationFrame(restoreScroll)}
164
+ open={props.view().review.open()}
165
+ onOpenChange={props.view().review.setOpen}
166
+ classes={{
167
+ root: props.classes?.root ?? "pb-40",
168
+ header: props.classes?.header ?? "px-6",
169
+ container: props.classes?.container ?? "px-6",
170
+ }}
171
+ diffs={props.diffs()}
172
+ diffStyle={props.diffStyle}
173
+ onDiffStyleChange={props.onDiffStyleChange}
174
+ onViewFile={props.onViewFile}
175
+ readFile={readFile}
176
+ onLineComment={props.onLineComment}
177
+ comments={props.comments}
178
+ focusedComment={props.focusedComment}
179
+ onFocusedCommentChange={props.onFocusedCommentChange}
180
+ />
181
+ )
182
+ }
183
+
184
+ export default function Page() {
185
+ const layout = useLayout()
186
+ const local = useLocal()
187
+ const file = useFile()
188
+ const sync = useSync()
189
+ const terminal = useTerminal()
190
+ const dialog = useDialog()
191
+ const codeComponent = useCodeComponent()
192
+ const command = useCommand()
193
+ const language = useLanguage()
194
+ const platform = usePlatform()
195
+ const params = useParams()
196
+ const navigate = useNavigate()
197
+ const sdk = useSDK()
198
+ const prompt = usePrompt()
199
+ const comments = useComments()
200
+ const permission = usePermission()
201
+ const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
202
+ const dirKey = createMemo(() => params.dir ?? "")
203
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
204
+ const tabs = createMemo(() => layout.tabs(dirKey))
205
+ const view = createMemo(() => layout.view(sessionKey))
206
+
207
+ if (import.meta.env.DEV) {
208
+ createEffect(
209
+ on(
210
+ () => [params.dir, params.id] as const,
211
+ ([dir, id], prev) => {
212
+ if (!id) return
213
+ navParams({ dir, from: prev?.[1], to: id })
214
+ },
215
+ ),
216
+ )
217
+
218
+ createEffect(() => {
219
+ const id = params.id
220
+ if (!id) return
221
+ if (!prompt.ready()) return
222
+ navMark({ dir: params.dir, to: id, name: "storage:prompt-ready" })
223
+ })
224
+
225
+ createEffect(() => {
226
+ const id = params.id
227
+ if (!id) return
228
+ if (!terminal.ready()) return
229
+ navMark({ dir: params.dir, to: id, name: "storage:terminal-ready" })
230
+ })
231
+
232
+ createEffect(() => {
233
+ const id = params.id
234
+ if (!id) return
235
+ if (!file.ready()) return
236
+ navMark({ dir: params.dir, to: id, name: "storage:file-view-ready" })
237
+ })
238
+
239
+ createEffect(() => {
240
+ const id = params.id
241
+ if (!id) return
242
+ if (sync.data.message[id] === undefined) return
243
+ navMark({ dir: params.dir, to: id, name: "session:data-ready" })
244
+ })
245
+ }
246
+
247
+ const isDesktop = createMediaQuery("(min-width: 768px)")
248
+
249
+ function normalizeTab(tab: string) {
250
+ if (!tab.startsWith("file://")) return tab
251
+ return file.tab(tab)
252
+ }
253
+
254
+ function normalizeTabs(list: string[]) {
255
+ const seen = new Set<string>()
256
+ const next: string[] = []
257
+ for (const item of list) {
258
+ const value = normalizeTab(item)
259
+ if (seen.has(value)) continue
260
+ seen.add(value)
261
+ next.push(value)
262
+ }
263
+ return next
264
+ }
265
+
266
+ const openTab = (value: string) => {
267
+ const next = normalizeTab(value)
268
+ tabs().open(next)
269
+
270
+ const path = file.pathFromTab(next)
271
+ if (path) file.load(path)
272
+ }
273
+
274
+ createEffect(() => {
275
+ const active = tabs().active()
276
+ if (!active) return
277
+
278
+ const path = file.pathFromTab(active)
279
+ if (path) file.load(path)
280
+ })
281
+
282
+ createEffect(() => {
283
+ const current = tabs().all()
284
+ if (current.length === 0) return
285
+
286
+ const next = normalizeTabs(current)
287
+ if (same(current, next)) return
288
+
289
+ tabs().setAll(next)
290
+
291
+ const active = tabs().active()
292
+ if (!active) return
293
+ if (!active.startsWith("file://")) return
294
+
295
+ const normalized = normalizeTab(active)
296
+ if (active === normalized) return
297
+ tabs().setActive(normalized)
298
+ })
299
+
300
+ const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
301
+ const reviewCount = createMemo(() => info()?.summary?.files ?? 0)
302
+ const hasReview = createMemo(() => reviewCount() > 0)
303
+ const revertMessageID = createMemo(() => info()?.revert?.messageID)
304
+ const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
305
+ const messagesReady = createMemo(() => {
306
+ const id = params.id
307
+ if (!id) return true
308
+ return sync.data.message[id] !== undefined
309
+ })
310
+ const historyMore = createMemo(() => {
311
+ const id = params.id
312
+ if (!id) return false
313
+ return sync.session.history.more(id)
314
+ })
315
+ const historyLoading = createMemo(() => {
316
+ const id = params.id
317
+ if (!id) return false
318
+ return sync.session.history.loading(id)
319
+ })
320
+ const emptyUserMessages: UserMessage[] = []
321
+ const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
322
+ const visibleUserMessages = createMemo(() => {
323
+ const revert = revertMessageID()
324
+ if (!revert) return userMessages()
325
+ return userMessages().filter((m) => m.id < revert)
326
+ }, emptyUserMessages)
327
+ const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
328
+
329
+ createEffect(
330
+ on(
331
+ () => lastUserMessage()?.id,
332
+ () => {
333
+ const msg = lastUserMessage()
334
+ if (!msg) return
335
+ if (msg.agent) local.agent.set(msg.agent)
336
+ if (msg.model) local.model.set(msg.model)
337
+ },
338
+ ),
339
+ )
340
+
341
+ const [store, setStore] = createStore({
342
+ activeDraggable: undefined as string | undefined,
343
+ activeTerminalDraggable: undefined as string | undefined,
344
+ expanded: {} as Record<string, boolean>,
345
+ messageId: undefined as string | undefined,
346
+ turnStart: 0,
347
+ mobileTab: "session" as "session" | "review",
348
+ newSessionWorktree: "main",
349
+ promptHeight: 0,
350
+ })
351
+
352
+ const renderedUserMessages = createMemo(() => {
353
+ const msgs = visibleUserMessages()
354
+ const start = store.turnStart
355
+ if (start <= 0) return msgs
356
+ if (start >= msgs.length) return emptyUserMessages
357
+ return msgs.slice(start)
358
+ }, emptyUserMessages)
359
+
360
+ const newSessionWorktree = createMemo(() => {
361
+ if (store.newSessionWorktree === "create") return "create"
362
+ const project = sync.project
363
+ if (project && sync.data.path.directory !== project.worktree) return sync.data.path.directory
364
+ return "main"
365
+ })
366
+
367
+ const activeMessage = createMemo(() => {
368
+ if (!store.messageId) return lastUserMessage()
369
+ const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
370
+ return found ?? lastUserMessage()
371
+ })
372
+ const setActiveMessage = (message: UserMessage | undefined) => {
373
+ setStore("messageId", message?.id)
374
+ }
375
+
376
+ function navigateMessageByOffset(offset: number) {
377
+ const msgs = visibleUserMessages()
378
+ if (msgs.length === 0) return
379
+
380
+ const current = activeMessage()
381
+ const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1
382
+ const targetIndex = currentIndex === -1 ? (offset > 0 ? 0 : msgs.length - 1) : currentIndex + offset
383
+ if (targetIndex < 0 || targetIndex >= msgs.length) return
384
+
385
+ scrollToMessage(msgs[targetIndex], "auto")
386
+ }
387
+
388
+ const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
389
+ const diffsReady = createMemo(() => {
390
+ const id = params.id
391
+ if (!id) return true
392
+ if (!hasReview()) return true
393
+ return sync.data.session_diff[id] !== undefined
394
+ })
395
+
396
+ const idle = { type: "idle" as const }
397
+ let inputRef!: HTMLDivElement
398
+ let promptDock: HTMLDivElement | undefined
399
+ let scroller: HTMLDivElement | undefined
400
+
401
+ const [scrollGesture, setScrollGesture] = createSignal(0)
402
+ const scrollGestureWindowMs = 250
403
+
404
+ const markScrollGesture = (target?: EventTarget | null) => {
405
+ const root = scroller
406
+ if (!root) return
407
+
408
+ const el = target instanceof Element ? target : undefined
409
+ const nested = el?.closest("[data-scrollable]")
410
+ if (nested && nested !== root) return
411
+
412
+ setScrollGesture(Date.now())
413
+ }
414
+
415
+ const hasScrollGesture = () => Date.now() - scrollGesture() < scrollGestureWindowMs
416
+
417
+ createEffect(() => {
418
+ if (!params.id) return
419
+ sync.session.sync(params.id)
420
+ })
421
+
422
+ const [autoCreated, setAutoCreated] = createSignal(false)
423
+
424
+ createEffect(() => {
425
+ if (!view().terminal.opened()) {
426
+ setAutoCreated(false)
427
+ return
428
+ }
429
+ if (!terminal.ready() || terminal.all().length !== 0 || autoCreated()) return
430
+ terminal.new()
431
+ setAutoCreated(true)
432
+ })
433
+
434
+ createEffect(
435
+ on(
436
+ () => terminal.all().length,
437
+ (count, prevCount) => {
438
+ if (prevCount !== undefined && prevCount > 0 && count === 0) {
439
+ if (view().terminal.opened()) {
440
+ view().terminal.toggle()
441
+ }
442
+ }
443
+ },
444
+ ),
445
+ )
446
+
447
+ createEffect(
448
+ on(
449
+ () => terminal.active(),
450
+ (activeId) => {
451
+ if (!activeId || !view().terminal.opened()) return
452
+ // Immediately remove focus
453
+ if (document.activeElement instanceof HTMLElement) {
454
+ document.activeElement.blur()
455
+ }
456
+ const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
457
+ const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
458
+ if (!element) return
459
+
460
+ // Find and focus the ghostty textarea (the actual input element)
461
+ const textarea = element.querySelector("textarea") as HTMLTextAreaElement
462
+ if (textarea) {
463
+ textarea.focus()
464
+ return
465
+ }
466
+ // Fallback: focus container and dispatch pointer event
467
+ element.focus()
468
+ element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
469
+ },
470
+ ),
471
+ )
472
+
473
+ createEffect(
474
+ on(
475
+ () => visibleUserMessages().at(-1)?.id,
476
+ (lastId, prevLastId) => {
477
+ if (lastId && prevLastId && lastId > prevLastId) {
478
+ setStore("messageId", undefined)
479
+ }
480
+ },
481
+ { defer: true },
482
+ ),
483
+ )
484
+
485
+ const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
486
+
487
+ createEffect(
488
+ on(
489
+ () => params.id,
490
+ () => {
491
+ setStore("messageId", undefined)
492
+ setStore("expanded", {})
493
+ },
494
+ { defer: true },
495
+ ),
496
+ )
497
+
498
+ createEffect(() => {
499
+ const id = lastUserMessage()?.id
500
+ if (!id) return
501
+ setStore("expanded", id, status().type !== "idle")
502
+ })
503
+
504
+ const selectionPreview = (path: string, selection: FileSelection) => {
505
+ const content = file.get(path)?.content?.content
506
+ if (!content) return undefined
507
+ const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
508
+ const end = Math.max(selection.startLine, selection.endLine)
509
+ const lines = content.split("\n").slice(start - 1, end)
510
+ if (lines.length === 0) return undefined
511
+ return lines.slice(0, 2).join("\n")
512
+ }
513
+
514
+ const addSelectionToContext = (path: string, selection: FileSelection) => {
515
+ const preview = selectionPreview(path, selection)
516
+ prompt.context.add({ type: "file", path, selection, preview })
517
+ }
518
+
519
+ const addCommentToContext = (input: {
520
+ file: string
521
+ selection: SelectedLineRange
522
+ comment: string
523
+ preview?: string
524
+ }) => {
525
+ const selection = selectionFromLines(input.selection)
526
+ const preview = input.preview ?? selectionPreview(input.file, selection)
527
+ const saved = comments.add({
528
+ file: input.file,
529
+ selection: input.selection,
530
+ comment: input.comment,
531
+ })
532
+ prompt.context.add({
533
+ type: "file",
534
+ path: input.file,
535
+ selection,
536
+ comment: input.comment,
537
+ commentID: saved.id,
538
+ preview,
539
+ })
540
+ }
541
+
542
+ command.register(() => [
543
+ {
544
+ id: "session.new",
545
+ title: "New session",
546
+ category: "Session",
547
+ keybind: "mod+shift+s",
548
+ slash: "new",
549
+ onSelect: () => navigate(`/${params.dir}/session`),
550
+ },
551
+ {
552
+ id: "file.open",
553
+ title: "Open file",
554
+ description: "Search files and commands",
555
+ category: "File",
556
+ keybind: "mod+p",
557
+ slash: "open",
558
+ onSelect: () => dialog.show(() => <DialogSelectFile />),
559
+ },
560
+ {
561
+ id: "context.addSelection",
562
+ title: "Add selection to context",
563
+ description: "Add selected lines from the current file",
564
+ category: "Context",
565
+ keybind: "mod+shift+l",
566
+ disabled: (() => {
567
+ const active = tabs().active()
568
+ if (!active) return true
569
+ const path = file.pathFromTab(active)
570
+ if (!path) return true
571
+ return file.selectedLines(path) == null
572
+ })(),
573
+ onSelect: () => {
574
+ const active = tabs().active()
575
+ if (!active) return
576
+ const path = file.pathFromTab(active)
577
+ if (!path) return
578
+
579
+ const range = file.selectedLines(path)
580
+ if (!range) {
581
+ showToast({
582
+ title: "No line selection",
583
+ description: "Select a line range in a file tab first.",
584
+ })
585
+ return
586
+ }
587
+
588
+ addSelectionToContext(path, selectionFromLines(range))
589
+ },
590
+ },
591
+ {
592
+ id: "terminal.toggle",
593
+ title: "Toggle terminal",
594
+ description: "",
595
+ category: "View",
596
+ keybind: "ctrl+`",
597
+ slash: "terminal",
598
+ onSelect: () => view().terminal.toggle(),
599
+ },
600
+ {
601
+ id: "review.toggle",
602
+ title: "Toggle review",
603
+ description: "",
604
+ category: "View",
605
+ keybind: "mod+shift+r",
606
+ onSelect: () => view().reviewPanel.toggle(),
607
+ },
608
+ {
609
+ id: "terminal.new",
610
+ title: language.t("command.terminal.new"),
611
+ description: language.t("command.terminal.new.description"),
612
+ category: language.t("command.category.terminal"),
613
+ keybind: "ctrl+alt+t",
614
+ onSelect: () => {
615
+ if (terminal.all().length > 0) terminal.new()
616
+ view().terminal.open()
617
+ },
618
+ },
619
+ {
620
+ id: "steps.toggle",
621
+ title: "Toggle steps",
622
+ description: "Show or hide steps for the current message",
623
+ category: "View",
624
+ keybind: "mod+e",
625
+ slash: "steps",
626
+ disabled: !params.id,
627
+ onSelect: () => {
628
+ const msg = activeMessage()
629
+ if (!msg) return
630
+ setStore("expanded", msg.id, (open: boolean | undefined) => !open)
631
+ },
632
+ },
633
+ {
634
+ id: "message.previous",
635
+ title: "Previous message",
636
+ description: "Go to the previous user message",
637
+ category: "Session",
638
+ keybind: "mod+arrowup",
639
+ disabled: !params.id,
640
+ onSelect: () => navigateMessageByOffset(-1),
641
+ },
642
+ {
643
+ id: "message.next",
644
+ title: "Next message",
645
+ description: "Go to the next user message",
646
+ category: "Session",
647
+ keybind: "mod+arrowdown",
648
+ disabled: !params.id,
649
+ onSelect: () => navigateMessageByOffset(1),
650
+ },
651
+ {
652
+ id: "model.choose",
653
+ title: "Choose model",
654
+ description: "Select a different model",
655
+ category: "Model",
656
+ keybind: "mod+'",
657
+ slash: "model",
658
+ onSelect: () => dialog.show(() => <DialogSelectModel />),
659
+ },
660
+ {
661
+ id: "mcp.toggle",
662
+ title: "Toggle MCPs",
663
+ description: "Toggle MCPs",
664
+ category: "MCP",
665
+ keybind: "mod+;",
666
+ slash: "mcp",
667
+ onSelect: () => dialog.show(() => <DialogSelectMcp />),
668
+ },
669
+ {
670
+ id: "agent.cycle",
671
+ title: "Cycle agent",
672
+ description: "Switch to the next agent",
673
+ category: "Agent",
674
+ keybind: "mod+.",
675
+ slash: "agent",
676
+ onSelect: () => local.agent.move(1),
677
+ },
678
+ {
679
+ id: "agent.cycle.reverse",
680
+ title: "Cycle agent backwards",
681
+ description: "Switch to the previous agent",
682
+ category: "Agent",
683
+ keybind: "shift+mod+.",
684
+ onSelect: () => local.agent.move(-1),
685
+ },
686
+ {
687
+ id: "model.variant.cycle",
688
+ title: "Cycle thinking effort",
689
+ description: "Switch to the next effort level",
690
+ category: "Model",
691
+ keybind: "shift+mod+d",
692
+ onSelect: () => {
693
+ local.model.variant.cycle()
694
+ },
695
+ },
696
+ {
697
+ id: "permissions.autoaccept",
698
+ title:
699
+ params.id && permission.isAutoAccepting(params.id, sdk.directory)
700
+ ? "Stop auto-accepting edits"
701
+ : "Auto-accept edits",
702
+ category: "Permissions",
703
+ keybind: "mod+shift+a",
704
+ disabled: !params.id || !permission.permissionsEnabled(),
705
+ onSelect: () => {
706
+ const sessionID = params.id
707
+ if (!sessionID) return
708
+ permission.toggleAutoAccept(sessionID, sdk.directory)
709
+ showToast({
710
+ title: permission.isAutoAccepting(sessionID, sdk.directory)
711
+ ? "Auto-accepting edits"
712
+ : "Stopped auto-accepting edits",
713
+ description: permission.isAutoAccepting(sessionID, sdk.directory)
714
+ ? "Edit and write permissions will be automatically approved"
715
+ : "Edit and write permissions will require approval",
716
+ })
717
+ },
718
+ },
719
+ {
720
+ id: "session.undo",
721
+ title: "Undo",
722
+ description: "Undo the last message",
723
+ category: "Session",
724
+ slash: "undo",
725
+ disabled: !params.id || visibleUserMessages().length === 0,
726
+ onSelect: async () => {
727
+ const sessionID = params.id
728
+ if (!sessionID) return
729
+ if (status()?.type !== "idle") {
730
+ await sdk.client.session.abort({ sessionID }).catch(() => {})
731
+ }
732
+ const revert = info()?.revert?.messageID
733
+ // Find the last user message that's not already reverted
734
+ const message = userMessages().findLast((x) => !revert || x.id < revert)
735
+ if (!message) return
736
+ await sdk.client.session.revert({ sessionID, messageID: message.id })
737
+ // Restore the prompt from the reverted message
738
+ const parts = sync.data.part[message.id]
739
+ if (parts) {
740
+ const restored = extractPromptFromParts(parts, { directory: sdk.directory })
741
+ prompt.set(restored)
742
+ }
743
+ // Navigate to the message before the reverted one (which will be the new last visible message)
744
+ const priorMessage = userMessages().findLast((x) => x.id < message.id)
745
+ setActiveMessage(priorMessage)
746
+ },
747
+ },
748
+ {
749
+ id: "session.redo",
750
+ title: "Redo",
751
+ description: "Redo the last undone message",
752
+ category: "Session",
753
+ slash: "redo",
754
+ disabled: !params.id || !info()?.revert?.messageID,
755
+ onSelect: async () => {
756
+ const sessionID = params.id
757
+ if (!sessionID) return
758
+ const revertMessageID = info()?.revert?.messageID
759
+ if (!revertMessageID) return
760
+ const nextMessage = userMessages().find((x) => x.id > revertMessageID)
761
+ if (!nextMessage) {
762
+ // Full unrevert - restore all messages and navigate to last
763
+ await sdk.client.session.unrevert({ sessionID })
764
+ prompt.reset()
765
+ // Navigate to the last message (the one that was at the revert point)
766
+ const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID)
767
+ setActiveMessage(lastMsg)
768
+ return
769
+ }
770
+ // Partial redo - move forward to next message
771
+ await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
772
+ // Navigate to the message before the new revert point
773
+ const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id)
774
+ setActiveMessage(priorMsg)
775
+ },
776
+ },
777
+ {
778
+ id: "session.compact",
779
+ title: "Compact session",
780
+ description: "Summarize the session to reduce context size",
781
+ category: "Session",
782
+ slash: "compact",
783
+ disabled: !params.id || visibleUserMessages().length === 0,
784
+ onSelect: async () => {
785
+ const sessionID = params.id
786
+ if (!sessionID) return
787
+ const model = local.model.current()
788
+ if (!model) {
789
+ showToast({
790
+ title: "No model selected",
791
+ description: "Connect a provider to summarize this session",
792
+ })
793
+ return
794
+ }
795
+ await sdk.client.session.summarize({
796
+ sessionID,
797
+ modelID: model.id,
798
+ providerID: model.provider.id,
799
+ })
800
+ },
801
+ },
802
+ {
803
+ id: "session.fork",
804
+ title: "Fork from message",
805
+ description: "Create a new session from a previous message",
806
+ category: "Session",
807
+ slash: "fork",
808
+ disabled: !params.id || visibleUserMessages().length === 0,
809
+ onSelect: () => dialog.show(() => <DialogFork />),
810
+ },
811
+ ...(sync.data.config.share !== "disabled"
812
+ ? [
813
+ {
814
+ id: "session.share",
815
+ title: "Share session",
816
+ description: "Share this session and copy the URL to clipboard",
817
+ category: "Session",
818
+ slash: "share",
819
+ disabled: !params.id || !!info()?.share?.url,
820
+ onSelect: async () => {
821
+ if (!params.id) return
822
+ await sdk.client.session
823
+ .share({ sessionID: params.id })
824
+ .then((res) => {
825
+ navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
826
+ showToast({
827
+ title: "Failed to copy URL to clipboard",
828
+ variant: "error",
829
+ }),
830
+ )
831
+ })
832
+ .then(() =>
833
+ showToast({
834
+ title: "Session shared",
835
+ description: "Share URL copied to clipboard!",
836
+ variant: "success",
837
+ }),
838
+ )
839
+ .catch(() =>
840
+ showToast({
841
+ title: "Failed to share session",
842
+ description: "An error occurred while sharing the session",
843
+ variant: "error",
844
+ }),
845
+ )
846
+ },
847
+ },
848
+ {
849
+ id: "session.unshare",
850
+ title: "Unshare session",
851
+ description: "Stop sharing this session",
852
+ category: "Session",
853
+ slash: "unshare",
854
+ disabled: !params.id || !info()?.share?.url,
855
+ onSelect: async () => {
856
+ if (!params.id) return
857
+ await sdk.client.session
858
+ .unshare({ sessionID: params.id })
859
+ .then(() =>
860
+ showToast({
861
+ title: "Session unshared",
862
+ description: "Session unshared successfully!",
863
+ variant: "success",
864
+ }),
865
+ )
866
+ .catch(() =>
867
+ showToast({
868
+ title: "Failed to unshare session",
869
+ description: "An error occurred while unsharing the session",
870
+ variant: "error",
871
+ }),
872
+ )
873
+ },
874
+ },
875
+ ]
876
+ : []),
877
+ ])
878
+
879
+ const handleKeyDown = (event: KeyboardEvent) => {
880
+ const activeElement = document.activeElement as HTMLElement | undefined
881
+ if (activeElement) {
882
+ const isProtected = activeElement.closest("[data-prevent-autofocus]")
883
+ const isInput = /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(activeElement.tagName) || activeElement.isContentEditable
884
+ if (isProtected || isInput) return
885
+ }
886
+ if (dialog.active) return
887
+
888
+ if (activeElement === inputRef) {
889
+ if (event.key === "Escape") inputRef?.blur()
890
+ return
891
+ }
892
+
893
+ // Don't autofocus chat if terminal panel is open
894
+ if (view().terminal.opened()) return
895
+
896
+ // Only treat explicit scroll keys as potential "user scroll" gestures.
897
+ if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
898
+ markScrollGesture()
899
+ return
900
+ }
901
+
902
+ if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
903
+ inputRef?.focus()
904
+ }
905
+ }
906
+
907
+ const handleDragStart = (event: unknown) => {
908
+ const id = getDraggableId(event)
909
+ if (!id) return
910
+ setStore("activeDraggable", id)
911
+ }
912
+
913
+ const handleDragOver = (event: DragEvent) => {
914
+ const { draggable, droppable } = event
915
+ if (draggable && droppable) {
916
+ const currentTabs = tabs().all()
917
+ const fromIndex = currentTabs?.indexOf(draggable.id.toString())
918
+ const toIndex = currentTabs?.indexOf(droppable.id.toString())
919
+ if (fromIndex !== toIndex && toIndex !== undefined) {
920
+ tabs().move(draggable.id.toString(), toIndex)
921
+ }
922
+ }
923
+ }
924
+
925
+ const handleDragEnd = () => {
926
+ setStore("activeDraggable", undefined)
927
+ }
928
+
929
+ const handleTerminalDragStart = (event: unknown) => {
930
+ const id = getDraggableId(event)
931
+ if (!id) return
932
+ setStore("activeTerminalDraggable", id)
933
+ }
934
+
935
+ const handleTerminalDragOver = (event: DragEvent) => {
936
+ const { draggable, droppable } = event
937
+ if (draggable && droppable) {
938
+ const terminals = terminal.all()
939
+ const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
940
+ const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
941
+ if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
942
+ terminal.move(draggable.id.toString(), toIndex)
943
+ }
944
+ }
945
+ }
946
+
947
+ const handleTerminalDragEnd = () => {
948
+ setStore("activeTerminalDraggable", undefined)
949
+ const activeId = terminal.active()
950
+ if (!activeId) return
951
+ setTimeout(() => {
952
+ const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
953
+ const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
954
+ if (!element) return
955
+
956
+ // Find and focus the ghostty textarea (the actual input element)
957
+ const textarea = element.querySelector("textarea") as HTMLTextAreaElement
958
+ if (textarea) {
959
+ textarea.focus()
960
+ return
961
+ }
962
+ // Fallback: focus container and dispatch pointer event
963
+ element.focus()
964
+ element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
965
+ }, 0)
966
+ }
967
+
968
+ const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
969
+ const openedTabs = createMemo(() =>
970
+ tabs()
971
+ .all()
972
+ .filter((tab) => tab !== "context"),
973
+ )
974
+
975
+ const mobileReview = createMemo(() => !isDesktop() && layout.review.panelOpened() && store.mobileTab === "review")
976
+
977
+ const showTabs = createMemo(() => layout.review.panelOpened())
978
+
979
+ const activeTab = createMemo(() => {
980
+ const active = tabs().active()
981
+ if (active) return active
982
+ if (hasReview()) return "review"
983
+
984
+ const first = openedTabs()[0]
985
+ if (first) return first
986
+ if (contextOpen()) return "context"
987
+ return "review"
988
+ })
989
+
990
+ createEffect(() => {
991
+ if (!layout.ready()) return
992
+ if (tabs().active()) return
993
+ if (!hasReview() && openedTabs().length === 0 && !contextOpen()) return
994
+ tabs().setActive(activeTab())
995
+ })
996
+
997
+ createEffect(() => {
998
+ const id = params.id
999
+ if (!id) return
1000
+ if (!hasReview()) return
1001
+
1002
+ const wants = isDesktop() ? layout.review.panelOpened() && activeTab() === "review" : store.mobileTab === "review"
1003
+ if (!wants) return
1004
+ if (diffsReady()) return
1005
+
1006
+ sync.session.diff(id)
1007
+ })
1008
+
1009
+ const autoScroll = createAutoScroll({
1010
+ working: () => true,
1011
+ overflowAnchor: "dynamic",
1012
+ })
1013
+
1014
+ const resumeScroll = () => {
1015
+ setStore("messageId", undefined)
1016
+ autoScroll.forceScrollToBottom()
1017
+ }
1018
+
1019
+ // When the user returns to the bottom, treat the active message as "latest".
1020
+ createEffect(
1021
+ on(
1022
+ autoScroll.userScrolled,
1023
+ (scrolled) => {
1024
+ if (scrolled) return
1025
+ setStore("messageId", undefined)
1026
+ },
1027
+ { defer: true },
1028
+ ),
1029
+ )
1030
+
1031
+ let scrollSpyFrame: number | undefined
1032
+ let scrollSpyTarget: HTMLDivElement | undefined
1033
+
1034
+ const anchor = (id: string) => `message-${id}`
1035
+
1036
+ const setScrollRef = (el: HTMLDivElement | undefined) => {
1037
+ scroller = el
1038
+ autoScroll.scrollRef(el)
1039
+ }
1040
+
1041
+ const turnInit = 20
1042
+ const turnBatch = 20
1043
+ let turnHandle: number | undefined
1044
+ let turnIdle = false
1045
+
1046
+ function cancelTurnBackfill() {
1047
+ const handle = turnHandle
1048
+ if (handle === undefined) return
1049
+ turnHandle = undefined
1050
+
1051
+ if (turnIdle && window.cancelIdleCallback) {
1052
+ window.cancelIdleCallback(handle)
1053
+ return
1054
+ }
1055
+
1056
+ clearTimeout(handle)
1057
+ }
1058
+
1059
+ function scheduleTurnBackfill() {
1060
+ if (turnHandle !== undefined) return
1061
+ if (store.turnStart <= 0) return
1062
+
1063
+ if (window.requestIdleCallback) {
1064
+ turnIdle = true
1065
+ turnHandle = window.requestIdleCallback(() => {
1066
+ turnHandle = undefined
1067
+ backfillTurns()
1068
+ })
1069
+ return
1070
+ }
1071
+
1072
+ turnIdle = false
1073
+ turnHandle = window.setTimeout(() => {
1074
+ turnHandle = undefined
1075
+ backfillTurns()
1076
+ }, 0)
1077
+ }
1078
+
1079
+ function backfillTurns() {
1080
+ const start = store.turnStart
1081
+ if (start <= 0) return
1082
+
1083
+ const next = start - turnBatch
1084
+ const nextStart = next > 0 ? next : 0
1085
+
1086
+ const el = scroller
1087
+ if (!el) {
1088
+ setStore("turnStart", nextStart)
1089
+ scheduleTurnBackfill()
1090
+ return
1091
+ }
1092
+
1093
+ const beforeTop = el.scrollTop
1094
+ const beforeHeight = el.scrollHeight
1095
+
1096
+ setStore("turnStart", nextStart)
1097
+
1098
+ requestAnimationFrame(() => {
1099
+ const delta = el.scrollHeight - beforeHeight
1100
+ if (delta) el.scrollTop = beforeTop + delta
1101
+ })
1102
+
1103
+ scheduleTurnBackfill()
1104
+ }
1105
+
1106
+ createEffect(
1107
+ on(
1108
+ () => [params.id, messagesReady()] as const,
1109
+ ([id, ready]) => {
1110
+ cancelTurnBackfill()
1111
+ setStore("turnStart", 0)
1112
+ if (!id || !ready) return
1113
+
1114
+ const len = visibleUserMessages().length
1115
+ const start = len > turnInit ? len - turnInit : 0
1116
+ setStore("turnStart", start)
1117
+ scheduleTurnBackfill()
1118
+ },
1119
+ { defer: true },
1120
+ ),
1121
+ )
1122
+
1123
+ createResizeObserver(
1124
+ () => promptDock,
1125
+ ({ height }) => {
1126
+ const next = Math.ceil(height)
1127
+
1128
+ if (next === store.promptHeight) return
1129
+
1130
+ const el = scroller
1131
+ const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false
1132
+
1133
+ setStore("promptHeight", next)
1134
+
1135
+ if (stick && el) {
1136
+ requestAnimationFrame(() => {
1137
+ el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
1138
+ })
1139
+ }
1140
+ },
1141
+ )
1142
+
1143
+ const updateHash = (id: string) => {
1144
+ window.history.replaceState(null, "", `#${anchor(id)}`)
1145
+ }
1146
+
1147
+ createEffect(() => {
1148
+ const sessionID = params.id
1149
+ if (!sessionID) return
1150
+ const raw = sessionStorage.getItem("jonsoc.pendingMessage")
1151
+ if (!raw) return
1152
+ const parts = raw.split("|")
1153
+ const pendingSessionID = parts[0]
1154
+ const messageID = parts[1]
1155
+ if (!pendingSessionID || !messageID) return
1156
+ if (pendingSessionID !== sessionID) return
1157
+
1158
+ sessionStorage.removeItem("jonsoc.pendingMessage")
1159
+ setPendingMessage(messageID)
1160
+ })
1161
+
1162
+ const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
1163
+ const root = scroller
1164
+ if (!root) return false
1165
+
1166
+ const a = el.getBoundingClientRect()
1167
+ const b = root.getBoundingClientRect()
1168
+ const top = a.top - b.top + root.scrollTop
1169
+ root.scrollTo({ top, behavior })
1170
+ return true
1171
+ }
1172
+
1173
+ const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
1174
+ setActiveMessage(message)
1175
+
1176
+ const msgs = visibleUserMessages()
1177
+ const index = msgs.findIndex((m) => m.id === message.id)
1178
+ if (index !== -1 && index < store.turnStart) {
1179
+ setStore("turnStart", index)
1180
+ scheduleTurnBackfill()
1181
+
1182
+ requestAnimationFrame(() => {
1183
+ const el = document.getElementById(anchor(message.id))
1184
+ if (!el) {
1185
+ requestAnimationFrame(() => {
1186
+ const next = document.getElementById(anchor(message.id))
1187
+ if (!next) return
1188
+ scrollToElement(next, behavior)
1189
+ })
1190
+ return
1191
+ }
1192
+ scrollToElement(el, behavior)
1193
+ })
1194
+
1195
+ updateHash(message.id)
1196
+ return
1197
+ }
1198
+
1199
+ const el = document.getElementById(anchor(message.id))
1200
+ if (!el) {
1201
+ updateHash(message.id)
1202
+ requestAnimationFrame(() => {
1203
+ const next = document.getElementById(anchor(message.id))
1204
+ if (!next) return
1205
+ if (!scrollToElement(next, behavior)) return
1206
+ })
1207
+ return
1208
+ }
1209
+ if (scrollToElement(el, behavior)) {
1210
+ updateHash(message.id)
1211
+ return
1212
+ }
1213
+
1214
+ requestAnimationFrame(() => {
1215
+ const next = document.getElementById(anchor(message.id))
1216
+ if (!next) return
1217
+ if (!scrollToElement(next, behavior)) return
1218
+ })
1219
+ updateHash(message.id)
1220
+ }
1221
+
1222
+ const applyHash = (behavior: ScrollBehavior) => {
1223
+ const hash = window.location.hash.slice(1)
1224
+ if (!hash) {
1225
+ autoScroll.forceScrollToBottom()
1226
+ return
1227
+ }
1228
+
1229
+ const match = hash.match(/^message-(.+)$/)
1230
+ if (match) {
1231
+ const msg = visibleUserMessages().find((m) => m.id === match[1])
1232
+ if (msg) {
1233
+ scrollToMessage(msg, behavior)
1234
+ return
1235
+ }
1236
+
1237
+ // If we have a message hash but the message isn't loaded/rendered yet,
1238
+ // don't fall back to "bottom". We'll retry once messages arrive.
1239
+ return
1240
+ }
1241
+
1242
+ const target = document.getElementById(hash)
1243
+ if (target) {
1244
+ scrollToElement(target, behavior)
1245
+ return
1246
+ }
1247
+
1248
+ autoScroll.forceScrollToBottom()
1249
+ }
1250
+
1251
+ const getActiveMessageId = (container: HTMLDivElement) => {
1252
+ const cutoff = container.scrollTop + 100
1253
+ const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
1254
+ let id: string | undefined
1255
+
1256
+ for (const node of nodes) {
1257
+ const next = node.dataset.messageId
1258
+ if (!next) continue
1259
+ if (node.offsetTop > cutoff) break
1260
+ id = next
1261
+ }
1262
+
1263
+ return id
1264
+ }
1265
+
1266
+ const scheduleScrollSpy = (container: HTMLDivElement) => {
1267
+ scrollSpyTarget = container
1268
+ if (scrollSpyFrame !== undefined) return
1269
+
1270
+ scrollSpyFrame = requestAnimationFrame(() => {
1271
+ scrollSpyFrame = undefined
1272
+
1273
+ const target = scrollSpyTarget
1274
+ scrollSpyTarget = undefined
1275
+ if (!target) return
1276
+
1277
+ const id = getActiveMessageId(target)
1278
+ if (!id) return
1279
+ if (id === store.messageId) return
1280
+
1281
+ setStore("messageId", id)
1282
+ })
1283
+ }
1284
+
1285
+ createEffect(() => {
1286
+ const sessionID = params.id
1287
+ const ready = messagesReady()
1288
+ if (!sessionID || !ready) return
1289
+
1290
+ requestAnimationFrame(() => {
1291
+ applyHash("auto")
1292
+ })
1293
+ })
1294
+
1295
+ // Retry message navigation once the target message is actually loaded.
1296
+ createEffect(() => {
1297
+ const sessionID = params.id
1298
+ const ready = messagesReady()
1299
+ if (!sessionID || !ready) return
1300
+
1301
+ // dependencies
1302
+ visibleUserMessages().length
1303
+ store.turnStart
1304
+
1305
+ const targetId =
1306
+ pendingMessage() ??
1307
+ (() => {
1308
+ const hash = window.location.hash.slice(1)
1309
+ const match = hash.match(/^message-(.+)$/)
1310
+ if (!match) return undefined
1311
+ return match[1]
1312
+ })()
1313
+ if (!targetId) return
1314
+ if (store.messageId === targetId) return
1315
+
1316
+ const msg = visibleUserMessages().find((m) => m.id === targetId)
1317
+ if (!msg) return
1318
+ if (pendingMessage() === targetId) setPendingMessage(undefined)
1319
+ requestAnimationFrame(() => scrollToMessage(msg, "auto"))
1320
+ })
1321
+
1322
+ createEffect(() => {
1323
+ const sessionID = params.id
1324
+ const ready = messagesReady()
1325
+ if (!sessionID || !ready) return
1326
+
1327
+ const handler = () => requestAnimationFrame(() => applyHash("auto"))
1328
+ window.addEventListener("hashchange", handler)
1329
+ onCleanup(() => window.removeEventListener("hashchange", handler))
1330
+ })
1331
+
1332
+ createEffect(() => {
1333
+ document.addEventListener("keydown", handleKeyDown)
1334
+ })
1335
+
1336
+ const previewPrompt = () =>
1337
+ prompt
1338
+ .current()
1339
+ .map((part) => {
1340
+ if (part.type === "file") return `[file:${part.path}]`
1341
+ if (part.type === "agent") return `@${part.name}`
1342
+ if (part.type === "image") return `[image:${part.filename}]`
1343
+ return part.content
1344
+ })
1345
+ .join("")
1346
+ .trim()
1347
+
1348
+ createEffect(() => {
1349
+ if (!prompt.ready()) return
1350
+ handoff.prompt = previewPrompt()
1351
+ })
1352
+
1353
+ createEffect(() => {
1354
+ if (!terminal.ready()) return
1355
+ language.locale()
1356
+
1357
+ const label = (pty: LocalPTY) => {
1358
+ const title = pty.title
1359
+ const number = pty.titleNumber
1360
+ const match = title.match(/^Terminal (\d+)$/)
1361
+ const parsed = match ? Number(match[1]) : undefined
1362
+ const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number
1363
+
1364
+ if (title && !isDefaultTitle) return title
1365
+ if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number })
1366
+ if (title) return title
1367
+ return language.t("terminal.title")
1368
+ }
1369
+
1370
+ handoff.terminals = terminal.all().map(label)
1371
+ })
1372
+
1373
+ createEffect(() => {
1374
+ if (!file.ready()) return
1375
+ handoff.files = Object.fromEntries(
1376
+ tabs()
1377
+ .all()
1378
+ .flatMap((tab) => {
1379
+ const path = file.pathFromTab(tab)
1380
+ if (!path) return []
1381
+ return [[path, file.selectedLines(path) ?? null] as const]
1382
+ }),
1383
+ )
1384
+ })
1385
+
1386
+ onCleanup(() => {
1387
+ cancelTurnBackfill()
1388
+ document.removeEventListener("keydown", handleKeyDown)
1389
+ if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
1390
+ })
1391
+
1392
+ return (
1393
+ <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
1394
+ <SessionHeader />
1395
+ <div class="flex-1 min-h-0 flex flex-col md:flex-row">
1396
+ {/* Mobile tab bar - only shown on mobile when user opened review */}
1397
+ <Show when={!isDesktop() && layout.review.panelOpened()}>
1398
+ <Tabs class="h-auto">
1399
+ <Tabs.List>
1400
+ <Tabs.Trigger
1401
+ value="session"
1402
+ class="w-1/2"
1403
+ classes={{ button: "w-full" }}
1404
+ onClick={() => setStore("mobileTab", "session")}
1405
+ >
1406
+ Session
1407
+ </Tabs.Trigger>
1408
+ <Tabs.Trigger
1409
+ value="review"
1410
+ class="w-1/2 !border-r-0"
1411
+ classes={{ button: "w-full" }}
1412
+ onClick={() => setStore("mobileTab", "review")}
1413
+ >
1414
+ <Switch>
1415
+ <Match when={hasReview()}>{reviewCount()} Files Changed</Match>
1416
+ <Match when={true}>Review</Match>
1417
+ </Switch>
1418
+ </Tabs.Trigger>
1419
+ </Tabs.List>
1420
+ </Tabs>
1421
+ </Show>
1422
+
1423
+ {/* Session panel */}
1424
+ <div
1425
+ classList={{
1426
+ "@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
1427
+ "flex-1 md:flex-none py-6 md:py-3": true,
1428
+ }}
1429
+ style={{
1430
+ width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%",
1431
+ "--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
1432
+ }}
1433
+ >
1434
+ <div class="flex-1 min-h-0 overflow-hidden relative">
1435
+ {/* Session view - always mounted, opacity transition */}
1436
+ <div
1437
+ class="absolute inset-0 transition-opacity duration-200"
1438
+ classList={{
1439
+ "opacity-100 pointer-events-auto": !!params.id,
1440
+ "opacity-0 pointer-events-none": !params.id,
1441
+ }}
1442
+ >
1443
+ <Show when={activeMessage()}>
1444
+ <Show
1445
+ when={!mobileReview()}
1446
+ fallback={
1447
+ <div class="relative h-full overflow-hidden">
1448
+ <Switch>
1449
+ <Match when={hasReview()}>
1450
+ <Show
1451
+ when={diffsReady()}
1452
+ fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
1453
+ >
1454
+ <SessionReviewTab
1455
+ diffs={diffs}
1456
+ view={view}
1457
+ diffStyle="unified"
1458
+ onLineComment={addCommentToContext}
1459
+ comments={comments.all()}
1460
+ focusedComment={comments.focus()}
1461
+ onFocusedCommentChange={comments.setFocus}
1462
+ onViewFile={(path) => {
1463
+ const value = file.tab(path)
1464
+ tabs().open(value)
1465
+ file.load(path)
1466
+ }}
1467
+ classes={{
1468
+ root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
1469
+ header: "px-4",
1470
+ container: "px-4",
1471
+ }}
1472
+ />
1473
+ </Show>
1474
+ </Match>
1475
+ <Match when={true}>
1476
+ <div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6">
1477
+ <Mark class="w-14 opacity-10" />
1478
+ <div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
1479
+ </div>
1480
+ </Match>
1481
+ </Switch>
1482
+ </div>
1483
+ }
1484
+ >
1485
+ <div class="relative w-full h-full min-w-0">
1486
+ <div
1487
+ class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out"
1488
+ classList={{
1489
+ "opacity-100 translate-y-0 scale-100": autoScroll.userScrolled(),
1490
+ "opacity-0 translate-y-2 scale-95 pointer-events-none": !autoScroll.userScrolled(),
1491
+ }}
1492
+ >
1493
+ <button
1494
+ class="pointer-events-auto size-8 flex items-center justify-center rounded-full bg-background-base border border-border-base shadow-sm text-text-base hover:bg-background-stronger transition-colors"
1495
+ onClick={() => {
1496
+ setStore("messageId", undefined)
1497
+ autoScroll.forceScrollToBottom()
1498
+ window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
1499
+ }}
1500
+ >
1501
+ <Icon name="arrow-down-to-line" />
1502
+ </button>
1503
+ </div>
1504
+ <div
1505
+ ref={setScrollRef}
1506
+ onWheel={(e) => markScrollGesture(e.target)}
1507
+ onTouchMove={(e) => markScrollGesture(e.target)}
1508
+ onPointerDown={(e) => {
1509
+ if (e.target !== e.currentTarget) return
1510
+ markScrollGesture(e.target)
1511
+ }}
1512
+ onScroll={(e) => {
1513
+ if (!hasScrollGesture()) return
1514
+ markScrollGesture(e.target)
1515
+ autoScroll.handleScroll()
1516
+ if (isDesktop()) scheduleScrollSpy(e.currentTarget)
1517
+ }}
1518
+ onClick={autoScroll.handleInteraction}
1519
+ class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
1520
+ style={{ "--session-title-height": info()?.title ? "40px" : "0px" }}
1521
+ >
1522
+ <Show when={info()?.title}>
1523
+ <div
1524
+ classList={{
1525
+ "sticky top-0 z-30 bg-background-stronger": true,
1526
+ "w-full": true,
1527
+ "px-4 md:px-6": true,
1528
+ "md:max-w-200 md:mx-auto": !showTabs(),
1529
+ }}
1530
+ >
1531
+ <div class="h-10 flex items-center">
1532
+ <h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
1533
+ </div>
1534
+ </div>
1535
+ </Show>
1536
+
1537
+ <div
1538
+ ref={autoScroll.contentRef}
1539
+ role="log"
1540
+ class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
1541
+ classList={{
1542
+ "w-full": true,
1543
+ "md:max-w-200 md:mx-auto": !showTabs(),
1544
+ "mt-0.5": !showTabs(),
1545
+ "mt-0": showTabs(),
1546
+ }}
1547
+ >
1548
+ <Show when={store.turnStart > 0}>
1549
+ <div class="w-full flex justify-center">
1550
+ <Button
1551
+ variant="ghost"
1552
+ size="large"
1553
+ class="text-12-medium opacity-50"
1554
+ onClick={() => setStore("turnStart", 0)}
1555
+ >
1556
+ Render earlier messages
1557
+ </Button>
1558
+ </div>
1559
+ </Show>
1560
+ <Show when={historyMore()}>
1561
+ <div class="w-full flex justify-center">
1562
+ <Button
1563
+ variant="ghost"
1564
+ size="large"
1565
+ class="text-12-medium opacity-50"
1566
+ disabled={historyLoading()}
1567
+ onClick={() => {
1568
+ const id = params.id
1569
+ if (!id) return
1570
+ setStore("turnStart", 0)
1571
+ sync.session.history.loadMore(id)
1572
+ }}
1573
+ >
1574
+ {historyLoading() ? "Loading earlier messages..." : "Load earlier messages"}
1575
+ </Button>
1576
+ </div>
1577
+ </Show>
1578
+ <For each={renderedUserMessages()}>
1579
+ {(message) => {
1580
+ if (import.meta.env.DEV) {
1581
+ onMount(() => {
1582
+ const id = params.id
1583
+ if (!id) return
1584
+ navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
1585
+ })
1586
+ }
1587
+
1588
+ return (
1589
+ <div
1590
+ id={anchor(message.id)}
1591
+ data-message-id={message.id}
1592
+ classList={{
1593
+ "min-w-0 w-full max-w-full": true,
1594
+ "md:max-w-200": !showTabs(),
1595
+ }}
1596
+ >
1597
+ <SessionTurn
1598
+ sessionID={params.id!}
1599
+ messageID={message.id}
1600
+ lastUserMessageID={lastUserMessage()?.id}
1601
+ stepsExpanded={store.expanded[message.id] ?? false}
1602
+ onStepsExpandedToggle={() =>
1603
+ setStore("expanded", message.id, (open: boolean | undefined) => !open)
1604
+ }
1605
+ classes={{
1606
+ root: "min-w-0 w-full relative",
1607
+ content: "flex flex-col justify-between !overflow-visible",
1608
+ container: "w-full px-4 md:px-6",
1609
+ }}
1610
+ />
1611
+ </div>
1612
+ )
1613
+ }}
1614
+ </For>
1615
+ </div>
1616
+ </div>
1617
+ </div>
1618
+ </Show>
1619
+ </Show>
1620
+ </div>
1621
+
1622
+ {/* New session view - always mounted, opacity transition */}
1623
+ <div
1624
+ class="absolute inset-0 transition-opacity duration-200"
1625
+ classList={{
1626
+ "opacity-100 pointer-events-auto": !params.id,
1627
+ "opacity-0 pointer-events-none": !!params.id,
1628
+ }}
1629
+ >
1630
+ <NewSessionView
1631
+ worktree={newSessionWorktree()}
1632
+ onWorktreeChange={(value) => {
1633
+ if (value === "create") {
1634
+ setStore("newSessionWorktree", value)
1635
+ return
1636
+ }
1637
+
1638
+ setStore("newSessionWorktree", "main")
1639
+
1640
+ const target = value === "main" ? sync.project?.worktree : value
1641
+ if (!target) return
1642
+ if (target === sync.data.path.directory) return
1643
+ layout.projects.open(target)
1644
+ navigate(`/${base64Encode(target)}/session`)
1645
+ }}
1646
+ />
1647
+ </div>
1648
+ </div>
1649
+
1650
+ {/* Prompt input */}
1651
+ <div
1652
+ ref={(el) => (promptDock = el)}
1653
+ class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-6 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
1654
+ >
1655
+ <div
1656
+ classList={{
1657
+ "w-full md:px-6 pointer-events-auto": true,
1658
+ "md:max-w-200": !showTabs(),
1659
+ }}
1660
+ >
1661
+ <Show
1662
+ when={prompt.ready()}
1663
+ fallback={
1664
+ <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
1665
+ {handoff.prompt || "Loading prompt..."}
1666
+ </div>
1667
+ }
1668
+ >
1669
+ <PromptInput
1670
+ ref={(el) => {
1671
+ inputRef = el
1672
+ }}
1673
+ newSessionWorktree={newSessionWorktree()}
1674
+ onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
1675
+ onSubmit={resumeScroll}
1676
+ />
1677
+ </Show>
1678
+ </div>
1679
+ </div>
1680
+
1681
+ <Show when={isDesktop() && showTabs()}>
1682
+ <ResizeHandle
1683
+ direction="horizontal"
1684
+ size={layout.session.width()}
1685
+ min={450}
1686
+ max={window.innerWidth * 0.45}
1687
+ onResize={layout.session.resize}
1688
+ />
1689
+ </Show>
1690
+ </div>
1691
+
1692
+ {/* Desktop tabs panel (Review + Context + Files) - hidden on mobile */}
1693
+ <Show when={isDesktop() && showTabs()}>
1694
+ <aside
1695
+ id="review-panel"
1696
+ aria-label={language.t("session.panel.reviewAndFiles")}
1697
+ class="relative flex-1 min-w-0 h-full border-l border-border-weak-base"
1698
+ >
1699
+ <DragDropProvider
1700
+ onDragStart={handleDragStart}
1701
+ onDragEnd={handleDragEnd}
1702
+ onDragOver={handleDragOver}
1703
+ collisionDetector={closestCenter}
1704
+ >
1705
+ <DragDropSensors />
1706
+ <ConstrainDragYAxis />
1707
+ <Tabs value={activeTab()} onChange={openTab}>
1708
+ <div class="sticky top-0 shrink-0 flex">
1709
+ <Tabs.List>
1710
+ <Show when={true}>
1711
+ <Tabs.Trigger value="review">
1712
+ <div class="flex items-center gap-3">
1713
+ <Show when={diffs()}>
1714
+ <DiffChanges changes={diffs()} variant="bars" />
1715
+ </Show>
1716
+ <div class="flex items-center gap-1.5">
1717
+ <div>Review</div>
1718
+ <Show when={info()?.summary?.files}>
1719
+ <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
1720
+ {info()?.summary?.files ?? 0}
1721
+ </div>
1722
+ </Show>
1723
+ </div>
1724
+ </div>
1725
+ </Tabs.Trigger>
1726
+ </Show>
1727
+ <Show when={contextOpen()}>
1728
+ <Tabs.Trigger
1729
+ value="context"
1730
+ closeButton={
1731
+ <Tooltip value={language.t("common.closeTab")} placement="bottom">
1732
+ <IconButton
1733
+ icon="close"
1734
+ variant="ghost"
1735
+ onClick={() => tabs().close("context")}
1736
+ aria-label={language.t("common.closeTab")}
1737
+ />
1738
+ </Tooltip>
1739
+ }
1740
+ hideCloseButton
1741
+ onMiddleClick={() => tabs().close("context")}
1742
+ >
1743
+ <div class="flex items-center gap-2">
1744
+ <SessionContextUsage variant="indicator" />
1745
+ <div>Context</div>
1746
+ </div>
1747
+ </Tabs.Trigger>
1748
+ </Show>
1749
+ <SortableProvider ids={openedTabs()}>
1750
+ <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
1751
+ </SortableProvider>
1752
+ <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
1753
+ <TooltipKeybind
1754
+ title="Open file"
1755
+ keybind={command.keybind("file.open")}
1756
+ class="flex items-center"
1757
+ >
1758
+ <IconButton
1759
+ icon="plus-small"
1760
+ variant="ghost"
1761
+ iconSize="large"
1762
+ onClick={() => dialog.show(() => <DialogSelectFile />)}
1763
+ aria-label={language.t("command.file.open")}
1764
+ />
1765
+ </TooltipKeybind>
1766
+ </div>
1767
+ </Tabs.List>
1768
+ </div>
1769
+ <Show when={true}>
1770
+ <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
1771
+ <Show when={activeTab() === "review"}>
1772
+ <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
1773
+ <Switch>
1774
+ <Match when={hasReview()}>
1775
+ <Show
1776
+ when={diffsReady()}
1777
+ fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
1778
+ >
1779
+ <SessionReviewTab
1780
+ diffs={diffs}
1781
+ view={view}
1782
+ diffStyle={layout.review.diffStyle()}
1783
+ onDiffStyleChange={layout.review.setDiffStyle}
1784
+ onLineComment={addCommentToContext}
1785
+ comments={comments.all()}
1786
+ focusedComment={comments.focus()}
1787
+ onFocusedCommentChange={comments.setFocus}
1788
+ onViewFile={(path) => {
1789
+ const value = file.tab(path)
1790
+ tabs().open(value)
1791
+ file.load(path)
1792
+ }}
1793
+ />
1794
+ </Show>
1795
+ </Match>
1796
+ <Match when={true}>
1797
+ <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
1798
+ <Mark class="w-14 opacity-10" />
1799
+ <div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
1800
+ </div>
1801
+ </Match>
1802
+ </Switch>
1803
+ </div>
1804
+ </Show>
1805
+ </Tabs.Content>
1806
+ </Show>
1807
+ <Show when={contextOpen()}>
1808
+ <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
1809
+ <Show when={activeTab() === "context"}>
1810
+ <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
1811
+ <SessionContextTab
1812
+ messages={messages}
1813
+ visibleUserMessages={visibleUserMessages}
1814
+ view={view}
1815
+ info={info}
1816
+ />
1817
+ </div>
1818
+ </Show>
1819
+ </Tabs.Content>
1820
+ </Show>
1821
+ <For each={openedTabs()}>
1822
+ {(tab) => {
1823
+ let scroll: HTMLDivElement | undefined
1824
+ let scrollFrame: number | undefined
1825
+ let pending: { x: number; y: number } | undefined
1826
+ let codeScroll: HTMLElement[] = []
1827
+
1828
+ const path = createMemo(() => file.pathFromTab(tab))
1829
+ const state = createMemo(() => {
1830
+ const p = path()
1831
+ if (!p) return
1832
+ return file.get(p)
1833
+ })
1834
+ const contents = createMemo(() => state()?.content?.content ?? "")
1835
+ const cacheKey = createMemo(() => checksum(contents()))
1836
+ const isImage = createMemo(() => {
1837
+ const c = state()?.content
1838
+ return (
1839
+ c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
1840
+ )
1841
+ })
1842
+ const isSvg = createMemo(() => {
1843
+ const c = state()?.content
1844
+ return c?.mimeType === "image/svg+xml"
1845
+ })
1846
+ const svgContent = createMemo(() => {
1847
+ if (!isSvg()) return
1848
+ const c = state()?.content
1849
+ if (!c) return
1850
+ if (c.encoding === "base64") return base64Decode(c.content)
1851
+ return c.content
1852
+ })
1853
+ const svgPreviewUrl = createMemo(() => {
1854
+ if (!isSvg()) return
1855
+ const c = state()?.content
1856
+ if (!c) return
1857
+ if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
1858
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
1859
+ })
1860
+ const imageDataUrl = createMemo(() => {
1861
+ if (!isImage()) return
1862
+ const c = state()?.content
1863
+ return `data:${c?.mimeType};base64,${c?.content}`
1864
+ })
1865
+ const selectedLines = createMemo(() => {
1866
+ const p = path()
1867
+ if (!p) return null
1868
+ if (file.ready()) return file.selectedLines(p) ?? null
1869
+ return handoff.files[p] ?? null
1870
+ })
1871
+
1872
+ let wrap: HTMLDivElement | undefined
1873
+ let textarea: HTMLTextAreaElement | undefined
1874
+
1875
+ const fileComments = createMemo(() => {
1876
+ const p = path()
1877
+ if (!p) return []
1878
+ return comments.list(p)
1879
+ })
1880
+
1881
+ const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
1882
+
1883
+ const [openedComment, setOpenedComment] = createSignal<string | null>(null)
1884
+ const [commenting, setCommenting] = createSignal<SelectedLineRange | null>(null)
1885
+ const [draft, setDraft] = createSignal("")
1886
+ const [positions, setPositions] = createSignal<Record<string, number>>({})
1887
+ const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
1888
+
1889
+ const commentLabel = (range: SelectedLineRange) => {
1890
+ const start = Math.min(range.start, range.end)
1891
+ const end = Math.max(range.start, range.end)
1892
+ if (start === end) return `line ${start}`
1893
+ return `lines ${start}-${end}`
1894
+ }
1895
+
1896
+ const getRoot = () => {
1897
+ const el = wrap
1898
+ if (!el) return
1899
+
1900
+ const host = el.querySelector("diffs-container")
1901
+ if (!(host instanceof HTMLElement)) return
1902
+
1903
+ const root = host.shadowRoot
1904
+ if (!root) return
1905
+
1906
+ return root
1907
+ }
1908
+
1909
+ const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
1910
+ const line = Math.max(range.start, range.end)
1911
+ const node = root.querySelector(`[data-line="${line}"]`)
1912
+ if (!(node instanceof HTMLElement)) return
1913
+ return node
1914
+ }
1915
+
1916
+ const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
1917
+ const wrapperRect = wrapper.getBoundingClientRect()
1918
+ const rect = marker.getBoundingClientRect()
1919
+ return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
1920
+ }
1921
+
1922
+ const updateComments = () => {
1923
+ const el = wrap
1924
+ const root = getRoot()
1925
+ if (!el || !root) {
1926
+ setPositions({})
1927
+ setDraftTop(undefined)
1928
+ return
1929
+ }
1930
+
1931
+ const next: Record<string, number> = {}
1932
+ for (const comment of fileComments()) {
1933
+ const marker = findMarker(root, comment.selection)
1934
+ if (!marker) continue
1935
+ next[comment.id] = markerTop(el, marker)
1936
+ }
1937
+
1938
+ setPositions(next)
1939
+
1940
+ const range = commenting()
1941
+ if (!range) {
1942
+ setDraftTop(undefined)
1943
+ return
1944
+ }
1945
+
1946
+ const marker = findMarker(root, range)
1947
+ if (!marker) {
1948
+ setDraftTop(undefined)
1949
+ return
1950
+ }
1951
+
1952
+ setDraftTop(markerTop(el, marker))
1953
+ }
1954
+
1955
+ const scheduleComments = () => {
1956
+ requestAnimationFrame(updateComments)
1957
+ }
1958
+
1959
+ createEffect(() => {
1960
+ fileComments()
1961
+ scheduleComments()
1962
+ })
1963
+
1964
+ createEffect(() => {
1965
+ commenting()
1966
+ scheduleComments()
1967
+ })
1968
+
1969
+ createEffect(() => {
1970
+ const range = commenting()
1971
+ if (!range) return
1972
+ setDraft("")
1973
+ requestAnimationFrame(() => textarea?.focus())
1974
+ })
1975
+
1976
+ const renderCode = (source: string, wrapperClass: string) => {
1977
+ const filename = path() ?? ""
1978
+ const lang = filename ? getFiletypeFromFileName(filename) : "text"
1979
+ return (
1980
+ <div
1981
+ ref={(el) => {
1982
+ wrap = el
1983
+ scheduleComments()
1984
+ }}
1985
+ class={`relative overflow-hidden ${wrapperClass}`}
1986
+ >
1987
+ <Dynamic
1988
+ component={codeComponent}
1989
+ file={{
1990
+ name: filename,
1991
+ contents: source,
1992
+ cacheKey: cacheKey(),
1993
+ lang,
1994
+ }}
1995
+ enableLineSelection
1996
+ selectedLines={selectedLines()}
1997
+ commentedLines={commentedLines()}
1998
+ onRendered={() => {
1999
+ requestAnimationFrame(restoreScroll)
2000
+ requestAnimationFrame(scheduleComments)
2001
+ }}
2002
+ onLineSelected={(range: SelectedLineRange | null) => {
2003
+ const p = path()
2004
+ if (!p) return
2005
+ file.setSelectedLines(p, range)
2006
+ if (!range) setCommenting(null)
2007
+ }}
2008
+ onLineSelectionEnd={(range: SelectedLineRange | null) => {
2009
+ if (!range) {
2010
+ setCommenting(null)
2011
+ return
2012
+ }
2013
+
2014
+ setOpenedComment(null)
2015
+ setCommenting(range)
2016
+ }}
2017
+ overflow="scroll"
2018
+ class="select-text"
2019
+ />
2020
+ <For each={fileComments()}>
2021
+ {(comment) => (
2022
+ <div
2023
+ class="absolute right-6 z-30"
2024
+ style={{
2025
+ top: `${positions()[comment.id] ?? 0}px`,
2026
+ opacity: positions()[comment.id] === undefined ? 0 : 1,
2027
+ "pointer-events": positions()[comment.id] === undefined ? "none" : "auto",
2028
+ }}
2029
+ >
2030
+ <button
2031
+ type="button"
2032
+ class="size-5 rounded-md flex items-center justify-center bg-surface-warning-base border border-border-warning-base text-icon-warning-active shadow-xs hover:bg-surface-warning-weak hover:border-border-warning-hover focus:outline-none focus-visible:shadow-xs-border-focus"
2033
+ onMouseEnter={() => {
2034
+ const p = path()
2035
+ if (!p) return
2036
+ file.setSelectedLines(p, comment.selection)
2037
+ }}
2038
+ onClick={() => {
2039
+ const p = path()
2040
+ if (!p) return
2041
+ setCommenting(null)
2042
+ setOpenedComment((current) => (current === comment.id ? null : comment.id))
2043
+ file.setSelectedLines(p, comment.selection)
2044
+ }}
2045
+ >
2046
+ <Icon name="speech-bubble" size="small" />
2047
+ </button>
2048
+ <Show when={openedComment() === comment.id}>
2049
+ <div class="absolute top-0 right-[calc(100%+12px)] z-40 min-w-[200px] max-w-[320px] rounded-md bg-surface-raised-stronger-non-alpha border border-border-base shadow-md p-3">
2050
+ <div class="flex flex-col gap-1.5">
2051
+ <div class="text-12-medium text-text-strong whitespace-nowrap">
2052
+ {getFilename(comment.file)}:{commentLabel(comment.selection)}
2053
+ </div>
2054
+ <div class="text-12-regular text-text-base whitespace-pre-wrap">
2055
+ {comment.comment}
2056
+ </div>
2057
+ </div>
2058
+ </div>
2059
+ </Show>
2060
+ </div>
2061
+ )}
2062
+ </For>
2063
+ <Show when={commenting()}>
2064
+ {(range) => (
2065
+ <Show when={draftTop() !== undefined}>
2066
+ <div class="absolute right-6 z-30" style={{ top: `${draftTop() ?? 0}px` }}>
2067
+ <button
2068
+ type="button"
2069
+ class="size-5 rounded-md flex items-center justify-center bg-surface-warning-base border border-border-warning-base text-icon-warning-active shadow-xs hover:bg-surface-warning-weak hover:border-border-warning-hover focus:outline-none focus-visible:shadow-xs-border-focus"
2070
+ onClick={() => textarea?.focus()}
2071
+ >
2072
+ <Icon name="speech-bubble" size="small" />
2073
+ </button>
2074
+ <div class="absolute top-0 right-[calc(100%+12px)] z-40 min-w-[200px] max-w-[320px] rounded-md bg-surface-raised-stronger-non-alpha border border-border-base shadow-md p-3">
2075
+ <div class="flex flex-col gap-2">
2076
+ <div class="text-12-medium text-text-strong">
2077
+ Commenting on {getFilename(path() ?? "")}:{commentLabel(range())}
2078
+ </div>
2079
+ <textarea
2080
+ ref={textarea}
2081
+ class="w-[320px] max-w-[calc(100vw-48px)] resize-vertical p-2 rounded-sm bg-surface-base border border-border-base text-text-strong text-12-regular leading-5 focus:outline-none focus:shadow-xs-border-focus"
2082
+ rows={3}
2083
+ placeholder="Add a comment"
2084
+ value={draft()}
2085
+ onInput={(e) => setDraft(e.currentTarget.value)}
2086
+ onKeyDown={(e) => {
2087
+ if (e.key !== "Enter") return
2088
+ if (e.shiftKey) return
2089
+ e.preventDefault()
2090
+ const value = draft().trim()
2091
+ if (!value) return
2092
+ const p = path()
2093
+ if (!p) return
2094
+ addCommentToContext({ file: p, selection: range(), comment: value })
2095
+ setCommenting(null)
2096
+ }}
2097
+ />
2098
+ <div class="flex justify-end gap-2">
2099
+ <Button size="small" variant="ghost" onClick={() => setCommenting(null)}>
2100
+ Cancel
2101
+ </Button>
2102
+ <Button
2103
+ size="small"
2104
+ variant="secondary"
2105
+ disabled={draft().trim().length === 0}
2106
+ onClick={() => {
2107
+ const value = draft().trim()
2108
+ if (!value) return
2109
+ const p = path()
2110
+ if (!p) return
2111
+ addCommentToContext({ file: p, selection: range(), comment: value })
2112
+ setCommenting(null)
2113
+ }}
2114
+ >
2115
+ Comment
2116
+ </Button>
2117
+ </div>
2118
+ </div>
2119
+ </div>
2120
+ </div>
2121
+ </Show>
2122
+ )}
2123
+ </Show>
2124
+ </div>
2125
+ )
2126
+ }
2127
+
2128
+ const getCodeScroll = () => {
2129
+ const el = scroll
2130
+ if (!el) return []
2131
+
2132
+ const host = el.querySelector("diffs-container")
2133
+ if (!(host instanceof HTMLElement)) return []
2134
+
2135
+ const root = host.shadowRoot
2136
+ if (!root) return []
2137
+
2138
+ return Array.from(root.querySelectorAll("[data-code]")).filter(
2139
+ (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
2140
+ )
2141
+ }
2142
+
2143
+ const queueScrollUpdate = (next: { x: number; y: number }) => {
2144
+ pending = next
2145
+ if (scrollFrame !== undefined) return
2146
+
2147
+ scrollFrame = requestAnimationFrame(() => {
2148
+ scrollFrame = undefined
2149
+
2150
+ const next = pending
2151
+ pending = undefined
2152
+ if (!next) return
2153
+
2154
+ view().setScroll(tab, next)
2155
+ })
2156
+ }
2157
+
2158
+ const handleCodeScroll = (event: Event) => {
2159
+ const el = scroll
2160
+ if (!el) return
2161
+
2162
+ const target = event.currentTarget
2163
+ if (!(target instanceof HTMLElement)) return
2164
+
2165
+ queueScrollUpdate({
2166
+ x: target.scrollLeft,
2167
+ y: el.scrollTop,
2168
+ })
2169
+ }
2170
+
2171
+ const syncCodeScroll = () => {
2172
+ const next = getCodeScroll()
2173
+ if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
2174
+
2175
+ for (const item of codeScroll) {
2176
+ item.removeEventListener("scroll", handleCodeScroll)
2177
+ }
2178
+
2179
+ codeScroll = next
2180
+
2181
+ for (const item of codeScroll) {
2182
+ item.addEventListener("scroll", handleCodeScroll)
2183
+ }
2184
+ }
2185
+
2186
+ const restoreScroll = () => {
2187
+ const el = scroll
2188
+ if (!el) return
2189
+
2190
+ const s = view()?.scroll(tab)
2191
+ if (!s) return
2192
+
2193
+ syncCodeScroll()
2194
+
2195
+ if (codeScroll.length > 0) {
2196
+ for (const item of codeScroll) {
2197
+ if (item.scrollLeft !== s.x) item.scrollLeft = s.x
2198
+ }
2199
+ }
2200
+
2201
+ if (el.scrollTop !== s.y) el.scrollTop = s.y
2202
+
2203
+ if (codeScroll.length > 0) return
2204
+
2205
+ if (el.scrollLeft !== s.x) el.scrollLeft = s.x
2206
+ }
2207
+
2208
+ const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
2209
+ if (codeScroll.length === 0) syncCodeScroll()
2210
+
2211
+ queueScrollUpdate({
2212
+ x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
2213
+ y: event.currentTarget.scrollTop,
2214
+ })
2215
+ }
2216
+
2217
+ createEffect(
2218
+ on(
2219
+ () => state()?.loaded,
2220
+ (loaded) => {
2221
+ if (!loaded) return
2222
+ requestAnimationFrame(restoreScroll)
2223
+ },
2224
+ { defer: true },
2225
+ ),
2226
+ )
2227
+
2228
+ createEffect(
2229
+ on(
2230
+ () => file.ready(),
2231
+ (ready) => {
2232
+ if (!ready) return
2233
+ requestAnimationFrame(restoreScroll)
2234
+ },
2235
+ { defer: true },
2236
+ ),
2237
+ )
2238
+
2239
+ createEffect(
2240
+ on(
2241
+ () => tabs().active() === tab,
2242
+ (active) => {
2243
+ if (!active) return
2244
+ if (!state()?.loaded) return
2245
+ requestAnimationFrame(restoreScroll)
2246
+ },
2247
+ ),
2248
+ )
2249
+
2250
+ onCleanup(() => {
2251
+ for (const item of codeScroll) {
2252
+ item.removeEventListener("scroll", handleCodeScroll)
2253
+ }
2254
+
2255
+ if (scrollFrame === undefined) return
2256
+ cancelAnimationFrame(scrollFrame)
2257
+ })
2258
+
2259
+ return (
2260
+ <Tabs.Content
2261
+ value={tab}
2262
+ class="mt-3 relative"
2263
+ ref={(el: HTMLDivElement) => {
2264
+ scroll = el
2265
+ restoreScroll()
2266
+ }}
2267
+ onScroll={handleScroll}
2268
+ >
2269
+ <Switch>
2270
+ <Match when={state()?.loaded && isImage()}>
2271
+ <div class="px-6 py-4 pb-40">
2272
+ <img
2273
+ src={imageDataUrl()}
2274
+ alt={path()}
2275
+ class="max-w-full"
2276
+ onLoad={() => requestAnimationFrame(restoreScroll)}
2277
+ />
2278
+ </div>
2279
+ </Match>
2280
+ <Match when={state()?.loaded && isSvg()}>
2281
+ <div class="flex flex-col gap-4 px-6 py-4">
2282
+ {renderCode(svgContent() ?? "", "")}
2283
+ <Show when={svgPreviewUrl()}>
2284
+ <div class="flex justify-center pb-40">
2285
+ <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
2286
+ </div>
2287
+ </Show>
2288
+ </div>
2289
+ </Match>
2290
+ <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
2291
+ <Match when={state()?.loading}>
2292
+ <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
2293
+ </Match>
2294
+ <Match when={state()?.error}>
2295
+ {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
2296
+ </Match>
2297
+ </Switch>
2298
+ </Tabs.Content>
2299
+ )
2300
+ }}
2301
+ </For>
2302
+ </Tabs>
2303
+ <DragOverlay>
2304
+ <Show when={store.activeDraggable}>
2305
+ {(tab) => {
2306
+ const path = createMemo(() => file.pathFromTab(tab()))
2307
+ return (
2308
+ <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
2309
+ <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
2310
+ </div>
2311
+ )
2312
+ }}
2313
+ </Show>
2314
+ </DragOverlay>
2315
+ </DragDropProvider>
2316
+ </aside>
2317
+ </Show>
2318
+ </div>
2319
+
2320
+ <Show when={isDesktop() && view().terminal.opened()}>
2321
+ <div
2322
+ id="terminal-panel"
2323
+ role="region"
2324
+ aria-label={language.t("terminal.title")}
2325
+ class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
2326
+ style={{ height: `${layout.terminal.height()}px` }}
2327
+ >
2328
+ <ResizeHandle
2329
+ direction="vertical"
2330
+ size={layout.terminal.height()}
2331
+ min={100}
2332
+ max={window.innerHeight * 0.6}
2333
+ collapseThreshold={50}
2334
+ onResize={layout.terminal.resize}
2335
+ onCollapse={view().terminal.close}
2336
+ />
2337
+ <Show
2338
+ when={terminal.ready()}
2339
+ fallback={
2340
+ <div class="flex flex-col h-full pointer-events-none">
2341
+ <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
2342
+ <For each={handoff.terminals}>
2343
+ {(title) => (
2344
+ <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
2345
+ {title}
2346
+ </div>
2347
+ )}
2348
+ </For>
2349
+ <div class="flex-1" />
2350
+ <div class="text-text-weak pr-2">Loading...</div>
2351
+ </div>
2352
+ <div class="flex-1 flex items-center justify-center text-text-weak">Loading terminal...</div>
2353
+ </div>
2354
+ }
2355
+ >
2356
+ <DragDropProvider
2357
+ onDragStart={handleTerminalDragStart}
2358
+ onDragEnd={handleTerminalDragEnd}
2359
+ onDragOver={handleTerminalDragOver}
2360
+ collisionDetector={closestCenter}
2361
+ >
2362
+ <DragDropSensors />
2363
+ <ConstrainDragYAxis />
2364
+ <div class="flex flex-col h-full">
2365
+ <Tabs
2366
+ variant="alt"
2367
+ value={terminal.active()}
2368
+ onChange={(id) => {
2369
+ // Only switch tabs if not in the middle of starting edit mode
2370
+ terminal.open(id)
2371
+ }}
2372
+ class="!h-auto !flex-none"
2373
+ >
2374
+ <Tabs.List class="h-10">
2375
+ <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
2376
+ <For each={terminal.all()}>
2377
+ {(pty) => (
2378
+ <SortableTerminalTab
2379
+ terminal={pty}
2380
+ onClose={() => {
2381
+ view().terminal.close()
2382
+ setAutoCreated(false)
2383
+ }}
2384
+ />
2385
+ )}
2386
+ </For>
2387
+ </SortableProvider>
2388
+ <div class="h-full flex items-center justify-center">
2389
+ <TooltipKeybind
2390
+ title={language.t("command.terminal.new")}
2391
+ keybind={command.keybind("terminal.new")}
2392
+ class="flex items-center"
2393
+ >
2394
+ <IconButton
2395
+ icon="plus-small"
2396
+ variant="ghost"
2397
+ iconSize="large"
2398
+ onClick={terminal.new}
2399
+ aria-label={language.t("command.terminal.new")}
2400
+ />
2401
+ </TooltipKeybind>
2402
+ </div>
2403
+ </Tabs.List>
2404
+ </Tabs>
2405
+ <div class="flex-1 min-h-0 relative">
2406
+ <For each={terminal.all()}>
2407
+ {(pty) => {
2408
+ const [dismissed, setDismissed] = createSignal(false)
2409
+ return (
2410
+ <div
2411
+ id={`terminal-wrapper-${pty.id}`}
2412
+ class="absolute inset-0"
2413
+ style={{
2414
+ display: terminal.active() === pty.id ? "block" : "none",
2415
+ }}
2416
+ >
2417
+ <Terminal
2418
+ pty={pty}
2419
+ onCleanup={(data) => terminal.update({ ...data, id: pty.id })}
2420
+ onConnect={() => {
2421
+ terminal.update({ id: pty.id, error: false })
2422
+ setDismissed(false)
2423
+ }}
2424
+ onConnectError={() => {
2425
+ setDismissed(false)
2426
+ terminal.update({ id: pty.id, error: true })
2427
+ }}
2428
+ />
2429
+ <Show when={pty.error && !dismissed()}>
2430
+ <div
2431
+ class="absolute inset-0 flex flex-col items-center justify-center gap-3"
2432
+ style={{ "background-color": "rgba(0, 0, 0, 0.6)" }}
2433
+ >
2434
+ <Icon
2435
+ name="circle-ban-sign"
2436
+ class="w-8 h-8"
2437
+ style={{ color: "rgba(239, 68, 68, 0.8)" }}
2438
+ />
2439
+ <div class="text-center" style={{ color: "rgba(255, 255, 255, 0.7)" }}>
2440
+ <div class="text-14-semibold mb-1">{language.t("terminal.connectionLost.title")}</div>
2441
+ <div class="text-12-regular" style={{ color: "rgba(255, 255, 255, 0.5)" }}>
2442
+ {language.t("terminal.connectionLost.description")}
2443
+ </div>
2444
+ </div>
2445
+ <button
2446
+ class="mt-2 px-3 py-1.5 text-12-medium rounded-lg transition-colors"
2447
+ style={{
2448
+ "background-color": "rgba(255, 255, 255, 0.1)",
2449
+ color: "rgba(255, 255, 255, 0.7)",
2450
+ border: "1px solid rgba(255, 255, 255, 0.2)",
2451
+ }}
2452
+ onMouseEnter={(e) =>
2453
+ (e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.15)")
2454
+ }
2455
+ onMouseLeave={(e) =>
2456
+ (e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.1)")
2457
+ }
2458
+ onClick={() => setDismissed(true)}
2459
+ >
2460
+ {language.t("common.dismiss")}
2461
+ </button>
2462
+ </div>
2463
+ </Show>
2464
+ </div>
2465
+ )
2466
+ }}
2467
+ </For>
2468
+ </div>
2469
+ </div>
2470
+ <DragOverlay>
2471
+ <Show when={store.activeTerminalDraggable}>
2472
+ {(draggedId) => {
2473
+ const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
2474
+ return (
2475
+ <Show when={pty()}>
2476
+ {(t) => (
2477
+ <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
2478
+ {(() => {
2479
+ const title = t().title
2480
+ const number = t().titleNumber
2481
+ const match = title.match(/^Terminal (\d+)$/)
2482
+ const parsed = match ? Number(match[1]) : undefined
2483
+ const isDefaultTitle =
2484
+ Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number
2485
+
2486
+ if (title && !isDefaultTitle) return title
2487
+ if (Number.isFinite(number) && number > 0)
2488
+ return language.t("terminal.title.numbered", { number })
2489
+ if (title) return title
2490
+ return language.t("terminal.title")
2491
+ })()}
2492
+ </div>
2493
+ )}
2494
+ </Show>
2495
+ )
2496
+ }}
2497
+ </Show>
2498
+ </DragOverlay>
2499
+ </DragDropProvider>
2500
+ </Show>
2501
+ </div>
2502
+ </Show>
2503
+ </div>
2504
+ )
2505
+ }