reigncode-app 1.3.2

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 (300) hide show
  1. package/AGENTS.md +30 -0
  2. package/Dockerfile +21 -0
  3. package/README.md +51 -0
  4. package/bunfig.toml +3 -0
  5. package/create-effect-simplification-spec.md +515 -0
  6. package/e2e/AGENTS.md +226 -0
  7. package/e2e/actions.ts +1018 -0
  8. package/e2e/app/home.spec.ts +24 -0
  9. package/e2e/app/navigation.spec.ts +10 -0
  10. package/e2e/app/palette.spec.ts +20 -0
  11. package/e2e/app/server-default.spec.ts +58 -0
  12. package/e2e/app/session.spec.ts +16 -0
  13. package/e2e/app/titlebar-history.spec.ts +120 -0
  14. package/e2e/commands/input-focus.spec.ts +15 -0
  15. package/e2e/commands/panels.spec.ts +33 -0
  16. package/e2e/commands/tab-close.spec.ts +32 -0
  17. package/e2e/files/file-open.spec.ts +31 -0
  18. package/e2e/files/file-tree.spec.ts +56 -0
  19. package/e2e/files/file-viewer.spec.ts +156 -0
  20. package/e2e/fixtures.ts +154 -0
  21. package/e2e/models/model-picker.spec.ts +48 -0
  22. package/e2e/models/models-visibility.spec.ts +61 -0
  23. package/e2e/projects/project-edit.spec.ts +43 -0
  24. package/e2e/projects/projects-close.spec.ts +54 -0
  25. package/e2e/projects/projects-switch.spec.ts +116 -0
  26. package/e2e/projects/workspace-new-session.spec.ts +94 -0
  27. package/e2e/projects/workspaces.spec.ts +375 -0
  28. package/e2e/prompt/context.spec.ts +95 -0
  29. package/e2e/prompt/prompt-async.spec.ts +76 -0
  30. package/e2e/prompt/prompt-drop-file-uri.spec.ts +22 -0
  31. package/e2e/prompt/prompt-drop-file.spec.ts +30 -0
  32. package/e2e/prompt/prompt-history.spec.ts +184 -0
  33. package/e2e/prompt/prompt-mention.spec.ts +26 -0
  34. package/e2e/prompt/prompt-multiline.spec.ts +24 -0
  35. package/e2e/prompt/prompt-shell.spec.ts +62 -0
  36. package/e2e/prompt/prompt-slash-open.spec.ts +22 -0
  37. package/e2e/prompt/prompt-slash-share.spec.ts +64 -0
  38. package/e2e/prompt/prompt-slash-terminal.spec.ts +18 -0
  39. package/e2e/prompt/prompt.spec.ts +55 -0
  40. package/e2e/selectors.ts +75 -0
  41. package/e2e/session/session-child-navigation.spec.ts +37 -0
  42. package/e2e/session/session-composer-dock.spec.ts +530 -0
  43. package/e2e/session/session-model-persistence.spec.ts +359 -0
  44. package/e2e/session/session-review.spec.ts +426 -0
  45. package/e2e/session/session-undo-redo.spec.ts +233 -0
  46. package/e2e/session/session.spec.ts +174 -0
  47. package/e2e/settings/settings-keybinds.spec.ts +389 -0
  48. package/e2e/settings/settings-models.spec.ts +122 -0
  49. package/e2e/settings/settings-providers.spec.ts +136 -0
  50. package/e2e/settings/settings.spec.ts +519 -0
  51. package/e2e/sidebar/sidebar-popover-actions.spec.ts +118 -0
  52. package/e2e/sidebar/sidebar-session-links.spec.ts +30 -0
  53. package/e2e/sidebar/sidebar.spec.ts +40 -0
  54. package/e2e/status/status-popover.spec.ts +94 -0
  55. package/e2e/terminal/terminal-init.spec.ts +28 -0
  56. package/e2e/terminal/terminal-reconnect.spec.ts +46 -0
  57. package/e2e/terminal/terminal-tabs.spec.ts +168 -0
  58. package/e2e/terminal/terminal.spec.ts +18 -0
  59. package/e2e/thinking-level.spec.ts +25 -0
  60. package/e2e/tsconfig.json +9 -0
  61. package/e2e/utils.ts +63 -0
  62. package/happydom.ts +75 -0
  63. package/index.html +23 -0
  64. package/package.json +77 -0
  65. package/playwright.config.ts +45 -0
  66. package/public/_headers +17 -0
  67. package/public/oc-theme-preload.js +35 -0
  68. package/script/e2e-local.ts +180 -0
  69. package/src/addons/serialize.test.ts +319 -0
  70. package/src/addons/serialize.ts +634 -0
  71. package/src/app.tsx +308 -0
  72. package/src/components/debug-bar.tsx +443 -0
  73. package/src/components/dialog-connect-provider.tsx +617 -0
  74. package/src/components/dialog-custom-provider-form.ts +158 -0
  75. package/src/components/dialog-custom-provider.test.ts +80 -0
  76. package/src/components/dialog-custom-provider.tsx +329 -0
  77. package/src/components/dialog-edit-project.tsx +255 -0
  78. package/src/components/dialog-fork.tsx +108 -0
  79. package/src/components/dialog-manage-models.tsx +101 -0
  80. package/src/components/dialog-release-notes.tsx +144 -0
  81. package/src/components/dialog-select-directory.tsx +392 -0
  82. package/src/components/dialog-select-file.tsx +466 -0
  83. package/src/components/dialog-select-mcp.tsx +107 -0
  84. package/src/components/dialog-select-model-unpaid.tsx +137 -0
  85. package/src/components/dialog-select-model.tsx +220 -0
  86. package/src/components/dialog-select-provider.tsx +86 -0
  87. package/src/components/dialog-select-server.tsx +649 -0
  88. package/src/components/dialog-settings.tsx +73 -0
  89. package/src/components/file-tree.test.ts +78 -0
  90. package/src/components/file-tree.tsx +507 -0
  91. package/src/components/link.tsx +26 -0
  92. package/src/components/model-tooltip.tsx +91 -0
  93. package/src/components/prompt-input/attachments.test.ts +44 -0
  94. package/src/components/prompt-input/attachments.ts +201 -0
  95. package/src/components/prompt-input/build-request-parts.test.ts +312 -0
  96. package/src/components/prompt-input/build-request-parts.ts +175 -0
  97. package/src/components/prompt-input/context-items.tsx +88 -0
  98. package/src/components/prompt-input/drag-overlay.tsx +25 -0
  99. package/src/components/prompt-input/editor-dom.test.ts +99 -0
  100. package/src/components/prompt-input/editor-dom.ts +148 -0
  101. package/src/components/prompt-input/files.ts +66 -0
  102. package/src/components/prompt-input/history.test.ts +153 -0
  103. package/src/components/prompt-input/history.ts +256 -0
  104. package/src/components/prompt-input/image-attachments.tsx +58 -0
  105. package/src/components/prompt-input/paste.ts +24 -0
  106. package/src/components/prompt-input/placeholder.test.ts +48 -0
  107. package/src/components/prompt-input/placeholder.ts +15 -0
  108. package/src/components/prompt-input/slash-popover.tsx +141 -0
  109. package/src/components/prompt-input/submit.test.ts +346 -0
  110. package/src/components/prompt-input/submit.ts +579 -0
  111. package/src/components/prompt-input.tsx +1595 -0
  112. package/src/components/server/server-row.tsx +130 -0
  113. package/src/components/session/index.ts +5 -0
  114. package/src/components/session/session-context-breakdown.test.ts +61 -0
  115. package/src/components/session/session-context-breakdown.ts +132 -0
  116. package/src/components/session/session-context-format.ts +20 -0
  117. package/src/components/session/session-context-metrics.test.ts +101 -0
  118. package/src/components/session/session-context-metrics.ts +82 -0
  119. package/src/components/session/session-context-tab.tsx +339 -0
  120. package/src/components/session/session-header.tsx +486 -0
  121. package/src/components/session/session-new-view.tsx +91 -0
  122. package/src/components/session/session-sortable-tab.tsx +70 -0
  123. package/src/components/session/session-sortable-terminal-tab.tsx +193 -0
  124. package/src/components/session-context-usage.tsx +122 -0
  125. package/src/components/settings-general.tsx +585 -0
  126. package/src/components/settings-keybinds.tsx +453 -0
  127. package/src/components/settings-list.tsx +5 -0
  128. package/src/components/settings-models.tsx +137 -0
  129. package/src/components/settings-providers.tsx +251 -0
  130. package/src/components/status-popover.tsx +419 -0
  131. package/src/components/terminal.tsx +653 -0
  132. package/src/components/titlebar-history.test.ts +63 -0
  133. package/src/components/titlebar-history.ts +57 -0
  134. package/src/components/titlebar.tsx +312 -0
  135. package/src/constants/file-picker.ts +89 -0
  136. package/src/context/command-keybind.test.ts +69 -0
  137. package/src/context/command.test.ts +25 -0
  138. package/src/context/command.tsx +437 -0
  139. package/src/context/comments.test.ts +186 -0
  140. package/src/context/comments.tsx +243 -0
  141. package/src/context/file/content-cache.ts +88 -0
  142. package/src/context/file/path.test.ts +360 -0
  143. package/src/context/file/path.ts +151 -0
  144. package/src/context/file/tree-store.ts +170 -0
  145. package/src/context/file/types.ts +41 -0
  146. package/src/context/file/view-cache.ts +146 -0
  147. package/src/context/file/watcher.test.ts +149 -0
  148. package/src/context/file/watcher.ts +53 -0
  149. package/src/context/file-content-eviction-accounting.test.ts +65 -0
  150. package/src/context/file.tsx +280 -0
  151. package/src/context/global-sdk.tsx +232 -0
  152. package/src/context/global-sync/bootstrap.ts +206 -0
  153. package/src/context/global-sync/child-store.test.ts +38 -0
  154. package/src/context/global-sync/child-store.ts +281 -0
  155. package/src/context/global-sync/event-reducer.test.ts +552 -0
  156. package/src/context/global-sync/event-reducer.ts +359 -0
  157. package/src/context/global-sync/eviction.ts +28 -0
  158. package/src/context/global-sync/queue.ts +83 -0
  159. package/src/context/global-sync/session-cache.test.ts +102 -0
  160. package/src/context/global-sync/session-cache.ts +62 -0
  161. package/src/context/global-sync/session-load.ts +25 -0
  162. package/src/context/global-sync/session-prefetch.test.ts +96 -0
  163. package/src/context/global-sync/session-prefetch.ts +100 -0
  164. package/src/context/global-sync/session-trim.test.ts +59 -0
  165. package/src/context/global-sync/session-trim.ts +56 -0
  166. package/src/context/global-sync/types.ts +133 -0
  167. package/src/context/global-sync/utils.ts +25 -0
  168. package/src/context/global-sync.test.ts +122 -0
  169. package/src/context/global-sync.tsx +408 -0
  170. package/src/context/highlights.tsx +233 -0
  171. package/src/context/language.tsx +248 -0
  172. package/src/context/layout-scroll.test.ts +64 -0
  173. package/src/context/layout-scroll.ts +126 -0
  174. package/src/context/layout.test.ts +69 -0
  175. package/src/context/layout.tsx +937 -0
  176. package/src/context/local.tsx +422 -0
  177. package/src/context/model-variant.test.ts +86 -0
  178. package/src/context/model-variant.ts +52 -0
  179. package/src/context/models.tsx +163 -0
  180. package/src/context/notification.tsx +373 -0
  181. package/src/context/permission-auto-respond.test.ts +102 -0
  182. package/src/context/permission-auto-respond.ts +51 -0
  183. package/src/context/permission.tsx +277 -0
  184. package/src/context/platform.tsx +99 -0
  185. package/src/context/prompt.tsx +297 -0
  186. package/src/context/sdk.tsx +49 -0
  187. package/src/context/server.tsx +295 -0
  188. package/src/context/settings.tsx +241 -0
  189. package/src/context/sync-optimistic.test.ts +123 -0
  190. package/src/context/sync.tsx +618 -0
  191. package/src/context/terminal-title.ts +51 -0
  192. package/src/context/terminal.test.ts +82 -0
  193. package/src/context/terminal.tsx +437 -0
  194. package/src/entry.tsx +144 -0
  195. package/src/env.d.ts +18 -0
  196. package/src/hooks/use-providers.ts +44 -0
  197. package/src/i18n/ar.ts +855 -0
  198. package/src/i18n/br.ts +867 -0
  199. package/src/i18n/bs.ts +943 -0
  200. package/src/i18n/da.ts +937 -0
  201. package/src/i18n/de.ts +879 -0
  202. package/src/i18n/en.ts +948 -0
  203. package/src/i18n/es.ts +950 -0
  204. package/src/i18n/fr.ts +878 -0
  205. package/src/i18n/ja.ts +861 -0
  206. package/src/i18n/ko.ts +860 -0
  207. package/src/i18n/no.ts +944 -0
  208. package/src/i18n/parity.test.ts +32 -0
  209. package/src/i18n/pl.ts +865 -0
  210. package/src/i18n/ru.ts +946 -0
  211. package/src/i18n/th.ts +933 -0
  212. package/src/i18n/tr.ts +952 -0
  213. package/src/i18n/zh.ts +930 -0
  214. package/src/i18n/zht.ts +925 -0
  215. package/src/index.css +29 -0
  216. package/src/index.ts +6 -0
  217. package/src/pages/directory-layout.tsx +88 -0
  218. package/src/pages/error.tsx +327 -0
  219. package/src/pages/home.tsx +131 -0
  220. package/src/pages/layout/deep-links.ts +50 -0
  221. package/src/pages/layout/helpers.test.ts +211 -0
  222. package/src/pages/layout/helpers.ts +98 -0
  223. package/src/pages/layout/inline-editor.tsx +126 -0
  224. package/src/pages/layout/sidebar-items.tsx +437 -0
  225. package/src/pages/layout/sidebar-project.tsx +384 -0
  226. package/src/pages/layout/sidebar-shell.tsx +125 -0
  227. package/src/pages/layout/sidebar-workspace.tsx +504 -0
  228. package/src/pages/layout.tsx +2509 -0
  229. package/src/pages/session/composer/index.ts +2 -0
  230. package/src/pages/session/composer/session-composer-region.tsx +255 -0
  231. package/src/pages/session/composer/session-composer-state.test.ts +128 -0
  232. package/src/pages/session/composer/session-composer-state.ts +249 -0
  233. package/src/pages/session/composer/session-followup-dock.tsx +109 -0
  234. package/src/pages/session/composer/session-permission-dock.tsx +74 -0
  235. package/src/pages/session/composer/session-question-dock.tsx +449 -0
  236. package/src/pages/session/composer/session-request-tree.ts +52 -0
  237. package/src/pages/session/composer/session-revert-dock.tsx +99 -0
  238. package/src/pages/session/composer/session-todo-dock.tsx +330 -0
  239. package/src/pages/session/file-tab-scroll.test.ts +40 -0
  240. package/src/pages/session/file-tab-scroll.ts +67 -0
  241. package/src/pages/session/file-tabs.tsx +456 -0
  242. package/src/pages/session/handoff.ts +36 -0
  243. package/src/pages/session/helpers.test.ts +181 -0
  244. package/src/pages/session/helpers.ts +198 -0
  245. package/src/pages/session/message-gesture.test.ts +62 -0
  246. package/src/pages/session/message-gesture.ts +21 -0
  247. package/src/pages/session/message-id-from-hash.ts +6 -0
  248. package/src/pages/session/message-timeline.tsx +1013 -0
  249. package/src/pages/session/review-tab.tsx +170 -0
  250. package/src/pages/session/session-layout.ts +20 -0
  251. package/src/pages/session/session-model-helpers.test.ts +51 -0
  252. package/src/pages/session/session-model-helpers.ts +16 -0
  253. package/src/pages/session/session-side-panel.tsx +453 -0
  254. package/src/pages/session/terminal-label.ts +16 -0
  255. package/src/pages/session/terminal-panel.test.ts +25 -0
  256. package/src/pages/session/terminal-panel.tsx +326 -0
  257. package/src/pages/session/use-session-commands.tsx +495 -0
  258. package/src/pages/session/use-session-hash-scroll.test.ts +16 -0
  259. package/src/pages/session/use-session-hash-scroll.ts +197 -0
  260. package/src/pages/session.tsx +1841 -0
  261. package/src/sst-env.d.ts +12 -0
  262. package/src/testing/model-selection.ts +80 -0
  263. package/src/testing/prompt.ts +56 -0
  264. package/src/testing/session-composer.ts +84 -0
  265. package/src/testing/terminal.ts +118 -0
  266. package/src/theme-preload.test.ts +46 -0
  267. package/src/utils/agent.ts +23 -0
  268. package/src/utils/aim.ts +138 -0
  269. package/src/utils/base64.ts +10 -0
  270. package/src/utils/comment-note.ts +88 -0
  271. package/src/utils/id.ts +99 -0
  272. package/src/utils/notification-click.test.ts +27 -0
  273. package/src/utils/notification-click.ts +13 -0
  274. package/src/utils/persist.test.ts +115 -0
  275. package/src/utils/persist.ts +476 -0
  276. package/src/utils/prompt.test.ts +44 -0
  277. package/src/utils/prompt.ts +203 -0
  278. package/src/utils/runtime-adapters.test.ts +62 -0
  279. package/src/utils/runtime-adapters.ts +39 -0
  280. package/src/utils/same.ts +6 -0
  281. package/src/utils/scoped-cache.test.ts +69 -0
  282. package/src/utils/scoped-cache.ts +104 -0
  283. package/src/utils/server-errors.test.ts +131 -0
  284. package/src/utils/server-errors.ts +80 -0
  285. package/src/utils/server-health.test.ts +123 -0
  286. package/src/utils/server-health.ts +91 -0
  287. package/src/utils/server.ts +22 -0
  288. package/src/utils/solid-dnd.tsx +49 -0
  289. package/src/utils/sound.ts +117 -0
  290. package/src/utils/terminal-writer.test.ts +64 -0
  291. package/src/utils/terminal-writer.ts +65 -0
  292. package/src/utils/time.ts +22 -0
  293. package/src/utils/uuid.test.ts +78 -0
  294. package/src/utils/uuid.ts +12 -0
  295. package/src/utils/worktree.test.ts +46 -0
  296. package/src/utils/worktree.ts +73 -0
  297. package/sst-env.d.ts +10 -0
  298. package/tsconfig.json +26 -0
  299. package/vite.config.ts +15 -0
  300. package/vite.js +26 -0
