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,375 @@
1
+ import fs from "node:fs/promises"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+ import { base64Decode } from "@reign-labs/util/encode"
5
+ import type { Page } from "@playwright/test"
6
+
7
+ import { test, expect } from "../fixtures"
8
+
9
+ test.describe.configure({ mode: "serial" })
10
+ import {
11
+ cleanupTestProject,
12
+ clickMenuItem,
13
+ confirmDialog,
14
+ openSidebar,
15
+ openWorkspaceMenu,
16
+ resolveSlug,
17
+ setWorkspacesEnabled,
18
+ slugFromUrl,
19
+ waitDir,
20
+ waitSlug,
21
+ } from "../actions"
22
+ import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
23
+ import { createSdk, dirSlug } from "../utils"
24
+
25
+ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
26
+ const rootSlug = project.slug
27
+ await openSidebar(page)
28
+
29
+ await setWorkspacesEnabled(page, rootSlug, true)
30
+
31
+ await page.getByRole("button", { name: "New workspace" }).first().click()
32
+ const next = await resolveSlug(await waitSlug(page, [rootSlug]))
33
+ await waitDir(page, next.directory)
34
+
35
+ await openSidebar(page)
36
+
37
+ await expect
38
+ .poll(
39
+ async () => {
40
+ const item = page.locator(workspaceItemSelector(next.slug)).first()
41
+ try {
42
+ await item.hover({ timeout: 500 })
43
+ return true
44
+ } catch {
45
+ return false
46
+ }
47
+ },
48
+ { timeout: 60_000 },
49
+ )
50
+ .toBe(true)
51
+
52
+ return { rootSlug, slug: next.slug, directory: next.directory }
53
+ }
54
+
55
+ test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
56
+ await page.setViewportSize({ width: 1400, height: 800 })
57
+
58
+ await withProject(async ({ slug }) => {
59
+ await openSidebar(page)
60
+
61
+ await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
62
+ await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
63
+
64
+ await setWorkspacesEnabled(page, slug, true)
65
+ await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
66
+ await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
67
+
68
+ await setWorkspacesEnabled(page, slug, false)
69
+ await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
70
+ await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
71
+ })
72
+ })
73
+
74
+ test("can create a workspace", async ({ page, withProject }) => {
75
+ await page.setViewportSize({ width: 1400, height: 800 })
76
+
77
+ await withProject(async ({ slug }) => {
78
+ await openSidebar(page)
79
+ await setWorkspacesEnabled(page, slug, true)
80
+
81
+ await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
82
+
83
+ await page.getByRole("button", { name: "New workspace" }).first().click()
84
+ const next = await resolveSlug(await waitSlug(page, [slug]))
85
+ await waitDir(page, next.directory)
86
+
87
+ await openSidebar(page)
88
+
89
+ await expect
90
+ .poll(
91
+ async () => {
92
+ const item = page.locator(workspaceItemSelector(next.slug)).first()
93
+ try {
94
+ await item.hover({ timeout: 500 })
95
+ return true
96
+ } catch {
97
+ return false
98
+ }
99
+ },
100
+ { timeout: 60_000 },
101
+ )
102
+ .toBe(true)
103
+
104
+ await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
105
+
106
+ await cleanupTestProject(next.directory)
107
+ })
108
+ })
109
+
110
+ test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
111
+ await page.setViewportSize({ width: 1400, height: 800 })
112
+
113
+ const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
114
+ const nonGitSlug = dirSlug(nonGit)
115
+
116
+ await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
117
+
118
+ try {
119
+ await withProject(async () => {
120
+ await page.goto(`/${nonGitSlug}/session`)
121
+
122
+ await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
123
+
124
+ const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
125
+ expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
126
+
127
+ await openSidebar(page)
128
+ await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
129
+
130
+ const trigger = page.locator('[data-action="project-menu"]').first()
131
+ const hasMenu = await trigger
132
+ .isVisible()
133
+ .then((x) => x)
134
+ .catch(() => false)
135
+ if (!hasMenu) return
136
+
137
+ await trigger.click({ force: true })
138
+
139
+ const menu = page.locator(dropdownMenuContentSelector).first()
140
+ await expect(menu).toBeVisible()
141
+
142
+ const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
143
+
144
+ await expect(toggle).toBeVisible()
145
+ await expect(toggle).toBeDisabled()
146
+ await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
147
+ })
148
+ } finally {
149
+ await cleanupTestProject(nonGit)
150
+ }
151
+ })
152
+
153
+ test("can rename a workspace", async ({ page, withProject }) => {
154
+ await page.setViewportSize({ width: 1400, height: 800 })
155
+
156
+ await withProject(async (project) => {
157
+ const { slug } = await setupWorkspaceTest(page, project)
158
+
159
+ const rename = `e2e workspace ${Date.now()}`
160
+ const menu = await openWorkspaceMenu(page, slug)
161
+ await clickMenuItem(menu, /^Rename$/i, { force: true })
162
+
163
+ await expect(menu).toHaveCount(0)
164
+
165
+ const item = page.locator(workspaceItemSelector(slug)).first()
166
+ await expect(item).toBeVisible()
167
+ const input = item.locator(inlineInputSelector).first()
168
+ await expect(input).toBeVisible()
169
+ await input.fill(rename)
170
+ await input.press("Enter")
171
+ await expect(item).toContainText(rename)
172
+ })
173
+ })
174
+
175
+ test("can reset a workspace", async ({ page, sdk, withProject }) => {
176
+ await page.setViewportSize({ width: 1400, height: 800 })
177
+
178
+ await withProject(async (project) => {
179
+ const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
180
+
181
+ const readme = path.join(createdDir, "README.md")
182
+ const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
183
+ const original = await fs.readFile(readme, "utf8")
184
+ const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
185
+ await fs.writeFile(readme, dirty, "utf8")
186
+ await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
187
+
188
+ await expect
189
+ .poll(async () => {
190
+ return await fs
191
+ .stat(extra)
192
+ .then(() => true)
193
+ .catch(() => false)
194
+ })
195
+ .toBe(true)
196
+
197
+ await expect
198
+ .poll(async () => {
199
+ const files = await sdk.file
200
+ .status({ directory: createdDir })
201
+ .then((r) => r.data ?? [])
202
+ .catch(() => [])
203
+ return files.length
204
+ })
205
+ .toBeGreaterThan(0)
206
+
207
+ const menu = await openWorkspaceMenu(page, slug)
208
+ await clickMenuItem(menu, /^Reset$/i, { force: true })
209
+ await confirmDialog(page, /^Reset workspace$/i)
210
+
211
+ await expect
212
+ .poll(
213
+ async () => {
214
+ const files = await sdk.file
215
+ .status({ directory: createdDir })
216
+ .then((r) => r.data ?? [])
217
+ .catch(() => [])
218
+ return files.length
219
+ },
220
+ { timeout: 60_000 },
221
+ )
222
+ .toBe(0)
223
+
224
+ await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
225
+
226
+ await expect
227
+ .poll(async () => {
228
+ return await fs
229
+ .stat(extra)
230
+ .then(() => true)
231
+ .catch(() => false)
232
+ })
233
+ .toBe(false)
234
+ })
235
+ })
236
+
237
+ test("can delete a workspace", async ({ page, withProject }) => {
238
+ await page.setViewportSize({ width: 1400, height: 800 })
239
+
240
+ await withProject(async (project) => {
241
+ const sdk = createSdk(project.directory)
242
+ const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
243
+
244
+ await expect
245
+ .poll(
246
+ async () => {
247
+ const worktrees = await sdk.worktree
248
+ .list()
249
+ .then((r) => r.data ?? [])
250
+ .catch(() => [] as string[])
251
+ return worktrees.includes(directory)
252
+ },
253
+ { timeout: 30_000 },
254
+ )
255
+ .toBe(true)
256
+
257
+ const menu = await openWorkspaceMenu(page, slug)
258
+ await clickMenuItem(menu, /^Delete$/i, { force: true })
259
+ await confirmDialog(page, /^Delete workspace$/i)
260
+
261
+ await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
262
+
263
+ await expect
264
+ .poll(
265
+ async () => {
266
+ const worktrees = await sdk.worktree
267
+ .list()
268
+ .then((r) => r.data ?? [])
269
+ .catch(() => [] as string[])
270
+ return worktrees.includes(directory)
271
+ },
272
+ { timeout: 60_000 },
273
+ )
274
+ .toBe(false)
275
+
276
+ await project.gotoSession()
277
+
278
+ await openSidebar(page)
279
+ await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
280
+ await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
281
+ })
282
+ })
283
+
284
+ test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
285
+ await page.setViewportSize({ width: 1400, height: 800 })
286
+ await withProject(async ({ slug: rootSlug }) => {
287
+ const workspaces = [] as { directory: string; slug: string }[]
288
+
289
+ const listSlugs = async () => {
290
+ const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
291
+ const slugs = await nodes.evaluateAll((els) => {
292
+ return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
293
+ })
294
+ return slugs
295
+ }
296
+
297
+ const waitReady = async (slug: string) => {
298
+ await expect
299
+ .poll(
300
+ async () => {
301
+ const item = page.locator(workspaceItemSelector(slug)).first()
302
+ try {
303
+ await item.hover({ timeout: 500 })
304
+ return true
305
+ } catch {
306
+ return false
307
+ }
308
+ },
309
+ { timeout: 60_000 },
310
+ )
311
+ .toBe(true)
312
+ }
313
+
314
+ const drag = async (from: string, to: string) => {
315
+ const src = page.locator(workspaceItemSelector(from)).first()
316
+ const dst = page.locator(workspaceItemSelector(to)).first()
317
+
318
+ const a = await src.boundingBox()
319
+ const b = await dst.boundingBox()
320
+ if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
321
+
322
+ await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
323
+ await page.mouse.down()
324
+ await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
325
+ await page.mouse.up()
326
+ }
327
+
328
+ try {
329
+ await openSidebar(page)
330
+
331
+ await setWorkspacesEnabled(page, rootSlug, true)
332
+
333
+ for (const _ of [0, 1]) {
334
+ const prev = slugFromUrl(page.url())
335
+ await page.getByRole("button", { name: "New workspace" }).first().click()
336
+ const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
337
+ await waitDir(page, next.directory)
338
+ workspaces.push(next)
339
+
340
+ await openSidebar(page)
341
+ }
342
+
343
+ if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
344
+
345
+ const a = workspaces[0].slug
346
+ const b = workspaces[1].slug
347
+
348
+ await waitReady(a)
349
+ await waitReady(b)
350
+
351
+ const list = async () => {
352
+ const slugs = await listSlugs()
353
+ return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
354
+ }
355
+
356
+ await expect
357
+ .poll(async () => {
358
+ const slugs = await list()
359
+ return slugs.length === 2
360
+ })
361
+ .toBe(true)
362
+
363
+ const before = await list()
364
+ const from = before[1]
365
+ const to = before[0]
366
+ if (!from || !to) throw new Error("Failed to resolve initial workspace order")
367
+
368
+ await drag(from, to)
369
+
370
+ await expect.poll(async () => await list()).toEqual([from, to])
371
+ } finally {
372
+ await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
373
+ }
374
+ })
375
+ })
@@ -0,0 +1,95 @@
1
+ import { test, expect } from "../fixtures"
2
+ import type { Page } from "@playwright/test"
3
+ import { promptSelector } from "../selectors"
4
+ import { withSession } from "../actions"
5
+
6
+ function contextButton(page: Page) {
7
+ return page
8
+ .locator('[data-component="button"]')
9
+ .filter({ has: page.locator('[data-component="progress-circle"]').first() })
10
+ .first()
11
+ }
12
+
13
+ async function seedContextSession(input: { sessionID: string; sdk: Parameters<typeof withSession>[0] }) {
14
+ await input.sdk.session.promptAsync({
15
+ sessionID: input.sessionID,
16
+ noReply: true,
17
+ parts: [
18
+ {
19
+ type: "text",
20
+ text: "seed context",
21
+ },
22
+ ],
23
+ })
24
+
25
+ await expect
26
+ .poll(async () => {
27
+ const messages = await input.sdk.session
28
+ .messages({ sessionID: input.sessionID, limit: 1 })
29
+ .then((r) => r.data ?? [])
30
+ return messages.length
31
+ })
32
+ .toBeGreaterThan(0)
33
+ }
34
+
35
+ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
36
+ const title = `e2e smoke context ${Date.now()}`
37
+
38
+ await withSession(sdk, title, async (session) => {
39
+ await seedContextSession({ sessionID: session.id, sdk })
40
+
41
+ await gotoSession(session.id)
42
+
43
+ const trigger = contextButton(page)
44
+ await expect(trigger).toBeVisible()
45
+ await trigger.click()
46
+
47
+ const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
48
+ await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
49
+ })
50
+ })
51
+
52
+ test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => {
53
+ await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => {
54
+ await seedContextSession({ sessionID: session.id, sdk })
55
+ await gotoSession(session.id)
56
+
57
+ await page.locator(promptSelector).click()
58
+
59
+ const trigger = contextButton(page)
60
+ await expect(trigger).toBeVisible()
61
+ await trigger.click()
62
+
63
+ const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
64
+ const context = tabs.getByRole("tab", { name: "Context" })
65
+ await expect(context).toBeVisible()
66
+
67
+ await page.getByRole("button", { name: "Close tab" }).first().click()
68
+ await expect(context).toHaveCount(0)
69
+ })
70
+ })
71
+
72
+ test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => {
73
+ await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => {
74
+ await seedContextSession({ sessionID: session.id, sdk })
75
+ await gotoSession(session.id)
76
+
77
+ await page.locator(promptSelector).click()
78
+
79
+ const trigger = contextButton(page)
80
+ await expect(trigger).toBeVisible()
81
+ await trigger.click()
82
+
83
+ await expect(page.getByRole("tab", { name: "Context" })).toBeVisible()
84
+ await page.getByRole("button", { name: "Open file" }).first().click()
85
+
86
+ const dialog = page
87
+ .getByRole("dialog")
88
+ .filter({ has: page.getByPlaceholder(/search files/i) })
89
+ .first()
90
+ await expect(dialog).toBeVisible()
91
+
92
+ await page.keyboard.press("Escape")
93
+ await expect(dialog).toHaveCount(0)
94
+ })
95
+ })
@@ -0,0 +1,76 @@
1
+ import { test, expect } from "../fixtures"
2
+ import { promptSelector } from "../selectors"
3
+ import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
4
+
5
+ const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
6
+
7
+ // Regression test for Issue #12453: the synchronous POST /message endpoint holds
8
+ // the connection open while the agent works, causing "Failed to fetch" over
9
+ // VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
10
+ test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
11
+ test.setTimeout(120_000)
12
+
13
+ // Simulate Tailscale/VPN killing the long-lived sync connection
14
+ await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
15
+
16
+ await gotoSession()
17
+
18
+ const token = `E2E_ASYNC_${Date.now()}`
19
+ await page.locator(promptSelector).click()
20
+ await page.keyboard.type(`Reply with exactly: ${token}`)
21
+ await page.keyboard.press("Enter")
22
+
23
+ await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
24
+ const sessionID = sessionIDFromUrl(page.url())!
25
+
26
+ try {
27
+ // Agent response arrives via SSE despite sync endpoint being dead
28
+ await expect
29
+ .poll(
30
+ async () => {
31
+ const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
32
+ return messages
33
+ .filter((m) => m.info.role === "assistant")
34
+ .flatMap((m) => m.parts)
35
+ .filter((p) => p.type === "text")
36
+ .map((p) => p.text)
37
+ .join("\n")
38
+ },
39
+ { timeout: 90_000 },
40
+ )
41
+ .toContain(token)
42
+ } finally {
43
+ await cleanupSession({ sdk, sessionID })
44
+ }
45
+ })
46
+
47
+ test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
48
+ await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
49
+ const prompt = page.locator(promptSelector)
50
+ const value = `restore ${Date.now()}`
51
+
52
+ await page.route(`**/session/${session.id}/prompt_async`, (route) =>
53
+ route.fulfill({
54
+ status: 500,
55
+ contentType: "application/json",
56
+ body: JSON.stringify({ message: "e2e prompt failure" }),
57
+ }),
58
+ )
59
+
60
+ await gotoSession(session.id)
61
+ await prompt.click()
62
+ await page.keyboard.type(value)
63
+ await page.keyboard.press("Enter")
64
+
65
+ await expect.poll(async () => text(await prompt.textContent())).toBe(value)
66
+ await expect
67
+ .poll(
68
+ async () => {
69
+ const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
70
+ return messages.length
71
+ },
72
+ { timeout: 15_000 },
73
+ )
74
+ .toBe(0)
75
+ })
76
+ })
@@ -0,0 +1,22 @@
1
+ import { test, expect } from "../fixtures"
2
+ import { promptSelector } from "../selectors"
3
+
4
+ test("dropping text/plain file: uri inserts a file pill", async ({ page, gotoSession }) => {
5
+ await gotoSession()
6
+
7
+ const prompt = page.locator(promptSelector)
8
+ await prompt.click()
9
+
10
+ const path = process.platform === "win32" ? "C:\\opencode-e2e-drop.txt" : "/tmp/opencode-e2e-drop.txt"
11
+ const dt = await page.evaluateHandle((text) => {
12
+ const dt = new DataTransfer()
13
+ dt.setData("text/plain", text)
14
+ return dt
15
+ }, `file:${path}`)
16
+
17
+ await page.dispatchEvent("body", "drop", { dataTransfer: dt })
18
+
19
+ const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
20
+ await expect(pill).toBeVisible()
21
+ await expect(pill).toHaveAttribute("data-path", path)
22
+ })
@@ -0,0 +1,30 @@
1
+ import { test, expect } from "../fixtures"
2
+ import { promptSelector } from "../selectors"
3
+
4
+ test("dropping an image file adds an attachment", async ({ page, gotoSession }) => {
5
+ await gotoSession()
6
+
7
+ const prompt = page.locator(promptSelector)
8
+ await prompt.click()
9
+
10
+ const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3+4uQAAAAASUVORK5CYII="
11
+ const dt = await page.evaluateHandle((b64) => {
12
+ const dt = new DataTransfer()
13
+ const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
14
+ const file = new File([bytes], "drop.png", { type: "image/png" })
15
+ dt.items.add(file)
16
+ return dt
17
+ }, png)
18
+
19
+ await page.dispatchEvent("body", "drop", { dataTransfer: dt })
20
+
21
+ const img = page.locator('img[alt="drop.png"]').first()
22
+ await expect(img).toBeVisible()
23
+
24
+ const remove = page.getByRole("button", { name: "Remove attachment" }).first()
25
+ await expect(remove).toBeVisible()
26
+
27
+ await img.hover()
28
+ await remove.click()
29
+ await expect(page.locator('img[alt="drop.png"]')).toHaveCount(0)
30
+ })