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
package/e2e/actions.ts ADDED
@@ -0,0 +1,1018 @@
1
+ import { base64Decode, base64Encode } from "@reign-labs/util/encode"
2
+ import { expect, type Locator, type Page } from "@playwright/test"
3
+ import fs from "node:fs/promises"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import { execSync } from "node:child_process"
7
+ import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
8
+ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
9
+ import {
10
+ dropdownMenuTriggerSelector,
11
+ dropdownMenuContentSelector,
12
+ projectSwitchSelector,
13
+ projectMenuTriggerSelector,
14
+ projectCloseMenuSelector,
15
+ projectWorkspacesToggleSelector,
16
+ titlebarRightSelector,
17
+ popoverBodySelector,
18
+ listItemSelector,
19
+ listItemKeySelector,
20
+ listItemKeyStartsWithSelector,
21
+ promptSelector,
22
+ terminalSelector,
23
+ workspaceItemSelector,
24
+ workspaceMenuTriggerSelector,
25
+ } from "./selectors"
26
+
27
+ const phase = new WeakMap<Page, "test" | "cleanup">()
28
+
29
+ export function setHealthPhase(page: Page, value: "test" | "cleanup") {
30
+ phase.set(page, value)
31
+ }
32
+
33
+ export function healthPhase(page: Page) {
34
+ return phase.get(page) ?? "test"
35
+ }
36
+
37
+ export async function defocus(page: Page) {
38
+ await page
39
+ .evaluate(() => {
40
+ const el = document.activeElement
41
+ if (el instanceof HTMLElement) el.blur()
42
+ })
43
+ .catch(() => undefined)
44
+ }
45
+
46
+ async function terminalID(term: Locator) {
47
+ const id = await term.getAttribute(terminalAttr)
48
+ if (id) return id
49
+ throw new Error(`Active terminal missing ${terminalAttr}`)
50
+ }
51
+
52
+ export async function terminalConnects(page: Page, input?: { term?: Locator }) {
53
+ const term = input?.term ?? page.locator(terminalSelector).first()
54
+ const id = await terminalID(term)
55
+ return page.evaluate((id) => {
56
+ return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0
57
+ }, id)
58
+ }
59
+
60
+ export async function disconnectTerminal(page: Page, input?: { term?: Locator }) {
61
+ const term = input?.term ?? page.locator(terminalSelector).first()
62
+ const id = await terminalID(term)
63
+ await page.evaluate((id) => {
64
+ ;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.()
65
+ }, id)
66
+ }
67
+
68
+ async function terminalReady(page: Page, term?: Locator) {
69
+ const next = term ?? page.locator(terminalSelector).first()
70
+ const id = await terminalID(next)
71
+ return page.evaluate((id) => {
72
+ const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
73
+ return !!state?.connected && (state.settled ?? 0) > 0
74
+ }, id)
75
+ }
76
+
77
+ async function terminalFocusIdle(page: Page, term?: Locator) {
78
+ const next = term ?? page.locator(terminalSelector).first()
79
+ const id = await terminalID(next)
80
+ return page.evaluate((id) => {
81
+ const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
82
+ return (state?.focusing ?? 0) === 0
83
+ }, id)
84
+ }
85
+
86
+ async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
87
+ const next = input.term ?? page.locator(terminalSelector).first()
88
+ const id = await terminalID(next)
89
+ return page.evaluate(
90
+ (input) => {
91
+ const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
92
+ return state?.rendered.includes(input.token) ?? false
93
+ },
94
+ { id, token: input.token },
95
+ )
96
+ }
97
+
98
+ async function promptSlashActive(page: Page, id: string) {
99
+ return page.evaluate((id) => {
100
+ const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
101
+ if (state?.popover !== "slash") return false
102
+ if (!state.slash.ids.includes(id)) return false
103
+ return state.slash.active === id
104
+ }, id)
105
+ }
106
+
107
+ async function promptSlashSelects(page: Page) {
108
+ return page.evaluate(() => {
109
+ return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
110
+ })
111
+ }
112
+
113
+ async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
114
+ return page.evaluate((input) => {
115
+ const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
116
+ if (!state) return false
117
+ return state.selected === input.id && state.selects >= input.count
118
+ }, input)
119
+ }
120
+
121
+ export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
122
+ const term = input?.term ?? page.locator(terminalSelector).first()
123
+ const timeout = input?.timeout ?? 10_000
124
+ await expect(term).toBeVisible()
125
+ await expect(term.locator("textarea")).toHaveCount(1)
126
+ await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
127
+ }
128
+
129
+ export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
130
+ const term = input?.term ?? page.locator(terminalSelector).first()
131
+ const timeout = input?.timeout ?? 10_000
132
+ await waitTerminalReady(page, { term, timeout })
133
+ await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
134
+ }
135
+
136
+ export async function showPromptSlash(
137
+ page: Page,
138
+ input: { id: string; text: string; prompt?: Locator; timeout?: number },
139
+ ) {
140
+ const prompt = input.prompt ?? page.locator(promptSelector)
141
+ const timeout = input.timeout ?? 10_000
142
+ await expect
143
+ .poll(
144
+ async () => {
145
+ await prompt.click().catch(() => false)
146
+ await prompt.fill(input.text).catch(() => false)
147
+ return promptSlashActive(page, input.id).catch(() => false)
148
+ },
149
+ { timeout },
150
+ )
151
+ .toBe(true)
152
+ }
153
+
154
+ export async function runPromptSlash(
155
+ page: Page,
156
+ input: { id: string; text: string; prompt?: Locator; timeout?: number },
157
+ ) {
158
+ const prompt = input.prompt ?? page.locator(promptSelector)
159
+ const timeout = input.timeout ?? 10_000
160
+ const count = await promptSlashSelects(page)
161
+ await showPromptSlash(page, input)
162
+ await prompt.press("Enter")
163
+ await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
164
+ }
165
+
166
+ export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
167
+ const term = input.term ?? page.locator(terminalSelector).first()
168
+ const timeout = input.timeout ?? 10_000
169
+ await waitTerminalReady(page, { term, timeout })
170
+ const textarea = term.locator("textarea")
171
+ await term.click()
172
+ await expect(textarea).toBeFocused()
173
+ await page.keyboard.type(input.cmd)
174
+ await page.keyboard.press("Enter")
175
+ await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
176
+ }
177
+
178
+ export async function openPalette(page: Page, key = "K") {
179
+ await defocus(page)
180
+ await page.keyboard.press(`${modKey}+${key}`)
181
+
182
+ const dialog = page.getByRole("dialog")
183
+ await expect(dialog).toBeVisible()
184
+ await expect(dialog.getByRole("textbox").first()).toBeVisible()
185
+ return dialog
186
+ }
187
+
188
+ export async function closeDialog(page: Page, dialog: Locator) {
189
+ await page.keyboard.press("Escape")
190
+ const closed = await dialog
191
+ .waitFor({ state: "detached", timeout: 1500 })
192
+ .then(() => true)
193
+ .catch(() => false)
194
+
195
+ if (closed) return
196
+
197
+ await page.keyboard.press("Escape")
198
+ const closedSecond = await dialog
199
+ .waitFor({ state: "detached", timeout: 1500 })
200
+ .then(() => true)
201
+ .catch(() => false)
202
+
203
+ if (closedSecond) return
204
+
205
+ await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
206
+ await expect(dialog).toHaveCount(0)
207
+ }
208
+
209
+ export async function isSidebarClosed(page: Page) {
210
+ const button = await waitSidebarButton(page, "isSidebarClosed")
211
+ return (await button.getAttribute("aria-expanded")) !== "true"
212
+ }
213
+
214
+ async function errorBoundaryText(page: Page) {
215
+ const title = page.getByRole("heading", { name: /something went wrong/i }).first()
216
+ if (!(await title.isVisible().catch(() => false))) return
217
+
218
+ const description = await page
219
+ .getByText(/an error occurred while loading the application\./i)
220
+ .first()
221
+ .textContent()
222
+ .catch(() => "")
223
+ const detail = await page
224
+ .getByRole("textbox", { name: /error details/i })
225
+ .first()
226
+ .inputValue()
227
+ .catch(async () =>
228
+ (
229
+ (await page
230
+ .getByRole("textbox", { name: /error details/i })
231
+ .first()
232
+ .textContent()
233
+ .catch(() => "")) ?? ""
234
+ ).trim(),
235
+ )
236
+
237
+ return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
238
+ }
239
+
240
+ export async function assertHealthy(page: Page, context: string) {
241
+ const text = await errorBoundaryText(page)
242
+ if (!text) return
243
+ console.log(`[e2e:error-boundary][${context}]\n${text}`)
244
+ throw new Error(`Error boundary during ${context}\n${text}`)
245
+ }
246
+
247
+ async function waitSidebarButton(page: Page, context: string) {
248
+ const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
249
+ const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
250
+ await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
251
+ await assertHealthy(page, context)
252
+ return button
253
+ }
254
+
255
+ export async function toggleSidebar(page: Page) {
256
+ await defocus(page)
257
+ await page.keyboard.press(`${modKey}+B`)
258
+ }
259
+
260
+ export async function openSidebar(page: Page) {
261
+ if (!(await isSidebarClosed(page))) return
262
+
263
+ const button = await waitSidebarButton(page, "openSidebar")
264
+ await button.click()
265
+
266
+ const opened = await expect(button)
267
+ .toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
268
+ .then(() => true)
269
+ .catch(() => false)
270
+
271
+ if (opened) return
272
+
273
+ await toggleSidebar(page)
274
+ await expect(button).toHaveAttribute("aria-expanded", "true")
275
+ }
276
+
277
+ export async function closeSidebar(page: Page) {
278
+ if (await isSidebarClosed(page)) return
279
+
280
+ const button = await waitSidebarButton(page, "closeSidebar")
281
+ await button.click()
282
+
283
+ const closed = await expect(button)
284
+ .toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
285
+ .then(() => true)
286
+ .catch(() => false)
287
+
288
+ if (closed) return
289
+
290
+ await toggleSidebar(page)
291
+ await expect(button).toHaveAttribute("aria-expanded", "false")
292
+ }
293
+
294
+ export async function openSettings(page: Page) {
295
+ await assertHealthy(page, "openSettings")
296
+ await defocus(page)
297
+
298
+ const dialog = page.getByRole("dialog")
299
+ await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
300
+
301
+ const opened = await dialog
302
+ .waitFor({ state: "visible", timeout: 3000 })
303
+ .then(() => true)
304
+ .catch(() => false)
305
+
306
+ if (opened) return dialog
307
+
308
+ await assertHealthy(page, "openSettings")
309
+
310
+ await page.getByRole("button", { name: "Settings" }).first().click()
311
+ await expect(dialog).toBeVisible()
312
+ return dialog
313
+ }
314
+
315
+ export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
316
+ await page.addInitScript(
317
+ (args: { directory: string; serverUrl: string; extra: string[] }) => {
318
+ const key = "opencode.global.dat:server"
319
+ const raw = localStorage.getItem(key)
320
+ const parsed = (() => {
321
+ if (!raw) return undefined
322
+ try {
323
+ return JSON.parse(raw) as unknown
324
+ } catch {
325
+ return undefined
326
+ }
327
+ })()
328
+
329
+ const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
330
+ const list = Array.isArray(store.list) ? store.list : []
331
+ const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
332
+ const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
333
+ const nextProjects = { ...(projects as Record<string, unknown>) }
334
+
335
+ const add = (origin: string, directory: string) => {
336
+ const current = nextProjects[origin]
337
+ const items = Array.isArray(current) ? current : []
338
+ const existing = items.filter(
339
+ (p): p is { worktree: string; expanded?: boolean } =>
340
+ !!p &&
341
+ typeof p === "object" &&
342
+ "worktree" in p &&
343
+ typeof (p as { worktree?: unknown }).worktree === "string",
344
+ )
345
+
346
+ if (existing.some((p) => p.worktree === directory)) return
347
+ nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
348
+ }
349
+
350
+ const directories = [args.directory, ...args.extra]
351
+ for (const directory of directories) {
352
+ add("local", directory)
353
+ add(args.serverUrl, directory)
354
+ }
355
+
356
+ localStorage.setItem(
357
+ key,
358
+ JSON.stringify({
359
+ list,
360
+ projects: nextProjects,
361
+ lastProject,
362
+ }),
363
+ )
364
+ },
365
+ { directory: input.directory, serverUrl, extra: input.extra ?? [] },
366
+ )
367
+ }
368
+
369
+ export async function createTestProject() {
370
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
371
+ const id = `e2e-${path.basename(root)}`
372
+
373
+ await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
374
+
375
+ execSync("git init", { cwd: root, stdio: "ignore" })
376
+ await fs.writeFile(path.join(root, ".git", "opencode"), id)
377
+ execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
378
+ execSync("git add -A", { cwd: root, stdio: "ignore" })
379
+ execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
380
+ cwd: root,
381
+ stdio: "ignore",
382
+ })
383
+
384
+ return resolveDirectory(root)
385
+ }
386
+
387
+ export async function cleanupTestProject(directory: string) {
388
+ try {
389
+ execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
390
+ } catch {}
391
+ await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
392
+ }
393
+
394
+ export function slugFromUrl(url: string) {
395
+ return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
396
+ }
397
+
398
+ async function probeSession(page: Page) {
399
+ return page
400
+ .evaluate(() => {
401
+ const win = window as E2EWindow
402
+ const current = win.__opencode_e2e?.model?.current
403
+ if (!current) return null
404
+ return { dir: current.dir, sessionID: current.sessionID }
405
+ })
406
+ .catch(() => null as { dir?: string; sessionID?: string } | null)
407
+ }
408
+
409
+ export async function waitSlug(page: Page, skip: string[] = []) {
410
+ let prev = ""
411
+ let next = ""
412
+ await expect
413
+ .poll(
414
+ async () => {
415
+ await assertHealthy(page, "waitSlug")
416
+ const slug = slugFromUrl(page.url())
417
+ if (!slug) return ""
418
+ if (skip.includes(slug)) return ""
419
+ if (slug !== prev) {
420
+ prev = slug
421
+ next = ""
422
+ return ""
423
+ }
424
+ next = slug
425
+ return slug
426
+ },
427
+ { timeout: 45_000 },
428
+ )
429
+ .not.toBe("")
430
+ return next
431
+ }
432
+
433
+ export async function resolveSlug(slug: string) {
434
+ const directory = base64Decode(slug)
435
+ if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
436
+ const resolved = await resolveDirectory(directory)
437
+ return { directory: resolved, slug: base64Encode(resolved), raw: slug }
438
+ }
439
+
440
+ export async function waitDir(page: Page, directory: string) {
441
+ const target = await resolveDirectory(directory)
442
+ await expect
443
+ .poll(
444
+ async () => {
445
+ await assertHealthy(page, "waitDir")
446
+ const slug = slugFromUrl(page.url())
447
+ if (!slug) return ""
448
+ return resolveSlug(slug)
449
+ .then((item) => item.directory)
450
+ .catch(() => "")
451
+ },
452
+ { timeout: 45_000 },
453
+ )
454
+ .toBe(target)
455
+ return { directory: target, slug: base64Encode(target) }
456
+ }
457
+
458
+ export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
459
+ const target = await resolveDirectory(input.directory)
460
+ await expect
461
+ .poll(
462
+ async () => {
463
+ await assertHealthy(page, "waitSession")
464
+ const slug = slugFromUrl(page.url())
465
+ if (!slug) return false
466
+ const resolved = await resolveSlug(slug).catch(() => undefined)
467
+ if (!resolved || resolved.directory !== target) return false
468
+ if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
469
+
470
+ const state = await probeSession(page)
471
+ if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
472
+ if (state?.dir) {
473
+ const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
474
+ if (dir !== target) return false
475
+ }
476
+
477
+ return page
478
+ .locator(promptSelector)
479
+ .first()
480
+ .isVisible()
481
+ .catch(() => false)
482
+ },
483
+ { timeout: 45_000 },
484
+ )
485
+ .toBe(true)
486
+ return { directory: target, slug: base64Encode(target) }
487
+ }
488
+
489
+ export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
490
+ const sdk = createSdk(directory)
491
+ const target = await resolveDirectory(directory)
492
+
493
+ await expect
494
+ .poll(
495
+ async () => {
496
+ const data = await sdk.session
497
+ .get({ sessionID })
498
+ .then((x) => x.data)
499
+ .catch(() => undefined)
500
+ if (!data?.directory) return ""
501
+ return resolveDirectory(data.directory).catch(() => data.directory)
502
+ },
503
+ { timeout },
504
+ )
505
+ .toBe(target)
506
+
507
+ await expect
508
+ .poll(
509
+ async () => {
510
+ const items = await sdk.session
511
+ .messages({ sessionID, limit: 20 })
512
+ .then((x) => x.data ?? [])
513
+ .catch(() => [])
514
+ return items.some((item) => item.info.role === "user")
515
+ },
516
+ { timeout },
517
+ )
518
+ .toBe(true)
519
+ }
520
+
521
+ export function sessionIDFromUrl(url: string) {
522
+ const match = /\/session\/([^/?#]+)/.exec(url)
523
+ return match?.[1]
524
+ }
525
+
526
+ export async function hoverSessionItem(page: Page, sessionID: string) {
527
+ const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
528
+ await expect(sessionEl).toBeVisible()
529
+ await sessionEl.hover()
530
+ return sessionEl
531
+ }
532
+
533
+ export async function openSessionMoreMenu(page: Page, sessionID: string) {
534
+ await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
535
+
536
+ const scroller = page.locator(".scroll-view__viewport").first()
537
+ await expect(scroller).toBeVisible()
538
+ await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
539
+
540
+ const menu = page
541
+ .locator(dropdownMenuContentSelector)
542
+ .filter({ has: page.getByRole("menuitem", { name: /rename/i }) })
543
+ .filter({ has: page.getByRole("menuitem", { name: /archive/i }) })
544
+ .filter({ has: page.getByRole("menuitem", { name: /delete/i }) })
545
+ .first()
546
+
547
+ const opened = await menu
548
+ .isVisible()
549
+ .then((x) => x)
550
+ .catch(() => false)
551
+
552
+ if (opened) return menu
553
+
554
+ const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
555
+ await expect(menuTrigger).toBeVisible()
556
+ await menuTrigger.click()
557
+
558
+ await expect(menu).toBeVisible()
559
+ return menu
560
+ }
561
+
562
+ export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
563
+ const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
564
+ await expect(item).toBeVisible()
565
+ await item.click({ force: options?.force })
566
+ }
567
+
568
+ export async function confirmDialog(page: Page, buttonName: string | RegExp) {
569
+ const dialog = page.getByRole("dialog").first()
570
+ await expect(dialog).toBeVisible()
571
+
572
+ const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
573
+ await expect(button).toBeVisible()
574
+ await button.click()
575
+ }
576
+
577
+ export async function openSharePopover(page: Page) {
578
+ const rightSection = page.locator(titlebarRightSelector)
579
+ const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
580
+ await expect(shareButton).toBeVisible()
581
+
582
+ const popoverBody = page
583
+ .locator(popoverBodySelector)
584
+ .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
585
+ .first()
586
+
587
+ const opened = await popoverBody
588
+ .isVisible()
589
+ .then((x) => x)
590
+ .catch(() => false)
591
+
592
+ if (!opened) {
593
+ await shareButton.click()
594
+ await expect(popoverBody).toBeVisible()
595
+ }
596
+ return { rightSection, popoverBody }
597
+ }
598
+
599
+ export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
600
+ const button = page.getByRole("button").filter({ hasText: buttonName }).first()
601
+ await expect(button).toBeVisible()
602
+ await button.click()
603
+ }
604
+
605
+ export async function clickListItem(
606
+ container: Locator | Page,
607
+ filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
608
+ ): Promise<Locator> {
609
+ let item: Locator
610
+
611
+ if (typeof filter === "string" || filter instanceof RegExp) {
612
+ item = container.locator(listItemSelector).filter({ hasText: filter }).first()
613
+ } else if (filter.keyStartsWith) {
614
+ item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first()
615
+ } else if (filter.key) {
616
+ item = container.locator(listItemKeySelector(filter.key)).first()
617
+ } else if (filter.text) {
618
+ item = container.locator(listItemSelector).filter({ hasText: filter.text }).first()
619
+ } else {
620
+ throw new Error("Invalid filter provided to clickListItem")
621
+ }
622
+
623
+ await expect(item).toBeVisible()
624
+ await item.click()
625
+ return item
626
+ }
627
+
628
+ async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
629
+ const data = await sdk.session
630
+ .status()
631
+ .then((x) => x.data ?? {})
632
+ .catch(() => undefined)
633
+ return data?.[sessionID]
634
+ }
635
+
636
+ async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
637
+ let prev = ""
638
+ await expect
639
+ .poll(
640
+ async () => {
641
+ const info = await sdk.session
642
+ .get({ sessionID })
643
+ .then((x) => x.data)
644
+ .catch(() => undefined)
645
+ if (!info) return true
646
+ const next = `${info.title}:${info.time.updated ?? info.time.created}`
647
+ if (next !== prev) {
648
+ prev = next
649
+ return false
650
+ }
651
+ return true
652
+ },
653
+ { timeout },
654
+ )
655
+ .toBe(true)
656
+ }
657
+
658
+ export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
659
+ await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
660
+ }
661
+
662
+ export async function cleanupSession(input: {
663
+ sessionID: string
664
+ directory?: string
665
+ sdk?: ReturnType<typeof createSdk>
666
+ }) {
667
+ const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
668
+ if (!sdk) throw new Error("cleanupSession requires sdk or directory")
669
+ await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
670
+ const current = await status(sdk, input.sessionID).catch(() => undefined)
671
+ if (current && current.type !== "idle") {
672
+ await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
673
+ await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
674
+ }
675
+ await stable(sdk, input.sessionID).catch(() => undefined)
676
+ await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
677
+ }
678
+
679
+ export async function withSession<T>(
680
+ sdk: ReturnType<typeof createSdk>,
681
+ title: string,
682
+ callback: (session: { id: string; title: string }) => Promise<T>,
683
+ ): Promise<T> {
684
+ const session = await sdk.session.create({ title }).then((r) => r.data)
685
+ if (!session?.id) throw new Error("Session create did not return an id")
686
+
687
+ try {
688
+ return await callback(session)
689
+ } finally {
690
+ await cleanupSession({ sdk, sessionID: session.id })
691
+ }
692
+ }
693
+
694
+ const seedSystem = [
695
+ "You are seeding deterministic e2e UI state.",
696
+ "Follow the user's instruction exactly.",
697
+ "When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
698
+ "Do not call any extra tools.",
699
+ ].join(" ")
700
+
701
+ const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
702
+ const timeout = input.timeout ?? 30_000
703
+ const end = Date.now() + timeout
704
+ while (Date.now() < end) {
705
+ const value = await input.probe()
706
+ if (value !== undefined) return value
707
+ await new Promise((resolve) => setTimeout(resolve, 250))
708
+ }
709
+ }
710
+
711
+ const seed = async <T>(input: {
712
+ sessionID: string
713
+ prompt: string
714
+ sdk: ReturnType<typeof createSdk>
715
+ probe: () => Promise<T | undefined>
716
+ timeout?: number
717
+ attempts?: number
718
+ }) => {
719
+ for (let i = 0; i < (input.attempts ?? 2); i++) {
720
+ await input.sdk.session.promptAsync({
721
+ sessionID: input.sessionID,
722
+ agent: "build",
723
+ system: seedSystem,
724
+ parts: [{ type: "text", text: input.prompt }],
725
+ })
726
+ const value = await wait({ probe: input.probe, timeout: input.timeout })
727
+ if (value !== undefined) return value
728
+ }
729
+ }
730
+
731
+ export async function seedSessionQuestion(
732
+ sdk: ReturnType<typeof createSdk>,
733
+ input: {
734
+ sessionID: string
735
+ questions: Array<{
736
+ header: string
737
+ question: string
738
+ options: Array<{ label: string; description: string }>
739
+ multiple?: boolean
740
+ custom?: boolean
741
+ }>
742
+ },
743
+ ) {
744
+ const first = input.questions[0]
745
+ if (!first) throw new Error("Question seed requires at least one question")
746
+
747
+ const text = [
748
+ "Your only valid response is one question tool call.",
749
+ `Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
750
+ "Do not output plain text.",
751
+ "After calling the tool, wait for the user response.",
752
+ ].join("\n")
753
+
754
+ const result = await seed({
755
+ sdk,
756
+ sessionID: input.sessionID,
757
+ prompt: text,
758
+ timeout: 30_000,
759
+ probe: async () => {
760
+ const list = await sdk.question.list().then((x) => x.data ?? [])
761
+ return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
762
+ },
763
+ })
764
+
765
+ if (!result) throw new Error("Timed out seeding question request")
766
+ return { id: result.id }
767
+ }
768
+
769
+ export async function seedSessionPermission(
770
+ sdk: ReturnType<typeof createSdk>,
771
+ input: {
772
+ sessionID: string
773
+ permission: string
774
+ patterns: string[]
775
+ description?: string
776
+ },
777
+ ) {
778
+ const text = [
779
+ "Your only valid response is one bash tool call.",
780
+ `Use this JSON input: ${JSON.stringify({
781
+ command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
782
+ workdir: "/",
783
+ description: input.description ?? `seed ${input.permission} permission request`,
784
+ })}`,
785
+ "Do not output plain text.",
786
+ ].join("\n")
787
+
788
+ const result = await seed({
789
+ sdk,
790
+ sessionID: input.sessionID,
791
+ prompt: text,
792
+ timeout: 30_000,
793
+ probe: async () => {
794
+ const list = await sdk.permission.list().then((x) => x.data ?? [])
795
+ return list.find((item) => item.sessionID === input.sessionID)
796
+ },
797
+ })
798
+
799
+ if (!result) throw new Error("Timed out seeding permission request")
800
+ return { id: result.id }
801
+ }
802
+
803
+ export async function seedSessionTask(
804
+ sdk: ReturnType<typeof createSdk>,
805
+ input: {
806
+ sessionID: string
807
+ description: string
808
+ prompt: string
809
+ subagentType?: string
810
+ },
811
+ ) {
812
+ const text = [
813
+ "Your only valid response is one task tool call.",
814
+ `Use this JSON input: ${JSON.stringify({
815
+ description: input.description,
816
+ prompt: input.prompt,
817
+ subagent_type: input.subagentType ?? "general",
818
+ })}`,
819
+ "Do not output plain text.",
820
+ "Wait for the task to start and return the child session id.",
821
+ ].join("\n")
822
+
823
+ const result = await seed({
824
+ sdk,
825
+ sessionID: input.sessionID,
826
+ prompt: text,
827
+ timeout: 90_000,
828
+ probe: async () => {
829
+ const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
830
+ const part = messages
831
+ .flatMap((message) => message.parts)
832
+ .find((part) => {
833
+ if (part.type !== "tool" || part.tool !== "task") return false
834
+ if (!("state" in part) || !part.state || typeof part.state !== "object") return false
835
+ if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false
836
+ if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
837
+ if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
838
+ return false
839
+ if (!("sessionId" in part.state.metadata)) return false
840
+ return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
841
+ })
842
+
843
+ if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return
844
+ if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
845
+ if (!("sessionId" in part.state.metadata)) return
846
+ const id = part.state.metadata.sessionId
847
+ if (typeof id !== "string" || !id) return
848
+ const child = await sdk.session
849
+ .get({ sessionID: id })
850
+ .then((x) => x.data)
851
+ .catch(() => undefined)
852
+ if (!child?.id) return
853
+ return { sessionID: id }
854
+ },
855
+ })
856
+
857
+ if (!result) throw new Error("Timed out seeding task tool")
858
+ return result
859
+ }
860
+
861
+ export async function seedSessionTodos(
862
+ sdk: ReturnType<typeof createSdk>,
863
+ input: {
864
+ sessionID: string
865
+ todos: Array<{ content: string; status: string; priority: string }>
866
+ },
867
+ ) {
868
+ const text = [
869
+ "Your only valid response is one todowrite tool call.",
870
+ `Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
871
+ "Do not output plain text.",
872
+ ].join("\n")
873
+ const target = JSON.stringify(input.todos)
874
+
875
+ const result = await seed({
876
+ sdk,
877
+ sessionID: input.sessionID,
878
+ prompt: text,
879
+ timeout: 30_000,
880
+ probe: async () => {
881
+ const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
882
+ if (JSON.stringify(todos) !== target) return
883
+ return true
884
+ },
885
+ })
886
+
887
+ if (!result) throw new Error("Timed out seeding todos")
888
+ return true
889
+ }
890
+
891
+ export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
892
+ const [questions, permissions] = await Promise.all([
893
+ sdk.question.list().then((x) => x.data ?? []),
894
+ sdk.permission.list().then((x) => x.data ?? []),
895
+ ])
896
+
897
+ await Promise.all([
898
+ ...questions
899
+ .filter((item) => item.sessionID === sessionID)
900
+ .map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
901
+ ...permissions
902
+ .filter((item) => item.sessionID === sessionID)
903
+ .map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
904
+ ])
905
+
906
+ return true
907
+ }
908
+
909
+ export async function openStatusPopover(page: Page) {
910
+ await defocus(page)
911
+
912
+ const rightSection = page.locator(titlebarRightSelector)
913
+ const trigger = rightSection.getByRole("button", { name: /status/i }).first()
914
+
915
+ const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') })
916
+
917
+ const opened = await popoverBody
918
+ .isVisible()
919
+ .then((x) => x)
920
+ .catch(() => false)
921
+
922
+ if (!opened) {
923
+ await expect(trigger).toBeVisible()
924
+ await trigger.click()
925
+ await expect(popoverBody).toBeVisible()
926
+ }
927
+
928
+ return { rightSection, popoverBody }
929
+ }
930
+
931
+ export async function openProjectMenu(page: Page, projectSlug: string) {
932
+ await openSidebar(page)
933
+ const item = page.locator(projectSwitchSelector(projectSlug)).first()
934
+ await expect(item).toBeVisible()
935
+ await item.hover()
936
+
937
+ const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
938
+ await expect(trigger).toHaveCount(1)
939
+ await expect(trigger).toBeVisible()
940
+
941
+ const menu = page
942
+ .locator(dropdownMenuContentSelector)
943
+ .filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
944
+ .first()
945
+ const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
946
+
947
+ const clicked = await trigger
948
+ .click({ force: true, timeout: 1500 })
949
+ .then(() => true)
950
+ .catch(() => false)
951
+
952
+ if (clicked) {
953
+ const opened = await menu
954
+ .waitFor({ state: "visible", timeout: 1500 })
955
+ .then(() => true)
956
+ .catch(() => false)
957
+ if (opened) {
958
+ await expect(close).toBeVisible()
959
+ return menu
960
+ }
961
+ }
962
+
963
+ await trigger.focus()
964
+ await page.keyboard.press("Enter")
965
+
966
+ const opened = await menu
967
+ .waitFor({ state: "visible", timeout: 1500 })
968
+ .then(() => true)
969
+ .catch(() => false)
970
+
971
+ if (opened) {
972
+ await expect(close).toBeVisible()
973
+ return menu
974
+ }
975
+
976
+ throw new Error(`Failed to open project menu: ${projectSlug}`)
977
+ }
978
+
979
+ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
980
+ const current = await page
981
+ .getByRole("button", { name: "New workspace" })
982
+ .first()
983
+ .isVisible()
984
+ .then((x) => x)
985
+ .catch(() => false)
986
+
987
+ if (current === enabled) return
988
+
989
+ const flip = async (timeout?: number) => {
990
+ const menu = await openProjectMenu(page, projectSlug)
991
+ const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
992
+ await expect(toggle).toBeVisible()
993
+ return toggle.click({ force: true, timeout })
994
+ }
995
+
996
+ const flipped = await flip(1500)
997
+ .then(() => true)
998
+ .catch(() => false)
999
+
1000
+ if (!flipped) await flip()
1001
+
1002
+ const expected = enabled ? "New workspace" : "New session"
1003
+ await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
1004
+ }
1005
+
1006
+ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
1007
+ const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
1008
+ await expect(item).toBeVisible()
1009
+ await item.hover()
1010
+
1011
+ const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
1012
+ await expect(trigger).toBeVisible()
1013
+ await trigger.click({ force: true })
1014
+
1015
+ const menu = page.locator(dropdownMenuContentSelector).first()
1016
+ await expect(menu).toBeVisible()
1017
+ return menu
1018
+ }