@@ -0,0 +1,1841 @@
1
+ import type { Project, UserMessage } from "@reign-labs/sdk/v2"
2
+ import { useDialog } from "@reign-labs/ui/context/dialog"
3
+ import { useMutation } from "@tanstack/solid-query"
4
+ import {
5
+ batch,
6
+ onCleanup,
7
+ Show,
8
+ Match,
9
+ Switch,
10
+ createMemo,
11
+ createEffect,
12
+ createComputed,
13
+ on,
14
+ onMount,
15
+ untrack,
16
+ } from "solid-js"
17
+ import { createMediaQuery } from "@solid-primitives/media"
18
+ import { createResizeObserver } from "@solid-primitives/resize-observer"
19
+ import { useLocal } from "@/context/local"
20
+ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
21
+ import { createStore } from "solid-js/store"
22
+ import { ResizeHandle } from "@reign-labs/ui/resize-handle"
23
+ import { Select } from "@reign-labs/ui/select"
24
+ import { Tabs } from "@reign-labs/ui/tabs"
25
+ import { createAutoScroll } from "@reign-labs/ui/hooks"
26
+ import { previewSelectedLines } from "@reign-labs/ui/pierre/selection-bridge"
27
+ import { Button } from "@reign-labs/ui/button"
28
+ import { showToast } from "@reign-labs/ui/toast"
29
+ import { base64Encode, checksum } from "@reign-labs/util/encode"
30
+ import { useNavigate, useSearchParams } from "@solidjs/router"
31
+ import { NewSessionView, SessionHeader } from "@/components/session"
32
+ import { useComments } from "@/context/comments"
33
+ import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
34
+ import { useGlobalSync } from "@/context/global-sync"
35
+ import { useLanguage } from "@/context/language"
36
+ import { useLayout } from "@/context/layout"
37
+ import { usePrompt } from "@/context/prompt"
38
+ import { useSDK } from "@/context/sdk"
39
+ import { useSettings } from "@/context/settings"
40
+ import { useSync } from "@/context/sync"
41
+ import { useTerminal } from "@/context/terminal"
42
+ import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
43
+ import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
44
+ import {
45
+ createOpenReviewFile,
46
+ createSessionTabs,
47
+ createSizing,
48
+ focusTerminalById,
49
+ shouldFocusTerminalOnKeyDown,
50
+ } from "@/pages/session/helpers"
51
+ import { MessageTimeline } from "@/pages/session/message-timeline"
52
+ import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
53
+ import { useSessionLayout } from "@/pages/session/session-layout"
54
+ import { syncSessionModel } from "@/pages/session/session-model-helpers"
55
+ import { SessionSidePanel } from "@/pages/session/session-side-panel"
56
+ import { TerminalPanel } from "@/pages/session/terminal-panel"
57
+ import { useSessionCommands } from "@/pages/session/use-session-commands"
58
+ import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
59
+ import { Identifier } from "@/utils/id"
60
+ import { extractPromptFromParts } from "@/utils/prompt"
61
+ import { same } from "@/utils/same"
62
+ import { formatServerError } from "@/utils/server-errors"
63
+
64
+ const emptyUserMessages: UserMessage[] = []
65
+ const emptyFollowups: (FollowupDraft & { id: string })[] = []
66
+
67
+ type SessionHistoryWindowInput = {
68
+ sessionID: () => string | undefined
69
+ messagesReady: () => boolean
70
+ loaded: () => number
71
+ visibleUserMessages: () => UserMessage[]
72
+ historyMore: () => boolean
73
+ historyLoading: () => boolean
74
+ loadMore: (sessionID: string) => Promise<void>
75
+ userScrolled: () => boolean
76
+ scroller: () => HTMLDivElement | undefined
77
+ }
78
+
79
+ /**
80
+ * Maintains the rendered history window for a session timeline.
81
+ *
82
+ * It keeps initial paint bounded to recent turns, reveals cached turns in
83
+ * small batches while scrolling upward, and prefetches older history near top.
84
+ */
85
+ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
86
+ const turnInit = 10
87
+ const turnBatch = 8
88
+ const turnScrollThreshold = 200
89
+ const turnPrefetchBuffer = 16
90
+ const prefetchCooldownMs = 400
91
+ const prefetchNoGrowthLimit = 2
92
+
93
+ const [state, setState] = createStore({
94
+ turnID: undefined as string | undefined,
95
+ turnStart: 0,
96
+ prefetchUntil: 0,
97
+ prefetchNoGrowth: 0,
98
+ })
99
+
100
+ const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
101
+
102
+ const turnStart = createMemo(() => {
103
+ const id = input.sessionID()
104
+ const len = input.visibleUserMessages().length
105
+ if (!id || len <= 0) return 0
106
+ if (state.turnID !== id) return initialTurnStart(len)
107
+ if (state.turnStart <= 0) return 0
108
+ if (state.turnStart >= len) return initialTurnStart(len)
109
+ return state.turnStart
110
+ })
111
+
112
+ const setTurnStart = (start: number) => {
113
+ const id = input.sessionID()
114
+ const next = start > 0 ? start : 0
115
+ if (!id) {
116
+ setState({ turnID: undefined, turnStart: next })
117
+ return
118
+ }
119
+ setState({ turnID: id, turnStart: next })
120
+ }
121
+
122
+ const renderedUserMessages = createMemo(
123
+ () => {
124
+ const msgs = input.visibleUserMessages()
125
+ const start = turnStart()
126
+ if (start <= 0) return msgs
127
+ return msgs.slice(start)
128
+ },
129
+ emptyUserMessages,
130
+ {
131
+ equals: same,
132
+ },
133
+ )
134
+
135
+ const preserveScroll = (fn: () => void) => {
136
+ const el = input.scroller()
137
+ if (!el) {
138
+ fn()
139
+ return
140
+ }
141
+ const beforeTop = el.scrollTop
142
+ const beforeHeight = el.scrollHeight
143
+ fn()
144
+ requestAnimationFrame(() => {
145
+ const delta = el.scrollHeight - beforeHeight
146
+ if (!delta) return
147
+ el.scrollTop = beforeTop + delta
148
+ })
149
+ }
150
+
151
+ const backfillTurns = () => {
152
+ const start = turnStart()
153
+ if (start <= 0) return
154
+
155
+ const next = start - turnBatch
156
+ const nextStart = next > 0 ? next : 0
157
+
158
+ preserveScroll(() => setTurnStart(nextStart))
159
+ }
160
+
161
+ /** Button path: reveal all cached turns, fetch older history, reveal one batch. */
162
+ const loadAndReveal = async () => {
163
+ const id = input.sessionID()
164
+ if (!id) return
165
+
166
+ const start = turnStart()
167
+ const beforeVisible = input.visibleUserMessages().length
168
+ let loaded = input.loaded()
169
+
170
+ if (start > 0) setTurnStart(0)
171
+
172
+ if (!input.historyMore() || input.historyLoading()) return
173
+
174
+ let afterVisible = beforeVisible
175
+ let added = 0
176
+
177
+ while (true) {
178
+ await input.loadMore(id)
179
+ if (input.sessionID() !== id) return
180
+
181
+ afterVisible = input.visibleUserMessages().length
182
+ const nextLoaded = input.loaded()
183
+ const raw = nextLoaded - loaded
184
+ added += raw
185
+ loaded = nextLoaded
186
+
187
+ if (afterVisible > beforeVisible) break
188
+ if (raw <= 0) break
189
+ if (!input.historyMore()) break
190
+ }
191
+
192
+ if (added <= 0) return
193
+ if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
194
+
195
+ const growth = afterVisible - beforeVisible
196
+ if (growth <= 0) return
197
+ if (turnStart() !== 0) return
198
+
199
+ const target = Math.min(afterVisible, beforeVisible + turnBatch)
200
+ setTurnStart(Math.max(0, afterVisible - target))
201
+ }
202
+
203
+ /** Scroll/prefetch path: fetch older history from server. */
204
+ const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
205
+ const id = input.sessionID()
206
+ if (!id) return
207
+ if (!input.historyMore() || input.historyLoading()) return
208
+
209
+ if (opts?.prefetch) {
210
+ const now = Date.now()
211
+ if (state.prefetchUntil > now) return
212
+ if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
213
+ setState("prefetchUntil", now + prefetchCooldownMs)
214
+ }
215
+
216
+ const start = turnStart()
217
+ const beforeVisible = input.visibleUserMessages().length
218
+ const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
219
+ let loaded = input.loaded()
220
+ let added = 0
221
+ let growth = 0
222
+
223
+ while (true) {
224
+ await input.loadMore(id)
225
+ if (input.sessionID() !== id) return
226
+
227
+ const nextLoaded = input.loaded()
228
+ const raw = nextLoaded - loaded
229
+ added += raw
230
+ loaded = nextLoaded
231
+ growth = input.visibleUserMessages().length - beforeVisible
232
+
233
+ if (growth > 0) break
234
+ if (raw <= 0) break
235
+ if (opts?.prefetch) break
236
+ if (!input.historyMore()) break
237
+ }
238
+
239
+ const afterVisible = input.visibleUserMessages().length
240
+
241
+ if (opts?.prefetch) {
242
+ setState("prefetchNoGrowth", added > 0 ? 0 : state.prefetchNoGrowth + 1)
243
+ } else if (added > 0 && state.prefetchNoGrowth) {
244
+ setState("prefetchNoGrowth", 0)
245
+ }
246
+
247
+ if (added <= 0) return
248
+ if (growth <= 0) return
249
+
250
+ if (opts?.prefetch) {
251
+ const current = turnStart()
252
+ preserveScroll(() => setTurnStart(current + growth))
253
+ return
254
+ }
255
+
256
+ if (turnStart() !== start) return
257
+
258
+ const currentRendered = renderedUserMessages().length
259
+ const base = Math.max(beforeRendered, currentRendered)
260
+ const target = Math.min(afterVisible, base + turnBatch)
261
+ preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
262
+ }
263
+
264
+ const onScrollerScroll = () => {
265
+ if (!input.userScrolled()) return
266
+ const el = input.scroller()
267
+ if (!el) return
268
+ if (el.scrollTop >= turnScrollThreshold) return
269
+
270
+ const start = turnStart()
271
+ if (start > 0) {
272
+ if (start <= turnPrefetchBuffer) {
273
+ void fetchOlderMessages({ prefetch: true })
274
+ }
275
+ backfillTurns()
276
+ return
277
+ }
278
+
279
+ void fetchOlderMessages()
280
+ }
281
+
282
+ createEffect(
283
+ on(
284
+ input.sessionID,
285
+ () => {
286
+ setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
287
+ },
288
+ { defer: true },
289
+ ),
290
+ )
291
+
292
+ createEffect(
293
+ on(
294
+ () => [input.sessionID(), input.messagesReady()] as const,
295
+ ([id, ready]) => {
296
+ if (!id || !ready) return
297
+ setTurnStart(initialTurnStart(input.visibleUserMessages().length))
298
+ },
299
+ { defer: true },
300
+ ),
301
+ )
302
+
303
+ return {
304
+ turnStart,
305
+ setTurnStart,
306
+ renderedUserMessages,
307
+ loadAndReveal,
308
+ onScrollerScroll,
309
+ }
310
+ }
311
+
312
+ export default function Page() {
313
+ const globalSync = useGlobalSync()
314
+ const layout = useLayout()
315
+ const local = useLocal()
316
+ const file = useFile()
317
+ const sync = useSync()
318
+ const dialog = useDialog()
319
+ const language = useLanguage()
320
+ const navigate = useNavigate()
321
+ const sdk = useSDK()
322
+ const settings = useSettings()
323
+ const prompt = usePrompt()
324
+ const comments = useComments()
325
+ const terminal = useTerminal()
326
+ const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
327
+ const { params, sessionKey, tabs, view } = useSessionLayout()
328
+
329
+ createEffect(() => {
330
+ if (!untrack(() => prompt.ready())) return
331
+ prompt.ready()
332
+ untrack(() => {
333
+ if (params.id || !prompt.ready()) return
334
+ const text = searchParams.prompt
335
+ if (!text) return
336
+ prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
337
+ setSearchParams({ ...searchParams, prompt: undefined })
338
+ })
339
+ })
340
+
341
+ const [ui, setUi] = createStore({
342
+ pendingMessage: undefined as string | undefined,
343
+ reviewSnap: false,
344
+ scrollGesture: 0,
345
+ scroll: {
346
+ overflow: false,
347
+ bottom: true,
348
+ },
349
+ })
350
+
351
+ const composer = createSessionComposerState()
352
+
353
+ const workspaceKey = createMemo(() => params.dir ?? "")
354
+ const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
355
+
356
+ createEffect(
357
+ on(
358
+ () => params.id,
359
+ (id, prev) => {
360
+ if (!id) return
361
+ if (prev) return
362
+
363
+ const pending = layout.handoff.tabs()
364
+ if (!pending) return
365
+ if (Date.now() - pending.at > 60_000) {
366
+ layout.handoff.clearTabs()
367
+ return
368
+ }
369
+
370
+ if (pending.id !== id) return
371
+ layout.handoff.clearTabs()
372
+ if (pending.dir !== (params.dir ?? "")) return
373
+
374
+ const from = workspaceTabs().tabs()
375
+ if (from.all.length === 0 && !from.active) return
376
+
377
+ const current = tabs().tabs()
378
+ if (current.all.length > 0 || current.active) return
379
+
380
+ const all = normalizeTabs(from.all)
381
+ const active = from.active ? normalizeTab(from.active) : undefined
382
+ tabs().setAll(all)
383
+ tabs().setActive(active && all.includes(active) ? active : all[0])
384
+
385
+ workspaceTabs().setAll([])
386
+ workspaceTabs().setActive(undefined)
387
+ },
388
+ { defer: true },
389
+ ),
390
+ )
391
+
392
+ const isDesktop = createMediaQuery("(min-width: 768px)")
393
+ const size = createSizing()
394
+ const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
395
+ const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
396
+ const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
397
+ const sessionPanelWidth = createMemo(() => {
398
+ if (!desktopSidePanelOpen()) return "100%"
399
+ if (desktopReviewOpen()) return `${layout.session.width()}px`
400
+ return `calc(100% - ${layout.fileTree.width()}px)`
401
+ })
402
+ const centered = createMemo(() => isDesktop() && !desktopReviewOpen())
403
+
404
+ function normalizeTab(tab: string) {
405
+ if (!tab.startsWith("file://")) return tab
406
+ return file.tab(tab)
407
+ }
408
+
409
+ function normalizeTabs(list: string[]) {
410
+ const seen = new Set<string>()
411
+ const next: string[] = []
412
+ for (const item of list) {
413
+ const value = normalizeTab(item)
414
+ if (seen.has(value)) continue
415
+ seen.add(value)
416
+ next.push(value)
417
+ }
418
+ return next
419
+ }
420
+
421
+ const openReviewPanel = () => {
422
+ if (!view().reviewPanel.opened()) view().reviewPanel.open()
423
+ }
424
+
425
+ const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
426
+ const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
427
+ const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
428
+ const hasReview = createMemo(() => reviewCount() > 0)
429
+ const reviewTab = createMemo(() => isDesktop())
430
+ const tabState = createSessionTabs({
431
+ tabs,
432
+ pathFromTab: file.pathFromTab,
433
+ normalizeTab,
434
+ review: reviewTab,
435
+ hasReview,
436
+ })
437
+ const contextOpen = tabState.contextOpen
438
+ const openedTabs = tabState.openedTabs
439
+ const activeTab = tabState.activeTab
440
+ const activeFileTab = tabState.activeFileTab
441
+ const revertMessageID = createMemo(() => info()?.revert?.messageID)
442
+ const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
443
+ const messagesReady = createMemo(() => {
444
+ const id = params.id
445
+ if (!id) return true
446
+ return sync.data.message[id] !== undefined
447
+ })
448
+ const historyMore = createMemo(() => {
449
+ const id = params.id
450
+ if (!id) return false
451
+ return sync.session.history.more(id)
452
+ })
453
+ const historyLoading = createMemo(() => {
454
+ const id = params.id
455
+ if (!id) return false
456
+ return sync.session.history.loading(id)
457
+ })
458
+
459
+ const userMessages = createMemo(
460
+ () => messages().filter((m) => m.role === "user") as UserMessage[],
461
+ emptyUserMessages,
462
+ { equals: same },
463
+ )
464
+ const visibleUserMessages = createMemo(
465
+ () => {
466
+ const revert = revertMessageID()
467
+ if (!revert) return userMessages()
468
+ return userMessages().filter((m) => m.id < revert)
469
+ },
470
+ emptyUserMessages,
471
+ {
472
+ equals: same,
473
+ },
474
+ )
475
+ const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
476
+
477
+ createEffect(() => {
478
+ const tab = activeFileTab()
479
+ if (!tab) return
480
+
481
+ const path = file.pathFromTab(tab)
482
+ if (path) file.load(path)
483
+ })
484
+
485
+ createEffect(
486
+ on(
487
+ () => lastUserMessage()?.id,
488
+ () => {
489
+ const msg = lastUserMessage()
490
+ if (!msg) return
491
+ syncSessionModel(local, msg)
492
+ },
493
+ ),
494
+ )
495
+
496
+ createEffect(
497
+ on(
498
+ () => ({ dir: params.dir, id: params.id }),
499
+ (next, prev) => {
500
+ if (!prev) return
501
+ if (next.dir === prev.dir && next.id === prev.id) return
502
+ if (prev.id && !next.id) local.session.reset()
503
+ },
504
+ { defer: true },
505
+ ),
506
+ )
507
+
508
+ const [store, setStore] = createStore({
509
+ messageId: undefined as string | undefined,
510
+ mobileTab: "session" as "session" | "changes",
511
+ changes: "session" as "session" | "turn",
512
+ newSessionWorktree: "main",
513
+ deferRender: false,
514
+ })
515
+
516
+ const [followup, setFollowup] = createStore({
517
+ items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
518
+ failed: {} as Record<string, string | undefined>,
519
+ paused: {} as Record<string, boolean | undefined>,
520
+ edit: {} as Record<
521
+ string,
522
+ { id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] } | undefined
523
+ >,
524
+ })
525
+
526
+ createComputed((prev) => {
527
+ const key = sessionKey()
528
+ if (key !== prev) {
529
+ setStore("deferRender", true)
530
+ requestAnimationFrame(() => {
531
+ setTimeout(() => setStore("deferRender", false), 0)
532
+ })
533
+ }
534
+ return key
535
+ }, sessionKey())
536
+
537
+ let reviewFrame: number | undefined
538
+ let refreshFrame: number | undefined
539
+ let refreshTimer: number | undefined
540
+ let diffFrame: number | undefined
541
+ let diffTimer: number | undefined
542
+
543
+ createComputed((prev) => {
544
+ const open = desktopReviewOpen()
545
+ if (prev === undefined || prev === open) return open
546
+
547
+ if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
548
+ setUi("reviewSnap", true)
549
+ reviewFrame = requestAnimationFrame(() => {
550
+ reviewFrame = undefined
551
+ setUi("reviewSnap", false)
552
+ })
553
+ return open
554
+ }, desktopReviewOpen())
555
+
556
+ const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
557
+ const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
558
+
559
+ const newSessionWorktree = createMemo(() => {
560
+ if (store.newSessionWorktree === "create") return "create"
561
+ const project = sync.project
562
+ if (project && sdk.directory !== project.worktree) return sdk.directory
563
+ return "main"
564
+ })
565
+
566
+ const setActiveMessage = (message: UserMessage | undefined) => {
567
+ messageMark = scrollMark
568
+ setStore("messageId", message?.id)
569
+ }
570
+
571
+ const anchor = (id: string) => `message-${id}`
572
+
573
+ const cursor = () => {
574
+ const root = scroller
575
+ if (!root) return store.messageId
576
+
577
+ const box = root.getBoundingClientRect()
578
+ const line = box.top + 100
579
+ const list = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
580
+ .map((el) => {
581
+ const id = el.dataset.messageId
582
+ if (!id) return
583
+
584
+ const rect = el.getBoundingClientRect()
585
+ return { id, top: rect.top, bottom: rect.bottom }
586
+ })
587
+ .filter((item): item is { id: string; top: number; bottom: number } => !!item)
588
+
589
+ const shown = list.filter((item) => item.bottom > box.top && item.top < box.bottom)
590
+ const hit = shown.find((item) => item.top <= line && item.bottom >= line)
591
+ if (hit) return hit.id
592
+
593
+ const near = [...shown].sort((a, b) => {
594
+ const da = Math.abs(a.top - line)
595
+ const db = Math.abs(b.top - line)
596
+ if (da !== db) return da - db
597
+ return a.top - b.top
598
+ })[0]
599
+ if (near) return near.id
600
+
601
+ return list.filter((item) => item.top <= line).at(-1)?.id ?? list[0]?.id ?? store.messageId
602
+ }
603
+
604
+ function navigateMessageByOffset(offset: number) {
605
+ const msgs = visibleUserMessages()
606
+ if (msgs.length === 0) return
607
+
608
+ const current = store.messageId && messageMark === scrollMark ? store.messageId : cursor()
609
+ const base = current ? msgs.findIndex((m) => m.id === current) : msgs.length
610
+ const currentIndex = base === -1 ? msgs.length : base
611
+ const targetIndex = currentIndex + offset
612
+ if (targetIndex < 0 || targetIndex > msgs.length) return
613
+
614
+ if (targetIndex === msgs.length) {
615
+ resumeScroll()
616
+ return
617
+ }
618
+
619
+ autoScroll.pause()
620
+ scrollToMessage(msgs[targetIndex], "auto")
621
+ }
622
+
623
+ const diffsReady = createMemo(() => {
624
+ const id = params.id
625
+ if (!id) return true
626
+ if (!hasReview()) return true
627
+ return sync.data.session_diff[id] !== undefined
628
+ })
629
+ const reviewEmptyKey = createMemo(() => {
630
+ const project = sync.project
631
+ if (project && !project.vcs) return "session.review.noVcs"
632
+ if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
633
+ return "session.review.empty"
634
+ })
635
+
636
+ function upsert(next: Project) {
637
+ const list = globalSync.data.project
638
+ sync.set("project", next.id)
639
+ const idx = list.findIndex((item) => item.id === next.id)
640
+ if (idx >= 0) {
641
+ globalSync.set(
642
+ "project",
643
+ list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
644
+ )
645
+ return
646
+ }
647
+ const at = list.findIndex((item) => item.id > next.id)
648
+ if (at >= 0) {
649
+ globalSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
650
+ return
651
+ }
652
+ globalSync.set("project", [...list, next])
653
+ }
654
+
655
+ const gitMutation = useMutation(() => ({
656
+ mutationFn: () => sdk.client.project.initGit(),
657
+ onSuccess: (x) => {
658
+ if (!x.data) return
659
+ upsert(x.data)
660
+ },
661
+ onError: (err) => {
662
+ showToast({
663
+ variant: "error",
664
+ title: language.t("common.requestFailed"),
665
+ description: formatServerError(err, language.t),
666
+ })
667
+ },
668
+ }))
669
+
670
+ function initGit() {
671
+ if (gitMutation.isPending) return
672
+ gitMutation.mutate()
673
+ }
674
+
675
+ let inputRef!: HTMLDivElement
676
+ let promptDock: HTMLDivElement | undefined
677
+ let dockHeight = 0
678
+ let scroller: HTMLDivElement | undefined
679
+ let content: HTMLDivElement | undefined
680
+ let scrollMark = 0
681
+ let messageMark = 0
682
+
683
+ const scrollGestureWindowMs = 250
684
+
685
+ const markScrollGesture = (target?: EventTarget | null) => {
686
+ const root = scroller
687
+ if (!root) return
688
+
689
+ const el = target instanceof Element ? target : undefined
690
+ const nested = el?.closest("[data-scrollable]")
691
+ if (nested && nested !== root) return
692
+
693
+ setUi("scrollGesture", Date.now())
694
+ }
695
+
696
+ const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
697
+
698
+ createEffect(
699
+ on([() => sdk.directory, () => params.id] as const, ([, id]) => {
700
+ if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
701
+ if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
702
+ refreshFrame = undefined
703
+ refreshTimer = undefined
704
+ if (!id) return
705
+
706
+ const cached = untrack(() => sync.data.message[id] !== undefined)
707
+ const stale = !cached
708
+ ? false
709
+ : (() => {
710
+ const info = getSessionPrefetch(sdk.directory, id)
711
+ if (!info) return true
712
+ return Date.now() - info.at > SESSION_PREFETCH_TTL
713
+ })()
714
+ const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
715
+
716
+ untrack(() => {
717
+ void sync.session.sync(id)
718
+ })
719
+
720
+ refreshFrame = requestAnimationFrame(() => {
721
+ refreshFrame = undefined
722
+ refreshTimer = window.setTimeout(() => {
723
+ refreshTimer = undefined
724
+ if (params.id !== id) return
725
+ untrack(() => {
726
+ if (stale) void sync.session.sync(id, { force: true })
727
+ void sync.session.todo(id, todos ? { force: true } : undefined)
728
+ })
729
+ }, 0)
730
+ })
731
+ }),
732
+ )
733
+
734
+ createEffect(
735
+ on(
736
+ () => visibleUserMessages().at(-1)?.id,
737
+ (lastId, prevLastId) => {
738
+ if (lastId && prevLastId && lastId > prevLastId) {
739
+ setStore("messageId", undefined)
740
+ }
741
+ },
742
+ { defer: true },
743
+ ),
744
+ )
745
+
746
+ createEffect(
747
+ on(
748
+ sessionKey,
749
+ () => {
750
+ setStore("messageId", undefined)
751
+ setStore("changes", "session")
752
+ setUi("pendingMessage", undefined)
753
+ },
754
+ { defer: true },
755
+ ),
756
+ )
757
+
758
+ createEffect(
759
+ on(
760
+ () => params.dir,
761
+ (dir) => {
762
+ if (!dir) return
763
+ setStore("newSessionWorktree", "main")
764
+ },
765
+ { defer: true },
766
+ ),
767
+ )
768
+
769
+ const selectionPreview = (path: string, selection: FileSelection) => {
770
+ const content = file.get(path)?.content?.content
771
+ if (!content) return undefined
772
+ return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine })
773
+ }
774
+
775
+ const addCommentToContext = (input: {
776
+ file: string
777
+ selection: SelectedLineRange
778
+ comment: string
779
+ preview?: string
780
+ origin?: "review" | "file"
781
+ }) => {
782
+ const selection = selectionFromLines(input.selection)
783
+ const preview = input.preview ?? selectionPreview(input.file, selection)
784
+ const saved = comments.add({
785
+ file: input.file,
786
+ selection: input.selection,
787
+ comment: input.comment,
788
+ })
789
+ prompt.context.add({
790
+ type: "file",
791
+ path: input.file,
792
+ selection,
793
+ comment: input.comment,
794
+ commentID: saved.id,
795
+ commentOrigin: input.origin,
796
+ preview,
797
+ })
798
+ }
799
+
800
+ const updateCommentInContext = (input: {
801
+ id: string
802
+ file: string
803
+ selection: SelectedLineRange
804
+ comment: string
805
+ preview?: string
806
+ }) => {
807
+ comments.update(input.file, input.id, input.comment)
808
+ prompt.context.updateComment(input.file, input.id, {
809
+ comment: input.comment,
810
+ ...(input.preview ? { preview: input.preview } : {}),
811
+ })
812
+ }
813
+
814
+ const removeCommentFromContext = (input: { id: string; file: string }) => {
815
+ comments.remove(input.file, input.id)
816
+ prompt.context.removeComment(input.file, input.id)
817
+ }
818
+
819
+ const reviewCommentActions = createMemo(() => ({
820
+ moreLabel: language.t("common.moreOptions"),
821
+ editLabel: language.t("common.edit"),
822
+ deleteLabel: language.t("common.delete"),
823
+ saveLabel: language.t("common.save"),
824
+ }))
825
+
826
+ const isEditableTarget = (target: EventTarget | null | undefined) => {
827
+ if (!(target instanceof HTMLElement)) return false
828
+ return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
829
+ }
830
+
831
+ const deepActiveElement = () => {
832
+ let current: Element | null = document.activeElement
833
+ while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
834
+ current = current.shadowRoot.activeElement
835
+ }
836
+ return current instanceof HTMLElement ? current : undefined
837
+ }
838
+
839
+ const handleKeyDown = (event: KeyboardEvent) => {
840
+ const path = event.composedPath()
841
+ const target = path.find((item): item is HTMLElement => item instanceof HTMLElement)
842
+ const activeElement = deepActiveElement()
843
+
844
+ const protectedTarget = path.some(
845
+ (item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null,
846
+ )
847
+ if (protectedTarget || isEditableTarget(target)) return
848
+
849
+ if (activeElement) {
850
+ const isProtected = activeElement.closest("[data-prevent-autofocus]")
851
+ const isInput = isEditableTarget(activeElement)
852
+ if (isProtected || isInput) return
853
+ }
854
+ if (dialog.active) return
855
+
856
+ if (activeElement === inputRef) {
857
+ if (event.key === "Escape") inputRef?.blur()
858
+ return
859
+ }
860
+
861
+ // Prefer the open terminal over the composer when it can take focus
862
+ if (view().terminal.opened()) {
863
+ const id = terminal.active()
864
+ if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return
865
+ }
866
+
867
+ // Only treat explicit scroll keys as potential "user scroll" gestures.
868
+ if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
869
+ markScrollGesture()
870
+ return
871
+ }
872
+
873
+ if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
874
+ if (composer.blocked()) return
875
+ inputRef?.focus()
876
+ }
877
+ }
878
+
879
+ const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
880
+
881
+ const fileTreeTab = () => layout.fileTree.tab()
882
+ const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
883
+
884
+ const [tree, setTree] = createStore({
885
+ reviewScroll: undefined as HTMLDivElement | undefined,
886
+ pendingDiff: undefined as string | undefined,
887
+ activeDiff: undefined as string | undefined,
888
+ })
889
+
890
+ createEffect(
891
+ on(
892
+ sessionKey,
893
+ () => {
894
+ setTree({
895
+ reviewScroll: undefined,
896
+ pendingDiff: undefined,
897
+ activeDiff: undefined,
898
+ })
899
+ },
900
+ { defer: true },
901
+ ),
902
+ )
903
+
904
+ const showAllFiles = () => {
905
+ if (fileTreeTab() !== "changes") return
906
+ setFileTreeTab("all")
907
+ }
908
+
909
+ const focusInput = () => inputRef?.focus()
910
+
911
+ useSessionCommands({
912
+ navigateMessageByOffset,
913
+ setActiveMessage,
914
+ focusInput,
915
+ review: reviewTab,
916
+ })
917
+
918
+ const openReviewFile = createOpenReviewFile({
919
+ showAllFiles,
920
+ tabForPath: file.tab,
921
+ openTab: tabs().open,
922
+ setActive: tabs().setActive,
923
+ loadFile: file.load,
924
+ })
925
+
926
+ const changesOptions = ["session", "turn"] as const
927
+ const changesOptionsList = [...changesOptions]
928
+
929
+ const changesTitle = () => {
930
+ if (!hasReview()) {
931
+ return null
932
+ }
933
+
934
+ return (
935
+ <Select
936
+ options={changesOptionsList}
937
+ current={store.changes}
938
+ label={(option) =>
939
+ option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
940
+ }
941
+ onSelect={(option) => option && setStore("changes", option)}
942
+ variant="ghost"
943
+ size="small"
944
+ valueClass="text-14-medium"
945
+ />
946
+ )
947
+ }
948
+
949
+ const emptyTurn = () => (
950
+ <div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
951
+ <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
952
+ </div>
953
+ )
954
+
955
+ const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
956
+ if (store.changes === "turn") return emptyTurn()
957
+
958
+ if (hasReview() && !diffsReady()) {
959
+ return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
960
+ }
961
+
962
+ if (reviewEmptyKey() === "session.review.noVcs") {
963
+ return (
964
+ <div class={input.emptyClass}>
965
+ <div class="flex flex-col gap-3">
966
+ <div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
967
+ <div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
968
+ {language.t("session.review.noVcs.createGit.description")}
969
+ </div>
970
+ </div>
971
+ <Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
972
+ {gitMutation.isPending
973
+ ? language.t("session.review.noVcs.createGit.actionLoading")
974
+ : language.t("session.review.noVcs.createGit.action")}
975
+ </Button>
976
+ </div>
977
+ )
978
+ }
979
+
980
+ return (
981
+ <div class={input.emptyClass}>
982
+ <div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
983
+ </div>
984
+ )
985
+ }
986
+
987
+ const reviewContent = (input: {
988
+ diffStyle: DiffStyle
989
+ onDiffStyleChange?: (style: DiffStyle) => void
990
+ classes?: SessionReviewTabProps["classes"]
991
+ loadingClass: string
992
+ emptyClass: string
993
+ }) => (
994
+ <Show when={!store.deferRender}>
995
+ <SessionReviewTab
996
+ title={changesTitle()}
997
+ empty={reviewEmpty(input)}
998
+ diffs={reviewDiffs}
999
+ view={view}
1000
+ diffStyle={input.diffStyle}
1001
+ onDiffStyleChange={input.onDiffStyleChange}
1002
+ onScrollRef={(el) => setTree("reviewScroll", el)}
1003
+ focusedFile={tree.activeDiff}
1004
+ onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
1005
+ onLineCommentUpdate={updateCommentInContext}
1006
+ onLineCommentDelete={removeCommentFromContext}
1007
+ lineCommentActions={reviewCommentActions()}
1008
+ comments={comments.all()}
1009
+ focusedComment={comments.focus()}
1010
+ onFocusedCommentChange={comments.setFocus}
1011
+ onViewFile={openReviewFile}
1012
+ classes={input.classes}
1013
+ />
1014
+ </Show>
1015
+ )
1016
+
1017
+ const reviewPanel = () => (
1018
+ <div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
1019
+ <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
1020
+ {reviewContent({
1021
+ diffStyle: layout.review.diffStyle(),
1022
+ onDiffStyleChange: layout.review.setDiffStyle,
1023
+ loadingClass: "px-6 py-4 text-text-weak",
1024
+ emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
1025
+ })}
1026
+ </div>
1027
+ </div>
1028
+ )
1029
+
1030
+ createEffect(
1031
+ on(
1032
+ activeFileTab,
1033
+ (active) => {
1034
+ if (!active) return
1035
+ if (fileTreeTab() !== "changes") return
1036
+ showAllFiles()
1037
+ },
1038
+ { defer: true },
1039
+ ),
1040
+ )
1041
+
1042
+ const reviewDiffId = (path: string) => {
1043
+ const sum = checksum(path)
1044
+ if (!sum) return
1045
+ return `session-review-diff-${sum}`
1046
+ }
1047
+
1048
+ const reviewDiffTop = (path: string) => {
1049
+ const root = tree.reviewScroll
1050
+ if (!root) return
1051
+
1052
+ const id = reviewDiffId(path)
1053
+ if (!id) return
1054
+
1055
+ const el = document.getElementById(id)
1056
+ if (!(el instanceof HTMLElement)) return
1057
+ if (!root.contains(el)) return
1058
+
1059
+ const a = el.getBoundingClientRect()
1060
+ const b = root.getBoundingClientRect()
1061
+ return a.top - b.top + root.scrollTop
1062
+ }
1063
+
1064
+ const scrollToReviewDiff = (path: string) => {
1065
+ const root = tree.reviewScroll
1066
+ if (!root) return false
1067
+
1068
+ const top = reviewDiffTop(path)
1069
+ if (top === undefined) return false
1070
+
1071
+ view().setScroll("review", { x: root.scrollLeft, y: top })
1072
+ root.scrollTo({ top, behavior: "auto" })
1073
+ return true
1074
+ }
1075
+
1076
+ const focusReviewDiff = (path: string) => {
1077
+ openReviewPanel()
1078
+ view().review.openPath(path)
1079
+ setTree({ activeDiff: path, pendingDiff: path })
1080
+ }
1081
+
1082
+ createEffect(() => {
1083
+ const pending = tree.pendingDiff
1084
+ if (!pending) return
1085
+ if (!tree.reviewScroll) return
1086
+ if (!diffsReady()) return
1087
+
1088
+ const attempt = (count: number) => {
1089
+ if (tree.pendingDiff !== pending) return
1090
+ if (count > 60) {
1091
+ setTree("pendingDiff", undefined)
1092
+ return
1093
+ }
1094
+
1095
+ const root = tree.reviewScroll
1096
+ if (!root) {
1097
+ requestAnimationFrame(() => attempt(count + 1))
1098
+ return
1099
+ }
1100
+
1101
+ if (!scrollToReviewDiff(pending)) {
1102
+ requestAnimationFrame(() => attempt(count + 1))
1103
+ return
1104
+ }
1105
+
1106
+ const top = reviewDiffTop(pending)
1107
+ if (top === undefined) {
1108
+ requestAnimationFrame(() => attempt(count + 1))
1109
+ return
1110
+ }
1111
+
1112
+ if (Math.abs(root.scrollTop - top) <= 1) {
1113
+ setTree("pendingDiff", undefined)
1114
+ return
1115
+ }
1116
+
1117
+ requestAnimationFrame(() => attempt(count + 1))
1118
+ }
1119
+
1120
+ requestAnimationFrame(() => attempt(0))
1121
+ })
1122
+
1123
+ createEffect(() => {
1124
+ const id = params.id
1125
+ if (!id) return
1126
+
1127
+ const wants = isDesktop()
1128
+ ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
1129
+ : store.mobileTab === "changes"
1130
+ if (!wants) return
1131
+ if (sync.data.session_diff[id] !== undefined) return
1132
+ if (sync.status === "loading") return
1133
+
1134
+ void sync.session.diff(id)
1135
+ })
1136
+
1137
+ createEffect(
1138
+ on(
1139
+ () =>
1140
+ [
1141
+ sessionKey(),
1142
+ isDesktop()
1143
+ ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
1144
+ : store.mobileTab === "changes",
1145
+ ] as const,
1146
+ ([key, wants]) => {
1147
+ if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
1148
+ if (diffTimer !== undefined) window.clearTimeout(diffTimer)
1149
+ diffFrame = undefined
1150
+ diffTimer = undefined
1151
+ if (!wants) return
1152
+
1153
+ const id = params.id
1154
+ if (!id) return
1155
+ if (!untrack(() => sync.data.session_diff[id] !== undefined)) return
1156
+
1157
+ diffFrame = requestAnimationFrame(() => {
1158
+ diffFrame = undefined
1159
+ diffTimer = window.setTimeout(() => {
1160
+ diffTimer = undefined
1161
+ if (sessionKey() !== key) return
1162
+ void sync.session.diff(id, { force: true })
1163
+ }, 0)
1164
+ })
1165
+ },
1166
+ { defer: true },
1167
+ ),
1168
+ )
1169
+
1170
+ let treeDir: string | undefined
1171
+ createEffect(() => {
1172
+ const dir = sdk.directory
1173
+ if (!isDesktop()) return
1174
+ if (!layout.fileTree.opened()) return
1175
+ if (sync.status === "loading") return
1176
+
1177
+ fileTreeTab()
1178
+ const refresh = treeDir !== dir
1179
+ treeDir = dir
1180
+ void (refresh ? file.tree.refresh("") : file.tree.list(""))
1181
+ })
1182
+
1183
+ createEffect(
1184
+ on(
1185
+ () => sdk.directory,
1186
+ () => {
1187
+ void file.tree.list("")
1188
+
1189
+ const tab = activeFileTab()
1190
+ if (!tab) return
1191
+ const path = file.pathFromTab(tab)
1192
+ if (!path) return
1193
+ void file.load(path, { force: true })
1194
+ },
1195
+ { defer: true },
1196
+ ),
1197
+ )
1198
+
1199
+ const autoScroll = createAutoScroll({
1200
+ working: () => true,
1201
+ overflowAnchor: "dynamic",
1202
+ })
1203
+
1204
+ let scrollStateFrame: number | undefined
1205
+ let scrollStateTarget: HTMLDivElement | undefined
1206
+ let fillFrame: number | undefined
1207
+
1208
+ const updateScrollState = (el: HTMLDivElement) => {
1209
+ const max = el.scrollHeight - el.clientHeight
1210
+ const overflow = max > 1
1211
+ const bottom = !overflow || el.scrollTop >= max - 2
1212
+
1213
+ if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
1214
+ setUi("scroll", { overflow, bottom })
1215
+ }
1216
+
1217
+ const scheduleScrollState = (el: HTMLDivElement) => {
1218
+ scrollStateTarget = el
1219
+ if (scrollStateFrame !== undefined) return
1220
+
1221
+ scrollStateFrame = requestAnimationFrame(() => {
1222
+ scrollStateFrame = undefined
1223
+
1224
+ const target = scrollStateTarget
1225
+ scrollStateTarget = undefined
1226
+ if (!target) return
1227
+
1228
+ updateScrollState(target)
1229
+ })
1230
+ }
1231
+
1232
+ const resumeScroll = () => {
1233
+ setStore("messageId", undefined)
1234
+ autoScroll.forceScrollToBottom()
1235
+ clearMessageHash()
1236
+
1237
+ const el = scroller
1238
+ if (el) scheduleScrollState(el)
1239
+ }
1240
+
1241
+ // When the user returns to the bottom, treat the active message as "latest".
1242
+ createEffect(
1243
+ on(
1244
+ autoScroll.userScrolled,
1245
+ (scrolled) => {
1246
+ if (scrolled) return
1247
+ setStore("messageId", undefined)
1248
+ clearMessageHash()
1249
+ },
1250
+ { defer: true },
1251
+ ),
1252
+ )
1253
+
1254
+ let fill = () => {}
1255
+
1256
+ const setScrollRef = (el: HTMLDivElement | undefined) => {
1257
+ scroller = el
1258
+ autoScroll.scrollRef(el)
1259
+ if (!el) return
1260
+ scheduleScrollState(el)
1261
+ fill()
1262
+ }
1263
+
1264
+ const markUserScroll = () => {
1265
+ scrollMark += 1
1266
+ }
1267
+
1268
+ createResizeObserver(
1269
+ () => content,
1270
+ () => {
1271
+ const el = scroller
1272
+ if (el) scheduleScrollState(el)
1273
+ fill()
1274
+ },
1275
+ )
1276
+
1277
+ const historyWindow = createSessionHistoryWindow({
1278
+ sessionID: () => params.id,
1279
+ messagesReady,
1280
+ loaded: () => messages().length,
1281
+ visibleUserMessages,
1282
+ historyMore,
1283
+ historyLoading,
1284
+ loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
1285
+ userScrolled: autoScroll.userScrolled,
1286
+ scroller: () => scroller,
1287
+ })
1288
+
1289
+ fill = () => {
1290
+ if (fillFrame !== undefined) return
1291
+
1292
+ fillFrame = requestAnimationFrame(() => {
1293
+ fillFrame = undefined
1294
+
1295
+ if (!params.id || !messagesReady()) return
1296
+ if (autoScroll.userScrolled() || historyLoading()) return
1297
+
1298
+ const el = scroller
1299
+ if (!el) return
1300
+ if (el.scrollHeight > el.clientHeight + 1) return
1301
+ if (historyWindow.turnStart() <= 0 && !historyMore()) return
1302
+
1303
+ void historyWindow.loadAndReveal()
1304
+ })
1305
+ }
1306
+
1307
+ createEffect(
1308
+ on(
1309
+ () =>
1310
+ [
1311
+ params.id,
1312
+ messagesReady(),
1313
+ historyWindow.turnStart(),
1314
+ historyMore(),
1315
+ historyLoading(),
1316
+ autoScroll.userScrolled(),
1317
+ visibleUserMessages().length,
1318
+ ] as const,
1319
+ ([id, ready, start, more, loading, scrolled]) => {
1320
+ if (!id || !ready || loading || scrolled) return
1321
+ if (start <= 0 && !more) return
1322
+ fill()
1323
+ },
1324
+ { defer: true },
1325
+ ),
1326
+ )
1327
+
1328
+ const draft = (id: string) =>
1329
+ extractPromptFromParts(sync.data.part[id] ?? [], {
1330
+ directory: sdk.directory,
1331
+ attachmentName: language.t("common.attachment"),
1332
+ })
1333
+
1334
+ const line = (id: string) => {
1335
+ const text = draft(id)
1336
+ .map((part) => (part.type === "image" ? `[image:${part.filename}]` : part.content))
1337
+ .join("")
1338
+ .replace(/\s+/g, " ")
1339
+ .trim()
1340
+ if (text) return text
1341
+ return `[${language.t("common.attachment")}]`
1342
+ }
1343
+
1344
+ const fail = (err: unknown) => {
1345
+ showToast({
1346
+ variant: "error",
1347
+ title: language.t("common.requestFailed"),
1348
+ description: formatServerError(err, language.t),
1349
+ })
1350
+ }
1351
+
1352
+ const merge = (next: NonNullable<ReturnType<typeof info>>) =>
1353
+ sync.set("session", (list) => {
1354
+ const idx = list.findIndex((item) => item.id === next.id)
1355
+ if (idx < 0) return list
1356
+ const out = list.slice()
1357
+ out[idx] = next
1358
+ return out
1359
+ })
1360
+
1361
+ const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"]) =>
1362
+ sync.set("session", (list) => {
1363
+ const idx = list.findIndex((item) => item.id === sessionID)
1364
+ if (idx < 0) return list
1365
+ const out = list.slice()
1366
+ out[idx] = { ...out[idx], revert: next }
1367
+ return out
1368
+ })
1369
+
1370
+ const busy = (sessionID: string) => {
1371
+ if ((sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle") return true
1372
+ return (sync.data.message[sessionID] ?? []).some(
1373
+ (item) => item.role === "assistant" && typeof item.time.completed !== "number",
1374
+ )
1375
+ }
1376
+
1377
+ const queuedFollowups = createMemo(() => {
1378
+ const id = params.id
1379
+ if (!id) return emptyFollowups
1380
+ return followup.items[id] ?? emptyFollowups
1381
+ })
1382
+
1383
+ const editingFollowup = createMemo(() => {
1384
+ const id = params.id
1385
+ if (!id) return
1386
+ return followup.edit[id]
1387
+ })
1388
+
1389
+ const followupMutation = useMutation(() => ({
1390
+ mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
1391
+ const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
1392
+ if (!item) return
1393
+
1394
+ if (input.manual) setFollowup("paused", input.sessionID, undefined)
1395
+ setFollowup("failed", input.sessionID, undefined)
1396
+
1397
+ const ok = await sendFollowupDraft({
1398
+ client: sdk.client,
1399
+ sync,
1400
+ globalSync,
1401
+ draft: item,
1402
+ optimisticBusy: item.sessionDirectory === sdk.directory,
1403
+ }).catch((err) => {
1404
+ setFollowup("failed", input.sessionID, input.id)
1405
+ fail(err)
1406
+ return false
1407
+ })
1408
+ if (!ok) return
1409
+
1410
+ setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
1411
+ if (input.manual) resumeScroll()
1412
+ },
1413
+ }))
1414
+
1415
+ const followupBusy = (sessionID: string) =>
1416
+ followupMutation.isPending && followupMutation.variables?.sessionID === sessionID
1417
+
1418
+ const sendingFollowup = createMemo(() => {
1419
+ const id = params.id
1420
+ if (!id) return
1421
+ if (!followupBusy(id)) return
1422
+ return followupMutation.variables?.id
1423
+ })
1424
+
1425
+ const queueEnabled = createMemo(() => {
1426
+ const id = params.id
1427
+ if (!id) return false
1428
+ return settings.general.followup() === "queue" && busy(id) && !composer.blocked()
1429
+ })
1430
+
1431
+ const followupText = (item: FollowupDraft) => {
1432
+ const text = item.prompt
1433
+ .map((part) => {
1434
+ if (part.type === "image") return `[image:${part.filename}]`
1435
+ if (part.type === "file") return `[file:${part.path}]`
1436
+ if (part.type === "agent") return `@${part.name}`
1437
+ return part.content
1438
+ })
1439
+ .join("")
1440
+ .split(/\r?\n/)
1441
+ .map((line) => line.trim())
1442
+ .find((line) => !!line)
1443
+
1444
+ if (text) return text
1445
+ return `[${language.t("common.attachment")}]`
1446
+ }
1447
+
1448
+ const queueFollowup = (draft: FollowupDraft) => {
1449
+ setFollowup("items", draft.sessionID, (items) => [
1450
+ ...(items ?? []),
1451
+ { id: Identifier.ascending("message"), ...draft },
1452
+ ])
1453
+ setFollowup("failed", draft.sessionID, undefined)
1454
+ setFollowup("paused", draft.sessionID, undefined)
1455
+ }
1456
+
1457
+ const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
1458
+
1459
+ const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
1460
+ const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
1461
+ if (!item) return Promise.resolve()
1462
+ if (followupBusy(sessionID)) return Promise.resolve()
1463
+
1464
+ return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual })
1465
+ }
1466
+
1467
+ const editFollowup = (id: string) => {
1468
+ const sessionID = params.id
1469
+ if (!sessionID) return
1470
+ if (followupBusy(sessionID)) return
1471
+
1472
+ const item = queuedFollowups().find((entry) => entry.id === id)
1473
+ if (!item) return
1474
+
1475
+ setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
1476
+ setFollowup("failed", sessionID, (value) => (value === id ? undefined : value))
1477
+ setFollowup("edit", sessionID, {
1478
+ id: item.id,
1479
+ prompt: item.prompt,
1480
+ context: item.context,
1481
+ })
1482
+ }
1483
+
1484
+ const clearFollowupEdit = () => {
1485
+ const id = params.id
1486
+ if (!id) return
1487
+ setFollowup("edit", id, undefined)
1488
+ }
1489
+
1490
+ const halt = (sessionID: string) =>
1491
+ busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
1492
+
1493
+ const revertMutation = useMutation(() => ({
1494
+ mutationFn: async (input: { sessionID: string; messageID: string }) => {
1495
+ const prev = prompt.current().slice()
1496
+ const last = info()?.revert
1497
+ const value = draft(input.messageID)
1498
+ batch(() => {
1499
+ roll(input.sessionID, { messageID: input.messageID })
1500
+ prompt.set(value)
1501
+ })
1502
+ await halt(input.sessionID)
1503
+ .then(() => sdk.client.session.revert(input))
1504
+ .then((result) => {
1505
+ if (result.data) merge(result.data)
1506
+ })
1507
+ .catch((err) => {
1508
+ batch(() => {
1509
+ roll(input.sessionID, last)
1510
+ prompt.set(prev)
1511
+ })
1512
+ fail(err)
1513
+ })
1514
+ },
1515
+ }))
1516
+
1517
+ const restoreMutation = useMutation(() => ({
1518
+ mutationFn: async (id: string) => {
1519
+ const sessionID = params.id
1520
+ if (!sessionID) return
1521
+
1522
+ const next = userMessages().find((item) => item.id > id)
1523
+ const prev = prompt.current().slice()
1524
+ const last = info()?.revert
1525
+
1526
+ batch(() => {
1527
+ roll(sessionID, next ? { messageID: next.id } : undefined)
1528
+ if (next) {
1529
+ prompt.set(draft(next.id))
1530
+ return
1531
+ }
1532
+ prompt.reset()
1533
+ })
1534
+
1535
+ const task = !next
1536
+ ? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
1537
+ : halt(sessionID).then(() =>
1538
+ sdk.client.session.revert({
1539
+ sessionID,
1540
+ messageID: next.id,
1541
+ }),
1542
+ )
1543
+
1544
+ await task
1545
+ .then((result) => {
1546
+ if (result.data) merge(result.data)
1547
+ })
1548
+ .catch((err) => {
1549
+ batch(() => {
1550
+ roll(sessionID, last)
1551
+ prompt.set(prev)
1552
+ })
1553
+ fail(err)
1554
+ })
1555
+ },
1556
+ }))
1557
+
1558
+ const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
1559
+ const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
1560
+
1561
+ const fork = (input: { sessionID: string; messageID: string }) => {
1562
+ const value = draft(input.messageID)
1563
+ const dir = base64Encode(sdk.directory)
1564
+ return sdk.client.session
1565
+ .fork(input)
1566
+ .then((result) => {
1567
+ const next = result.data
1568
+ if (!next) {
1569
+ showToast({
1570
+ variant: "error",
1571
+ title: language.t("common.requestFailed"),
1572
+ })
1573
+ return
1574
+ }
1575
+ prompt.set(value, undefined, { dir, id: next.id })
1576
+ navigate(`/${dir}/session/${next.id}`)
1577
+ })
1578
+ .catch(fail)
1579
+ }
1580
+
1581
+ const revert = (input: { sessionID: string; messageID: string }) => {
1582
+ if (reverting()) return
1583
+ return revertMutation.mutateAsync(input)
1584
+ }
1585
+
1586
+ const restore = (id: string) => {
1587
+ if (!params.id || reverting()) return
1588
+ return restoreMutation.mutateAsync(id)
1589
+ }
1590
+
1591
+ const rolled = createMemo(() => {
1592
+ const id = revertMessageID()
1593
+ if (!id) return []
1594
+ return userMessages()
1595
+ .filter((item) => item.id >= id)
1596
+ .map((item) => ({ id: item.id, text: line(item.id) }))
1597
+ })
1598
+
1599
+ const actions = { fork, revert }
1600
+
1601
+ createEffect(() => {
1602
+ const sessionID = params.id
1603
+ if (!sessionID) return
1604
+
1605
+ const item = queuedFollowups()[0]
1606
+ if (!item) return
1607
+ if (followupBusy(sessionID)) return
1608
+ if (followup.failed[sessionID] === item.id) return
1609
+ if (followup.paused[sessionID]) return
1610
+ if (composer.blocked()) return
1611
+ if (busy(sessionID)) return
1612
+
1613
+ void sendFollowup(sessionID, item.id)
1614
+ })
1615
+
1616
+ createResizeObserver(
1617
+ () => promptDock,
1618
+ ({ height }) => {
1619
+ const next = Math.ceil(height)
1620
+
1621
+ if (next === dockHeight) return
1622
+
1623
+ const el = scroller
1624
+ const delta = next - dockHeight
1625
+ const stick = el
1626
+ ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
1627
+ : false
1628
+
1629
+ dockHeight = next
1630
+
1631
+ if (stick) autoScroll.forceScrollToBottom()
1632
+
1633
+ if (el) scheduleScrollState(el)
1634
+ fill()
1635
+ },
1636
+ )
1637
+
1638
+ const { clearMessageHash, scrollToMessage } = useSessionHashScroll({
1639
+ sessionKey,
1640
+ sessionID: () => params.id,
1641
+ messagesReady,
1642
+ visibleUserMessages,
1643
+ turnStart: historyWindow.turnStart,
1644
+ currentMessageId: () => store.messageId,
1645
+ pendingMessage: () => ui.pendingMessage,
1646
+ setPendingMessage: (value) => setUi("pendingMessage", value),
1647
+ setActiveMessage,
1648
+ setTurnStart: historyWindow.setTurnStart,
1649
+ autoScroll,
1650
+ scroller: () => scroller,
1651
+ anchor,
1652
+ scheduleScrollState,
1653
+ consumePendingMessage: layout.pendingMessage.consume,
1654
+ })
1655
+
1656
+ onMount(() => {
1657
+ document.addEventListener("keydown", handleKeyDown)
1658
+ })
1659
+
1660
+ onCleanup(() => {
1661
+ document.removeEventListener("keydown", handleKeyDown)
1662
+ if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
1663
+ if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
1664
+ if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
1665
+ if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
1666
+ if (diffTimer !== undefined) window.clearTimeout(diffTimer)
1667
+ if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
1668
+ if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
1669
+ })
1670
+
1671
+ return (
1672
+ <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
1673
+ <SessionHeader />
1674
+ <div class="flex-1 min-h-0 flex flex-col md:flex-row">
1675
+ <Show when={!isDesktop() && !!params.id}>
1676
+ <Tabs value={store.mobileTab} class="h-auto">
1677
+ <Tabs.List>
1678
+ <Tabs.Trigger
1679
+ value="session"
1680
+ class="!w-1/2 !max-w-none"
1681
+ classes={{ button: "w-full" }}
1682
+ onClick={() => setStore("mobileTab", "session")}
1683
+ >
1684
+ {language.t("session.tab.session")}
1685
+ </Tabs.Trigger>
1686
+ <Tabs.Trigger
1687
+ value="changes"
1688
+ class="!w-1/2 !max-w-none !border-r-0"
1689
+ classes={{ button: "w-full" }}
1690
+ onClick={() => setStore("mobileTab", "changes")}
1691
+ >
1692
+ {hasReview()
1693
+ ? language.t("session.review.filesChanged", { count: reviewCount() })
1694
+ : language.t("session.review.change.other")}
1695
+ </Tabs.Trigger>
1696
+ </Tabs.List>
1697
+ </Tabs>
1698
+ </Show>
1699
+
1700
+ {/* Session panel */}
1701
+ <div
1702
+ classList={{
1703
+ "@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
1704
+ "transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
1705
+ !size.active() && !ui.reviewSnap,
1706
+ }}
1707
+ style={{
1708
+ width: sessionPanelWidth(),
1709
+ }}
1710
+ >
1711
+ <div class="flex-1 min-h-0 overflow-hidden">
1712
+ <Switch>
1713
+ <Match when={params.id}>
1714
+ <Show when={lastUserMessage()}>
1715
+ <MessageTimeline
1716
+ mobileChanges={mobileChanges()}
1717
+ mobileFallback={reviewContent({
1718
+ diffStyle: "unified",
1719
+ classes: {
1720
+ root: "pb-8",
1721
+ header: "px-4",
1722
+ container: "px-4",
1723
+ },
1724
+ loadingClass: "px-4 py-4 text-text-weak",
1725
+ emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
1726
+ })}
1727
+ actions={actions}
1728
+ scroll={ui.scroll}
1729
+ onResumeScroll={resumeScroll}
1730
+ setScrollRef={setScrollRef}
1731
+ onScheduleScrollState={scheduleScrollState}
1732
+ onAutoScrollHandleScroll={autoScroll.handleScroll}
1733
+ onMarkScrollGesture={markScrollGesture}
1734
+ hasScrollGesture={hasScrollGesture}
1735
+ onUserScroll={markUserScroll}
1736
+ onTurnBackfillScroll={historyWindow.onScrollerScroll}
1737
+ onAutoScrollInteraction={autoScroll.handleInteraction}
1738
+ centered={centered()}
1739
+ setContentRef={(el) => {
1740
+ content = el
1741
+ autoScroll.contentRef(el)
1742
+
1743
+ const root = scroller
1744
+ if (root) scheduleScrollState(root)
1745
+ }}
1746
+ turnStart={historyWindow.turnStart()}
1747
+ historyMore={historyMore()}
1748
+ historyLoading={historyLoading()}
1749
+ onLoadEarlier={() => {
1750
+ void historyWindow.loadAndReveal()
1751
+ }}
1752
+ renderedUserMessages={historyWindow.renderedUserMessages()}
1753
+ anchor={anchor}
1754
+ />
1755
+ </Show>
1756
+ </Match>
1757
+ <Match when={true}>
1758
+ <NewSessionView worktree={newSessionWorktree()} />
1759
+ </Match>
1760
+ </Switch>
1761
+ </div>
1762
+
1763
+ <SessionComposerRegion
1764
+ state={composer}
1765
+ ready={!store.deferRender && messagesReady()}
1766
+ centered={centered()}
1767
+ inputRef={(el) => {
1768
+ inputRef = el
1769
+ }}
1770
+ newSessionWorktree={newSessionWorktree()}
1771
+ onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
1772
+ onSubmit={() => {
1773
+ comments.clear()
1774
+ resumeScroll()
1775
+ }}
1776
+ onResponseSubmit={resumeScroll}
1777
+ followup={
1778
+ params.id
1779
+ ? {
1780
+ queue: queueEnabled,
1781
+ items: followupDock(),
1782
+ sending: sendingFollowup(),
1783
+ edit: editingFollowup(),
1784
+ onQueue: queueFollowup,
1785
+ onAbort: () => {
1786
+ const id = params.id
1787
+ if (!id) return
1788
+ setFollowup("paused", id, true)
1789
+ },
1790
+ onSend: (id) => {
1791
+ void sendFollowup(params.id!, id, { manual: true })
1792
+ },
1793
+ onEdit: editFollowup,
1794
+ onEditLoaded: clearFollowupEdit,
1795
+ }
1796
+ : undefined
1797
+ }
1798
+ revert={
1799
+ rolled().length > 0
1800
+ ? {
1801
+ items: rolled(),
1802
+ restoring: restoring(),
1803
+ disabled: reverting(),
1804
+ onRestore: restore,
1805
+ }
1806
+ : undefined
1807
+ }
1808
+ setPromptDockRef={(el) => {
1809
+ promptDock = el
1810
+ }}
1811
+ />
1812
+
1813
+ <Show when={desktopReviewOpen()}>
1814
+ <div onPointerDown={() => size.start()}>
1815
+ <ResizeHandle
1816
+ direction="horizontal"
1817
+ size={layout.session.width()}
1818
+ min={450}
1819
+ max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
1820
+ onResize={(width) => {
1821
+ size.touch()
1822
+ layout.session.resize(width)
1823
+ }}
1824
+ />
1825
+ </div>
1826
+ </Show>
1827
+ </div>
1828
+
1829
+ <SessionSidePanel
1830
+ reviewPanel={reviewPanel}
1831
+ activeDiff={tree.activeDiff}
1832
+ focusReviewDiff={focusReviewDiff}
1833
+ reviewSnap={ui.reviewSnap}
1834
+ size={size}
1835
+ />
1836
+ </div>
1837
+
1838
+ <TerminalPanel />
1839
+ </div>
1840
+ )
1841
+ }