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,163 @@
1
+ import { createMemo } from "solid-js"
2
+ import { createStore } from "solid-js/store"
3
+ import { DateTime } from "luxon"
4
+ import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
5
+ import { createSimpleContext } from "@reign-labs/ui/context"
6
+ import { useProviders } from "@/hooks/use-providers"
7
+ import { Persist, persisted } from "@/utils/persist"
8
+
9
+ export type ModelKey = { providerID: string; modelID: string }
10
+
11
+ type Visibility = "show" | "hide"
12
+ type User = ModelKey & { visibility: Visibility; favorite?: boolean }
13
+ type Store = {
14
+ user: User[]
15
+ recent: ModelKey[]
16
+ variant?: Record<string, string | undefined>
17
+ }
18
+
19
+ const RECENT_LIMIT = 5
20
+
21
+ function modelKey(model: ModelKey) {
22
+ return `${model.providerID}:${model.modelID}`
23
+ }
24
+
25
+ export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
26
+ name: "Models",
27
+ init: () => {
28
+ const providers = useProviders()
29
+
30
+ const [store, setStore, _, ready] = persisted(
31
+ Persist.global("model", ["model.v1"]),
32
+ createStore<Store>({
33
+ user: [],
34
+ recent: [],
35
+ variant: {},
36
+ }),
37
+ )
38
+
39
+ const available = createMemo(() =>
40
+ providers.connected().flatMap((p) =>
41
+ Object.values(p.models).map((m) => ({
42
+ ...m,
43
+ provider: p,
44
+ })),
45
+ ),
46
+ )
47
+
48
+ const release = createMemo(
49
+ () =>
50
+ new Map(
51
+ available().map((model) => {
52
+ const parsed = DateTime.fromISO(model.release_date)
53
+ return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
54
+ }),
55
+ ),
56
+ )
57
+
58
+ const latest = createMemo(() =>
59
+ pipe(
60
+ available(),
61
+ filter(
62
+ (x) =>
63
+ Math.abs(
64
+ (release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
65
+ .diffNow()
66
+ .as("months"),
67
+ ) < 6,
68
+ ),
69
+ groupBy((x) => x.provider.id),
70
+ mapValues((models) =>
71
+ pipe(
72
+ models,
73
+ groupBy((x) => x.family),
74
+ values(),
75
+ (groups) =>
76
+ groups.flatMap((g) => {
77
+ const first = firstBy(g, [(x) => x.release_date, "desc"])
78
+ return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
79
+ }),
80
+ ),
81
+ ),
82
+ values(),
83
+ flat(),
84
+ ),
85
+ )
86
+
87
+ const latestSet = createMemo(() => new Set(latest().map((x) => modelKey(x))))
88
+
89
+ const visibility = createMemo(() => {
90
+ const map = new Map<string, Visibility>()
91
+ for (const item of store.user) map.set(`${item.providerID}:${item.modelID}`, item.visibility)
92
+ return map
93
+ })
94
+
95
+ const list = createMemo(() =>
96
+ available().map((m) => ({
97
+ ...m,
98
+ name: m.name.replace("(latest)", "").trim(),
99
+ latest: m.name.includes("(latest)"),
100
+ })),
101
+ )
102
+
103
+ const find = (key: ModelKey) => list().find((m) => m.id === key.modelID && m.provider.id === key.providerID)
104
+
105
+ function update(model: ModelKey, state: Visibility) {
106
+ const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
107
+ if (index >= 0) {
108
+ setStore("user", index, (current) => ({ ...current, visibility: state }))
109
+ return
110
+ }
111
+ setStore("user", store.user.length, { ...model, visibility: state })
112
+ }
113
+
114
+ const visible = (model: ModelKey) => {
115
+ const key = modelKey(model)
116
+ const state = visibility().get(key)
117
+ if (state === "hide") return false
118
+ if (state === "show") return true
119
+ if (latestSet().has(key)) return true
120
+ const date = release().get(key)
121
+ if (!date?.isValid) return true
122
+ return false
123
+ }
124
+
125
+ const setVisibility = (model: ModelKey, state: boolean) => {
126
+ update(model, state ? "show" : "hide")
127
+ }
128
+
129
+ const push = (model: ModelKey) => {
130
+ const uniq = uniqueBy([model, ...store.recent], (x) => `${x.providerID}:${x.modelID}`)
131
+ if (uniq.length > RECENT_LIMIT) uniq.pop()
132
+ setStore("recent", uniq)
133
+ }
134
+
135
+ const variantKey = (model: ModelKey) => `${model.providerID}/${model.modelID}`
136
+ const getVariant = (model: ModelKey) => store.variant?.[variantKey(model)]
137
+
138
+ const setVariant = (model: ModelKey, value: string | undefined) => {
139
+ const key = variantKey(model)
140
+ if (!store.variant) {
141
+ setStore("variant", { [key]: value })
142
+ return
143
+ }
144
+ setStore("variant", key, value)
145
+ }
146
+
147
+ return {
148
+ ready,
149
+ list,
150
+ find,
151
+ visible,
152
+ setVisibility,
153
+ recent: {
154
+ list: createMemo(() => store.recent),
155
+ push,
156
+ },
157
+ variant: {
158
+ get: getVariant,
159
+ set: setVariant,
160
+ },
161
+ }
162
+ },
163
+ })
@@ -0,0 +1,373 @@
1
+ import { createStore, reconcile } from "solid-js/store"
2
+ import { batch, createEffect, createMemo, onCleanup } from "solid-js"
3
+ import { useParams } from "@solidjs/router"
4
+ import { createSimpleContext } from "@reign-labs/ui/context"
5
+ import { useGlobalSDK } from "./global-sdk"
6
+ import { useGlobalSync } from "./global-sync"
7
+ import { usePlatform } from "@/context/platform"
8
+ import { useLanguage } from "@/context/language"
9
+ import { useSettings } from "@/context/settings"
10
+ import { Binary } from "@reign-labs/util/binary"
11
+ import { base64Encode } from "@reign-labs/util/encode"
12
+ import { decode64 } from "@/utils/base64"
13
+ import { EventSessionError } from "@reign-labs/sdk/v2"
14
+ import { Persist, persisted } from "@/utils/persist"
15
+ import { playSound, soundSrc } from "@/utils/sound"
16
+
17
+ type NotificationBase = {
18
+ directory?: string
19
+ session?: string
20
+ metadata?: unknown
21
+ time: number
22
+ viewed: boolean
23
+ }
24
+
25
+ type TurnCompleteNotification = NotificationBase & {
26
+ type: "turn-complete"
27
+ }
28
+
29
+ type ErrorNotification = NotificationBase & {
30
+ type: "error"
31
+ error: EventSessionError["properties"]["error"]
32
+ }
33
+
34
+ export type Notification = TurnCompleteNotification | ErrorNotification
35
+
36
+ type NotificationIndex = {
37
+ session: {
38
+ all: Record<string, Notification[]>
39
+ unseen: Record<string, Notification[]>
40
+ unseenCount: Record<string, number>
41
+ unseenHasError: Record<string, boolean>
42
+ }
43
+ project: {
44
+ all: Record<string, Notification[]>
45
+ unseen: Record<string, Notification[]>
46
+ unseenCount: Record<string, number>
47
+ unseenHasError: Record<string, boolean>
48
+ }
49
+ }
50
+
51
+ const MAX_NOTIFICATIONS = 500
52
+ const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
53
+
54
+ function pruneNotifications(list: Notification[]) {
55
+ const cutoff = Date.now() - NOTIFICATION_TTL_MS
56
+ const pruned = list.filter((n) => n.time >= cutoff)
57
+ if (pruned.length <= MAX_NOTIFICATIONS) return pruned
58
+ return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
59
+ }
60
+
61
+ function createNotificationIndex(): NotificationIndex {
62
+ return {
63
+ session: {
64
+ all: {},
65
+ unseen: {},
66
+ unseenCount: {},
67
+ unseenHasError: {},
68
+ },
69
+ project: {
70
+ all: {},
71
+ unseen: {},
72
+ unseenCount: {},
73
+ unseenHasError: {},
74
+ },
75
+ }
76
+ }
77
+
78
+ function buildNotificationIndex(list: Notification[]) {
79
+ const index = createNotificationIndex()
80
+
81
+ list.forEach((notification) => {
82
+ if (notification.session) {
83
+ const all = index.session.all[notification.session] ?? []
84
+ index.session.all[notification.session] = [...all, notification]
85
+ if (!notification.viewed) {
86
+ const unseen = index.session.unseen[notification.session] ?? []
87
+ index.session.unseen[notification.session] = [...unseen, notification]
88
+ index.session.unseenCount[notification.session] = unseen.length + 1
89
+ if (notification.type === "error") index.session.unseenHasError[notification.session] = true
90
+ }
91
+ }
92
+
93
+ if (notification.directory) {
94
+ const all = index.project.all[notification.directory] ?? []
95
+ index.project.all[notification.directory] = [...all, notification]
96
+ if (!notification.viewed) {
97
+ const unseen = index.project.unseen[notification.directory] ?? []
98
+ index.project.unseen[notification.directory] = [...unseen, notification]
99
+ index.project.unseenCount[notification.directory] = unseen.length + 1
100
+ if (notification.type === "error") index.project.unseenHasError[notification.directory] = true
101
+ }
102
+ }
103
+ })
104
+
105
+ return index
106
+ }
107
+
108
+ export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
109
+ name: "Notification",
110
+ init: () => {
111
+ const params = useParams()
112
+ const globalSDK = useGlobalSDK()
113
+ const globalSync = useGlobalSync()
114
+ const platform = usePlatform()
115
+ const settings = useSettings()
116
+ const language = useLanguage()
117
+
118
+ const empty: Notification[] = []
119
+
120
+ const currentDirectory = createMemo(() => {
121
+ return decode64(params.dir)
122
+ })
123
+
124
+ const currentSession = createMemo(() => params.id)
125
+
126
+ const [store, setStore, _, ready] = persisted(
127
+ Persist.global("notification", ["notification.v1"]),
128
+ createStore({
129
+ list: [] as Notification[],
130
+ }),
131
+ )
132
+ const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
133
+
134
+ const meta = { pruned: false, disposed: false }
135
+
136
+ const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
137
+ setIndex(scope, "unseen", key, unseen)
138
+ setIndex(scope, "unseenCount", key, unseen.length)
139
+ setIndex(
140
+ scope,
141
+ "unseenHasError",
142
+ key,
143
+ unseen.some((notification) => notification.type === "error"),
144
+ )
145
+ }
146
+
147
+ const appendToIndex = (notification: Notification) => {
148
+ if (notification.session) {
149
+ setIndex("session", "all", notification.session, (all = []) => [...all, notification])
150
+ if (!notification.viewed) {
151
+ setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
152
+ setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
153
+ if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
154
+ }
155
+ }
156
+
157
+ if (notification.directory) {
158
+ setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
159
+ if (!notification.viewed) {
160
+ setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
161
+ setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
162
+ if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
163
+ }
164
+ }
165
+ }
166
+
167
+ const removeFromIndex = (notification: Notification) => {
168
+ if (notification.session) {
169
+ setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
170
+ if (!notification.viewed) {
171
+ const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
172
+ updateUnseen("session", notification.session, unseen)
173
+ }
174
+ }
175
+
176
+ if (notification.directory) {
177
+ setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
178
+ if (!notification.viewed) {
179
+ const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
180
+ updateUnseen("project", notification.directory, unseen)
181
+ }
182
+ }
183
+ }
184
+
185
+ createEffect(() => {
186
+ if (!ready()) return
187
+ if (meta.pruned) return
188
+ meta.pruned = true
189
+ const list = pruneNotifications(store.list)
190
+ batch(() => {
191
+ setStore("list", list)
192
+ setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
193
+ })
194
+ })
195
+
196
+ const append = (notification: Notification) => {
197
+ const list = pruneNotifications([...store.list, notification])
198
+ const keep = new Set(list)
199
+ const removed = store.list.filter((n) => !keep.has(n))
200
+
201
+ batch(() => {
202
+ if (keep.has(notification)) appendToIndex(notification)
203
+ removed.forEach((n) => removeFromIndex(n))
204
+ setStore("list", list)
205
+ })
206
+ }
207
+
208
+ const lookup = async (directory: string, sessionID?: string) => {
209
+ if (!sessionID) return undefined
210
+ const [syncStore] = globalSync.child(directory, { bootstrap: false })
211
+ const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
212
+ if (match.found) return syncStore.session[match.index]
213
+ return globalSDK.client.session
214
+ .get({ directory, sessionID })
215
+ .then((x) => x.data)
216
+ .catch(() => undefined)
217
+ }
218
+
219
+ const viewedInCurrentSession = (directory: string, sessionID?: string) => {
220
+ const activeDirectory = currentDirectory()
221
+ const activeSession = currentSession()
222
+ if (!activeDirectory) return false
223
+ if (!activeSession) return false
224
+ if (!sessionID) return false
225
+ if (directory !== activeDirectory) return false
226
+ return sessionID === activeSession
227
+ }
228
+
229
+ const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
230
+ const sessionID = event.properties.sessionID
231
+ void lookup(directory, sessionID).then((session) => {
232
+ if (meta.disposed) return
233
+ if (!session) return
234
+ if (session.parentID) return
235
+
236
+ if (settings.sounds.agentEnabled()) {
237
+ playSound(soundSrc(settings.sounds.agent()))
238
+ }
239
+
240
+ append({
241
+ directory,
242
+ time,
243
+ viewed: viewedInCurrentSession(directory, sessionID),
244
+ type: "turn-complete",
245
+ session: sessionID,
246
+ })
247
+
248
+ const href = `/${base64Encode(directory)}/session/${sessionID}`
249
+ if (settings.notifications.agent()) {
250
+ void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
251
+ }
252
+ })
253
+ }
254
+
255
+ const handleSessionError = (
256
+ directory: string,
257
+ event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
258
+ time: number,
259
+ ) => {
260
+ const sessionID = event.properties.sessionID
261
+ void lookup(directory, sessionID).then((session) => {
262
+ if (meta.disposed) return
263
+ if (session?.parentID) return
264
+
265
+ if (settings.sounds.errorsEnabled()) {
266
+ playSound(soundSrc(settings.sounds.errors()))
267
+ }
268
+
269
+ const error = "error" in event.properties ? event.properties.error : undefined
270
+ append({
271
+ directory,
272
+ time,
273
+ viewed: viewedInCurrentSession(directory, sessionID),
274
+ type: "error",
275
+ session: sessionID ?? "global",
276
+ error,
277
+ })
278
+ const description =
279
+ session?.title ??
280
+ (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
281
+ const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
282
+ if (settings.notifications.errors()) {
283
+ void platform.notify(language.t("notification.session.error.title"), description, href)
284
+ }
285
+ })
286
+ }
287
+
288
+ const unsub = globalSDK.event.listen((e) => {
289
+ const event = e.details
290
+ if (event.type !== "session.idle" && event.type !== "session.error") return
291
+
292
+ const directory = e.name
293
+ const time = Date.now()
294
+ if (event.type === "session.idle") {
295
+ handleSessionIdle(directory, event, time)
296
+ return
297
+ }
298
+ handleSessionError(directory, event, time)
299
+ })
300
+ onCleanup(() => {
301
+ meta.disposed = true
302
+ unsub()
303
+ })
304
+
305
+ return {
306
+ ready,
307
+ session: {
308
+ all(session: string) {
309
+ return index.session.all[session] ?? empty
310
+ },
311
+ unseen(session: string) {
312
+ return index.session.unseen[session] ?? empty
313
+ },
314
+ unseenCount(session: string) {
315
+ return index.session.unseenCount[session] ?? 0
316
+ },
317
+ unseenHasError(session: string) {
318
+ return index.session.unseenHasError[session] ?? false
319
+ },
320
+ markViewed(session: string) {
321
+ const unseen = index.session.unseen[session] ?? empty
322
+ if (!unseen.length) return
323
+
324
+ const projects = [
325
+ ...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
326
+ ]
327
+ batch(() => {
328
+ setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
329
+ updateUnseen("session", session, [])
330
+ projects.forEach((directory) => {
331
+ const next = (index.project.unseen[directory] ?? empty).filter(
332
+ (notification) => notification.session !== session,
333
+ )
334
+ updateUnseen("project", directory, next)
335
+ })
336
+ })
337
+ },
338
+ },
339
+ project: {
340
+ all(directory: string) {
341
+ return index.project.all[directory] ?? empty
342
+ },
343
+ unseen(directory: string) {
344
+ return index.project.unseen[directory] ?? empty
345
+ },
346
+ unseenCount(directory: string) {
347
+ return index.project.unseenCount[directory] ?? 0
348
+ },
349
+ unseenHasError(directory: string) {
350
+ return index.project.unseenHasError[directory] ?? false
351
+ },
352
+ markViewed(directory: string) {
353
+ const unseen = index.project.unseen[directory] ?? empty
354
+ if (!unseen.length) return
355
+
356
+ const sessions = [
357
+ ...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
358
+ ]
359
+ batch(() => {
360
+ setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
361
+ updateUnseen("project", directory, [])
362
+ sessions.forEach((session) => {
363
+ const next = (index.session.unseen[session] ?? empty).filter(
364
+ (notification) => notification.directory !== directory,
365
+ )
366
+ updateUnseen("session", session, next)
367
+ })
368
+ })
369
+ },
370
+ },
371
+ }
372
+ },
373
+ })
@@ -0,0 +1,102 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import type { PermissionRequest, Session } from "@reign-labs/sdk/v2/client"
3
+ import { base64Encode } from "@reign-labs/util/encode"
4
+ import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
5
+
6
+ const session = (input: { id: string; parentID?: string }) =>
7
+ ({
8
+ id: input.id,
9
+ parentID: input.parentID,
10
+ }) as Session
11
+
12
+ const permission = (sessionID: string) =>
13
+ ({
14
+ sessionID,
15
+ }) as Pick<PermissionRequest, "sessionID">
16
+
17
+ describe("autoRespondsPermission", () => {
18
+ test("uses a parent session's directory-scoped auto-accept", () => {
19
+ const directory = "/tmp/project"
20
+ const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
21
+ const autoAccept = {
22
+ [`${base64Encode(directory)}/root`]: true,
23
+ }
24
+
25
+ expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
26
+ })
27
+
28
+ test("uses a parent session's legacy auto-accept key", () => {
29
+ const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
30
+
31
+ expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true)
32
+ })
33
+
34
+ test("defaults to requiring approval when no lineage override exists", () => {
35
+ const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
36
+ const autoAccept = {
37
+ other: true,
38
+ }
39
+
40
+ expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(false)
41
+ })
42
+
43
+ test("inherits a parent session's false override", () => {
44
+ const directory = "/tmp/project"
45
+ const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
46
+ const autoAccept = {
47
+ [`${base64Encode(directory)}/root`]: false,
48
+ }
49
+
50
+ expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
51
+ })
52
+
53
+ test("prefers a child override over parent override", () => {
54
+ const directory = "/tmp/project"
55
+ const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
56
+ const autoAccept = {
57
+ [`${base64Encode(directory)}/root`]: false,
58
+ [`${base64Encode(directory)}/child`]: true,
59
+ }
60
+
61
+ expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
62
+ })
63
+
64
+ test("falls back to directory-level auto-accept", () => {
65
+ const directory = "/tmp/project"
66
+ const sessions = [session({ id: "root" })]
67
+ const autoAccept = {
68
+ [`${base64Encode(directory)}/*`]: true,
69
+ }
70
+
71
+ expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
72
+ })
73
+
74
+ test("session-level override takes precedence over directory-level", () => {
75
+ const directory = "/tmp/project"
76
+ const sessions = [session({ id: "root" })]
77
+ const autoAccept = {
78
+ [`${base64Encode(directory)}/*`]: true,
79
+ [`${base64Encode(directory)}/root`]: false,
80
+ }
81
+
82
+ expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
83
+ })
84
+ })
85
+
86
+ describe("isDirectoryAutoAccepting", () => {
87
+ test("returns true when directory key is set", () => {
88
+ const directory = "/tmp/project"
89
+ const autoAccept = { [`${base64Encode(directory)}/*`]: true }
90
+ expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true)
91
+ })
92
+
93
+ test("returns false when directory key is not set", () => {
94
+ expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false)
95
+ })
96
+
97
+ test("returns false when directory key is explicitly false", () => {
98
+ const directory = "/tmp/project"
99
+ const autoAccept = { [`${base64Encode(directory)}/*`]: false }
100
+ expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
101
+ })
102
+ })
@@ -0,0 +1,51 @@
1
+ import { base64Encode } from "@reign-labs/util/encode"
2
+
3
+ export function acceptKey(sessionID: string, directory?: string) {
4
+ if (!directory) return sessionID
5
+ return `${base64Encode(directory)}/${sessionID}`
6
+ }
7
+
8
+ export function directoryAcceptKey(directory: string) {
9
+ return `${base64Encode(directory)}/*`
10
+ }
11
+
12
+ function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
13
+ const key = acceptKey(sessionID, directory)
14
+ const directoryKey = directory ? directoryAcceptKey(directory) : undefined
15
+ return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
16
+ }
17
+
18
+ export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
19
+ const key = directoryAcceptKey(directory)
20
+ return autoAccept[key] ?? false
21
+ }
22
+
23
+ function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
24
+ const parent = session.reduce((acc, item) => {
25
+ if (item.parentID) acc.set(item.id, item.parentID)
26
+ return acc
27
+ }, new Map<string, string>())
28
+ const seen = new Set([sessionID])
29
+ const ids = [sessionID]
30
+
31
+ for (const id of ids) {
32
+ const parentID = parent.get(id)
33
+ if (!parentID || seen.has(parentID)) continue
34
+ seen.add(parentID)
35
+ ids.push(parentID)
36
+ }
37
+
38
+ return ids
39
+ }
40
+
41
+ export function autoRespondsPermission(
42
+ autoAccept: Record<string, boolean>,
43
+ session: { id: string; parentID?: string }[],
44
+ permission: { sessionID: string },
45
+ directory?: string,
46
+ ) {
47
+ const value = sessionLineage(session, permission.sessionID)
48
+ .map((id) => accepted(autoAccept, id, directory))
49
+ .find((item): item is boolean => item !== undefined)
50
+ return value ?? false
51
+ }