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,476 @@
1
+ import { Platform, usePlatform } from "@/context/platform"
2
+ import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
3
+ import { checksum } from "@reign-labs/util/encode"
4
+ import { createResource, type Accessor } from "solid-js"
5
+ import type { SetStoreFunction, Store } from "solid-js/store"
6
+
7
+ type InitType = Promise<string> | string | null
8
+ type PersistedWithReady<T> = [
9
+ Store<T>,
10
+ SetStoreFunction<T>,
11
+ InitType,
12
+ Accessor<boolean> & { promise: undefined | Promise<any> },
13
+ ]
14
+
15
+ type PersistTarget = {
16
+ storage?: string
17
+ key: string
18
+ legacy?: string[]
19
+ migrate?: (value: unknown) => unknown
20
+ }
21
+
22
+ const LEGACY_STORAGE = "default.dat"
23
+ const GLOBAL_STORAGE = "opencode.global.dat"
24
+ const LOCAL_PREFIX = "opencode."
25
+ const fallback = new Map<string, boolean>()
26
+
27
+ const CACHE_MAX_ENTRIES = 500
28
+ const CACHE_MAX_BYTES = 8 * 1024 * 1024
29
+
30
+ type CacheEntry = { value: string; bytes: number }
31
+ const cache = new Map<string, CacheEntry>()
32
+ const cacheTotal = { bytes: 0 }
33
+
34
+ function cacheDelete(key: string) {
35
+ const entry = cache.get(key)
36
+ if (!entry) return
37
+ cacheTotal.bytes -= entry.bytes
38
+ cache.delete(key)
39
+ }
40
+
41
+ function cachePrune() {
42
+ for (;;) {
43
+ if (cache.size <= CACHE_MAX_ENTRIES && cacheTotal.bytes <= CACHE_MAX_BYTES) return
44
+ const oldest = cache.keys().next().value as string | undefined
45
+ if (!oldest) return
46
+ cacheDelete(oldest)
47
+ }
48
+ }
49
+
50
+ function cacheSet(key: string, value: string) {
51
+ const bytes = value.length * 2
52
+ if (bytes > CACHE_MAX_BYTES) {
53
+ cacheDelete(key)
54
+ return
55
+ }
56
+
57
+ const entry = cache.get(key)
58
+ if (entry) cacheTotal.bytes -= entry.bytes
59
+ cache.delete(key)
60
+ cache.set(key, { value, bytes })
61
+ cacheTotal.bytes += bytes
62
+ cachePrune()
63
+ }
64
+
65
+ function cacheGet(key: string) {
66
+ const entry = cache.get(key)
67
+ if (!entry) return
68
+ cache.delete(key)
69
+ cache.set(key, entry)
70
+ return entry.value
71
+ }
72
+
73
+ function fallbackDisabled(scope: string) {
74
+ return fallback.get(scope) === true
75
+ }
76
+
77
+ function fallbackSet(scope: string) {
78
+ fallback.set(scope, true)
79
+ }
80
+
81
+ function quota(error: unknown) {
82
+ if (error instanceof DOMException) {
83
+ if (error.name === "QuotaExceededError") return true
84
+ if (error.name === "NS_ERROR_DOM_QUOTA_REACHED") return true
85
+ if (error.name === "QUOTA_EXCEEDED_ERR") return true
86
+ if (error.code === 22 || error.code === 1014) return true
87
+ return false
88
+ }
89
+
90
+ if (!error || typeof error !== "object") return false
91
+ const name = (error as { name?: string }).name
92
+ if (name === "QuotaExceededError" || name === "NS_ERROR_DOM_QUOTA_REACHED") return true
93
+ if (name && /quota/i.test(name)) return true
94
+
95
+ const code = (error as { code?: number }).code
96
+ if (code === 22 || code === 1014) return true
97
+
98
+ const message = (error as { message?: string }).message
99
+ if (typeof message !== "string") return false
100
+ if (/quota/i.test(message)) return true
101
+ return false
102
+ }
103
+
104
+ type Evict = { key: string; size: number }
105
+
106
+ function evict(storage: Storage, keep: string, value: string) {
107
+ const total = storage.length
108
+ const indexes = Array.from({ length: total }, (_, index) => index)
109
+ const items: Evict[] = []
110
+
111
+ for (const index of indexes) {
112
+ const name = storage.key(index)
113
+ if (!name) continue
114
+ if (!name.startsWith(LOCAL_PREFIX)) continue
115
+ if (name === keep) continue
116
+ const stored = storage.getItem(name)
117
+ items.push({ key: name, size: stored?.length ?? 0 })
118
+ }
119
+
120
+ items.sort((a, b) => b.size - a.size)
121
+
122
+ for (const item of items) {
123
+ storage.removeItem(item.key)
124
+ cacheDelete(item.key)
125
+
126
+ try {
127
+ storage.setItem(keep, value)
128
+ cacheSet(keep, value)
129
+ return true
130
+ } catch (error) {
131
+ if (!quota(error)) throw error
132
+ }
133
+ }
134
+
135
+ return false
136
+ }
137
+
138
+ function write(storage: Storage, key: string, value: string) {
139
+ try {
140
+ storage.setItem(key, value)
141
+ cacheSet(key, value)
142
+ return true
143
+ } catch (error) {
144
+ if (!quota(error)) throw error
145
+ }
146
+
147
+ try {
148
+ storage.removeItem(key)
149
+ cacheDelete(key)
150
+ storage.setItem(key, value)
151
+ cacheSet(key, value)
152
+ return true
153
+ } catch (error) {
154
+ if (!quota(error)) throw error
155
+ }
156
+
157
+ const ok = evict(storage, key, value)
158
+ return ok
159
+ }
160
+
161
+ function snapshot(value: unknown) {
162
+ return JSON.parse(JSON.stringify(value)) as unknown
163
+ }
164
+
165
+ function isRecord(value: unknown): value is Record<string, unknown> {
166
+ return typeof value === "object" && value !== null && !Array.isArray(value)
167
+ }
168
+
169
+ function merge(defaults: unknown, value: unknown): unknown {
170
+ if (value === undefined) return defaults
171
+ if (value === null) return value
172
+
173
+ if (Array.isArray(defaults)) {
174
+ if (Array.isArray(value)) return value
175
+ return defaults
176
+ }
177
+
178
+ if (isRecord(defaults)) {
179
+ if (!isRecord(value)) return defaults
180
+
181
+ const result: Record<string, unknown> = { ...defaults }
182
+ for (const key of Object.keys(value)) {
183
+ if (key in defaults) {
184
+ result[key] = merge((defaults as Record<string, unknown>)[key], (value as Record<string, unknown>)[key])
185
+ } else {
186
+ result[key] = (value as Record<string, unknown>)[key]
187
+ }
188
+ }
189
+ return result
190
+ }
191
+
192
+ return value
193
+ }
194
+
195
+ function parse(value: string) {
196
+ try {
197
+ return JSON.parse(value) as unknown
198
+ } catch {
199
+ return undefined
200
+ }
201
+ }
202
+
203
+ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => unknown) {
204
+ const parsed = parse(raw)
205
+ if (parsed === undefined) return
206
+ const migrated = migrate ? migrate(parsed) : parsed
207
+ const merged = merge(defaults, migrated)
208
+ return JSON.stringify(merged)
209
+ }
210
+
211
+ function workspaceStorage(dir: string) {
212
+ const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
213
+ const sum = checksum(dir) ?? "0"
214
+ return `opencode.workspace.${head}.${sum}.dat`
215
+ }
216
+
217
+ function localStorageWithPrefix(prefix: string): SyncStorage {
218
+ const base = `${prefix}:`
219
+ const scope = `prefix:${prefix}`
220
+ const item = (key: string) => base + key
221
+ return {
222
+ getItem: (key) => {
223
+ const name = item(key)
224
+ const cached = cacheGet(name)
225
+ if (fallbackDisabled(scope)) return cached ?? null
226
+
227
+ const stored = (() => {
228
+ try {
229
+ return localStorage.getItem(name)
230
+ } catch {
231
+ fallbackSet(scope)
232
+ return null
233
+ }
234
+ })()
235
+ if (stored === null) return cached ?? null
236
+ cacheSet(name, stored)
237
+ return stored
238
+ },
239
+ setItem: (key, value) => {
240
+ const name = item(key)
241
+ if (fallbackDisabled(scope)) return
242
+ try {
243
+ if (write(localStorage, name, value)) return
244
+ } catch {
245
+ fallbackSet(scope)
246
+ return
247
+ }
248
+ fallbackSet(scope)
249
+ },
250
+ removeItem: (key) => {
251
+ const name = item(key)
252
+ cacheDelete(name)
253
+ if (fallbackDisabled(scope)) return
254
+ try {
255
+ localStorage.removeItem(name)
256
+ } catch {
257
+ fallbackSet(scope)
258
+ }
259
+ },
260
+ }
261
+ }
262
+
263
+ function localStorageDirect(): SyncStorage {
264
+ const scope = "direct"
265
+ return {
266
+ getItem: (key) => {
267
+ const cached = cacheGet(key)
268
+ if (fallbackDisabled(scope)) return cached ?? null
269
+
270
+ const stored = (() => {
271
+ try {
272
+ return localStorage.getItem(key)
273
+ } catch {
274
+ fallbackSet(scope)
275
+ return null
276
+ }
277
+ })()
278
+ if (stored === null) return cached ?? null
279
+ cacheSet(key, stored)
280
+ return stored
281
+ },
282
+ setItem: (key, value) => {
283
+ if (fallbackDisabled(scope)) return
284
+ try {
285
+ if (write(localStorage, key, value)) return
286
+ } catch {
287
+ fallbackSet(scope)
288
+ return
289
+ }
290
+ fallbackSet(scope)
291
+ },
292
+ removeItem: (key) => {
293
+ cacheDelete(key)
294
+ if (fallbackDisabled(scope)) return
295
+ try {
296
+ localStorage.removeItem(key)
297
+ } catch {
298
+ fallbackSet(scope)
299
+ }
300
+ },
301
+ }
302
+ }
303
+
304
+ export const PersistTesting = {
305
+ localStorageDirect,
306
+ localStorageWithPrefix,
307
+ normalize,
308
+ workspaceStorage,
309
+ }
310
+
311
+ export const Persist = {
312
+ global(key: string, legacy?: string[]): PersistTarget {
313
+ return { storage: GLOBAL_STORAGE, key, legacy }
314
+ },
315
+ workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
316
+ return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
317
+ },
318
+ session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
319
+ return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }
320
+ },
321
+ scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
322
+ if (session) return Persist.session(dir, session, key, legacy)
323
+ return Persist.workspace(dir, key, legacy)
324
+ },
325
+ }
326
+
327
+ export function removePersisted(target: { storage?: string; key: string }, platform?: Platform) {
328
+ const isDesktop = platform?.platform === "desktop" && !!platform.storage
329
+
330
+ if (isDesktop) {
331
+ return platform.storage?.(target.storage)?.removeItem(target.key)
332
+ }
333
+
334
+ if (!target.storage) {
335
+ localStorageDirect().removeItem(target.key)
336
+ return
337
+ }
338
+
339
+ localStorageWithPrefix(target.storage).removeItem(target.key)
340
+ }
341
+
342
+ export function persisted<T>(
343
+ target: string | PersistTarget,
344
+ store: [Store<T>, SetStoreFunction<T>],
345
+ ): PersistedWithReady<T> {
346
+ const platform = usePlatform()
347
+ const config: PersistTarget = typeof target === "string" ? { key: target } : target
348
+
349
+ const defaults = snapshot(store[0])
350
+ const legacy = config.legacy ?? []
351
+
352
+ const isDesktop = platform.platform === "desktop" && !!platform.storage
353
+
354
+ const currentStorage = (() => {
355
+ if (isDesktop) return platform.storage?.(config.storage)
356
+ if (!config.storage) return localStorageDirect()
357
+ return localStorageWithPrefix(config.storage)
358
+ })()
359
+
360
+ const legacyStorage = (() => {
361
+ if (!isDesktop) return localStorageDirect()
362
+ if (!config.storage) return platform.storage?.()
363
+ return platform.storage?.(LEGACY_STORAGE)
364
+ })()
365
+
366
+ const storage = (() => {
367
+ if (!isDesktop) {
368
+ const current = currentStorage as SyncStorage
369
+ const legacyStore = legacyStorage as SyncStorage
370
+
371
+ const api: SyncStorage = {
372
+ getItem: (key) => {
373
+ const raw = current.getItem(key)
374
+ if (raw !== null) {
375
+ const next = normalize(defaults, raw, config.migrate)
376
+ if (next === undefined) {
377
+ current.removeItem(key)
378
+ return null
379
+ }
380
+ if (raw !== next) current.setItem(key, next)
381
+ return next
382
+ }
383
+
384
+ for (const legacyKey of legacy) {
385
+ const legacyRaw = legacyStore.getItem(legacyKey)
386
+ if (legacyRaw === null) continue
387
+
388
+ const next = normalize(defaults, legacyRaw, config.migrate)
389
+ if (next === undefined) {
390
+ legacyStore.removeItem(legacyKey)
391
+ continue
392
+ }
393
+ current.setItem(key, next)
394
+ legacyStore.removeItem(legacyKey)
395
+ return next
396
+ }
397
+
398
+ return null
399
+ },
400
+ setItem: (key, value) => {
401
+ current.setItem(key, value)
402
+ },
403
+ removeItem: (key) => {
404
+ current.removeItem(key)
405
+ },
406
+ }
407
+
408
+ return api
409
+ }
410
+
411
+ const current = currentStorage as AsyncStorage
412
+ const legacyStore = legacyStorage as AsyncStorage | undefined
413
+
414
+ const api: AsyncStorage = {
415
+ getItem: async (key) => {
416
+ const raw = await current.getItem(key)
417
+ if (raw !== null) {
418
+ const next = normalize(defaults, raw, config.migrate)
419
+ if (next === undefined) {
420
+ await current.removeItem(key).catch(() => undefined)
421
+ return null
422
+ }
423
+ if (raw !== next) await current.setItem(key, next)
424
+ return next
425
+ }
426
+
427
+ if (!legacyStore) return null
428
+
429
+ for (const legacyKey of legacy) {
430
+ const legacyRaw = await legacyStore.getItem(legacyKey)
431
+ if (legacyRaw === null) continue
432
+
433
+ const next = normalize(defaults, legacyRaw, config.migrate)
434
+ if (next === undefined) {
435
+ await legacyStore.removeItem(legacyKey).catch(() => undefined)
436
+ continue
437
+ }
438
+ await current.setItem(key, next)
439
+ await legacyStore.removeItem(legacyKey)
440
+ return next
441
+ }
442
+
443
+ return null
444
+ },
445
+ setItem: async (key, value) => {
446
+ await current.setItem(key, value)
447
+ },
448
+ removeItem: async (key) => {
449
+ await current.removeItem(key)
450
+ },
451
+ }
452
+
453
+ return api
454
+ })()
455
+
456
+ const [state, setState, init] = makePersisted(store, { name: config.key, storage })
457
+
458
+ const isAsync = init instanceof Promise
459
+ const [ready] = createResource(
460
+ () => init,
461
+ async (initValue) => {
462
+ if (initValue instanceof Promise) await initValue
463
+ return true
464
+ },
465
+ { initialValue: !isAsync },
466
+ )
467
+
468
+ return [
469
+ state,
470
+ setState,
471
+ init,
472
+ Object.assign(() => ready() === true, {
473
+ promise: init instanceof Promise ? init : undefined,
474
+ }),
475
+ ]
476
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import type { Part } from "@reign-labs/sdk/v2"
3
+ import { extractPromptFromParts } from "./prompt"
4
+
5
+ describe("extractPromptFromParts", () => {
6
+ test("restores multiple uploaded attachments", () => {
7
+ const parts = [
8
+ {
9
+ id: "text_1",
10
+ type: "text",
11
+ text: "check these",
12
+ sessionID: "ses_1",
13
+ messageID: "msg_1",
14
+ },
15
+ {
16
+ id: "file_1",
17
+ type: "file",
18
+ mime: "image/png",
19
+ url: "data:image/png;base64,AAA",
20
+ filename: "a.png",
21
+ sessionID: "ses_1",
22
+ messageID: "msg_1",
23
+ },
24
+ {
25
+ id: "file_2",
26
+ type: "file",
27
+ mime: "application/pdf",
28
+ url: "data:application/pdf;base64,BBB",
29
+ filename: "b.pdf",
30
+ sessionID: "ses_1",
31
+ messageID: "msg_1",
32
+ },
33
+ ] satisfies Part[]
34
+
35
+ const result = extractPromptFromParts(parts)
36
+
37
+ expect(result).toHaveLength(3)
38
+ expect(result[0]).toMatchObject({ type: "text", content: "check these" })
39
+ expect(result.slice(1)).toMatchObject([
40
+ { type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
41
+ { type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" },
42
+ ])
43
+ })
44
+ })
@@ -0,0 +1,203 @@
1
+ import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@reign-labs/sdk/v2"
2
+ import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
3
+
4
+ type Inline =
5
+ | {
6
+ type: "file"
7
+ start: number
8
+ end: number
9
+ value: string
10
+ path: string
11
+ selection?: {
12
+ startLine: number
13
+ endLine: number
14
+ startChar: number
15
+ endChar: number
16
+ }
17
+ }
18
+ | {
19
+ type: "agent"
20
+ start: number
21
+ end: number
22
+ value: string
23
+ name: string
24
+ }
25
+
26
+ function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["selection"] {
27
+ const queryIndex = url.indexOf("?")
28
+ if (queryIndex === -1) return undefined
29
+ const params = new URLSearchParams(url.slice(queryIndex + 1))
30
+ const startLine = Number(params.get("start"))
31
+ const endLine = Number(params.get("end"))
32
+ if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) return undefined
33
+ return {
34
+ startLine,
35
+ endLine,
36
+ startChar: 0,
37
+ endChar: 0,
38
+ }
39
+ }
40
+
41
+ function textPartValue(parts: Part[]) {
42
+ const candidates = parts
43
+ .filter((part): part is TextPart => part.type === "text")
44
+ .filter((part) => !part.synthetic && !part.ignored)
45
+ return candidates.reduce((best: TextPart | undefined, part) => {
46
+ if (!best) return part
47
+ if (part.text.length > best.text.length) return part
48
+ return best
49
+ }, undefined)
50
+ }
51
+
52
+ /**
53
+ * Extract prompt content from message parts for restoring into the prompt input.
54
+ * This is used by undo to restore the original user prompt.
55
+ */
56
+ export function extractPromptFromParts(parts: Part[], opts?: { directory?: string; attachmentName?: string }): Prompt {
57
+ const textPart = textPartValue(parts)
58
+ const text = textPart?.text ?? ""
59
+ const directory = opts?.directory
60
+ const attachmentName = opts?.attachmentName ?? "attachment"
61
+
62
+ const toRelative = (path: string) => {
63
+ if (!directory) return path
64
+
65
+ const prefix = directory.endsWith("/") ? directory : directory + "/"
66
+ if (path.startsWith(prefix)) return path.slice(prefix.length)
67
+
68
+ if (path.startsWith(directory)) {
69
+ const next = path.slice(directory.length)
70
+ if (next.startsWith("/")) return next.slice(1)
71
+ return next
72
+ }
73
+
74
+ return path
75
+ }
76
+
77
+ const inline: Inline[] = []
78
+ const images: ImageAttachmentPart[] = []
79
+
80
+ for (const part of parts) {
81
+ if (part.type === "file") {
82
+ const filePart = part as FilePart
83
+ const sourceText = filePart.source?.text
84
+ if (sourceText) {
85
+ const value = sourceText.value
86
+ const start = sourceText.start
87
+ const end = sourceText.end
88
+ let path = value
89
+ if (value.startsWith("@")) path = value.slice(1)
90
+ if (!value.startsWith("@") && filePart.source && "path" in filePart.source) {
91
+ path = filePart.source.path
92
+ }
93
+ inline.push({
94
+ type: "file",
95
+ start,
96
+ end,
97
+ value,
98
+ path: toRelative(path),
99
+ selection: selectionFromFileUrl(filePart.url),
100
+ })
101
+ continue
102
+ }
103
+
104
+ if (filePart.url.startsWith("data:")) {
105
+ images.push({
106
+ type: "image",
107
+ id: filePart.id,
108
+ filename: filePart.filename ?? attachmentName,
109
+ mime: filePart.mime,
110
+ dataUrl: filePart.url,
111
+ })
112
+ }
113
+ }
114
+
115
+ if (part.type === "agent") {
116
+ const agentPart = part as MessageAgentPart
117
+ const source = agentPart.source
118
+ if (!source) continue
119
+ inline.push({
120
+ type: "agent",
121
+ start: source.start,
122
+ end: source.end,
123
+ value: source.value,
124
+ name: agentPart.name,
125
+ })
126
+ }
127
+ }
128
+
129
+ inline.sort((a, b) => {
130
+ if (a.start !== b.start) return a.start - b.start
131
+ return a.end - b.end
132
+ })
133
+
134
+ const result: Prompt = []
135
+ let position = 0
136
+ let cursor = 0
137
+
138
+ const pushText = (content: string) => {
139
+ if (!content) return
140
+ result.push({
141
+ type: "text",
142
+ content,
143
+ start: position,
144
+ end: position + content.length,
145
+ })
146
+ position += content.length
147
+ }
148
+
149
+ const pushFile = (item: Extract<Inline, { type: "file" }>) => {
150
+ const content = item.value
151
+ const attachment: FileAttachmentPart = {
152
+ type: "file",
153
+ path: item.path,
154
+ content,
155
+ start: position,
156
+ end: position + content.length,
157
+ selection: item.selection,
158
+ }
159
+ result.push(attachment)
160
+ position += content.length
161
+ }
162
+
163
+ const pushAgent = (item: Extract<Inline, { type: "agent" }>) => {
164
+ const content = item.value
165
+ const mention: AgentPart = {
166
+ type: "agent",
167
+ name: item.name,
168
+ content,
169
+ start: position,
170
+ end: position + content.length,
171
+ }
172
+ result.push(mention)
173
+ position += content.length
174
+ }
175
+
176
+ for (const item of inline) {
177
+ if (item.start < 0 || item.end < item.start) continue
178
+
179
+ const expected = item.value
180
+ if (!expected) continue
181
+
182
+ const mismatch = item.end > text.length || item.start < cursor || text.slice(item.start, item.end) !== expected
183
+ const start = mismatch ? text.indexOf(expected, cursor) : item.start
184
+ if (start === -1) continue
185
+ const end = mismatch ? start + expected.length : item.end
186
+
187
+ pushText(text.slice(cursor, start))
188
+
189
+ if (item.type === "file") pushFile(item)
190
+ if (item.type === "agent") pushAgent(item)
191
+
192
+ cursor = end
193
+ }
194
+
195
+ pushText(text.slice(cursor))
196
+
197
+ if (result.length === 0) {
198
+ result.push({ type: "text", content: "", start: 0, end: 0 })
199
+ }
200
+
201
+ if (images.length === 0) return result
202
+ return [...result, ...images]
203
+ }