Compare commits

..

960 Commits

Author SHA1 Message Date
opencode dd6eb8fddb release: v1.14.19 2026-04-20 07:21:35 +00:00
Luke Parker cf2442454e fix: defer MessageV2.Assistant.shape access to break circular dep in compiled binary (#23495) 2026-04-20 06:39:13 +00:00
opencode-agent[bot] b052a498ff chore: update nix node_modules hashes 2026-04-20 06:36:01 +00:00
opencode-agent[bot] df13729e0c chore: generate 2026-04-20 06:07:28 +00:00
Aiden Cline 2d908487dd tweak: rename tail_tokens -> preserve_recent_tokens (#23491) 2026-04-20 01:06:29 -05:00
Aiden Cline 99dc3dabeb fix: patch arborist to get around bun bug (#23460) 2026-04-20 00:49:46 -05:00
Brendan Allan 303da3ff6e app: better loading (#23489) 2026-04-20 05:17:37 +00:00
opencode-agent[bot] 4b57854407 chore: generate 2026-04-20 05:15:29 +00:00
Aiden Cline b3cb3a21ce fix: parallel edits sometimes would override each other (#23483) 2026-04-20 00:14:21 -05:00
Brendan Allan 29b8f55a29 refactor(app): move QueryProvider to AppInterface (#23484) 2026-04-20 12:51:34 +08:00
opencode-agent[bot] ec81636453 chore: generate 2026-04-20 04:40:12 +00:00
Luke Parker e28b5beb33 fix(core): fix Windows managed install and bump ripgrep to 15.1.0 for ARM64 support (#23477) 2026-04-20 14:39:15 +10:00
Annie Surla 327aea49de feat(provider): add NVIDIA to popular providers, docs, and attribution headers (#22927)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-19 19:57:49 -05:00
Dax Raad be7923b571 ci: skip beta smoke fixes for now 2026-04-19 20:01:30 -04:00
Dax Raad c901654658 core: fix session compaction test to properly enable prune config option 2026-04-19 19:53:43 -04:00
opencode-agent[bot] 9d78f7e852 chore: generate 2026-04-19 23:48:44 +00:00
Dax Raad 1e733ffd74 ci: skip Docker builds during preview releases to save time 2026-04-19 19:47:29 -04:00
Dax Raad 8890f79f60 Merge branch 'nxl/improve-compaction-strategy' into dev 2026-04-19 19:38:46 -04:00
Dax Raad 01c7eb258e flip toolcall prune defaults 2026-04-19 19:34:44 -04:00
opencode-agent[bot] e9a70f562a chore: update nix node_modules hashes 2026-04-19 22:52:42 +00:00
Sebastian 9e7085998b stabilize TUI theme persistence and KV writes (#23188) 2026-04-20 00:10:31 +02:00
opencode-agent[bot] 244172b030 chore: generate 2026-04-19 09:47:36 +00:00
Luke Parker 745cf7e678 feat: add terminal font settings and built-in Nerd Font (#23391) 2026-04-19 19:46:34 +10:00
opencode 93c13b2f3b sync release versions for v1.14.18 2026-04-19 09:36:56 +00:00
Brendan Allan 4b4a7d3b7a fix(version): remove --target flag from beta release creation (#23403) 2026-04-19 17:05:03 +08:00
opencode-agent[bot] f940f665d6 chore: update nix node_modules hashes 2026-04-19 08:29:51 +00:00
Brendan Allan 7b4c80d0a8 fix(desktop-electron): run JSON migration before spawning sidecar (#23396) 2026-04-19 15:53:47 +08:00
opencode-agent[bot] 467cda49fb chore: update nix node_modules hashes 2026-04-19 07:35:27 +00:00
Shoubhit Dash aad63bf2f3 fix(ripgrep): restore native rg backend (#22773)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-04-19 06:58:15 +00:00
opencode-agent[bot] 4f3cec1e75 chore: update nix node_modules hashes 2026-04-19 06:38:10 +00:00
Dax e77a330044 fix: change Free download button text to Download (#23388) 2026-04-19 02:19:40 -04:00
opencode-agent[bot] 77530e2ef8 chore: generate 2026-04-19 05:59:31 +00:00
opencode-agent[bot] e2cb805f00 chore: generate 2026-04-19 05:58:31 +00:00
Dax Raad b31a823cd6 core: allow users with credits but no payment method to access zen mode 2026-04-19 01:57:16 -04:00
Aiden Cline fa2a602a47 fix (#23385) 2026-04-19 00:45:44 -05:00
opencode-agent[bot] 0dae754842 chore: generate 2026-04-19 04:25:23 +00:00
Ariane Emory 0d29ba973f docs: document --dangerously-skip-permissions CLI flag (#23371) 2026-04-18 23:24:23 -05:00
opencode-agent[bot] 6817ca9a40 chore: update nix node_modules hashes 2026-04-19 03:40:53 +00:00
Luke Parker 1b236bd87e fix incorrect config directory by lazily loading electron-store (#23373) 2026-04-19 13:06:07 +10:00
opencode 004143580b sync release versions for v1.14.17 2026-04-19 03:02:14 +00:00
Luke Parker 37eab5444f chore: bump electron and fix taskbar icon (#23368) 2026-04-19 02:35:02 +00:00
Dax Raad 3cc3072b5a core: ensure executable permissions are set before Docker builds
Fixes an issue where GitHub artifact downloads could strip executable bits
from binaries, causing Docker builds to fail when using unpacked dist files
directly rather than published tarballs. The chmod now runs before the
publish check to guarantee binaries are executable.
2026-04-18 22:32:53 -04:00
Dax Raad 5ae5a6e60f fix plugins reinstalling too often 2026-04-18 20:02:24 -04:00
Dax Raad bf22402ff7 ci 2026-04-18 19:29:21 -04:00
Dax Raad aecd9bc19f ci 2026-04-18 18:59:44 -04:00
Dax Raad afc36d6f3d ci: fix 2026-04-18 18:56:14 -04:00
Aiden Cline 2408ee6785 test: fix bedrock test (#23351) 2026-04-18 17:46:15 -05:00
Ryan Vogel 86c9ab73ba Stefan/enterprise forms waitlist (#23158)
Co-authored-by: Ryan Vogel <me@ryan.ceo>
2026-04-18 18:30:28 -04:00
opencode-agent[bot] 1229ccca54 chore: generate 2026-04-18 21:37:45 +00:00
Frank 1643fd390e zen: redeem go 2026-04-18 17:33:28 -04:00
Frank a6e6b42837 zen: redeem credit 2026-04-18 17:33:28 -04:00
opencode-agent[bot] 919c2fb83d chore: update nix node_modules hashes 2026-04-18 21:32:47 +00:00
Aiden Cline 3afb6765a5 fix: ensure display: summarized is sent by default for bedrock (#23343) 2026-04-18 16:04:00 -05:00
Aiden Cline 108d3b930a chore: bump @ai-sdk/amazon-bedrock (#23341) 2026-04-18 16:00:46 -05:00
Dax de5691503a tui: allow full-session forks from the session dialog (#23339) 2026-04-18 20:20:23 +00:00
Dax Raad 4b08910bbf release: avoid package.json drift during publish 2026-04-18 12:32:23 -04:00
opencode-agent[bot] 3831af62b5 chore: generate 2026-04-18 16:00:01 +00:00
Kit Langton 32c3017b00 fix: detect attachment mime from file contents (#23291) 2026-04-18 11:59:08 -04:00
opencode-agent[bot] f0051d95cb chore: generate 2026-04-18 15:21:48 +00:00
Dax Raad 0a1c0663e9 core: support OTEL_RESOURCE_ATTRIBUTES environment variable for custom telemetry attributes
Users can now pass custom OpenTelemetry resource attributes via the OTEL_RESOURCE_ATTRIBUTES environment variable (comma-separated key=value format). These attributes are automatically included in all telemetry data sent from both the main process and workspace environments, enabling better observability integration with existing monitoring systems that rely on custom resource tags.
2026-04-18 11:20:29 -04:00
Dax Raad ff80694ddf core: fix early return when node_modules is missing during package install 2026-04-18 10:42:33 -04:00
Dax Raad e438ce9c50 tui: fix sync loading indicator to properly show loading state on startup 2026-04-18 10:39:10 -04:00
Dax Raad 08710de30f core: track retry attempts with detailed error context on assistant entries
users can now see when transient failures occur during assistant responses,
such as rate limits or provider overloads, giving visibility into what
issues were encountered and automatically resolved before the final response
2026-04-18 10:38:35 -04:00
opencode-agent[bot] db322bdb85 chore: generate 2026-04-18 05:49:37 +00:00
Dax Raad ed50480ba1 core: extract session entry stepping logic into dedicated module
Move the step function from session-entry.ts to session-entry-stepper.ts and remove immer dependency. Add static fromEvent factory methods to Synthetic, Assistant, and Compaction classes for cleaner event-to-entry conversion.
2026-04-18 01:48:21 -04:00
Aiden Cline 5264cffe42 fix: make skills logic more token efficient (#23253) 2026-04-17 23:51:16 -05:00
opencode-agent[bot] 777a534766 chore: generate 2026-04-18 03:52:28 +00:00
Kit Langton 1251c61f48 docs(effect): track schema migration progress with concrete file checklists (#23242) 2026-04-18 03:51:30 +00:00
Kit Langton 7f4564ee5c refactor(config): migrate config.ts root Info to Effect Schema (#23241) 2026-04-18 03:44:35 +00:00
OpeOginni 0d5ef472ce fix(desktop): adjust ui tool diff sticky header offset (#23149) 2026-04-18 11:31:38 +08:00
opencode-agent[bot] 11488f5e36 chore: generate 2026-04-18 03:17:28 +00:00
Kit Langton b9e0d0c1fc refactor(config): migrate agent.ts Info to Effect Schema (#23237) 2026-04-18 03:16:24 +00:00
opencode-agent[bot] 995f4cee56 chore: generate 2026-04-18 03:05:59 +00:00
Kit Langton 839be49f54 refactor(config): migrate permission.ts Info to Effect Schema (#23231) 2026-04-18 03:05:06 +00:00
Kit Langton 37552df84a refactor: use InstanceState context in worktree cleanup paths (#23019) 2026-04-17 23:04:16 -04:00
Kit Langton 1c02eb64b4 fix: stop rewriting dev during release publish (#22982) 2026-04-18 02:53:19 +00:00
Kit Langton eed3a0d64f fix(generate): make openapi output deterministic by formatting in-place (#23228) 2026-04-17 22:31:21 -04:00
Kit Langton c8b854f651 refactor(config): migrate keybinds.ts to Effect Schema (#23227) 2026-04-18 02:28:45 +00:00
Kit Langton 9ff289fe66 refactor: use instance state in small services (#23022) 2026-04-18 02:16:15 +00:00
Kit Langton 57357b6779 refactor: use InstanceState context in File service (#23015) 2026-04-17 22:08:57 -04:00
opencode-agent[bot] caf95068a8 chore: generate 2026-04-18 02:03:30 +00:00
Kit Langton cdc1e5daf3 feat(effect-zod): add ZodPreprocess annotation for pre-parse transforms (#23222) 2026-04-18 02:02:37 +00:00
Aiden Cline 7b6d07f74a fix: gh copilot issue w/ haiku (eager_input_streaming not supported) (#23223) 2026-04-17 20:57:48 -05:00
opencode-agent[bot] 926c92cc7c chore: generate 2026-04-18 01:48:50 +00:00
Kit Langton f8327dea11 refactor: remove ambient instance reads from lsp (#23023) 2026-04-17 21:47:59 -04:00
Kit Langton 081707691a refactor: pass formatter instance context explicitly (#23020) 2026-04-18 01:22:36 +00:00
opencode-agent[bot] 7ea774bedf chore: generate 2026-04-18 01:21:01 +00:00
Kit Langton c951c438fc refactor(config): migrate Server + Layout to Effect Schema (#23216) 2026-04-18 01:20:06 +00:00
Kit Langton 68563a3e5d fix(observability): standardize session telemetry attrs (#23213) 2026-04-17 21:14:23 -04:00
Kit Langton b9de690ad6 refactor(config): drop ZodOverride from PositiveInt in provider.ts (#23215) 2026-04-18 01:04:40 +00:00
opencode-agent[bot] 665a86a320 chore: generate 2026-04-18 00:56:33 +00:00
Kit Langton bb98729bb5 feat(effect-zod): translate Schema.withDecodingDefault into zod .default() (#23207) 2026-04-17 20:55:38 -04:00
Kit Langton 39c717ab2a feat(effect-zod): translate well-known filters into native Zod methods (#23209) 2026-04-17 20:50:36 -04:00
Kit Langton 1762028f59 refactor(v2): tag session unions and exhaustively match events (#23201) 2026-04-18 00:29:26 +00:00
Kit Langton f835376491 docs(effect): refresh migration status specs (#23206) 2026-04-18 00:29:26 +00:00
opencode-agent[bot] 14a218cb68 chore: generate 2026-04-18 00:29:26 +00:00
Kit Langton 0fd839cd9f feat(effect-zod): transform support + walk memoization + flattened checks (#23203) 2026-04-18 00:29:26 +00:00
opencode-agent[bot] 82e2236bd2 chore: generate 2026-04-18 00:29:26 +00:00
Kit Langton 75ee8c3148 refactor(config): migrate provider (Model + Info) to Effect Schema (#23197) 2026-04-18 00:29:26 +00:00
Kit Langton 7bedccacce refactor(server): align route-span attrs with OTel semantic conventions (#23198) 2026-04-18 00:29:26 +00:00
Kit Langton abe67f0140 feat(effect-zod): add catchall (StructWithRest) support to the walker (#23186) 2026-04-18 00:29:26 +00:00
Kit Langton 6796e6704c feat(tui): show session ID in sidebar on non-prod channels (#23185) 2026-04-18 00:29:26 +00:00
Kit Langton 4ca91ab698 feat(server): auto-tag route spans with route params (session.id, message.id, …) (#23189) 2026-04-18 00:29:26 +00:00
opencode 3c5bf8b2a3 release: v1.4.11 2026-04-18 00:29:16 +00:00
Dax 00f0febe02 refactor(npm): use object-based package spec for install API (#23181) 2026-04-17 17:30:50 -04:00
Aiden Cline 742ed593b0 fix: ensure copilot model list filters out disabled models (#23176) 2026-04-17 16:27:32 -05:00
opencode-agent[bot] ee46adfe42 chore: generate 2026-04-17 21:08:00 +00:00
Kit Langton d3ec3f6cd6 feat(effect-zod): add tuple support; migrate config/plugin to Effect Schema (#23178) 2026-04-17 21:06:55 +00:00
opencode-agent[bot] 7abe09bbd9 chore: generate 2026-04-17 21:00:20 +00:00
Kit Langton c2654aee58 refactor(config): migrate model-id and command to Effect Schema (#23175) 2026-04-17 20:59:24 +00:00
Kit Langton 643537c01c feat(server): wrap remaining route handlers in request spans (#23169) 2026-04-17 16:52:40 -04:00
opencode-agent[bot] 0cc0ecd5f2 chore: generate 2026-04-17 20:50:37 +00:00
Kit Langton dbd1131e3d refactor(config): migrate skills, formatter, console-state to Effect Schema (#23162) 2026-04-17 20:49:36 +00:00
opencode-agent[bot] 57908d0e67 chore: generate 2026-04-17 20:47:05 +00:00
Kit Langton eb2a78fd0f feat(effect-zod): translate Schema.check filters into zod .superRefine + promote LSP refinement to Effect layer (#23173) 2026-04-17 20:46:05 +00:00
James Long da74142b42 fix(core): more explicit routing to fix workspace instance issue (#23171) 2026-04-17 16:39:34 -04:00
Kit Langton 58d24fff76 refactor(config): migrate permission Action/Object/Rule leaves to Effect Schema (#23168) 2026-04-17 20:35:42 +00:00
Kit Langton 29c200e790 refactor(config): migrate lsp schemas to Effect Schema (#23167) 2026-04-17 20:34:47 +00:00
opencode-agent[bot] b74337606d chore: generate 2026-04-17 20:26:43 +00:00
Kit Langton 691f2200ca refactor(config): migrate mcp schemas to Effect Schema.Class (#23163) 2026-04-17 20:25:49 +00:00
Kit Langton 3620877510 docs(http-api): refresh bridge inventory and clarify Schema.Class vs Struct (#23164) 2026-04-17 16:20:57 -04:00
Kit Langton eef12eef4d fix(opencode): skip share sync for unshared sessions (#23159) 2026-04-17 20:15:24 +00:00
opencode-agent[bot] d6a26ff4fe chore: generate 2026-04-17 20:13:34 +00:00
Dax f6b8e559be docs(opencode): annotate plugin loader flow (#23160) 2026-04-17 20:13:34 +00:00
opencode 3aa96e9db2 release: v1.4.10 2026-04-17 20:13:25 +00:00
opencode-agent[bot] 0a8aaafe93 chore: generate 2026-04-17 19:30:28 +00:00
Kit Langton 339f4a2c16 fix(opencode): untrace streaming event hot paths (#23156) 2026-04-17 15:29:32 -04:00
James Long dedc0b1c16 fix(core): pass OTEL config to workspace env (#23154) 2026-04-17 15:25:58 -04:00
Kit Langton b895e66465 fix(opencode): normalize provider metadata and tag otel runs (#23140) 2026-04-17 15:22:08 -04:00
James Long 24fc5d4339 fix(core): move instance middleware after control plane routes (#23150) 2026-04-17 15:20:11 -04:00
opencode-agent[bot] 951e33c34a chore: update nix node_modules hashes 2026-04-17 19:18:55 +00:00
opencode-agent[bot] d1857c2bc1 chore: generate 2026-04-17 19:15:07 +00:00
James Long 8dcab8cef7 feat(tui): minor UX improvements for workspaces (#23146) 2026-04-17 15:14:05 -04:00
Dax 99871ed954 refactor: consolidate npm exports and trace flock acquisition (#23151) 2026-04-17 18:58:37 +00:00
Aiden Cline 171cdc1f5a chore: bump ai to 6.0.168 and @ai-sdk/gateway to 3.0.104 (#23145) 2026-04-17 13:47:22 -05:00
Dax Raad 0b8e65bacb test: align plugin loader npm mocks
- switch plugin loader tests to the effect npm module
- return Option.none() for mocked npm entrypoints
- keep test fixtures aligned with the current Npm.add contract
2026-04-17 14:33:02 -04:00
Dax Raad f8fe5dc89a do not flock until reify 2026-04-17 14:18:48 -04:00
Dax Raad f05db05a03 trace npm fully 2026-04-17 14:08:45 -04:00
James Long 3f4cdda3c4 fix: revert sdk generation script change (#23133) 2026-04-17 13:33:11 -04:00
James Long bc5ef2be93 fix(core): add historical sync on workspace connect (#23121) 2026-04-17 13:30:09 -04:00
opencode-agent[bot] 04e81510ad chore: update nix node_modules hashes 2026-04-17 17:25:44 +00:00
James Long b9dc2be4cc when generating sdk only format sdk, much faster (#23122) 2026-04-17 13:01:22 -04:00
opencode-agent[bot] ea2fc2262b chore: generate 2026-04-17 16:46:34 +00:00
Kit Langton c00c594239 tui: fix agent cycling and prompt metadata polish (#23115) 2026-04-17 12:45:29 -04:00
Aiden Cline 7a5278d5b4 chore: bump @ai-sdk/anthropic to 3.0.71 and dependents (#23120) 2026-04-17 11:40:24 -05:00
rasdani e698ce3ac5 fix(opencode): pass EXA_API_KEY to websearch tool to avoid rate limits (#16362)
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-17 11:28:23 -05:00
Vladimir Glafirov 3d6f4dbdbe chore: bump gitlab-ai-provider to 6.6.0 (#23057) 2026-04-17 11:22:43 -05:00
Dax Raad fe34b0f40a Improve light mode dark mode copy 2026-04-17 16:19:57 +00:00
opencode bda6b99142 release: v1.4.9 2026-04-17 16:19:46 +00:00
Dax Raad 8a20b79359 tui: fix session resumption with --session-id flag to navigate after app initialization
Previously when passing a session ID directly, the route was set during initial
render which could cause navigation issues before the router was fully ready.
Now the session navigation happens after initialization completes, ensuring
the TUI properly loads the requested session when users resume with --session-id.
2026-04-17 11:41:24 -04:00
Dax Raad 9bce498764 fix incorrect light mode in ghostty 2026-04-17 11:32:17 -04:00
opencode-agent[bot] 97d1aca4f9 chore: update nix node_modules hashes 2026-04-17 15:31:50 +00:00
Ismail Ghallou 10db5e78c0 feat: add LLM Gateway provider (#7847)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-04-17 10:29:31 -05:00
opencode-agent[bot] f1d03ff886 chore: generate 2026-04-17 15:26:45 +00:00
Jen Person 1e90d3f579 docs: adding Mistral to docs as a provider (it is already a provider, just docs update) #23070 (#23072) 2026-04-17 10:25:42 -05:00
OpeOginni 9853a5993e fix(opencode): rescrict github copilot opus 4.7 variants to "medium" (#23097) 2026-04-17 10:25:12 -05:00
Brendan Allan c14a4b6d0b fix: conditionally show file tree in beta channel (#23099) 2026-04-17 15:13:59 +00:00
Frank 41ed145807 sync 2026-04-17 10:53:45 -04:00
Dax Raad b22e6b7884 roll back opentui 2026-04-17 10:51:02 -04:00
Dax Raad 5e577e913d back to opentui 0.1.99 2026-04-17 10:43:17 -04:00
opencode-agent[bot] a4abf03cf9 chore: generate 2026-04-17 13:56:17 +00:00
Frank 69c6969ac9 zen: routing logic 2026-04-17 09:54:47 -04:00
opencode-agent[bot] 152c26a567 chore: generate 2026-04-17 13:53:11 +00:00
James Long e616f040a1 feat(core): exponential backoff of workspace reconnect (#23083) 2026-04-17 09:52:10 -04:00
Dax Raad 7d615e81c8 remove log 2026-04-17 09:46:35 -04:00
opencode 438eb6edf5 release: v1.4.8 2026-04-17 13:37:06 +00:00
opencode-agent[bot] 2ed0d6479e chore: update nix node_modules hashes 2026-04-17 13:07:11 +00:00
Sebastian a4df4d44ee upgrade opentui to 0.1.100 (#22928) 2026-04-17 14:29:46 +02:00
opencode-agent[bot] 784c8c3b03 chore: generate 2026-04-17 07:27:13 +00:00
Brendan Allan a13c34d6e0 fix(app): workspace loading and persist ready state (#23046) 2026-04-17 07:26:14 +00:00
Dax 28ce430a27 core: migrate config loading to Effect framework (#23032) 2026-04-17 06:44:01 +00:00
opencode-agent[bot] f50f9ca6e6 chore: generate 2026-04-17 06:13:42 +00:00
Dax 2aa8cec363 fade in prompt metadata transitions (#23037) 2026-04-17 06:12:41 +00:00
James Long cfc87d84a6 refactor(core): move server routes around to clarify workspacing (#23031) 2026-04-17 02:06:20 -04:00
Dax Raad e4087e4d9c fix crash on experimental 2026-04-17 01:14:08 -04:00
James Long ca967c59dd remove accidental commit of daytona plugin (#23030) 2026-04-17 00:42:45 -04:00
opencode-agent[bot] 3ab214209c chore: generate 2026-04-17 04:32:17 +00:00
Aiden Cline 0fce19f42c chore: delete filetime module (#22999) 2026-04-16 23:31:21 -05:00
Dax bb8e86d1e5 fix: preserve prompt input across unmount/remount cycles (#22508) 2026-04-17 04:23:30 +00:00
opencode-agent[bot] f00c3204bf chore: update nix node_modules hashes 2026-04-17 04:14:26 +00:00
Brendan Allan d6e43861d5 fix(app): use fetchQuery instead of ensureQueryData in global sync (#23025) 2026-04-17 03:54:19 +00:00
Brendan Allan 118a2b3939 Merge branch 'dev' into nxl/improve-compaction-strategy 2026-04-17 11:53:17 +08:00
Kit Langton 64e2d12d2b refactor: move project read routes onto HttpApi (#23003) 2026-04-17 03:48:12 +00:00
opencode-agent[bot] 3d12e0668b chore: generate 2026-04-17 03:37:21 +00:00
Kit Langton 2fbc8c959e refactor(tui): inline final Go shimmer settings (#23017) 2026-04-17 03:36:21 +00:00
James Long 53e47bc9ca fix(tui): tui resiliency when workspace is dead, disable directory filter in session list (#23013) 2026-04-16 23:35:36 -04:00
Jay b0e6764895 feat(app): hide desktop titlebar tools behind settings (#19029)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-04-17 03:31:00 +00:00
Kit Langton d9eb9d7ca0 feat(tui): animated GO logo + radial pulse in free-limit upsell dialog (#22976) 2026-04-16 23:19:18 -04:00
Kit Langton 3d36cc8c72 refactor: move provider and config provider routes onto HttpApi (#23004) 2026-04-16 23:10:45 -04:00
Kit Langton 938e428e82 chore: retire namespace migration tooling + document module shape (#23010) 2026-04-17 02:48:40 +00:00
Jason Quense 8e8f7a1fba fix: preserve plugin tool metadata in execute result (#22827)
Co-authored-by: jquense <jquense@ramp.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-16 21:38:21 -05:00
opencode-agent[bot] 65372e02b9 chore: generate 2026-04-17 02:34:07 +00:00
Dax 8e9172e230 refactor: make formatter config opt-in (#22997) 2026-04-17 02:33:09 +00:00
Brendan Allan bc034ccd93 app: use tanstack query to load session vcs state (#22277) 2026-04-17 02:27:08 +00:00
opencode-agent[bot] 6a090caf99 chore: generate 2026-04-17 02:26:48 +00:00
Dax 31baaf507b fix: prefer real undo filenames over /dev/null (#23006) 2026-04-16 22:25:43 -04:00
Kit Langton 8e7996fe26 refactor: unwrap FileWatcher namespace + self-reexport (redo) (#23000) 2026-04-17 02:20:43 +00:00
Dax 103d30f53a refactor: split config parsing steps (#22996) 2026-04-17 01:57:43 +00:00
Kit Langton af93eb5041 refactor: convert Flag namespace to const object with getters (#22984) 2026-04-17 01:57:21 +00:00
Kit Langton 898e6243f4 refactor: eliminate account/ barrel, route consumers to sibling files (#22995) 2026-04-17 01:55:50 +00:00
Kit Langton 996a92556f refactor: collapse auth/ barrel — merge auth.ts into index.ts + self-reexport (#22993) 2026-04-17 01:52:19 +00:00
Kit Langton 419de8eb84 refactor: unwrap Question namespace + fix script to emit "." for index.ts (#22992) 2026-04-17 01:51:02 +00:00
Kit Langton 05240cfc2e refactor: normalize AccountRepo to canonical Effect service pattern (#22991) 2026-04-17 01:43:57 +00:00
opencode-agent[bot] 9120a8902a chore: generate 2026-04-17 01:36:45 +00:00
Dax 500ed30754 perf: speed up skill directory discovery (#22990) 2026-04-17 01:35:47 +00:00
Dax dd12f6da94 refactor: split config lsp and formatter schemas (#22986) 2026-04-17 01:35:26 +00:00
Dax e2c7f6b543 tui: stabilize session dialog ordering (#22987) 2026-04-17 01:32:36 +00:00
Kit Langton 06da224cde refactor: extract Diagnostic namespace into lsp/diagnostic.ts + self-reexport (#22983) 2026-04-17 01:19:01 +00:00
Kit Langton 206e073a5c chore: delete empty v2/session-common + collapse patch barrel (#22981) 2026-04-17 01:02:09 +00:00
Kit Langton 84b3dda387 refactor: unwrap TuiPluginRuntime namespace + self-reexport (#22980) 2026-04-17 01:02:07 +00:00
Kit Langton 107a83c3e1 refactor: unwrap ExperimentalHttpApiServer namespace + self-reexport (#22979) 2026-04-17 01:01:24 +00:00
Kit Langton 60e95c664c refactor: unwrap SessionV2 namespace + self-reexport (#22978) 2026-04-17 01:00:30 +00:00
Kit Langton d2a41b2224 refactor: unwrap SessionEntry namespace + self-reexport (#22977) 2026-04-17 00:59:42 +00:00
opencode-agent[bot] d81f3358ef chore: generate 2026-04-17 00:50:44 +00:00
Kit Langton 31670f655c refactor: unwrap session/ tier-2 namespaces + self-reexport (#22973) 2026-04-17 00:49:39 +00:00
Dax Raad 9b072e8aef tui: convert TuiConfig namespace to ES module exports 2026-04-16 20:46:40 -04:00
Dax Raad 92013b8ef0 tui: fix Windows terminal suspend and input undo keybindings
On Windows, native terminals don't support POSIX suspend (ctrl+z), so we now
assign ctrl+z to input undo instead of terminal suspend. Terminal suspend is
disabled on Windows to avoid conflicts with the undo functionality.
2026-04-16 20:37:58 -04:00
Kit Langton be58af7018 refactor: unwrap Shell namespace + self-reexport (#22964) 2026-04-16 20:11:19 -04:00
Kit Langton e467281b76 tooling: add unwrap-and-self-reexport + batch-unwrap-pr scripts (#22929) 2026-04-16 20:11:17 -04:00
opencode-agent[bot] cd6302db74 chore: generate 2026-04-17 00:04:01 +00:00
Kit Langton c8655f6903 refactor: unwrap ConfigSkills namespace + self-reexport (#22950) 2026-04-17 00:02:53 +00:00
Kit Langton e5793ea5b8 refactor: unwrap Protected namespace + self-reexport (#22938) 2026-04-17 00:02:51 +00:00
Kit Langton 730155023b refactor: unwrap TuiConfig namespace + self-reexport (#22952) 2026-04-17 00:02:24 +00:00
Kit Langton 378d934122 refactor: unwrap FileIgnore namespace + self-reexport (#22937) 2026-04-17 00:02:08 +00:00
Kit Langton 3d789619ba refactor: unwrap PluginMeta namespace + self-reexport (#22945) 2026-04-17 00:02:05 +00:00
Kit Langton 12469e258f refactor: unwrap Agent namespace + self-reexport (#22935) 2026-04-17 00:01:44 +00:00
Kit Langton 77fddd2e47 refactor: unwrap UI namespace + self-reexport (#22951) 2026-04-17 00:01:41 +00:00
Kit Langton 70aa00c4e9 refactor: unwrap Heap namespace + self-reexport (#22931) 2026-04-17 00:01:37 +00:00
Kit Langton b638e7ef47 refactor: unwrap McpAuth namespace + self-reexport (#22942) 2026-04-17 00:01:12 +00:00
Kit Langton 9c0678626f refactor: unwrap CopilotModels namespace + self-reexport (#22947) 2026-04-17 00:01:09 +00:00
Kit Langton d476fd22f7 refactor: unwrap ACP namespace + self-reexport (#22936) 2026-04-17 00:00:50 +00:00
Kit Langton 2a33b20cad refactor: unwrap ConfigProvider namespace + self-reexport (#22949) 2026-04-17 00:00:48 +00:00
Kit Langton d5d638460c refactor: unwrap McpOAuthCallback namespace + self-reexport (#22943) 2026-04-17 00:00:46 +00:00
Kit Langton 42d63ba4d2 refactor: unwrap Workspace namespace + self-reexport (#22934) 2026-04-17 00:00:15 +00:00
opencode-agent[bot] 1806d799a1 chore: generate 2026-04-16 23:53:10 +00:00
Kit Langton 8ffb5340aa refactor: unwrap ConfigMCP namespace + self-reexport (#22948) 2026-04-16 19:52:04 -04:00
Kit Langton 4f0ccadab7 refactor: unwrap Server namespace + self-reexport (#22970) 2026-04-16 23:51:01 +00:00
Kit Langton 6afc2c0cca refactor: unwrap ServerProxy namespace + self-reexport (#22969) 2026-04-16 23:50:32 +00:00
Kit Langton 861c1dc0a9 refactor: unwrap FileTime namespace + self-reexport (#22966) 2026-04-16 23:50:15 +00:00
Kit Langton bbb113d24a refactor: unwrap MDNS namespace + self-reexport (#22968) 2026-04-16 23:50:11 +00:00
Kit Langton a281e8656d refactor: unwrap Ripgrep namespace + self-reexport (#22965) 2026-04-16 19:49:52 -04:00
Kit Langton 402b3c0bc9 refactor: unwrap Identifier namespace + self-reexport (#22963) 2026-04-16 23:48:24 +00:00
Kit Langton d9f6ff0d44 refactor: unwrap BusEvent namespace + self-reexport (#22962) 2026-04-16 23:46:49 +00:00
Aiden Cline e552698a24 fix: ensure azure sets prompt cache key by default (#22957) 2026-04-16 17:45:35 -05:00
Dax Raad 2d3b5e1dc2 fix tui otel profiling 2026-04-16 18:40:22 -04:00
Dax Raad 556174196a fix build script 2026-04-16 18:01:58 -04:00
opencode 49f8f087bc release: v1.4.7 2026-04-16 21:54:54 +00:00
Kit Langton bf429102de refactor: unwrap Discovery namespace to flat exports + self-reexport (#22878) 2026-04-16 16:59:30 -04:00
Kit Langton b3fb3fe933 refactor: unwrap ConfigPlugin namespace to flat exports + self-reexport (#22876) 2026-04-16 16:59:17 -04:00
James Long 90dfe63e72 fix(tui): minor logging cleanup (#22924) 2026-04-16 16:58:17 -04:00
Aiden Cline 43c27708b2 fix: uncomment import (#22923) 2026-04-16 15:56:29 -05:00
Kit Langton 12b7ba4ac1 refactor: collapse file barrel into file/index.ts (#22901) 2026-04-16 16:56:09 -04:00
Kit Langton 46b8e7a034 refactor: collapse bus barrel into bus/index.ts (#22902) 2026-04-16 16:55:57 -04:00
thakrarsagar 329dcc3a33 fix(opencode): use low reasoning effort for GitHub Copilot gpt-5 models (#22824)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-04-16 15:44:58 -05:00
Kit Langton 44e80315df refactor: collapse plugin barrel into plugin/index.ts (#22914) 2026-04-16 20:37:13 +00:00
Kit Langton 5b272e151c refactor: collapse snapshot barrel into snapshot/index.ts (#22916) 2026-04-16 20:36:45 +00:00
Kit Langton 4366fd7a14 refactor: collapse mcp barrel into mcp/index.ts (#22913) 2026-04-16 20:36:23 +00:00
Kit Langton 73b84e1c8f refactor: collapse git barrel into git/index.ts (#22909) 2026-04-16 20:36:07 +00:00
Kit Langton 6d4e75b55d refactor: collapse permission barrel into permission/index.ts (#22915) 2026-04-16 20:36:04 +00:00
Kit Langton 1882dd032a refactor: collapse npm barrel into npm/index.ts (#22911) 2026-04-16 20:36:02 +00:00
Kit Langton defb3f64af refactor: collapse skill barrel into skill/index.ts (#22912) 2026-04-16 20:35:43 +00:00
Kit Langton d32c4622fd refactor: collapse installation barrel into installation/index.ts (#22910) 2026-04-16 20:35:28 +00:00
Kit Langton e70d4c3224 refactor: collapse worktree barrel into worktree/index.ts (#22906) 2026-04-16 20:35:26 +00:00
Kit Langton 936b8c2c0b refactor: collapse ide barrel into ide/index.ts (#22904) 2026-04-16 20:35:04 +00:00
Kit Langton 77a901069b refactor: collapse sync barrel into sync/index.ts (#22907) 2026-04-16 20:34:33 +00:00
Kit Langton eb17982249 refactor: collapse global barrel into global/index.ts (#22905) 2026-04-16 20:33:52 +00:00
opencode-agent[bot] b57815ba22 chore: generate 2026-04-16 20:31:50 +00:00
Kit Langton 2bc1411fe4 refactor: collapse command barrel into command/index.ts (#22903) 2026-04-16 20:30:52 +00:00
James Long a18eece477 feat(core): pass auth data to workspace (#22897) 2026-04-16 16:30:11 -04:00
Kit Langton 218689103e refactor: collapse env barrel into env/index.ts (#22900) 2026-04-16 16:29:54 -04:00
Kit Langton 7155c95662 refactor: collapse format barrel into format/index.ts (#22898) 2026-04-16 16:29:51 -04:00
James Long 465991be46 fix(core): fix detection of local installation channel (#22899) 2026-04-16 20:26:33 +00:00
Dax Raad 15aaf884d8 core: eager load config on startup for better traces and refactor npm install for improved error reporting
Config is now loaded eagerly during project bootstrap so users can see config loading in traces during startup. This helps diagnose configuration issues earlier in the initialization flow.

NPM installation logic has been refactored with a unified reify function and improved InstallFailedError that includes both the packages being installed and the target directory. This provides users with complete context when package installations fail, making it easier to identify which dependency or project directory caused the issue.
2026-04-16 16:23:19 -04:00
Kit Langton d06012083f tooling: add collapse-barrel.ts for single-namespace barrel migration (#22887) 2026-04-16 16:12:46 -04:00
Kit Langton 2902acc08c fix: remove 10 more unnecessary as any casts in opencode core (#22882) 2026-04-16 20:11:05 +00:00
Kobi Hudson 61f887383c fix(opencode): drop max_tokens for OpenAI reasoning models on Cloudflare AI Gateway (#22864)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-16 15:01:21 -05:00
Kit Langton 010bba974e refactor: move Pty into pty/index.ts with self-reexport (#22881) 2026-04-16 15:49:21 -04:00
Aiden Cline 99533a9dc4 fix: ensure that double pasting doesnt happen after tui perf commit was merged (#22880) 2026-04-16 14:38:39 -05:00
Kit Langton c311b72695 fix: remove 8 more unnecessary as any casts in opencode core (#22877) 2026-04-16 19:27:53 +00:00
Kit Langton a2f8b3b9f0 refactor: unwrap BashArity namespace to flat exports + self-reexport (#22874) 2026-04-16 15:24:24 -04:00
opencode-agent[bot] 5c1749478b chore: update nix node_modules hashes 2026-04-16 19:19:52 +00:00
Aiden Cline 359d134354 tweak: set display 'summarized' by default for opus 4.7 thorugh messages api (#22873) 2026-04-16 14:12:43 -05:00
Kit Langton 58db5dfccd fix: remove 7 unnecessary as any casts in opencode core (#22840) 2026-04-16 15:07:02 -04:00
Dax d354d5fb04 fix(test): make plugin loader theme source path separator-safe (#22870) 2026-04-16 14:45:17 -04:00
opencode-agent[bot] a0a4ad793f chore: generate 2026-04-16 18:29:57 +00:00
Aiden Cline 2281845f47 chore: bump ai sdk deps for opus 4.7 (#22869) 2026-04-16 13:28:20 -05:00
Thomas Butler fe972955b4 fix: add missing glob dependency (#22851) 2026-04-16 13:21:04 -05:00
Dax Raad e4217c982d core: reorganize ConfigPaths module export for cleaner dependency management 2026-04-16 13:33:54 -04:00
Dax Raad 3519d2cbb1 core: reorganize ConfigPaths module export for cleaner dependency management 2026-04-16 13:32:22 -04:00
Dax Raad 85ff86fe9e core: modularize config parsing to improve maintainability
Extract error handling, parsing logic, and variable substitution into dedicated
modules. This reduces duplication between tui.json and opencode.json parsing
and makes the config system easier to extend for future config formats.
2026-04-16 13:29:03 -04:00
Aiden Cline 9dd99d19d5 fix: ensure azure has store = true by default (#22764) 2026-04-16 12:19:01 -05:00
Dax Raad acfb0b8073 core: add documentation comments to plugin configuration merge logic
Adds explanatory comments to config.ts and plugin.ts clarifying:

- How plugin specs are stored and normalized during config loading

- Why plugin_origins tracks provenance for location-sensitive decisions

- Why path-like specs are resolved early to prevent reinterpretation during merges

- How plugin deduplication works while keeping origin metadata for writes and diagnostics
2026-04-16 12:55:40 -04:00
Dax Raad 4a9532dd55 core: refactor config schemas into separate modules for better maintainability 2026-04-16 12:47:09 -04:00
Dax Raad c150b57903 config: refactor 2026-04-16 12:40:24 -04:00
Dax Raad a598ec3eea tui: ensure TUI plugins load with proper project context when multiple directories are open
Fixes potential plugin resolution issues when switching between projects by wrapping
plugin loading in Instance.provide(). This ensures each plugin resolves dependencies
relative to its correct project directory instead of inheriting context from whatever
instance happened to be active.

Also reorganizes config loading code into focused modules (command.ts, managed.ts,
plugin.ts) to make the codebase easier to maintain and test.
2026-04-16 12:40:24 -04:00
James Long 23cd589656 fix(core): better global sync event structure (#22858) 2026-04-16 12:35:37 -04:00
opencode-agent[bot] 7b779f4862 chore: generate 2026-04-16 16:26:12 +00:00
James Long 5b94e49a4b refactor(tui): improve workspace management (#22691) 2026-04-16 12:24:40 -04:00
James Long 5e0135f0b8 fix: add a few more tests for sync and session restore (#22837) 2026-04-16 12:15:44 -04:00
Nacai 1da77c773e fix: align stale bot message with actual 60-day threshold (#22842)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-04-16 11:01:35 -05:00
Graham Campbell eee7abc9b6 feat: Add support for claude opus 4.7 xhigh adaptive reasoning effort (#22833)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-16 10:57:36 -05:00
Jérôme Benoit f1dae64d39 fix(nix): add shared package to bun install filters (#22665) 2026-04-16 10:43:15 -05:00
Frank 1eb13ba4af zen: opus 4.7 2026-04-16 11:32:56 -04:00
Dax Raad 2bba026d98 Revert "tui: fix path comparison in theme installer to handle different path formats"
This reverts commit d803e5fdee.
2026-04-16 11:28:19 -04:00
Dax Raad d803e5fdee tui: fix path comparison in theme installer to handle different path formats 2026-04-16 11:18:44 -04:00
Dax Raad 4afe7699d0 move useful scripts to script folder 2026-04-16 10:09:14 -04:00
Shoubhit Dash a141c51a43 fix(compaction): budget retained tail with media 2026-04-16 17:30:29 +05:30
Shoubhit Dash f1e4d71bab merge dev 2026-04-16 17:30:14 +05:30
opencode-agent[bot] b7733d83fd chore: generate 2026-04-16 07:15:05 +00:00
Dax 541318b34e fix: restore .gitignore logic for config dirs and migrate to shared Npm service (#22772) 2026-04-16 03:14:06 -04:00
Dax 5df71fad7e perf: make vcs init non-blocking by forking git branch resolution (#22771) 2026-04-16 03:02:19 -04:00
opencode-agent[bot] 02fcb24323 chore: update nix node_modules hashes 2026-04-16 06:59:47 +00:00
opencode-agent[bot] eef729924d chore: generate 2026-04-16 06:11:24 +00:00
Brendan Allan ed51d214d9 app: start migrating bootstrap data fetching to TanStack Query (#22756) 2026-04-16 06:10:23 +00:00
Brendan Allan 5851b77196 Fix desktop download asset names for beta channel (#22766) 2026-04-16 06:10:03 +00:00
Adam 75d82f7dc9 beta badge for desktop app (#14471)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-04-16 06:03:41 +00:00
Dax 8a41e28f5b CLI perf: reduce deps (#22652) 2026-04-16 02:03:03 -04:00
opencode-agent[bot] b0955d0079 chore: generate 2026-04-16 05:03:50 +00:00
Kit Langton 8a117354fd feat: unwrap provider namespaces to flat exports + barrel (#22760) 2026-04-16 05:02:50 +00:00
opencode-agent[bot] 2188c1a235 chore: generate 2026-04-16 03:57:53 +00:00
Kit Langton 181ca1a17f feat: unwrap tool namespaces to flat exports + barrel (#22762) 2026-04-16 03:56:54 +00:00
Kit Langton cd564678ae feat: unwrap cli-tui namespaces to flat exports + barrel (#22759) 2026-04-16 03:56:51 +00:00
Kit Langton 946ea995e3 fix: clean up final 16 no-unused-vars warnings (#22751) 2026-04-16 03:54:21 +00:00
Kit Langton 673c2e4e8e feat: enable type-aware no-misused-spread rule, fix 8 violations (#22749) 2026-04-16 03:50:50 +00:00
Kit Langton eea507f2eb feat: enable type-aware no-base-to-string rule, fix 56 violations (#22750) 2026-04-16 03:50:47 +00:00
Kit Langton dfaec0c494 docs: add circular import rules to namespace treeshake spec (#22754) 2026-04-15 23:44:08 -04:00
opencode-agent[bot] e06de8d075 chore: generate 2026-04-16 03:42:25 +00:00
Kit Langton 28fca2ce79 fix: resolve circular sibling imports causing runtime ReferenceError (#22752) 2026-04-15 23:41:34 -04:00
Kit Langton 073d4c02f6 feat: unwrap lsp namespaces to flat exports + barrel (#22748) 2026-04-15 23:30:52 -04:00
Kit Langton 97d87eac9f feat: unwrap storage namespaces to flat exports + barrel (#22747) 2026-04-15 23:30:49 -04:00
Kit Langton 0249f58509 feat: unwrap config namespaces to flat exports + barrel (#22746) 2026-04-15 23:29:14 -04:00
Kit Langton 138d37a87d feat: unwrap effect namespaces to flat exports + barrel (#22745) 2026-04-15 23:29:12 -04:00
Kit Langton cde3ff010c feat: unwrap share namespaces to flat exports + barrel (#22744) 2026-04-15 23:28:46 -04:00
Kit Langton 2d9b134b1b feat: unwrap project namespaces to flat exports + barrel (#22743) 2026-04-15 23:28:46 -04:00
opencode-agent[bot] 6071cafce1 chore: generate 2026-04-16 03:28:30 +00:00
Kit Langton 949ac8ed15 feat: enable type-aware no-floating-promises rule, fix all 177 violations (#22741)
publish-github-action / publish (push) Has been cancelled
2026-04-15 23:27:32 -04:00
Kit Langton d2ec847f0c feat: unwrap 11 util namespaces to flat exports + barrel (#22739) 2026-04-15 23:15:58 -04:00
Kit Langton aae9495938 feat: bridge permission and provider auth routes behind OPENCODE_EXPERIMENTAL_HTTPAPI (#22736) 2026-04-15 23:02:48 -04:00
Kit Langton f53f31d33f feat: enable oxlint suspicious category, fix 24 violations (#22727)
publish-github-action / publish (push) Has been cancelled
2026-04-16 02:53:10 +00:00
Kit Langton 186907ef94 feat: unwrap Archive namespace to flat exports + barrel (#22722) 2026-04-16 02:52:34 +00:00
Kit Langton 7825daef72 feat: bridge question routes from Hono to Effect HttpApi (#22718) 2026-04-15 22:50:22 -04:00
opencode-agent[bot] ed448861ed chore: generate 2026-04-16 02:46:39 +00:00
Kit Langton 7159d09978 feat: unwrap InstanceState + EffectBridge namespaces to flat exports + barrel (#22721) 2026-04-16 02:45:45 +00:00
Kit Langton 7a85583582 refactor: migrate Effect call sites from Flock to EffectFlock (#22688) 2026-04-16 02:39:59 +00:00
opencode-agent[bot] ce815ee2aa chore: update nix node_modules hashes 2026-04-16 02:39:40 +00:00
Kit Langton e9dc9d2d01 feat: unwrap Pty namespace to flat exports + barrel (#22719) 2026-04-16 02:21:46 +00:00
opencode-agent[bot] 612ed0fc18 chore: generate 2026-04-16 02:18:58 +00:00
Kit Langton 0cbf9e9199 fix: remove 10 unused type-only imports and declarations (#22696) 2026-04-16 02:17:59 +00:00
Kit Langton 5229f9f700 feat: unwrap uskill namespace to flat exports + barrel (#22714) 2026-04-16 02:17:19 +00:00
Kit Langton 21110551ee feat: unwrap ugit namespace to flat exports + barrel (#22704) 2026-04-16 02:16:42 +00:00
Kit Langton d4b2eb10f6 feat: unwrap uworktree namespace to flat exports + barrel (#22717) 2026-04-16 02:16:17 +00:00
Kit Langton cdea1a959e feat: unwrap uformat namespace to flat exports + barrel (#22703) 2026-04-16 02:16:01 +00:00
Kit Langton 7c3958e658 feat: unwrap upatch namespace to flat exports + barrel (#22709) 2026-04-16 02:15:58 +00:00
Kit Langton b78e5c1962 feat: unwrap usync namespace to flat exports + barrel (#22716) 2026-04-16 02:15:46 +00:00
Kit Langton 6578a2db95 feat: unwrap uglobal namespace to flat exports + barrel (#22705) 2026-04-16 02:15:36 +00:00
Kit Langton b6670fa42d feat: unwrap uide namespace to flat exports + barrel (#22706) 2026-04-16 02:15:21 +00:00
Kit Langton 70d454489e feat: unwrap usnapshot namespace to flat exports + barrel (#22715) 2026-04-16 02:15:20 +00:00
Kit Langton 2e1e60ca0f feat: unwrap uplugin namespace to flat exports + barrel (#22711) 2026-04-16 02:15:19 +00:00
Kit Langton 5ba13419b3 feat: unwrap usession namespace to flat exports + barrel (#22713) 2026-04-16 02:15:17 +00:00
Kit Langton c8d2ca87aa feat: unwrap upermission namespace to flat exports + barrel (#22710) 2026-04-16 02:14:59 +00:00
Kit Langton 64d4f5efe1 feat: unwrap unpm namespace to flat exports + barrel (#22708) 2026-04-16 02:14:44 +00:00
Kit Langton ea45b1a447 feat: unwrap ufile namespace to flat exports + barrel (#22702) 2026-04-16 02:14:37 +00:00
Kit Langton b7d1eab2bb feat: unwrap uinstallation namespace to flat exports + barrel (#22707) 2026-04-16 02:14:34 +00:00
Kit Langton 23fcb7fb2e feat: unwrap ucommand namespace to flat exports + barrel (#22700) 2026-04-16 02:14:18 +00:00
Kit Langton a2aa93c6bd feat: unwrap uenv namespace to flat exports + barrel (#22701) 2026-04-16 02:14:14 +00:00
Kit Langton 1461c05776 feat: unwrap uauth namespace to flat exports + barrel (#22699) 2026-04-16 02:13:56 +00:00
Kit Langton c905c43333 feat: unwrap uaccount namespace to flat exports + barrel (#22698) 2026-04-16 02:13:33 +00:00
opencode-agent[bot] 9c6585cad7 chore: generate 2026-04-16 02:08:47 +00:00
Kit Langton 773998133d add experimental provider auth HttpApi slice (#22389) 2026-04-16 02:07:42 +00:00
Kit Langton 021fe76e47 fix: clean up 49 unused variables, catch params, and stale imports (#22695) 2026-04-16 02:01:53 +00:00
Kit Langton 4a078e05b1 fix: delete 9 dead functions with zero callers (#22697) 2026-04-16 02:01:02 +00:00
opencode-agent[bot] 8a8dfd5e2f chore: generate 2026-04-16 01:57:23 +00:00
Kit Langton 1f77795ac6 fix: prefix 32 unused parameters with underscore (#22694) 2026-04-15 21:56:23 -04:00
Kit Langton 5f3ad19794 feat: unwrap MCP namespace to flat exports + barrel (#22693) 2026-04-16 01:56:02 +00:00
opencode-agent[bot] a1ae53e35d chore: generate 2026-04-16 01:45:44 +00:00
Kit Langton b047f7af36 feat: unwrap Provider namespace + improved automation script (#22690) 2026-04-15 21:44:46 -04:00
Kit Langton 228636345a fix: resolve oxlint warnings — suppress false positives, remove unused imports (#22687)
publish-github-action / publish (push) Has been cancelled
2026-04-15 21:33:54 -04:00
Kit Langton b5f4d032fb feat: unwrap Config namespace to flat exports + barrel (#22689) 2026-04-15 21:26:24 -04:00
Kit Langton 5853ca2a79 refactor: remove makeRuntime facades from File and Ripgrep (#22513) 2026-04-15 21:22:18 -04:00
Kit Langton eaadd8d76b feat: namespace → flat export migration (Bus proof-of-concept) (#22685) 2026-04-16 01:18:36 +00:00
Kit Langton 261e6607bc fix: revert "core: move plugin initialisation to config layer override" (#22686) 2026-04-15 21:14:39 -04:00
Aiden Cline 7487ea03a4 fix: bash memory usage (#22660) 2026-04-15 20:09:06 -05:00
James Long 8b6f750171 feat(core): add fence to make all methods strongly consistent when syncing (#22679) 2026-04-15 21:04:37 -04:00
Kit Langton e03433df42 fix(session): retry 5xx server errors even when isRetryable is unset (#22511) 2026-04-16 00:58:48 +00:00
Kit Langton d776cd22fa feat(shared): add Effect-idiomatic file lock (EffectFlock) (#22681) 2026-04-16 00:55:14 +00:00
opencode-agent[bot] c65b6dd4e9 chore: generate 2026-04-16 00:46:18 +00:00
Kit Langton a11169b562 feat: add oxlint with correctness defaults (#22682) 2026-04-15 20:45:19 -04:00
Carlo Wood 1da8ac3eda fix(tui): Don't overwrite the agent that was specified on the command line (#20554) 2026-04-15 18:41:35 -05:00
Kit Langton 8b2c135ad9 refactor(opencode): use AppFileSystem path helpers (#22637) 2026-04-15 19:36:30 -04:00
Kit Langton 061b21d980 fix(opencode): avoid org lookup during config startup (#22670) 2026-04-15 19:20:25 -04:00
David Hill 320fd1aa5b tweak(ui): session spacing (#20839)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-04-15 18:58:05 -04:00
opencode-agent[bot] cc6b77bfed chore: generate 2026-04-15 22:45:54 +00:00
Brendan Allan 1436e4d86b core: move plugin intialisation to config layer override (#22620) 2026-04-15 22:44:55 +00:00
Ariane Emory cab36f6ef4 fix(core): Remove dead code and documentation related to the obsolete list tool. (#22672) 2026-04-15 17:44:53 -05:00
Kit Langton 69116ed7a4 feat(opencode): improve telemetry tracing and request spans (#22653) 2026-04-15 17:32:56 -04:00
opencode-agent[bot] 321ef36657 chore: generate 2026-04-15 21:29:10 +00:00
Kit Langton 39d6666dbf add experimental permission HttpApi slice (#22385) 2026-04-15 21:28:01 +00:00
Aiden Cline 16abb5fad1 tweak: ensure auto continuing compaction is tracked as agent initiated for github copilot (#22567)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-04-15 15:50:33 -05:00
Aiden Cline 8196bd2621 fix: ensure tool_use is always followed by tool_result (#22646)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-04-15 14:56:45 -05:00
opencode-agent[bot] 0efe1ab075 chore: update nix node_modules hashes 2026-04-15 17:38:21 +00:00
Kit Langton c4ddd59eb8 fix: register OTel context manager so AI SDK spans thread into Effect traces (#22645) 2026-04-15 16:35:14 +00:00
Dax 4be9af983d migrate: move flock and hash utilities to shared package (#22640) 2026-04-15 15:50:24 +00:00
Kit Langton 615c1fbc75 fix(effect): add effect bridge for callback contexts (#22504) 2026-04-15 15:22:34 +00:00
opencode-agent[bot] 7f89f4d6f5 chore: update nix node_modules hashes 2026-04-15 15:08:13 +00:00
Kit Langton 0ea98a0929 docs(opencode): add instance context migration plan (#22529) 2026-04-15 10:57:58 -04:00
Kit Langton 9d69bc8ce9 remove makeRuntime facade from Env (#22523) 2026-04-15 10:55:50 -04:00
Kit Langton 44cabe27f1 feat(opencode): trace tool execution spans (#22531) 2026-04-15 10:49:47 -04:00
Dax 780c051059 shared package (#22626) 2026-04-15 14:26:20 +00:00
James Long 4aa52779d0 feat(core): sync routes, refactor proxy, session restore, and more syncing (#22518) 2026-04-15 10:18:48 -04:00
Frank d4adc11f9e zen: better error 2026-04-15 09:19:28 -04:00
Frank 0ae48b2490 sync 2026-04-15 09:19:28 -04:00
Sebastian ddb1cad62a handle non-throwing requests (#22604) 2026-04-15 14:29:09 +02:00
Brendan Allan d755f47f31 refactor(electron): update store configuration (#22597) 2026-04-15 09:21:04 +00:00
Brendan Allan 8c93fa3475 ui: update accordion styles and session review component (#22582) 2026-04-15 07:29:36 +00:00
Brendan Allan eb6fb9faaf fix(ui): disable accordion items for binary files and improve disabled state styling (#22577) 2026-04-15 07:26:34 +00:00
opencode fdc567dee9 release: v1.4.6 2026-04-15 07:26:23 +00:00
Luke Parker 20f3898f1f fix(snapshot): avoid ENAMETOOLONG and improve staging perf via stdin pathspecs (#22560) 2026-04-15 06:43:36 +00:00
Frank 892e659128 sync 2026-04-15 02:22:47 -04:00
opencode-agent[bot] 9faa0ad5a5 chore: generate 2026-04-15 06:08:19 +00:00
Frank e5d7b01f57 Go: list model providers 2026-04-15 02:07:06 -04:00
Frank c2d0c8acea Go: qwen 3.5 & 3.6 plus 2026-04-15 02:07:06 -04:00
Frank a8e78884e9 doc: qwen3.5 & 3.6 2026-04-15 02:07:06 -04:00
Brendan Allan 855cc6bc12 fix: prevent tooltip reopen on trigger click (#22571) 2026-04-15 06:03:29 +00:00
Dax Raad 091b7ccb73 docs: use latest release for downloads instead of pinned version 2026-04-15 01:50:22 -04:00
github-actions[bot] c5c1027cb0 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/22569#issuecomment-4249515283
2026-04-15 05:36:27 +00:00
Brendan Allan 25d2096519 fix: update prompt input submit handler (#22566) 2026-04-15 05:32:52 +00:00
Dax 46d87691a5 fix(observability): handle OTEL headers with '=' in value (#22564)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-04-15 01:32:32 -04:00
Brendan Allan f60769628f fix: add left padding to session title input (#22556) 2026-04-15 04:35:57 +00:00
opencode-agent[bot] a14de93a39 chore: update nix node_modules hashes 2026-04-15 04:25:29 +00:00
opencode 631e7da409 release: v1.4.5 2026-04-15 04:25:18 +00:00
Brendan Allan 62e4e64a25 fix(bootstrap): await plugin initialization 2026-04-15 11:40:33 +08:00
Brendan Allan 16e4358992 core: parallelise bootstrap (#22514) 2026-04-15 03:23:28 +00:00
Dax Raad 7837ae74ff ci: ok one more time 2026-04-14 23:22:07 -04:00
Aiden Cline e25dbf7137 chore: bump ai sdk pkgs (#22539) 2026-04-14 22:14:32 -05:00
Dax Raad b061c5b683 ci: remove Tauri desktop builds from release workflow to simplify distribution 2026-04-14 23:14:08 -04:00
Dax Raad e9f4933205 delete all e2e tests (#22501)
Cherry-picked from ea463e604cdd2a3e83e1c286e39b789455f0d413
2026-04-14 23:10:25 -04:00
Brendan Allan 471030afd7 fix(desktop): chdir to homedir on macOS to fix ripgrep issues (#22537) 2026-04-15 10:56:22 +08:00
Brendan Allan fc74af964b fix(desktop): start tauri shell commands from home directory (#22535) 2026-04-15 10:51:53 +08:00
Dax Raad 0c735b747d core: pin downloads to v1.4.3 to ensure users get a tested, stable build instead of potentially unstable latest releases 2026-04-14 22:35:36 -04:00
opencode-agent[bot] 9d44c327d3 chore: generate 2026-04-15 02:31:50 +00:00
Kit Langton 97b465cc06 fix(opencode): export AI SDK telemetry spans (#22526) 2026-04-14 22:30:50 -04:00
opencode-agent[bot] 8f36d81509 chore: update nix node_modules hashes 2026-04-15 01:49:59 +00:00
opencode-agent[bot] b753cca9cf chore: generate 2026-04-15 00:57:20 +00:00
LukeParkerDev bb6aca2e5d Update test.yml 2026-04-14 20:55:42 -04:00
Shoubhit Dash d5414ac029 feat(server): extract question handler factory 2026-04-14 20:55:41 -04:00
Shoubhit Dash 1e9842de99 feat(server): extract question httpapi contract 2026-04-14 20:55:39 -04:00
opencode-agent[bot] 77fd6f6689 chore: generate 2026-04-15 00:30:27 +00:00
Frank 44c7c2a371 zen: rate limiter 2026-04-14 20:29:21 -04:00
Brendan Allan e5afe2009c fix(app): avoid bootstrap error popups during global sync init (#22426) 2026-04-15 08:24:52 +08:00
opencode 311d54dd1e release: v1.4.4 2026-04-15 00:03:43 +00:00
opencode-agent[bot] cc7bc93056 chore: update nix node_modules hashes 2026-04-14 23:13:28 +00:00
Frank 82f00e2c6c zen: support alibaba cache write 2026-04-14 18:48:00 -04:00
Frank f8d2b991c1 sync zen 2026-04-14 18:37:02 -04:00
Shoubhit Dash 4ff310a4ec feat(core): bootstrap packages/server and document extraction plan (#22492) 2026-04-15 04:01:45 +05:30
Shoubhit Dash b6d5203fbd fix: restore instance context in prompt runs (#22498) 2026-04-15 03:59:12 +05:30
Luke Parker 35097a0f1f fix(session): keep GitHub Copilot compaction requests valid (#22371) 2026-04-15 08:02:27 +10:00
Caleb Norton 86428faa94 chore(nix): remove external ripgrep (#22482) 2026-04-14 16:49:44 -05:00
Kit Langton bc769cfe97 fix(effect): preserve logger context in prompt runs (#22496) 2026-04-14 17:33:44 -04:00
Aiden Cline 25f6720b4c feat: add --sanitize flag to opencode export to strip PII or confidential info (#22489) 2026-04-14 16:24:18 -05:00
opencode-agent[bot] 46a0a03464 chore: generate 2026-04-14 20:58:35 +00:00
Kit Langton f1635f29d6 fix(question): restore flat reply sdk shape (#22487) 2026-04-14 16:57:32 -04:00
opencode-agent[bot] 20ea688b38 chore: generate 2026-04-14 19:45:10 +00:00
Kit Langton 5e6a35ba91 add experimental question HttpApi slice (#22357) 2026-04-14 19:43:49 +00:00
opencode-agent[bot] 991141b709 chore: update nix node_modules hashes 2026-04-14 19:08:59 +00:00
Sebastian 2bf70bfad2 upgrade opentui to 0.1.99 (#22283) 2026-04-14 20:29:56 +02:00
Dax Raad 09256f3622 ignore: v2 experiments 2026-04-14 14:25:38 -04:00
Sebastian 4a82fc5552 Fix diff line number contrast for built-in themes (#22464) 2026-04-14 19:59:41 +02:00
Kit Langton ea3f6be477 fix(mcp): persist immediate oauth connections (#22376) 2026-04-14 13:56:45 -04:00
Goni Zahavy 4a27172599 fix(cli): handlePluginAuth asks for api key only if authorize method exists (#22475) 2026-04-14 12:53:00 -05:00
Kit Langton 236253c405 refactor(session): remove async facade exports (#22471) 2026-04-14 13:45:13 -04:00
Kit Langton 668cb3b151 improve bash timeout retry hint (#22390) 2026-04-14 12:55:03 -04:00
Kit Langton 480a52d27f fix(project): reuse runtime in instance boot (#22470) 2026-04-14 12:53:13 -04:00
Kit Langton b019cd6822 refactor(project): remove async facade exports (#22387) 2026-04-14 12:49:20 -04:00
opencode-agent[bot] 4a56425282 chore: generate 2026-04-14 16:39:15 +00:00
Kit Langton 186c345f01 refactor(share): remove session share async facade exports (#22386) 2026-04-14 12:38:11 -04:00
Aiden Cline 87dd02777a core: prevent duplicate user messages in ACP clients (#22468) 2026-04-14 11:37:33 -05:00
RAIT-09 b81238d54f fix(acp): stop emitting user_message_chunk during session/prompt turn (#21851) 2026-04-14 11:25:00 -05:00
Aiden Cline e1d60b5a35 fix: rm effect logger from processor.ts, use old logger for now instead (#22460) 2026-04-14 10:39:01 -05:00
Shoubhit Dash 1386cd6e24 fix(opencode): resolve ripgrep worker path in builds (#22436) 2026-04-14 16:39:21 +05:30
opencode-agent[bot] ea23cb7c3d chore: update nix node_modules hashes 2026-04-14 06:58:22 +00:00
Shoubhit Dash fe416f8838 refactor(ripgrep): use embedded wasm backend (#21703) 2026-04-14 11:56:23 +05:30
Luke Parker 5e60aa52fd build(opencode): shrink single-file executable size (#22362) 2026-04-14 15:49:26 +10:00
Kit Langton a57c606e51 refactor(instance): remove state helper (#22381) 2026-04-13 22:40:12 -04:00
Kit Langton 5902701654 kit/env instance state (#22383) 2026-04-13 22:28:16 -04:00
Kit Langton 98f7765c3b refactor(tui): move config cache to InstanceState (#22378) 2026-04-13 22:24:40 -04:00
Kit Langton 0be4b81da0 refactor(snapshot): remove async facade exports (#22370) 2026-04-13 21:24:54 -04:00
Kit Langton 0ece76a789 refactor(plugin): remove async facade exports (#22367) 2026-04-13 21:24:20 -04:00
Kit Langton cf613cba34 refactor(session): remove compaction async facade exports (#22366) 2026-04-13 21:23:34 -04:00
Kit Langton 598584d608 refactor(worktree): remove async facade exports (#22369) 2026-04-13 21:23:15 -04:00
Nazar H. 1449660b04 fix(provider): guard reasoningSummary injection for @ai-sdk/openai-compatible providers (#22352)
Co-authored-by: Nazar Hnatyshen <nazar.hnatyshen@atolls.com>
2026-04-13 20:20:06 -05:00
Aiden Cline e1c846850a feat: add experimental.compaction.autocontinue hook to disable auto continuing after compaction (#22361) 2026-04-13 20:14:53 -05:00
opencode-agent[bot] 26937e199a chore: update nix node_modules hashes 2026-04-14 00:23:25 +00:00
Kit Langton 6ee000dd65 refactor(session): move llm stream into layer (#22358) 2026-04-13 19:53:30 -04:00
Kit Langton a2c81f4185 feat: add interactive burst to the TUI logo (#22098) 2026-04-13 19:36:28 -04:00
Kit Langton 3bfc06ee79 refactor(permission): remove async facade exports (#22342) 2026-04-13 19:33:58 -04:00
Kit Langton 2c57db7f0e fix grep exact file path searches (#22356) 2026-04-13 19:26:50 -04:00
Dax Raad 035954b2bf ignore: v2 thoughts 2026-04-13 17:33:34 -04:00
James Long 0ee6e47bde refactor(core): publish sync events to global event stream (#22347) 2026-04-13 16:51:59 -04:00
Kit Langton 17ad9aac9e refactor(session): remove revert async facade exports (#22339) 2026-04-13 16:16:13 -04:00
Kit Langton d7bcf152cb refactor(mcp): remove mcp auth async facade exports (#22338) 2026-04-13 15:36:12 -04:00
Kit Langton 4adfab4a36 refactor(session): remove summary async facades (#22337) 2026-04-13 15:35:38 -04:00
Kit Langton 202f585eec refactor(agent): remove async facade exports (#22341) 2026-04-13 14:54:01 -04:00
Kit Langton 90f6e3513d update effect docs (#22340) 2026-04-13 14:07:59 -04:00
opencode-agent[bot] 70905b09a3 chore: generate 2026-04-13 18:02:12 +00:00
Dax Raad acb5815c10 session entry 2026-04-13 14:00:49 -04:00
Dax 154ceb5b1f 2.0 exploration (#22335) 2026-04-13 13:47:33 -04:00
Kit Langton 40ab2c74fe refactor(mcp): remove async facade exports (#22324) 2026-04-13 13:45:34 -04:00
opencode-agent[bot] c0e2d7c40e chore: generate 2026-04-13 17:37:41 +00:00
James Long a7569f1fc3 feat(core): expose workspace adaptors to plugins (#21927) 2026-04-13 13:33:13 -04:00
Kit Langton 1813d8e056 refactor(file): remove async facade exports (#22322) 2026-04-13 13:12:02 -04:00
Kit Langton 37d470346d refactor(config): remove async facade exports (#22325) 2026-04-13 13:11:05 -04:00
github-actions[bot] 14366f3146 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/22239#issuecomment-4238224546
2026-04-13 17:06:03 +00:00
Kit Langton 5f002222c4 refactor(lsp): remove async facade exports (#22321) 2026-04-13 12:47:52 -04:00
Kit Langton 128c60aff0 refactor(provider): remove async facade exports (#22320) 2026-04-13 12:40:00 -04:00
Aiden Cline 77e6a8bf18 test: ensure project and global instructions are loaded (#22317) 2026-04-13 11:34:38 -05:00
Kit Langton d470317b3f refactor(auth): remove async auth facade exports (#22306) 2026-04-13 12:31:43 -04:00
Kit Langton 83dfb7e1ae refactor(pty): remove async facade exports (#22305) 2026-04-13 11:47:05 -04:00
Kit Langton 2a2d798e1e refactor(file): stream ripgrep search parsing (#22303) 2026-04-13 11:39:37 -04:00
Kit Langton f70946770e fix: dispose e2e app runtime (#22316) 2026-04-13 11:36:56 -04:00
Kit Langton 00088f2e77 refactor: remove vcs async facade exports (#22304) 2026-04-13 11:22:20 -04:00
Kit Langton fb40523548 refactor(skill): remove async facade exports (#22308) 2026-04-13 11:18:10 -04:00
Kit Langton e6b81503ba refactor: remove ToolRegistry runtime facade (#22307) 2026-04-13 11:09:32 -04:00
opencode-agent[bot] 13fd3f36cd chore: generate 2026-04-13 14:18:05 +00:00
Brendan Allan cc0a3eebf7 core: make InstanceBootstrap into an effect (#22274)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-04-13 10:16:40 -04:00
Kit Langton e624571111 refactor: share TUI terminal background detection (#22297) 2026-04-13 10:05:37 -04:00
Kit Langton d39a41f632 refactor(file): add ripgrep search service (#22295) 2026-04-13 10:04:32 -04:00
Kit Langton d691884399 refactor: finish small effect service adoption cleanups (#22094) 2026-04-13 09:17:13 -04:00
Brendan Allan f54a6ab5ba app: replace parsePatchFiles with parseDiffFromFile (#22270) 2026-04-13 17:19:14 +08:00
Brendan Allan 50279ca66c fix(electron): wait until ready before showing the main window (#22262) 2026-04-13 15:17:09 +08:00
opencode-agent[bot] 94f5caae4e chore: update nix node_modules hashes 2026-04-13 06:02:50 +00:00
Brendan Allan d3faaf0011 app: align workspace load more button (#22251) 2026-04-13 13:58:35 +08:00
Brendan Allan 3b1c982f4f app: fix scroll to bottom light mode style (#22250) 2026-04-13 13:55:33 +08:00
Aiden Cline 2073a298a2 fix: forgot to put alibaba case in last commit (#22249) 2026-04-13 00:39:12 -05:00
Aiden Cline 516359684a feat: add alibaba pkg and cache support (#22248) 2026-04-13 00:08:07 -05:00
Aiden Cline 574b77b0d0 tweak: adjust session getUsage function to use more up to date LanguageModelUsage instead of LanguageModelV2Usage (#22224) 2026-04-12 21:39:06 -05:00
Brendan Allan 90ca9d91c6 sdk: throw error if response has text/html content type (#21289) 2026-04-13 09:39:53 +08:00
Goni Zahavy 4a0a2a213e fix(cli): auth login now asks for api key in handlePluginAuth (#21641)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-12 20:37:57 -05:00
Aiden Cline db7ee181f1 chore: rm git ignored files (#22200) 2026-04-12 15:52:55 -05:00
Dax Raad 6a616207e8 ci: enable beta branch releases with auto-update support 2026-04-12 14:40:24 -04:00
Dax adce21c1fa fix(snapshot): complete gitignore respect for previously tracked files (#22172) 2026-04-12 14:05:46 -04:00
shafdev d4d9c038b2 fix(opencode): remove spurious scripts and randomField from package.json (#22160) 2026-04-12 13:49:24 -04:00
Dax 005e44ddd4 fix(snapshot): respect gitignore for previously tracked files (#22171) 2026-04-12 13:41:50 -04:00
Dax Raad 618dd14702 ci: enable signed Windows builds on beta branch
Allows beta releases to include properly signed Windows CLI executables, ensuring consistent security verification across all release channels.
2026-04-12 13:16:38 -04:00
Dax Raad baece3eeac ignore: exploration 2026-04-12 13:14:46 -04:00
Aiden Cline bb9c2750ee fix: ensure images read by agent dont count against quota (#22168) 2026-04-12 12:02:39 -05:00
Simon Klee 18ff7ab00f chore(github): vouch simonklee (#22127) 2026-04-12 11:33:38 +02:00
Aiden Cline 748df390f4 chore: refactor log.ts, go back to glob but add sort (#22107) 2026-04-11 23:09:19 -05:00
Aiden Cline d08638b2be feat: make gh copilot use msgs api when available (#22106) 2026-04-11 23:06:35 -05:00
Aiden Cline 2b77ff5216 fix: ensure logger cleanup properly orders list before deleting files (#22101) 2026-04-11 22:07:34 -05:00
opencode-agent[bot] a0a770588f chore: generate 2026-04-12 01:21:17 +00:00
Kit Langton 65c58554b3 refactor(tool): destroy Truncate facade, effectify Tool.define (#22093) 2026-04-11 21:20:12 -04:00
Kit Langton c0a23276e6 refactor(file): destroy FileWatcher facade (#22091) 2026-04-11 21:19:12 -04:00
opencode-agent[bot] 1b9e337dbc chore: generate 2026-04-12 00:58:05 +00:00
Kit Langton f75f3f0a9e refactor(question): destroy Question facade (#22092) 2026-04-11 20:57:01 -04:00
Kit Langton 5bbb28a6c7 refactor(file): destroy FileTime facade (#22090) 2026-04-11 20:08:55 -04:00
Kit Langton a52b5706d2 refactor(session): destroy Instruction facade (#22089) 2026-04-11 20:04:09 -04:00
opencode-agent[bot] 68f1a265a7 chore: generate 2026-04-12 00:03:01 +00:00
Kit Langton 87b789f0fc refactor(session): destroy SessionRunState facade (#22064) 2026-04-11 20:01:52 -04:00
opencode-agent[bot] 25c7b05631 chore: generate 2026-04-11 22:15:53 +00:00
Tommy D. Rossi 04559e85ab feat: allow session permission updates (#22070) 2026-04-11 17:14:30 -05:00
opencode-agent[bot] b027cc52fc chore: generate 2026-04-11 20:56:22 +00:00
Dax c5f3cc0c9d refactor(server): simplify router middleware with next() (#21720) 2026-04-11 16:55:17 -04:00
opencode-agent[bot] 6da03efa22 chore: generate 2026-04-11 20:52:52 +00:00
ryan.h.park 4d604ea369 fix: enable thinking for zhipuai-coding-plan & prevent Korean IME truncation (#22041)
Co-authored-by: claudianus <claudianus@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 15:51:49 -05:00
opencode-agent[bot] 5df1054d1e chore: update nix node_modules hashes 2026-04-11 19:30:50 +00:00
Aiden Cline 025bd4a529 chore: bump ai sdk deps (#22005) 2026-04-11 13:45:14 -05:00
Dax Raad e62a919749 hide download button 2026-04-11 14:18:58 -04:00
opencode-agent[bot] 95bd6b5526 chore: generate 2026-04-11 18:18:40 +00:00
Dax Raad 314d75163b ignore: exploration 2026-04-11 14:17:22 -04:00
Kit Langton fc1eb78e91 refactor(account): destroy Account facade (#22068) 2026-04-11 14:16:36 -04:00
opencode-agent[bot] 3a8ac59795 chore: generate 2026-04-11 16:53:40 +00:00
Kit Langton fca2dc9951 refactor(session): make SystemPrompt a proper Effect Service (#21992) 2026-04-11 12:52:35 -04:00
Kit Langton 0fcd20f0dd refactor(tool): make Tool.Info init effectful (#21989) 2026-04-11 12:33:17 -04:00
opencode-agent[bot] ed8f1f8af7 chore: update nix node_modules hashes 2026-04-11 04:45:55 +00:00
opencode-agent[bot] 21f82c881f chore: generate 2026-04-11 03:53:48 +00:00
Kit Langton d77d22daf3 refactor(git): remove runtime facade wrappers (#21982) 2026-04-10 23:52:48 -04:00
Kit Langton 485eb95365 refactor: collapse command facade (#21981) 2026-04-10 23:52:12 -04:00
Kit Langton 5c6e657ad9 refactor(plugin): return Effect from ToolContext.ask (#21986) 2026-04-10 23:50:50 -04:00
opencode-agent[bot] 07f4201c12 chore: update nix node_modules hashes 2026-04-11 03:39:49 +00:00
Kit Langton 5b2b825a0f docs(effect): mark SessionTodo migrated (#21987) 2026-04-10 23:35:50 -04:00
opencode-agent[bot] fb07263d47 chore: generate 2026-04-11 03:28:30 +00:00
Kit Langton f40978a84b refactor(session): remove dead updatePartDelta facade (#21985) 2026-04-10 23:27:30 -04:00
Kit Langton 5b5fc9aac1 refactor(installation): drop facade runtime wrappers (#21984) 2026-04-10 23:26:16 -04:00
Kit Langton a9a51fb28f refactor: remove ProviderAuth facade (#21983) 2026-04-10 23:25:43 -04:00
opencode-agent[bot] bd48f8f612 chore: generate 2026-04-11 03:19:50 +00:00
Kit Langton 6f9fcaa965 refactor: collapse Format facade (#21980) 2026-04-10 23:18:54 -04:00
Kit Langton f87825cf31 refactor(tool): use Session.Service directly in TaskTool (#21975) 2026-04-10 23:18:30 -04:00
Kit Langton db64dfa831 refactor(session): eliminate Effect.promise roundtrips for sync MessageV2.stream (#21973) 2026-04-10 23:18:13 -04:00
opencode-agent[bot] f5ec05d6c7 chore: generate 2026-04-11 03:13:03 +00:00
Kit Langton 0fba75a824 refactor(tool): Tool.Context.metadata returns Effect (#21972) 2026-04-10 23:12:04 -04:00
Kit Langton 0a790bc740 refactor(effect): upgrade opencode to beta.46 context APIs (#21977) 2026-04-10 23:06:28 -04:00
Kit Langton 44f256a75e refactor: make TaskPromptOps effectful (#21971) 2026-04-10 22:57:47 -04:00
opencode-agent[bot] 072b0a1411 chore: generate 2026-04-11 02:56:03 +00:00
Kit Langton 82f35ac077 fix: provide EffectLogger.layer to bare Effect.runPromise/runFork calls (#21974) 2026-04-10 22:55:08 -04:00
Brendan Allan 4d06f7d4a9 refactor(config): pass instance context to containsPath (#21882) 2026-04-11 10:43:40 +08:00
opencode-agent[bot] 5c921f902d chore: generate 2026-04-11 02:36:59 +00:00
Kit Langton f800941bcc refactor(tool): Tool.Def.execute returns Effect, rename defineEffect → define (#21961) 2026-04-10 22:36:02 -04:00
Kit Langton f47f910ae1 refactor: destroy SessionStatus facade (#21968) 2026-04-10 22:16:53 -04:00
opencode-agent[bot] b003e0d406 chore: generate 2026-04-11 02:04:02 +00:00
Kit Langton 8811669aa0 refactor: destroy ShareNext facade (#21965) 2026-04-10 22:03:06 -04:00
Kit Langton 4d49b7c8cd refactor: extract LSP diagnostic report formatter (#21964) 2026-04-10 22:00:56 -04:00
opencode-agent[bot] 62faf81122 chore: generate 2026-04-11 01:48:25 +00:00
Kit Langton c8888f5ea5 refactor: destroy Todo facade (#21962) 2026-04-10 21:47:28 -04:00
Kit Langton 2432c83668 fix: disable default Effect console logger (#21963) 2026-04-10 21:27:24 -04:00
opencode-agent[bot] ca843cbcf4 chore: generate 2026-04-11 01:22:01 +00:00
Kit Langton bf4c363d64 refactor: destroy Storage facades (#21956) 2026-04-10 21:21:02 -04:00
Kit Langton 019bf9ca96 refactor: add Effect logger for motel observability (#21954) 2026-04-10 21:10:58 -04:00
Aiden Cline 17e4384e8a chore: rm unnecessary test (now we use effect) and the test is flaky (#21959) 2026-04-10 19:59:33 -05:00
Aiden Cline 5752fb6943 tweak: rm processor .trim calls (#21958) 2026-04-10 19:47:08 -05:00
Kit Langton e029812e93 convert skill tool to Tool.defineEffect (#21936) 2026-04-10 19:49:53 -04:00
Kit Langton 437400e5ec feat: add AppRuntime for unified service composition (#21953) 2026-04-10 19:46:52 -04:00
opencode-agent[bot] 506f1b092e chore: generate 2026-04-10 23:43:20 +00:00
Kit Langton 1d96701390 refactor(tool): convert apply_patch to Tool.defineEffect (#21938) 2026-04-10 19:42:14 -04:00
Kit Langton f8b0d4064b refactor: break SessionPrompt/TaskTool cycle via ctx injection (#21948) 2026-04-10 19:36:13 -04:00
Kit Langton 0ccc297bc8 refactor(tool): convert grep tool to Tool.defineEffect (#21937) 2026-04-10 19:20:00 -04:00
opencode-agent[bot] 0415549373 chore: generate 2026-04-10 21:12:22 +00:00
Kit Langton 57b8a79d27 refactor: convert edit tool to Tool.defineEffect (#21904) 2026-04-10 17:10:28 -04:00
opencode-agent[bot] fd827655af chore: generate 2026-04-10 21:01:33 +00:00
Frank e2d0849674 zen: nemotron doc 2026-04-10 16:58:38 -04:00
Kit Langton 8e0d79bb29 refactor(tool): convert write tool to Tool.defineEffect (#21901) 2026-04-10 16:57:12 -04:00
opencode-agent[bot] 607ac7b214 chore: generate 2026-04-10 19:40:10 +00:00
Kit Langton 2f6d62165d convert list tool to Tool.defineEffect (#21899) 2026-04-10 15:38:52 -04:00
Aiden Cline e80fba93cc tweak: make it so disabling uv or ruff fmters disables both (#21921) 2026-04-10 13:30:30 -05:00
opencode-agent[bot] 44badf4578 chore: generate 2026-04-10 17:57:46 +00:00
Kit Langton e815ccf13b convert glob tool to Tool.defineEffect (#21897) 2026-04-10 13:56:42 -04:00
opencode-agent[bot] 055e0439a8 chore: generate 2026-04-10 17:27:31 +00:00
Kit Langton b8e69d52b2 refactor(tool): convert bash to defineEffect with ChildProcessSpawner (#21895) 2026-04-10 13:26:31 -04:00
James Long a37b37d00c rector(core,tui): handle workspace state in project context, add workspace status, improve ui (#21896) 2026-04-10 13:03:20 -04:00
Kit Langton d1c6cc8bbc refactor(tool): convert codesearch tool internals to Effect (#21811) 2026-04-10 11:49:20 -04:00
opencode-agent[bot] f8c45d7c74 chore: generate 2026-04-10 15:31:39 +00:00
Kit Langton 22dac66b22 refactor(tool): convert websearch tool internals to Effect (#21810) 2026-04-10 11:30:38 -04:00
opencode-agent[bot] 27c6a0a21d chore: generate 2026-04-10 14:51:27 +00:00
Kit Langton de99edef4c refactor(tool): convert webfetch tool internals to Effect (#21809) 2026-04-10 10:50:13 -04:00
James Long 10ce83d5e4 refactor(tui): switch to global events and start passing workspace param (#21719) 2026-04-10 10:47:27 -04:00
Kit Langton 5531e7de24 refactor(tool): convert plan tool internals to Effect (#21807) 2026-04-10 10:38:46 -04:00
opencode-agent[bot] fcdd8079f2 chore: generate 2026-04-10 14:08:27 +00:00
Kit Langton e7fa657119 refactor(tool): convert lsp tool internals to Effect (#21806) 2026-04-10 10:07:19 -04:00
Kit Langton 30269d8944 refactor(tool): convert question tool internals to Effect (#21808) 2026-04-10 09:42:06 -04:00
Aiden Cline d7808ebac2 fix: ts lsp (#21827) 2026-04-10 00:15:45 -05:00
Aiden Cline 2fe8776597 tweak: disable 2026-04-09 23:11:09 -05:00
Shoubhit Dash 585bbee1fe refactor compaction tail selection 2026-04-10 09:36:39 +05:30
Shoubhit Dash b8b49dc6c0 keep recent turns during session compaction 2026-04-10 09:34:01 +05:30
Kit Langton 28a6be5a1d refactor(effect): use Git service in file and storage (#21803) 2026-04-09 22:49:36 -04:00
Kit Langton dde5ad473a refactor(effect): use SessionRevert service in prompt (#21796) 2026-04-09 22:28:11 -04:00
Kit Langton a2545c7417 refactor(effect): move tool descriptions into registry (#21795) 2026-04-09 22:20:27 -04:00
Kit Langton a92b47d66c refactor(session): extract sharing orchestration (#21759) 2026-04-09 21:47:48 -04:00
Dax Raad 9b49a0a6d3 Merge remote-tracking branch 'origin/dev' into dev 2026-04-09 21:35:09 -04:00
Dax Raad f11c20a60e core: enable prod channel to use shared production database
Ensures users on the prod channel have their data persisted to the same
database as latest and beta channels, preventing data fragmentation
across different release channels.
2026-04-09 21:34:52 -04:00
Luke Parker 67fe37cfde ci use node 24 in test workflow fixing random ECONNRESET (#21782) 2026-04-10 01:00:21 +00:00
Luke Parker 452fc6c70b fix windows e2e backend not stopping on sigterm waiting 10s for no reason (#21781) 2026-04-10 01:00:21 +00:00
opencode 7b73a59501 release: v1.4.3 2026-04-10 01:00:12 +00:00
Dax Raad 6916bd4be2 ci: prevent beta branch builds from triggering production release steps
Skip Windows and Linux code signing, along with artifact downloads for
the beta branch to ensure beta builds don't go through production
release processes.
2026-04-09 17:06:53 -04:00
Dax Raad 40d030cd51 ci: skip winget publish on beta and ensure finalize always runs
Beta releases no longer trigger unnecessary Winget submissions, and release
finalization now completes even when some build artifacts are missing.
2026-04-09 16:34:05 -04:00
opencode-agent[bot] 9149024c1d chore: generate 2026-04-09 20:29:48 +00:00
Kit Langton a55038cc62 refactor(session): inline init route orchestration (#21754) 2026-04-09 16:28:42 -04:00
Aiden Cline 8db3f197c9 fix: ensure that openai oauth works for agent create cmd, use temporary hack (#21749)
Co-authored-by: OpeOginni <brightoginni123@gmail.com>
2026-04-09 15:25:59 -05:00
opencode-agent[bot] 35f3e332e0 chore: generate 2026-04-09 20:05:05 +00:00
Kit Langton f1e2560a1a refactor(effect): extract session run state service (#21744) 2026-04-09 16:03:40 -04:00
Kit Langton db3a528899 fix: finalize interrupted bash via tool result path (#21724) 2026-04-09 15:20:28 -04:00
Kit Langton 19796cdac6 refactor: remove unused runtime facade exports (#21731) 2026-04-09 14:52:06 -04:00
Kit Langton 42b399da22 fix(effect): suspend agent default layer construction (#21732) 2026-04-09 14:32:41 -04:00
Simon Klee 15ba875b3a Revert "opencode: lazy-load top-level CLI commands" (#21726) 2026-04-09 20:03:48 +02:00
Kit Langton 9731a1edeb delete unused withALS method (#21723) 2026-04-09 13:52:58 -04:00
Simon Klee da80539ecf opencode: lazy-load top-level CLI commands
The CLI imports every top-level command before argument parsing has
decided which handler will run. This makes simple invocations pay for
the full command graph up front and slows down the default startup path.

Parse the root argv first and load only the command module that matches
the selected top-level command. Keep falling back to the default TUI
path for non-command positionals, and preserve root help, version and
completion handling
2026-04-09 19:42:25 +02:00
Aiden Cline 3d794bdc24 feat: add support for fast modes for claude and gpt models (that support it) (#21706) 2026-04-09 12:06:26 -05:00
Kit Langton 6e883aab36 fix(tui): restore hidden session scrollbar default (#20947) 2026-04-09 13:01:08 -04:00
opencode-agent[bot] cd3ab0e03d chore: generate 2026-04-09 16:47:02 +00:00
Aleksandr Lossenko 5befad3e2f feat(mcp): add OAuth redirect URI configuration for MCP servers (#21385)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-09 11:45:52 -05:00
opencode c34c30011a release: v1.4.2 2026-04-09 16:24:43 +00:00
Dax 19e24a5850 refactor: fix tool call state handling and clean up imports (#21709) 2026-04-09 11:56:19 -04:00
Kit Langton 18745d2cc6 refactor(effect): drop shell abort signals from runner (#21599) 2026-04-09 10:54:26 -04:00
Kit Langton 039bd423fa fix: preserve text part timing in session processor (#21691) 2026-04-09 10:05:14 -04:00
Kit Langton 8a63aaef6c fix: preserve interrupted bash output in tool results (#21598) 2026-04-09 10:03:26 -04:00
Brendan Allan 7d99bfdbf1 app: remove min loading duration (#21655) 2026-04-09 16:29:46 +08:00
opencode 5cd672b6f9 release: v1.4.1 2026-04-09 07:12:17 +00:00
Aiden Cline d21835e80f feat: add opencode go upsell modal when limits are hit (#21583)
Co-authored-by: Frank <frank@anoma.ly>
2026-04-09 02:16:29 -04:00
opencode-agent[bot] ac76bb2ebc chore: update nix node_modules hashes 2026-04-09 06:03:26 +00:00
opencode-agent[bot] e8dc178705 chore: generate 2026-04-09 05:19:52 +00:00
Brendan Allan 30d17b3b82 Remove CLI from electron app (#17803)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-04-09 13:18:46 +08:00
Cho HyeonJong 5be0c5b08f fix(lsp): remove CMakeLists.txt and Makefile from clangd root markers (#21466) 2026-04-08 23:10:06 -05:00
opencode-agent[bot] 99950c8838 chore: update nix node_modules hashes 2026-04-09 02:27:34 +00:00
Vladimir Glafirov bb097d4c50 feat(llm): integrate GitLab DWS tool approval with permission system (#19955)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-08 20:39:33 -05:00
Kit Langton 97c2d085d7 refactor(effect): inline session processor interrupt cleanup (#21593) 2026-04-08 21:19:01 -04:00
Kit Langton 00407ce6e2 fix: propagate abort signal to inline read tool (#21584) 2026-04-08 21:07:55 -04:00
OpeOginni 6b93f1351d fix(app): skip url password setting for same-origin server and web app (#19923) 2026-04-09 07:13:10 +08:00
Kit Langton e4d476619c refactor(effect): build task tool from agent services (#21017) 2026-04-08 19:02:19 -04:00
Aiden Cline 802a7905ec tweak: separate ModelsDev.Model and Config model schemas (#21561) 2026-04-08 15:55:14 -05:00
Aiden Cline 56df672deb fix: dont show invalid variants for BP (#21555) 2026-04-08 14:52:34 -05:00
Adam 969183a744 fix(app): diff list normalization 2026-04-08 14:02:23 -05:00
Adam 3d27a30e3b fix(app): patch tool diff rendering 2026-04-08 13:49:16 -05:00
Aiden Cline 808d35190d fix: ensure that /providers list and shell endpoints are correctly typed in sdk and openapi schema (#21543) 2026-04-08 12:56:15 -05:00
Aiden Cline 1f2458919f test: update webfetch test (#21398)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-04-08 12:25:02 -05:00
Brendan Allan 636603c1aa ui: fix sticky session diffs header (#21486) 2026-04-08 17:01:52 +08:00
Kit Langton 520d11d0d0 fix(tui): simplify console org display (#21339)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-04-07 21:03:24 -04:00
opencode-agent[bot] c92a40d97c chore: update nix node_modules hashes 2026-04-08 00:32:40 +00:00
opencode 0f0770f26e release: v1.4.0 2026-04-08 00:32:31 +00:00
opencode-agent[bot] e04429742d chore: generate 2026-04-07 23:49:25 +00:00
Dax 6cc1116f8e refactor(snapshot): store unified patches in file diffs (#21244)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-04-07 19:48:23 -04:00
Dax b2aee46183 core: refactor tool system to remove agent context from initialization (#21052) 2026-04-07 19:48:12 -04:00
Frank 3a9161b72e go: glm5.1 2026-04-07 17:55:09 -04:00
Frank aabccaab07 zen: glm5.1 doc 2026-04-07 17:46:11 -04:00
Frank 689377cd99 zen: glm5.1 doc 2026-04-07 17:21:24 -04:00
Dax 505b017e7a feat(opencode): add OTLP observability support (#21387) 2026-04-07 17:02:55 -04:00
Aiden Cline 0d2bb1aa73 fix(opencode): clear webfetch timeouts on failed fetches (#21378) 2026-04-07 20:46:02 +00:00
Kyle Altendorf 27981483ab test: disable GPG signing in test fixtures (#20386) 2026-04-07 15:26:01 -05:00
Ariane Emory 2420c17d98 feat(tui): allow variant_list keybind for the "Switch model variant" command (#21185)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-07 14:46:04 -05:00
opencode-agent[bot] c589e9683e chore: generate 2026-04-07 18:33:24 +00:00
Kit Langton 4dc9adde52 fix(opencode): improve console login transport errors (#21350) 2026-04-07 18:31:53 +00:00
Aiden Cline 4e35e466a9 fix: ensure the alibaba provider errors are retried (#21355) 2026-04-07 17:46:01 +00:00
Ariane Emory 7b51129f0c fix(tui): use sentence case for theme mode command palette items (#21192)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-07 12:25:13 -05:00
James Long 7625a7e644 refactor(core): support multiple event streams in worker and remove workspaces from plugin api (#21348) 2026-04-07 13:22:34 -04:00
Adam 2d528d4ac7 feat(app): better subagent experience (#20708) 2026-04-07 11:06:23 -05:00
Adam fe9b6d7118 chore: update web stats 2026-04-07 11:02:37 -05:00
opencode-agent[bot] ab1b1377b9 chore: generate 2026-04-07 14:14:07 +00:00
Dax b9ded6db4e fix(opencode): keep user message variants scoped to model (#21332) 2026-04-07 10:12:53 -04:00
Frank 634b2f15b5 go: support coupon 2026-04-07 10:09:00 -04:00
Shoubhit Dash 976bce3890 Move auto-accept permissions to settings (#21308) 2026-04-07 11:00:13 +00:00
Shoubhit Dash 8bb5660f4b style(app): redesign jump-to-bottom button per figma spec (#21313) 2026-04-07 10:47:53 +00:00
Shoubhit Dash f103f18f3f feat(app): show full names on composer attachment chips (#21306) 2026-04-07 10:15:22 +00:00
gitpush-gitpaid 58b633c54d feat(opencode): Add PDF attachment Drag and Drop (#16926)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-04-07 04:39:59 +00:00
opencode-agent[bot] 897cec3eb7 chore: update nix node_modules hashes 2026-04-07 04:38:49 +00:00
Aiden Cline cd2dacfec7 feat: add --dangerously-skip-permissions flag to opencode run (#21266) 2026-04-06 23:30:05 -05:00
Frank 72e9cb431f go: add mimo 2026-04-07 00:16:34 -04:00
James Long ef54e6f5b2 refactor(core): add full http proxy and change workspace adaptor interface (#21239) 2026-04-06 23:19:55 -04:00
Aiden Cline 5b92e6e76b chore: bump anthropic ai sdk pkg, delete patch (#21247) 2026-04-06 20:28:32 -05:00
opencode-agent[bot] 6e8ea27c8d chore: update nix node_modules hashes 2026-04-07 00:30:30 +00:00
Aiden Cline 7a5a537944 chore: remove ai-sdk/provider-utils patch and update pkg (#21245) 2026-04-06 23:53:27 +00:00
opencode-agent[bot] bd001aec46 chore: update nix node_modules hashes 2026-04-06 23:42:39 +00:00
Aiden Cline 461c134cf2 fix: bump openrouter ai sdk pkg to fix openrouter issues (#21242) 2026-04-06 17:56:11 -05:00
Aiden Cline 1648eeccd2 tweak: move the max token exclusions to plugins @rekram1-node (#21225) 2026-04-06 17:43:58 -05:00
Aiden Cline 3c67cd1d14 tweak: adjust chat.params hook to allow altering of the maxOutputTokens (#21220) 2026-04-06 18:13:30 +00:00
Aiden Cline 119a21463a tweak: ensure copilot anthropic models have same reasoning effort model as copilot cli, also fix qwen incorrectly having variants (#21212) 2026-04-06 18:12:43 +00:00
opencode-agent[bot] c3105187dd chore: update nix node_modules hashes 2026-04-06 18:08:04 +00:00
Dax 3da76ef72e refactor(server): replace Bun serve with Hono node adapters (#18335)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-04-06 13:24:55 -04:00
Sebastian 9db478dfc5 upgrade opentui to 0.1.97 (#21137) 2026-04-06 18:42:05 +02:00
Dax da77ff47b3 refactor: replace Bun.serve with Node http.createServer in OAuth handlers (#18327)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-04-06 12:17:29 -04:00
MC c27bf31167 docs: update Cloudflare provider setup to reflect /connect prompt flow (#20589) 2026-04-06 10:50:24 -05:00
opencode-agent[bot] 54f9533b7a chore: generate 2026-04-06 13:51:36 +00:00
Derek Barrera 47f39c686e fix(lsp): MEMORY LEAK: ensure typescript server uses native project config (#19953) 2026-04-06 09:50:36 -04:00
opencode e95a8da87a release: v1.3.17 2026-04-06 07:39:18 +00:00
Luke Parker 0eb56c4308 fix(tui): revert kitty keyboard events workaround on windows (#20180) 2026-04-06 07:04:50 +00:00
MC 853ca28a99 fix: show clear error when Cloudflare provider env vars are missing (#20399)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-04-06 05:26:04 +00:00
Frank 3d66625066 zen: normalize ipv6 2026-04-06 00:32:55 -04:00
Frank a194092969 zen: remove header check 2026-04-05 23:51:37 -04:00
opencode a851c3807a release: v1.3.16 2026-04-06 03:44:46 +00:00
Luke Parker cfa597b9a1 fix(tui): default Ctrl+Z to undo on Windows (#21138) 2026-04-06 02:38:35 +00:00
Corné Steenhuis 1777c480a4 fix: pass both 'openai' and 'azure' providerOptions keys for @ai-sdk/azure (#20272)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-06 02:34:53 +00:00
George Harker 56a9e8502c fix(core): implement proper configOptions for acp (#21134) 2026-04-05 21:29:34 -05:00
Gautier DI FOLCO 4b3bfcdb02 feat(tui): make the mouse disablable (#6824, #7926) (#13748) 2026-04-05 21:14:11 -05:00
opencode-agent[bot] 76fe8ba038 chore: update nix node_modules hashes 2026-04-06 01:18:03 +00:00
Luke Parker 4a95c9c44e fix(plugin): parse package specifiers with npm-package-arg and sanitize win32 cache paths (#21135) 2026-04-06 00:26:40 +00:00
Aiden Cline 918b7ccf7e tweak: add newline between <content> and first line of read tool output to prevent confusion (#21070) 2026-04-05 04:55:22 +00:00
Frank 8c0e16069c doc: udpate doc 2026-04-05 00:35:40 -04:00
Aiden Cline 0658bf4931 test: add regression test for double counting bug (#21053) 2026-04-04 16:40:28 -07:00
opencode-agent[bot] 8398bf933a chore: generate 2026-04-04 23:26:37 +00:00
Kit Langton 8f3de3aaad fix(tui): only show org switch affordances when useful (#21054) 2026-04-04 19:25:37 -04:00
opencode-agent[bot] b03daa9d14 chore: generate 2026-04-04 23:07:01 +00:00
Kit Langton 971151694f refactor(effect): move read tool onto defineEffect (#21016) 2026-04-04 19:06:00 -04:00
Kit Langton 94b5e89b2c feat(tui): show console-managed providers (#20956) 2026-04-04 19:05:45 -04:00
Aiden Cline 762ae4e70f fix: ensure reasoning tokens arent double counted when calculating usage (#21047) 2026-04-04 15:29:53 -07:00
opencode de0edcd25b release: v1.3.15 2026-04-04 20:54:20 +00:00
Dax 9f1a587d16 fix(npm): Arborist reify fails on compiled binary — Bun pre-resolves node-gyp path at build time (#21040) 2026-04-04 16:27:20 -04:00
Yuxin Dong 9661d82302 refactor: remove redundant Kimi skill section (#20393)
Co-authored-by: dongyuxin <dongyuxin@dev.dongyuxin.msh-dev.svc.cluster.local>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-04 18:32:26 +00:00
opencode b575e2a47a release: v1.3.14 2026-04-04 18:32:17 +00:00
Kit Langton f494fd5ea4 fix(ci): create JUnit output dirs before tests (#20959) 2026-04-03 22:47:20 -04:00
Kit Langton 0efe20d808 perf(opencode): batch snapshot diffFull blob reads (#20752)
Co-authored-by: Nate Williams <50088025+natewill@users.noreply.github.com>
2026-04-04 01:05:23 +00:00
Kit Langton 81b73e789a refactor(provider): stop custom loaders using facades (#20776)
Co-authored-by: luanweslley77 <213105503+luanweslley77@users.noreply.github.com>
2026-04-04 00:24:24 +00:00
opencode-agent[bot] 25e735d8a5 chore: generate 2026-04-03 22:55:05 +00:00
Sebastian ba02c4d28a notes on v2 (#20941) 2026-04-04 00:54:03 +02:00
Aiden Cline c696785e6a chore: rm models snapshot (#20929) 2026-04-03 19:44:50 +00:00
Kit Langton 209547f414 test(ci): publish unit reports in actions (#20547) 2026-04-03 16:12:01 +00:00
Kit Langton d613d8f4e3 refactor(effect): build todowrite tool from Todo service (#20789)
Co-authored-by: Juan Pablo Carranza Hurtado <52012198+jpcarranza94@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 12:05:40 -04:00
Adam 85e391654a chore(app): remove queued follow-ups for now 2026-04-03 10:56:40 -05:00
Adam f5215e1686 fix(app): show correct submit icon when typing follow up 2026-04-03 10:51:07 -05:00
opencode-agent[bot] e789089df2 chore: generate 2026-04-03 14:56:44 +00:00
Shoubhit Dash cf23c87965 feat: restore git-backed review modes (#20845) 2026-04-03 09:54:57 -05:00
Shoubhit Dash 1aee1475e8 fix: restore prompt focus after footer selection (#20841) 2026-04-03 14:36:33 +00:00
Kit Langton dd1f1687b5 refactor(effect): resolve built tools through the registry (#20787) 2026-04-03 10:31:00 -04:00
Shoubhit Dash 53642326b1 fix(app): hide default session timestamps (#20892) 2026-04-03 13:51:54 +00:00
Shoubhit Dash 4465a48d7e feat(ui): redesign modified files section in session turn (#20348)
Co-authored-by: David Hill <iamdavidhill@gmail.com>
2026-04-03 08:32:53 -05:00
Shoubhit Dash 7b3a672ec7 fix(session): disable todo dock auto-scroll (#20840) 2026-04-03 08:32:30 -05:00
Shoubhit Dash ab1b0250ed fix(prompt): unmount model controls in shell mode (#20886) 2026-04-03 13:16:26 +00:00
Shoubhit Dash e786645701 fix(session): delay jump-to-bottom button (#20853) 2026-04-03 08:12:01 -05:00
Nate Williams f0f021f47f fix(core): fix restoring earlier messages in a reverted chain (#20780) 2026-04-03 18:23:00 +05:30
Brendan Allan e4221bc7ed electron: better menus (#20878) 2026-04-03 12:41:30 +00:00
opencode-agent[bot] 198e5e4e2a chore: update nix node_modules hashes 2026-04-03 03:53:46 +00:00
Luke Parker e3743551fc fix(sdk): handle Windows opencode spawn and shutdown (#20772) 2026-04-03 13:18:50 +10:00
Kevin Flansburg 6611bcc8b5 feat: Send x-session-affinity and x-parent-session-id headers (#20744) 2026-04-02 22:09:53 -05:00
Dax 8b29acf8a3 add automatic heap snapshots for high-memory cli processes (#20788) 2026-04-03 02:34:33 +00:00
opencode-agent[bot] f0812a1f7b chore: generate 2026-04-03 02:10:50 +00:00
Juan Pablo Carranza Hurtado 4d97b32fbf fix: prevent Tool.define() wrapper accumulation on object-defined tools (#16952)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 22:09:53 -04:00
Luke Parker 4c8045066d fix: call models.dev once instead of twice on start (#20765) 2026-04-03 11:26:53 +10:00
Kit Langton d41085ce3a refactor(share): effectify share next (#20596) 2026-04-03 00:56:56 +00:00
Aiden Cline 6fa3493390 fix: rm dynamic part from bash tool description again to restore cache hits across projects (#20771) 2026-04-03 00:16:40 +00:00
Kit Langton 4f3e1ac6a5 refactor(effect): prune unused facades (#20748) 2026-04-02 20:15:09 -04:00
ykswang 71f8cc612d fix(opencode): honor model limit.input overrides (#16306)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-03 00:13:10 +00:00
Sebastian e74b820274 dialog aware prompt cursor (#20753) 2026-04-02 23:20:56 +02:00
opencode-agent[bot] 99f7530997 chore: update nix node_modules hashes 2026-04-02 20:47:24 +00:00
Sebastian b822aab5cd Adds TUI prompt traits, refs, and plugin slots (#20741) 2026-04-02 22:11:17 +02:00
Kit Langton 6a3ab9a388 refactor(todo): effectify session todo (#20595) 2026-04-02 19:11:23 +00:00
Kit Langton 2303038653 test(app): emit junit artifacts for playwright (#20732) 2026-04-02 15:07:46 -04:00
Kit Langton 0307a3f17f refactor(app): unexport internal e2e helpers (#20730) 2026-04-02 18:53:51 +00:00
Kit Langton 9f8f613190 test(opencode): remove temporary e2e url repro (#20729) 2026-04-02 14:35:21 -04:00
Kit Langton d1616c005f docs(effect): refresh migration status (#20665) 2026-04-02 14:33:58 -04:00
opencode-agent[bot] 3aea9253bf chore: update nix node_modules hashes 2026-04-02 18:31:14 +00:00
Kit Langton e4881e8d94 test(app): add a golden path for mocked e2e prompts (#20593) 2026-04-02 18:17:28 +00:00
opencode-agent[bot] 0ad5355f55 chore: generate 2026-04-02 17:58:00 +00:00
Noam Bressler b8e14248a0 feat(acp): Add messageID and emit user_message_chunk on prompt/command (#18625)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-02 12:56:56 -05:00
opencode-agent[bot] 79e4e81b92 chore: generate 2026-04-02 17:54:07 +00:00
Lenny Vaknine 46c2b0c30b feat: add macOS managed preferences support for enterprise MDM deployments (#19178)
Co-authored-by: Lenny Vaknine <lvaknine@gitlab.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-02 12:52:49 -05:00
Brendan Allan d96ca8c629 electron: add basic context menu for inspect element (#20723) 2026-04-02 17:41:26 +00:00
Frank 4a217df094 go: add mimo 2026-04-02 12:59:04 -04:00
Brendan Allan ba598a309c app: unify auto scroll ref handling (#20716) 2026-04-02 16:44:52 +00:00
Dax 15fb043349 refactor(format): update formatter interface to return command from enabled() (#20703) 2026-04-02 12:22:01 -04:00
Jack 32c7a82d6f Add MiMo-V2 models to Go UI and docs (#20709) 2026-04-02 12:01:04 -04:00
Aiden Cline 5becfc38c5 tweak: add abort signal timeout to the github copilot model fetch to prevent infinite blocking (#20705) 2026-04-02 10:53:08 -05:00
Burak Yigit Kaya 4a2b6f3157 fix(format): use biome format instead of check to prevent import removal (#20545)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-02 10:52:52 -05:00
Aiden Cline ca0014e54e ignore: fix typecheck in dev (#20702) 2026-04-02 15:38:30 +00:00
opencode-agent[bot] e5d2c2fa6d chore: generate 2026-04-02 15:19:26 +00:00
Noam Bressler fc84936a69 feat: add optional messageID to ShellInput (#20657) 2026-04-02 10:18:16 -05:00
opencode-agent[bot] 51840590c4 chore: generate 2026-04-02 15:01:45 +00:00
Aiden Cline 7cc1b528e8 refactor: split up models.dev and config model definitions to prevent coupling (#20605) 2026-04-02 10:00:43 -05:00
opencode-agent[bot] 11c99d76a8 chore: update nix node_modules hashes 2026-04-02 10:23:59 +00:00
Brendan Allan 1c97b92774 cleanup event listeners with solid-primitives/event-listener (#20619) 2026-04-02 09:40:03 +00:00
Brendan Allan b7382ab630 use solid-primitives/resize-observer across web code (#20613) 2026-04-02 17:24:10 +08:00
Shoubhit Dash decf3415af refactor: simplify solid reactivity across app and web (#20497) 2026-04-02 17:14:05 +08:00
Frank 0fd602ea74 zen: friendly trial ended message 2026-04-02 03:15:35 -04:00
Brendan Allan bb3bb3ac98 fix(node): set OPENCODE_CHANNEL during build (#20616) 2026-04-02 06:05:36 +00:00
opencode-agent[bot] ec30e8ce3c chore: update nix node_modules hashes 2026-04-02 04:47:27 +00:00
Frank e92710ab73 cli: update usage exceeded error 2026-04-02 00:25:23 -04:00
opencode-agent[bot] 4745f4f8a5 chore: generate 2026-04-02 04:06:47 +00:00
dpuyosa a2b2b97dd4 feat(opencode): Add Venice AI package as dependency (#20570) 2026-04-01 23:05:49 -05:00
Kit Langton a6e97f2cc0 fix(cli): restore colored help logo (#20592) 2026-04-02 03:21:07 +00:00
Kit Langton 1ef2b6e522 refactor(account): share token freshness helper (#20591) 2026-04-02 02:57:45 +00:00
Aaron Zhu d08c6fa5d1 fix(session): compaction agent responds in same language as conversation (#20581)
Co-authored-by: Aaron Zhu <aaron@Aarons-MacBook-Air.local>
2026-04-01 21:44:16 -05:00
Valentin Vivaldi ea1b93cc90 fix(core): prevent agent loop from stopping after tool calls with OpenAI-compatible providers (#14973)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-04-01 21:34:01 -05:00
Kit Langton 584107690b fix(cli): use simple logo in CLI (#20585) 2026-04-02 02:27:09 +00:00
Kit Langton d29bffd4ba refactor(instruction): migrate to Effect service pattern (#20542) 2026-04-01 22:22:51 -04:00
Kit Langton b6a3a921c1 test(app): block real llm calls in e2e prompts (#20579) 2026-04-01 22:22:43 -04:00
Aiden Cline 2d16129741 tui: add consent dialog when sharing for the first time (#20525) 2026-04-02 01:58:57 +00:00
opencode-agent[bot] a2f403f708 chore: generate 2026-04-02 01:56:34 +00:00
Kit Langton 90bed48f49 fix(build): replace require() with dynamic import() in cross-spawn-spawner (#20580) 2026-04-01 21:55:35 -04:00
MC 8d71cc78df chore: add User-Agent headers for Cloudflare providers (#20538)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-01 20:02:17 -05:00
Kit Langton fd7ff95cbd fix(account): refresh console tokens before expiry (#20558) 2026-04-02 00:25:24 +00:00
Sebastian abe10a6395 flock npm.add (#20557) 2026-04-02 00:21:26 +00:00
Kit Langton 1fe4bff157 fix: show model display name in message footer and transcript (#20539) 2026-04-02 00:17:38 +00:00
Kit Langton 9201507e5c refactor(revert): yield SessionSummary.Service directly (#20541) 2026-04-01 20:10:59 -04:00
Kit Langton b2bf8b89ec fix(test): use effect helper in snapshot race test (#20567) 2026-04-01 20:05:47 -04:00
Sebastian 077da62cde Refactor plugin/config loading, add theme-only plugin package support (#20556) 2026-04-01 23:50:22 +00:00
opencode-agent[bot] 765b93e9ea chore: generate 2026-04-01 23:49:44 +00:00
Kit Langton 0da4aa8cf2 refactor(bash): use Effect ChildProcess for bash tool execution (#20496) 2026-04-01 19:48:47 -04:00
Kit Langton 0a1efc63fe refactor: add Effect-returning versions of MessageV2 functions (#20374) 2026-04-01 19:48:36 -04:00
opencode-agent[bot] 58bdd9e14b chore: generate 2026-04-01 23:48:30 +00:00
Kit Langton 3d31c43241 fix(test): auto-acknowledge tool-result follow-ups in mock LLM server (#20528) 2026-04-01 23:47:26 +00:00
Kit Langton c5a7250c97 fix(opencode): batch snapshot revert without reordering (#20564) 2026-04-01 23:46:06 +00:00
Luke Parker 2f173e018a chore(tui): clean up scroll config follow-up (#20561) 2026-04-02 09:36:49 +10:00
Luke Parker c7fc628e19 fix(tui): apply scroll configuration uniformly across all scrollboxes (#14735) 2026-04-02 09:15:19 +10:00
Aiden Cline 2ed135358d feat: add new provider plugin hook for resolving models and sync models from github models endpoint (falls back to models.dev) (#20533) 2026-04-01 23:04:14 +00:00
Luke Parker a0f590bc64 Fix selection expansion by retaining focused input selections during global key events (#20205) 2026-04-02 08:43:40 +10:00
Sebastian 6fa4cc04f0 resolve subpath only packages for plugins (#20555) 2026-04-01 22:14:36 +00:00
Joscha Götzer cbe82e80f9 fix: normalize filepath in FileTime to prevent Windows path mismatch (#20367)
Co-authored-by: JosXa <info@josxa.dev>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-04-02 07:45:50 +10:00
Frank aed22ae63e zen: sync 2026-04-01 17:41:04 -04:00
Dax 99a2f241f8 refactor: replace BunProc with Npm module using @npmcli/arborist (#18308)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-04-01 21:01:37 +00:00
Frank 7a4adf501d wip: zen 2026-04-01 14:17:31 -04:00
Kit Langton a9809cc69a test(app): fix isolated backend follow-ups (#20513) 2026-04-01 17:43:19 +00:00
Kit Langton ad16ae20dd fix(account): coalesce concurrent console token refreshes (#20503) 2026-04-01 13:16:35 -04:00
Kit Langton 8e6b0e35cd test(app): migrate more e2e suites to isolated backend (#20505) 2026-04-01 13:15:42 -04:00
github-actions[bot] f29806353d Update VOUCHED list
https://github.com/anomalyco/opencode/issues/20482#issuecomment-4171492178
2026-04-01 16:50:21 +00:00
opencode-agent[bot] 0dbac145cc chore: update nix node_modules hashes 2026-04-01 16:47:33 +00:00
opencode-agent[bot] 4c3a97e7b3 chore: generate 2026-04-01 16:14:37 +00:00
Kit Langton 9bb7db7ef5 refactor: use Effect services instead of async facades in provider, auth, and file (#20480) 2026-04-01 16:13:13 +00:00
Kit Langton c1db6afc10 refactor(shell): use Effect ChildProcess for shell command execution (#20494) 2026-04-01 12:07:57 -04:00
Shoubhit Dash a0916847f9 refactor(init): tighten AGENTS guidance (#20422) 2026-04-01 21:37:25 +05:30
opencode-agent[bot] 20fa6be937 chore: generate 2026-04-01 15:59:28 +00:00
Kit Langton 3d4ba8b1aa test(e2e): isolate prompt tests with per-worker backend (#20464) 2026-04-01 15:58:11 +00:00
Dax Raad 42f032965e fall back to first agent if last used agent is not available 2026-04-01 11:09:29 -04:00
Kit Langton ba3b43dbd0 refactor: standardize InstanceState variable name to state (#20267) 2026-04-01 10:39:43 -04:00
Shoubhit Dash d96a37c06b perf(review): defer offscreen diff mounts (#20469) 2026-04-01 19:29:12 +05:30
Kit Langton 32bc9e0e59 test(app): route prompt e2e through mock llm (#20383) 2026-04-01 08:28:38 -04:00
Shoubhit Dash 345b76313e feat(comments): support file mentions (#20447) 2026-04-01 16:11:57 +05:30
Shoubhit Dash 7a9f4c5489 fix(session): add keyboard support to question dock (#20439) 2026-04-01 15:47:15 +05:30
Brendan Allan 80a75b5004 app: try to hide autofill popups in prompt input (#20197) 2026-04-01 08:43:03 +00:00
Brendan Allan c32c5245df electron: port mergeShellEnv logic from tauri (#20192) 2026-04-01 07:01:44 +00:00
Kit Langton 549ac73fe5 test(app): add mock llm e2e fixture (#20375) 2026-03-31 21:24:39 -04:00
opencode-agent[bot] 2362d09e3c chore: update nix node_modules hashes 2026-04-01 01:15:51 +00:00
Kit Langton 92b86bd2a0 test: finish HTTP mock processor coverage (#20372) 2026-04-01 00:45:42 +00:00
Kit Langton 0fc81f6d0b refactor(prompt): use Provider service in effect layers (#20167) 2026-04-01 00:44:15 +00:00
opencode 46feb7f777 release: v1.3.13 2026-04-01 00:44:06 +00:00
Sebastian 3ae7586aea upgrade opentui to 0.1.95 (#20369) 2026-04-01 01:53:05 +02:00
opencode-agent[bot] 07ef448abb chore: update nix node_modules hashes 2026-03-31 23:19:18 +00:00
opencode-agent[bot] e806141be0 chore: generate 2026-03-31 23:15:56 +00:00
Kit Langton 00d6a0486c refactor(session): effectify SessionRevert service (#20143) 2026-03-31 19:14:49 -04:00
Kit Langton a4a7e80c88 refactor(session): effectify SessionSummary service (#20142) 2026-03-31 19:14:45 -04:00
Kit Langton 9f35ffe300 test: migrate prompt tests to HTTP mock LLM server (#20304) 2026-03-31 19:14:32 -04:00
github-actions[bot] 1c6c935738 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/20333#issuecomment-4166038038
2026-03-31 22:35:10 +00:00
opencode 766dadcb24 release: v1.3.12 2026-03-31 22:35:01 +00:00
Sebastian a481f1766b upgrade opentui to 0.1.94 (#20357) 2026-03-31 23:54:13 +02:00
github-actions[bot] 6354149e70 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/20342#issuecomment-4165277636
2026-03-31 20:24:07 +00:00
Major Hayden d4c3b2944a feat: enable prompt caching and cache token tracking for google-vertex-anthropic (#20266)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-03-31 15:16:14 -05:00
Aiden Cline 63fead38a8 fix: rm exclusion of ai-sdk/azure in transform.ts, when we migrated to v6 the ai sdk changed the key for ai-sdk/azure so the exclusion is no longer needed (#20326) 2026-03-31 14:57:15 -05:00
opencode 238dcd5db1 release: v1.3.11 2026-03-31 19:55:41 +00:00
Dax 32a1c9efaa refactor(session): simplify LLM stream by replacing queue with fromAsyncIterable (#20324) 2026-03-31 15:27:51 -04:00
Aiden Cline 485d16d79a test: update model test fixture (#20182) 2026-03-31 16:20:01 +00:00
Aiden Cline 762c662c9e chore: rm harcoded model definition from codex plugin (#20294) 2026-03-31 11:13:11 -05:00
Adam 621fc80fdc chore: skip 2 tests 2026-03-31 10:56:06 -05:00
Yuxin Dong f7ec65357e feat: add a dedicated system prompt for Kimi models (#20259)
Co-authored-by: dongyuxin <dongyuxin@dev.dongyuxin.msh-dev.svc.cluster.local>
2026-03-31 17:44:17 +02:00
opencode-agent[bot] d30818e2dc chore: update nix node_modules hashes 2026-03-31 15:33:12 +00:00
Adam f53b5db603 chore: e2e model 2026-03-31 10:14:26 -05:00
Sebastian bea4bfe674 warn only and ignore plugins without entrypoints, default config via exports (#20284) 2026-03-31 17:14:03 +02:00
Adam 9583c9f627 chore: use paid zen model in e2e 2026-03-31 10:06:44 -05:00
Sebastian 089cd11b1f ensure pinned plugin versions and do not run package scripts on install (#20248) 2026-03-31 16:59:43 +02:00
Sebastian d5cd77887f upgrade opentui to 0.1.93 (#19950) 2026-03-31 16:50:23 +02:00
opencode 6572c8b70e release: v1.3.10 2026-03-31 13:31:37 +00:00
Dax 0de4cac6e7 fix(session): subagents not being clickable (#20263) 2026-03-31 08:58:46 -04:00
Frank 292b51b708 wip: zen 2026-03-31 00:07:56 -04:00
opencode-agent[bot] d0c3e93929 chore: generate 2026-03-31 01:57:41 +00:00
Kit Langton 0b7afbed8c refactor(provider): effectify Provider service (#20160) 2026-03-30 21:56:43 -04:00
Kit Langton af252ebc18 refactor(storage): effectify Storage service (#20132) 2026-03-31 01:16:02 +00:00
Kit Langton 44be3543cc fix(theme): darken muted text in catppuccin tui themes (#20161) 2026-03-30 21:06:05 -04:00
Luke Parker cbc283934f smarter changelog (#20138) 2026-03-31 00:05:46 +00:00
opencode b92339b88d release: v1.3.9 2026-03-30 23:43:58 +00:00
Luke Parker 176129b648 fix(plugin): properly resolve entrypoints without leading dot (#20140) 2026-03-31 09:21:17 +10:00
Frank d6aa8fd29b wip: zen 2026-03-30 19:04:42 -04:00
Frank 8b08b5ebec wip: zen 2026-03-30 18:50:09 -04:00
opencode-agent[bot] a62a96603e chore: generate 2026-03-30 21:49:45 +00:00
Kit Langton 54a250fb38 refactor: replace Filesystem util with AppFileSystem service (#20127) 2026-03-30 21:48:28 +00:00
opencode 316283c7e2 release: v1.3.8 2026-03-30 21:35:10 +00:00
opencode-agent[bot] 9e159205a5 chore: update nix node_modules hashes 2026-03-30 20:46:10 +00:00
Frank 9daddf0de2 zen: qwen3.6 plus 2026-03-30 16:43:55 -04:00
Frank 7326189450 zen: qwen3.6 plus 2026-03-30 16:32:22 -04:00
Kit Langton 045b8c8077 fix(session): restore busy route handling and add regression coverage (#20125) 2026-03-30 20:30:34 +00:00
Frank d107f5229e zen: add safety identifier 2026-03-30 16:28:40 -04:00
Jaaneek c504f5fc50 fix(Opencode): Bump ai-sdk/xai to 3.0.75 to fix tool calls (#20123)
Co-authored-by: Jaaneek <jankiewiczmilosz@gmail.com>
2026-03-30 15:11:21 -05:00
opencode-agent[bot] 53c4a2d104 chore: generate 2026-03-30 20:08:09 +00:00
Kit Langton 9ee382c8c7 refactor(session): effectify SessionPrompt service (#19483) 2026-03-30 20:06:51 +00:00
Sebastian bf5e0a293e Refactor into plugin loader and do not enforce (#20112) 2026-03-30 20:36:21 +02:00
Aiden Cline 1613c9687e fix: agent value passed to chat.params and chat.headers hooks was not a string (#19996) 2026-03-30 16:32:27 +00:00
Adam 1f36ca7b39 chore(app): cleanup (#20062) 2026-03-30 08:50:42 -05:00
Sebastian 23214cc7e1 pluggable home footer (#20057) 2026-03-30 14:33:01 +02:00
Sebastian c5c8783ff3 update plugin themes when plugin was updated (#20052) 2026-03-30 13:51:07 +02:00
Jack 692840c93c fix: preserve image attachments when selecting slash commands (#19771) 2026-03-30 17:11:34 +08:00
opencode 8a34e1c134 release: v1.3.7 2026-03-30 06:06:12 +00:00
Luke Parker 51eb419de1 make variant modal less annoying (#19998) 2026-03-30 15:42:38 +10:00
Luke Parker 087bebe8d1 fix: stabilize release changelog generation (#19987) 2026-03-30 04:37:02 +00:00
Chris Yang 4595aa6e37 docs: rename patch tool to apply_patch and clarify apply_patch behavior (#19979) 2026-03-29 23:01:57 -05:00
opencode-agent[bot] 5b62dce2d8 chore: update nix node_modules hashes 2026-03-30 03:53:11 +00:00
Knut Zuidema 32f39df153 fix: discourage _noop tool call during LiteLLM compaction (#18539) 2026-03-29 22:48:17 -05:00
Luke Parker 24f7ef41d6 feat(windows): add first-class pwsh/powershell support (#16069) 2026-03-30 13:10:01 +10:00
Sebastian 3790ae221c theme colors for dialog textarea placeholders (#19939) 2026-03-29 21:37:46 +02:00
Sebastian 4e16f97f88 plugins installs should preserve jsonc comments (#19938) 2026-03-29 21:15:03 +02:00
Aiden Cline 25f1fa8d04 fix: ensure OPENCODE_DISABLE_CLAUDE_CODE_PROMPT is respected for project lvl CLAUDE.md (#19924) 2026-03-29 18:02:29 +00:00
opencode 2de8244b49 release: v1.3.6 2026-03-29 18:02:20 +00:00
ualtinok 0707c1d53c fix(session): fix token usage double-counting w/ anthropic & bedrock due to AI SDK v6 upgrade (#19758)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-03-29 12:40:10 -05:00
Ariane Emory d33a7984c8 docs: add question tool to available permissions list (#19922) 2026-03-29 17:35:52 +00:00
Frank 8c78a0b1f5 wip: zen 2026-03-29 13:06:00 -04:00
Frank 4d182a3551 wip: zen 2026-03-29 12:47:53 -04:00
Kit Langton 65a4a1be7e Fix variant dialog filtering (#19917) 2026-03-29 16:43:13 +00:00
Frank 05fc23013e ci: fix 2026-03-29 12:41:52 -04:00
Frank a2ae7ddf00 wip: zen 2026-03-29 12:04:20 -04:00
Luke Parker a434ed79e0 test: add regression coverage for sync plugin hooks (#19589) 2026-03-29 03:43:00 +00:00
1210 changed files with 163274 additions and 94904 deletions
+7 -1
View File
@@ -11,6 +11,7 @@ adamdotdevin
-agusbasari29 AI PR slop
ariane-emory
-atharvau AI review spamming literally every PR
-borealbytes
-danieljoshuanazareth
-danieljoshuanazareth
edemaine
@@ -21,8 +22,13 @@ jayair
kitlangton
kommander
-opencode2026
-opencodeengineer bot that spams issues
r44vc0rp
rekram1-node
-ricardo-m-l
-robinmordasiewicz
shantur
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
-OpenCodeEngineer bot that spams issues
-toastythebot
+3 -2
View File
@@ -9,7 +9,8 @@ on:
jobs:
sync-locales:
if: github.actor != 'opencode-agent[bot]'
if: false
#if: github.actor != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
@@ -34,7 +35,7 @@ jobs:
- name: Compute changed English docs
id: changes
run: |
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true)
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- ':(glob)packages/web/src/content/docs/*.mdx' || true)
if [ -z "$FILES" ]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No English docs changed in push range"
+2 -1
View File
@@ -389,6 +389,7 @@ jobs:
needs:
- build-cli
- version
if: github.repository == 'anomalyco/opencode'
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -421,7 +422,6 @@ jobs:
target: aarch64-unknown-linux-gnu
platform_flag: --linux
runs-on: ${{ matrix.settings.host }}
# if: github.ref_name == 'beta'
steps:
- uses: actions/checkout@v3
@@ -547,6 +547,7 @@ jobs:
- sign-cli-windows
- build-tauri
- build-electron
if: always() && !failure() && !cancelled()
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
+50 -3
View File
@@ -15,6 +15,10 @@ concurrency:
permissions:
contents: read
checks: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
unit:
@@ -37,6 +41,11 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: ./.github/actions/setup-bun
@@ -45,8 +54,39 @@ jobs:
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
- name: Cache Turbo
uses: actions/cache@v4
with:
path: node_modules/.cache/turbo
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
turbo-${{ runner.os }}-
- name: Run unit tests
run: bun turbo test
run: bun turbo test:ci
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Publish unit reports
if: always()
uses: mikepenz/action-junit-report@v6
with:
report_paths: packages/*/.artifacts/unit/junit.xml
check_name: "unit results (${{ matrix.settings.name }})"
detailed_summary: true
include_time_in_summary: true
fail_on_failure: false
- name: Upload unit artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/*/.artifacts/unit/junit.xml
e2e:
name: e2e (${{ matrix.settings.name }})
@@ -70,13 +110,18 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Read Playwright version
id: playwright-version
run: |
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
@@ -100,15 +145,17 @@ jobs:
run: bun --cwd packages/app test:e2e:local
env:
CI: true
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
timeout-minutes: 30
- name: Upload Playwright artifacts
if: failure()
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
if-no-files-found: ignore
retention-days: 7
path: |
packages/app/e2e/junit-*.xml
packages/app/e2e/test-results
packages/app/e2e/playwright-report
+1
View File
@@ -25,6 +25,7 @@ target
# Local dev files
opencode-dev
UPCOMING_CHANGELOG.md
logs/
*.bun-build
tsconfig.tsbuildinfo
+2 -1
View File
@@ -3,4 +3,5 @@ plans
package.json
bun.lock
.gitignore
package-lock.json
package-lock.json
references/
-1
View File
@@ -594,7 +594,6 @@ OPENCODE_DISABLE_CLAUDE_CODE
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
OPENCODE_DISABLE_DEFAULT_PLUGINS
OPENCODE_DISABLE_FILETIME_CHECK
OPENCODE_DISABLE_LSP_DOWNLOAD
OPENCODE_DISABLE_MODELS_FETCH
OPENCODE_DISABLE_PRUNE
+35 -12
View File
@@ -1,23 +1,46 @@
---
model: opencode/kimi-k2.5
model: opencode/gpt-5.4
---
create UPCOMING_CHANGELOG.md
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
Do not preserve, merge, or reuse text from the existing file.
it should have sections
The input already contains the exact commit range since the last non-draft release.
The commits are already filtered to the release-relevant packages and grouped into
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
The input may also include a `## Community Contributors Input` section.
```
## TUI
Before writing any entry you keep, inspect the real diff with
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so you can
understand the actual code changes and not just the commit message (they may be misleading).
Do not use `git log` or author metadata when deciding attribution.
## Desktop
Rules:
## Core
- Write the final file with sections in this order:
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
- Only include sections that have at least one notable entry
- Keep one bullet per commit you keep
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
- Start each bullet with a capital letter
- Prefer what changed for users over what code changed internally
- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)`
- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input
- If an input bullet has no `(@username)` suffix, do not add one
- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses
- If no notable entries remain and there is no contributor block, write exactly `No notable changes.`
- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block
- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
- Do not derive the thank-you section from the main summary bullets
- Do not include the heading `## Community Contributors Input` in the final file
- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise
## Misc
```
**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail**
fetch the latest github release for this repository to determine the last release version.
<changelog_input>
find each PR that was merged since the last release
!`bun script/raw-changelog.ts $ARGUMENTS`
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.
</changelog_input>
+1 -5
View File
@@ -1,10 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"opencode": {
"options": {},
},
},
"provider": {},
"permission": {
"edit": {
"packages/opencode/migration/*": "deny",
+53 -7
View File
@@ -653,23 +653,30 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
const skin = look(ctx.theme.current)
type Prompt = (props: {
workspaceID?: string
visible?: boolean
disabled?: boolean
onSubmit?: () => void
hint?: JSX.Element
right?: JSX.Element
showPlaceholder?: boolean
placeholders?: {
normal?: string[]
shell?: string[]
}
}) => JSX.Element
if (!("Prompt" in api.ui)) return null
const view = api.ui.Prompt
if (typeof view !== "function") return null
const Prompt = view as Prompt
type Slot = (
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
) => JSX.Element | null
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
const Prompt = ui.Prompt
const Slot = ui.Slot
const normal = [
`[SMOKE] route check for ${input.label}`,
"[SMOKE] confirm home_prompt slot override",
"[SMOKE] verify api.ui.Prompt rendering",
"[SMOKE] verify prompt-right slot passthrough",
]
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
const Hint = (
const hint = (
<box flexShrink={0} flexDirection="row" gap={1}>
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}></span> smoke home prompt
@@ -677,7 +684,46 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
</box>
)
return <Prompt workspaceID={value.workspace_id} hint={Hint} placeholders={{ normal, shell }} />
return (
<Prompt
workspaceID={value.workspace_id}
hint={hint}
right={
<box flexDirection="row" gap={1}>
<Slot name="home_prompt_right" workspace_id={value.workspace_id} />
<Slot name="smoke_prompt_right" workspace_id={value.workspace_id} label={input.label} />
</box>
}
placeholders={{ normal, shell }}
/>
)
},
home_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
const id = value.workspace_id?.slice(0, 8) ?? "none"
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> home:{id}
</text>
)
},
session_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> session:{value.session_id.slice(0, 8)}
</text>
)
},
smoke_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
const id = typeof value.workspace_id === "string" ? value.workspace_id.slice(0, 8) : "none"
const label = typeof value.label === "string" ? value.label : input.label
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{label}</span> custom:{id}
</text>
)
},
home_bottom(ctx) {
const skin = look(ctx.theme.current)
+21
View File
@@ -0,0 +1,21 @@
---
name: effect
description: Answer questions about the Effect framework
---
# Effect
This codebase uses Effect, a framework for writing typescript.
## How to Answer Effect Questions
1. Clone the Effect repository: `https://github.com/Effect-TS/effect-smol` to
`.opencode/references/effect-smol` in this project NOT the skill folder.
2. Use the explore agent to search the codebase for answers about Effect patterns, APIs, and concepts
3. Provide responses based on the actual Effect source code and documentation
## Guidelines
- Always use the explore agent with the cloned repository when answering Effect-related questions
- Reference specific files and patterns found in the Effect codebase
- Do not answer from memory - always verify against the source
+2 -2
View File
@@ -116,8 +116,8 @@
"light": "nord5"
},
"diffLineNumber": {
"dark": "nord2",
"light": "nord4"
"dark": "#abafb7",
"light": "textMuted"
},
"diffAddedLineNumberBg": {
"dark": "#3B4252",
+1 -1
View File
@@ -7,7 +7,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
...options.headers,
...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers),
},
})
if (!response.ok) {
+1 -1
View File
@@ -28,7 +28,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
...options.headers,
...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers),
},
})
if (!response.ok) {
+51
View File
@@ -0,0 +1,51 @@
{
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
"options": {
"typeAware": true
},
"categories": {
"suspicious": "warn"
},
"rules": {
"typescript/no-base-to-string": "warn",
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
"require-yield": "off",
// SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime
"no-unassigned-vars": "off",
// SolidJS tracks reactive deps by reading properties inside createEffect
"no-unused-expressions": "off",
// Intentional control char matching (ANSI escapes, null byte sanitization)
"no-control-regex": "off",
// SST and plugin tools require triple-slash references
"triple-slash-reference": "off",
// Suspicious category: suppress noisy rules
// Effect's nested function* closures inherently shadow outer scope
"no-shadow": "off",
// Namespace-heavy codebase makes this too noisy
"unicorn/consistent-function-scoping": "off",
// Opinionated — .sort()/.reverse() mutation is fine in this codebase
"unicorn/no-array-sort": "off",
"unicorn/no-array-reverse": "off",
// Not relevant — this isn't a DOM event handler codebase
"unicorn/prefer-add-event-listener": "off",
// Bundler handles module resolution
"unicorn/require-module-specifiers": "off",
// postMessage target origin not relevant for this codebase
"unicorn/require-post-message-target-origin": "off",
// Side-effectful constructors are intentional in some places
"no-new": "off",
// Type-aware: catch unhandled promises
"typescript/no-floating-promises": "warn",
// Warn when spreading non-plain objects (Headers, class instances, etc.)
"typescript/no-misused-spread": "warn"
},
"options": {
"typeAware": true
},
"options": {
"typeAware": true
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
}
+1 -26
View File
@@ -11,35 +11,10 @@
- Keep things in one function unless composable or reusable
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Prefer single word variable names where possible
- Use Bun APIs when possible, like `Bun.file()`
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
### Naming
Prefer single word names for variables and functions. Only use multiple words if necessary.
### Naming Enforcement (Read This)
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
- Use single word names by default for new locals, params, and helper functions.
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
```ts
// Good
const foo = 1
function journal(dir: string) {}
// Bad
const fooBar = 1
function prepareJournal(dir: string) {}
```
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
Reduce total variable count by inlining when a value is only used once.
+1145 -622
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -281,7 +281,7 @@ async function assertOpencodeConnected() {
})
connected = true
break
} catch (e) {}
} catch {}
await sleep(300)
} while (retry++ < 30)
@@ -513,7 +513,7 @@ async function subscribeSessionEvents() {
const decoder = new TextDecoder()
let text = ""
;(async () => {
void (async () => {
while (true) {
try {
const { done, value } = await reader.read()
@@ -542,7 +542,7 @@ async function subscribeSessionEvents() {
? JSON.stringify(part.state.input)
: "Unknown"
console.log()
console.log(color + `|`, "\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`, "", "\x1b[0m" + title)
console.log(`${color}|`, `\x1b[0m\x1b[2m ${tool.padEnd(7, " ")}`, "", `\x1b[0m${title}`)
}
if (part.type === "text") {
@@ -561,7 +561,7 @@ async function subscribeSessionEvents() {
if (evt.properties.info.id !== session.id) continue
session = evt.properties.info
}
} catch (e) {
} catch {
// Ignore parse errors
}
}
@@ -576,7 +576,7 @@ async function subscribeSessionEvents() {
async function summarize(response: string) {
try {
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
} catch (e) {
} catch {
if (isScheduleEvent()) {
return "Scheduled task changes"
}
@@ -776,7 +776,7 @@ async function assertPermissions() {
console.log(` permission: ${permission}`)
} catch (error) {
console.error(`Failed to check permissions: ${error}`)
throw new Error(`Failed to check permissions for user ${actor}: ${error}`)
throw new Error(`Failed to check permissions for user ${actor}: ${error}`, { cause: error })
}
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
+7
View File
@@ -109,6 +109,12 @@ const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50",
appliesToProducts: [zenLiteProduct.id],
duration: "once",
})
const zenLiteCouponFirstMonth100 = new stripe.Coupon("ZenLiteCouponFirstMonth100", {
name: "First month 100% off",
percentOff: 100,
appliesToProducts: [zenLiteProduct.id],
duration: "once",
})
const zenLitePrice = new stripe.Price("ZenLitePrice", {
product: zenLiteProduct.id,
currency: "usd",
@@ -124,6 +130,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
price: zenLitePrice.id,
priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
firstMonth100Coupon: zenLiteCouponFirstMonth100.id,
},
})
+2 -2
View File
@@ -1,9 +1,9 @@
import { SECRET } from "./secret"
import { domain, shortDomain } from "./stage"
import { shortDomain } from "./stage"
const storage = new sst.cloudflare.Bucket("EnterpriseStorage")
const teams = new sst.cloudflare.x.SolidStart("Teams", {
new sst.cloudflare.x.SolidStart("Teams", {
domain: shortDomain,
path: "packages/enterprise",
buildCommand: "bun run build:cloudflare",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-ppK5TVMsmy/7uP1kc6hw3gHMxokD/hBZYt5IGHR3/ok=",
"aarch64-linux": "sha256-lrZjanBS8iHJa5TJJHQ9Gaz+lUqNaTgAUuDd6QHu8No=",
"aarch64-darwin": "sha256-EojkRZQF5NqKPF3Bd/8UIiNngpkBk7uAM8m875bfOUo=",
"x86_64-darwin": "sha256-fEO9Hx8yikkvdGj8nC06fy4u/hTGWO6kjENsU/B2OyY="
"x86_64-linux": "sha256-DOGOZdPdkcuyDhVAyWHGsL4rrV28S+YFZj/VORuoQ8Q=",
"aarch64-linux": "sha256-WRnAaEoKvgFFZ+UkbYtD9gBw0HtV1jdUqv7yUE2uTAQ=",
"aarch64-darwin": "sha256-LxIj/dsL88M99T3WLaD9FL6Qdu2TV+kr1RMZaZ3i4WM=",
"x86_64-darwin": "sha256-PgIvplw6yz9KN5nBWox3BXZIXDbkJ3ZuDPKKSVF82MU="
}
}
+1
View File
@@ -55,6 +55,7 @@ stdenvNoCC.mkDerivation {
--filter './packages/opencode' \
--filter './packages/desktop' \
--filter './packages/app' \
--filter './packages/shared' \
--frozen-lockfile \
--ignore-scripts \
--no-progress
+23 -9
View File
@@ -7,11 +7,13 @@
"packageManager": "bun@1.3.11",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev",
"dev:desktop": "bun --cwd packages/desktop-electron dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"typecheck": "bun turbo typecheck",
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'",
@@ -25,10 +27,15 @@
"packages/slack"
],
"catalog": {
"@effect/platform-node": "4.0.0-beta.42",
"@effect/opentelemetry": "4.0.0-beta.48",
"@effect/platform-node": "4.0.0-beta.48",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.11",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -45,8 +52,9 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.42",
"ai": "6.0.138",
"effect": "4.0.0-beta.48",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
@@ -54,7 +62,8 @@
"marked": "17.0.1",
"marked-shiki": "1.2.1",
"remend": "1.3.0",
"@playwright/test": "1.51.0",
"@playwright/test": "1.59.1",
"semver": "7.7.4",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
@@ -68,7 +77,8 @@
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10"
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.10"
}
},
"devDependencies": {
@@ -78,6 +88,8 @@
"@typescript/native-preview": "catalog:",
"glob": "13.0.5",
"husky": "9.1.7",
"oxlint": "1.60.0",
"oxlint-tsgolint": "0.21.0",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "3.18.10",
@@ -88,6 +100,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:"
},
"repository": {
@@ -101,9 +114,11 @@
},
"trustedDependencies": [
"esbuild",
"node-pty",
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron"
],
@@ -112,9 +127,8 @@
"@types/node": "catalog:"
},
"patchedDependencies": {
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/provider-utils@4.0.21": "patches/@ai-sdk%2Fprovider-utils@4.0.21.patch",
"@ai-sdk/anthropic@3.0.64": "patches/@ai-sdk%2Fanthropic@3.0.64.patch"
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
}
}
+2 -3
View File
@@ -31,11 +31,10 @@ Your app is ready to be deployed!
## E2E Testing
Playwright starts the Vite dev server automatically via `webServer`, and UI tests need an opencode backend (defaults to `localhost:4096`).
Use the local runner to create a temp sandbox, seed data, and run the tests.
Playwright starts the Vite dev server automatically via `webServer`, and UI tests expect an opencode backend at `localhost:4096` by default.
```bash
bunx playwright install
bunx playwright install chromium
bun run test:e2e:local
bun run test:e2e:local -- --grep "settings"
```
-226
View File
@@ -1,226 +0,0 @@
# E2E Testing Guide
## Build/Lint/Test Commands
```bash
# Run all e2e tests
bun test:e2e
# Run specific test file
bun test:e2e -- app/home.spec.ts
# Run single test by title
bun test:e2e -- -g "home renders and shows core entrypoints"
# Run tests with UI mode (for debugging)
bun test:e2e:ui
# Run tests locally with full server setup
bun test:e2e:local
# View test report
bun test:e2e:report
# Typecheck
bun typecheck
```
## Test Structure
All tests live in `packages/app/e2e/`:
```
e2e/
├── fixtures.ts # Test fixtures (test, expect, gotoSession, sdk)
├── actions.ts # Reusable action helpers
├── selectors.ts # DOM selectors
├── utils.ts # Utilities (serverUrl, modKey, path helpers)
└── [feature]/
└── *.spec.ts # Test files
```
## Test Patterns
### Basic Test Structure
```typescript
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
test("test description", async ({ page, sdk, gotoSession }) => {
await gotoSession() // or gotoSession(sessionID)
// Your test code
await expect(page.locator(promptSelector)).toBeVisible()
})
```
### Using Fixtures
- `page` - Playwright page
- `sdk` - OpenCode SDK client for API calls
- `gotoSession(sessionID?)` - Navigate to session
### Helper Functions
**Actions** (`actions.ts`):
- `openPalette(page)` - Open command palette
- `openSettings(page)` - Open settings dialog
- `closeDialog(page, dialog)` - Close any dialog
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
- `withSession(sdk, title, callback)` - Create temp session
- `withProject(...)` - Create temp project/workspace
- `sessionIDFromUrl(url)` - Read session ID from URL
- `slugFromUrl(url)` - Read workspace slug from URL
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
- `trackDirectory(directory)` - Register directory for fixture cleanup
- `clickListItem(container, filter)` - Click list item by key/text
**Selectors** (`selectors.ts`):
- `promptSelector` - Prompt input
- `terminalSelector` - Terminal panel
- `sessionItemSelector(id)` - Session in sidebar
- `listItemSelector` - Generic list items
**Utils** (`utils.ts`):
- `modKey` - Meta (Mac) or Control (Linux/Win)
- `serverUrl` - Backend server URL
- `sessionPath(dir, id?)` - Build session URL
## Code Style Guidelines
### Imports
Always import from `../fixtures`, not `@playwright/test`:
```typescript
// ✅ Good
import { test, expect } from "../fixtures"
// ❌ Bad
import { test, expect } from "@playwright/test"
```
### Naming Conventions
- Test files: `feature-name.spec.ts`
- Test names: lowercase, descriptive: `"sidebar can be toggled"`
- Variables: camelCase
- Constants: SCREAMING_SNAKE_CASE
### Error Handling
Tests should clean up after themselves. Prefer fixture-managed cleanup:
```typescript
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, "test session", async (session) => {
await gotoSession(session.id)
// Test code...
}) // Auto-deletes session
})
```
- Prefer `withSession(...)` for temp sessions
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
- Avoid calling `sdk.session.delete(...)` directly
### Timeouts
Default: 60s per test, 10s per assertion. Override when needed:
```typescript
test.setTimeout(120_000) // For long LLM operations
test("slow test", async () => {
await expect.poll(() => check(), { timeout: 90_000 }).toBe(true)
})
```
### Selectors
Use `data-component`, `data-action`, or semantic roles:
```typescript
// ✅ Good
await page.locator('[data-component="prompt-input"]').click()
await page.getByRole("button", { name: "Open settings" }).click()
// ❌ Bad
await page.locator(".css-class-name").click()
await page.locator("#id-name").click()
```
### Keyboard Shortcuts
Use `modKey` for cross-platform compatibility:
```typescript
import { modKey } from "../utils"
await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
await page.keyboard.press(`${modKey}+Comma`) // Open settings
```
### Terminal Tests
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters.
- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts.
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
### Wait on state
- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass
- Avoid race-prone flows that assume work is finished after an action
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops
- Do not treat a visible element as proof that the app will route the next action to it
- When fixing a flake, validate with `--repeat-each` and multiple workers when practical
### Add hooks
- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable
- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states
### Prefer helpers
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
- Use direct locators when the interaction is simple and a helper would not add clarity
- Prefer helpers that both perform an action and verify the app consumed it
- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state
- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions
## Writing New Tests
1. Choose appropriate folder or create new one
2. Import from `../fixtures`
3. Use helper functions from `../actions` and `../selectors`
4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs.
5. Clean up any created resources
6. Use specific selectors (avoid CSS classes)
7. Test one feature per test file
## Local Development
For UI debugging, use:
```bash
bun test:e2e:ui
```
This opens Playwright's interactive UI for step-through debugging.
File diff suppressed because it is too large Load Diff
-24
View File
@@ -1,24 +0,0 @@
import { test, expect } from "../fixtures"
import { serverNamePattern } from "../utils"
test("home renders and shows core entrypoints", async ({ page }) => {
await page.goto("/")
const nav = page.locator('[data-component="sidebar-nav-desktop"]')
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
await expect(nav.getByText("No projects open")).toBeVisible()
await expect(nav.getByText("Open a project to get started")).toBeVisible()
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
})
test("server picker dialog opens from home", async ({ page }) => {
await page.goto("/")
const trigger = page.getByRole("button", { name: serverNamePattern })
await expect(trigger).toBeVisible()
await trigger.click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("textbox").first()).toBeVisible()
})
-10
View File
@@ -1,10 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { dirPath } from "../utils"
test("project route redirects to /session", async ({ page, directory, slug }) => {
await page.goto(dirPath(directory))
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
await expect(page.locator(promptSelector)).toBeVisible()
})
-20
View File
@@ -1,20 +0,0 @@
import { test, expect } from "../fixtures"
import { closeDialog, openPalette } from "../actions"
test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page)
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page, "P")
await closeDialog(page, dialog)
await expect(dialog).toHaveCount(0)
})
@@ -1,58 +0,0 @@
import { test, expect } from "../fixtures"
import { serverNamePattern, serverUrls } from "../utils"
import { closeDialog, clickMenuItem } from "../actions"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
test("can set a default server on web", async ({ page, gotoSession }) => {
await page.addInitScript((key: string) => {
try {
localStorage.removeItem(key)
} catch {
return
}
}, DEFAULT_SERVER_URL_KEY)
await gotoSession()
const status = page.getByRole("button", { name: "Status" })
await expect(status).toBeVisible()
const popover = page.locator('[data-component="popover-content"]').filter({ hasText: "Manage servers" })
const ensurePopoverOpen = async () => {
if (await popover.isVisible()) return
await status.click()
await expect(popover).toBeVisible()
}
await ensurePopoverOpen()
await popover.getByRole("button", { name: "Manage servers" }).click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByText(serverNamePattern).first()).toBeVisible()
const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click({ force: true })
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
await expect(menu).toBeVisible()
await clickMenuItem(menu, /set as default/i)
await expect
.poll(async () =>
serverUrls.includes((await page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)) ?? ""),
)
.toBe(true)
await expect(dialog.getByText("Default", { exact: true })).toBeVisible()
await closeDialog(page, dialog)
await ensurePopoverOpen()
const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first()
await expect(serverRow).toBeVisible()
await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
})
-16
View File
@@ -1,16 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke ${Date.now()}`
await withSession(sdk, title, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type("hello from e2e")
await expect(prompt).toContainText("hello from e2e")
})
})
@@ -1,120 +0,0 @@
import { test, expect } from "../fixtures"
import { defocus, openSidebar, withSession } from "../actions"
import { promptSelector } from "../selectors"
import { modKey } from "../utils"
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const stamp = Date.now()
await withSession(sdk, `e2e titlebar history 1 ${stamp}`, async (one) => {
await withSession(sdk, `e2e titlebar history 2 ${stamp}`, async (two) => {
await gotoSession(one.id)
await openSidebar(page)
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
const back = page.getByRole("button", { name: "Back" })
const forward = page.getByRole("button", { name: "Forward" })
await expect(back).toBeVisible()
await expect(back).toBeEnabled()
await back.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(forward).toBeVisible()
await expect(forward).toBeEnabled()
await forward.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("titlebar forward is cleared after branching history from sidebar", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const stamp = Date.now()
await withSession(sdk, `e2e titlebar history a ${stamp}`, async (a) => {
await withSession(sdk, `e2e titlebar history b ${stamp}`, async (b) => {
await withSession(sdk, `e2e titlebar history c ${stamp}`, async (c) => {
await gotoSession(a.id)
await openSidebar(page)
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
await expect(second).toBeVisible()
await second.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
const back = page.getByRole("button", { name: "Back" })
const forward = page.getByRole("button", { name: "Forward" })
await expect(back).toBeVisible()
await expect(back).toBeEnabled()
await back.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${a.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await openSidebar(page)
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
await expect(third).toBeVisible()
await third.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(forward).toBeVisible()
await expect(forward).toBeDisabled()
})
})
})
})
test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const stamp = Date.now()
await withSession(sdk, `e2e titlebar shortcuts 1 ${stamp}`, async (one) => {
await withSession(sdk, `e2e titlebar shortcuts 2 ${stamp}`, async (two) => {
await gotoSession(one.id)
await openSidebar(page)
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await defocus(page)
await page.keyboard.press(`${modKey}+[`)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await defocus(page)
await page.keyboard.press(`${modKey}+]`)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
@@ -1,15 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("ctrl+l focuses the prompt", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await page.locator("main").click({ position: { x: 5, y: 5 } })
await expect(prompt).not.toBeFocused()
await page.keyboard.press("Control+L")
await expect(prompt).toBeFocused()
})
-33
View File
@@ -1,33 +0,0 @@
import { test, expect } from "../fixtures"
import { modKey } from "../utils"
const expanded = async (el: { getAttribute: (name: string) => Promise<string | null> }) => {
const value = await el.getAttribute("aria-expanded")
if (value !== "true" && value !== "false") throw new Error(`Expected aria-expanded to be true|false, got: ${value}`)
return value === "true"
}
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
await gotoSession()
const reviewPanel = page.locator("#review-panel")
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
await expect(treeToggle).toBeVisible()
if (await expanded(treeToggle)) await treeToggle.click()
await expect(treeToggle).toHaveAttribute("aria-expanded", "false")
const reviewToggle = page.getByRole("button", { name: "Toggle review" }).first()
await expect(reviewToggle).toBeVisible()
if (await expanded(reviewToggle)) await reviewToggle.click()
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
await expect(reviewPanel).toHaveAttribute("aria-hidden", "false")
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
})
@@ -1,32 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { modKey } from "../utils"
test("mod+w closes the active file tab", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
await expect(page.locator('[data-slash-id="file.open"]').first()).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
await dialog.getByRole("textbox").first().fill("package.json")
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
await expect(item).toBeVisible({ timeout: 30_000 })
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" }).first()
await expect(tab).toBeVisible()
await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
await page.keyboard.press(`${modKey}+W`)
await expect(page.getByRole("tab", { name: "package.json" })).toHaveCount(0)
})
-31
View File
@@ -1,31 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
await expect(item).toBeVisible({ timeout: 30_000 })
await item.click()
await expect(dialog).toHaveCount(0)
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
await expect(tabs.locator('[data-slot="tabs-trigger"]').first()).toBeVisible()
})
-56
View File
@@ -1,56 +0,0 @@
import { test, expect } from "../fixtures"
test("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
await gotoSession()
const toggle = page.getByRole("button", { name: "Toggle file tree" })
const panel = page.locator("#file-tree-panel")
const treeTabs = panel.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
await expect(toggle).toBeVisible()
if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(panel).toBeVisible()
await expect(treeTabs).toBeVisible()
const allTab = treeTabs.getByRole("tab", { name: /^all files$/i })
await expect(allTab).toBeVisible()
await allTab.click()
await expect(allTab).toHaveAttribute("aria-selected", "true")
const tree = treeTabs.locator('[data-slot="tabs-content"]:not([hidden])')
await expect(tree).toBeVisible()
const expand = async (name: string) => {
const folder = tree.getByRole("button", { name, exact: true }).first()
await expect(folder).toBeVisible()
await expect(folder).toHaveAttribute("aria-expanded", /true|false/)
if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click()
await expect(folder).toHaveAttribute("aria-expanded", "true")
}
await expand("packages")
await expand("app")
await expand("src")
await expand("components")
const file = tree.getByRole("button", { name: "file-tree.tsx", exact: true }).first()
await expect(file).toBeVisible()
await file.click()
const tab = page.getByRole("tab", { name: "file-tree.tsx" })
await expect(tab).toBeVisible()
await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(allTab).toHaveAttribute("aria-selected", "true")
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await expect(viewer).toContainText("export default function FileTree")
})
-156
View File
@@ -1,156 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { modKey } from "../utils"
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" })
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await expect(viewer.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
})
test("cmd+f opens text viewer search while prompt is focused", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" })
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await page.locator(promptSelector).click()
await page.keyboard.press(`${modKey}+f`)
const findInput = page.getByPlaceholder("Find")
await expect(findInput).toBeVisible()
await expect(findInput).toBeFocused()
})
test("cmd+f opens text viewer search while prompt is not focused", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" })
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await viewer.click()
await page.keyboard.press(`${modKey}+f`)
const findInput = page.getByPlaceholder("Find")
await expect(findInput).toBeVisible()
await expect(findInput).toBeFocused()
})
-154
View File
@@ -1,154 +0,0 @@
import { test as base, expect, type Page } from "@playwright/test"
import type { E2EWindow } from "../src/testing/terminal"
import {
healthPhase,
cleanupSession,
cleanupTestProject,
createTestProject,
setHealthPhase,
seedProjects,
sessionIDFromUrl,
waitSlug,
waitSession,
} from "./actions"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
export const settingsKey = "settings.v3"
type TestFixtures = {
sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void>
withProject: <T>(
callback: (project: {
directory: string
slug: string
gotoSession: (sessionID?: string) => Promise<void>
trackSession: (sessionID: string, directory?: string) => void
trackDirectory: (directory: string) => void
}) => Promise<T>,
options?: { extra?: string[] },
) => Promise<T>
}
type WorkerFixtures = {
directory: string
slug: string
}
export const test = base.extend<TestFixtures, WorkerFixtures>({
page: async ({ page }, use) => {
let boundary: string | undefined
setHealthPhase(page, "test")
const consoleHandler = (msg: { text(): string }) => {
const text = msg.text()
if (!text.includes("[e2e:error-boundary]")) return
if (healthPhase(page) === "cleanup") {
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
return
}
boundary ||= text
console.log(text)
}
const pageErrorHandler = (err: Error) => {
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
}
page.on("console", consoleHandler)
page.on("pageerror", pageErrorHandler)
await use(page)
page.off("console", consoleHandler)
page.off("pageerror", pageErrorHandler)
if (boundary) throw new Error(boundary)
},
directory: [
async ({}, use) => {
const directory = await getWorktree()
await use(directory)
},
{ scope: "worker" },
],
slug: [
async ({ directory }, use) => {
await use(dirSlug(directory))
},
{ scope: "worker" },
],
sdk: async ({ directory }, use) => {
await use(createSdk(directory))
},
gotoSession: async ({ page, directory }, use) => {
await seedStorage(page, { directory })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
await waitSession(page, { directory, sessionID })
}
await use(gotoSession)
},
withProject: async ({ page }, use) => {
await use(async (callback, options) => {
const root = await createTestProject()
const sessions = new Map<string, string>()
const dirs = new Set<string>()
await seedStorage(page, { directory: root, extra: options?.extra })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID))
await waitSession(page, { directory: root, sessionID })
const current = sessionIDFromUrl(page.url())
if (current) trackSession(current)
}
const trackSession = (sessionID: string, directory?: string) => {
sessions.set(sessionID, directory ?? root)
}
const trackDirectory = (directory: string) => {
if (directory !== root) dirs.add(directory)
}
try {
await gotoSession()
const slug = await waitSlug(page)
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
} finally {
setHealthPhase(page, "cleanup")
await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
)
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root)
setHealthPhase(page, "test")
}
})
},
})
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
await seedProjects(page, input)
await page.addInitScript(() => {
const win = window as E2EWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
model: {
enabled: true,
},
prompt: {
enabled: true,
},
terminal: {
enabled: true,
terminals: {},
},
}
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
user: [],
variant: {},
}),
)
})
}
export { expect }
@@ -1,48 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { clickListItem } from "../actions"
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
const command = page.locator('[data-slash-id="model.choose"]')
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
const selected = dialog.locator('[data-slot="list-item"][data-selected="true"]').first()
await expect(selected).toBeVisible()
const other = dialog.locator('[data-slot="list-item"]:not([data-selected="true"])').first()
const target = (await other.count()) > 0 ? other : selected
const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item")
const model = key.split(":").slice(1).join(":")
await input.fill(model)
await clickListItem(dialog, { key })
await expect(dialog).toHaveCount(0)
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const dialogAgain = page.getByRole("dialog")
await expect(dialogAgain).toBeVisible()
await expect(dialogAgain.locator(`[data-slot="list-item"][data-key="${key}"][data-selected="true"]`)).toBeVisible()
})
@@ -1,61 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { closeDialog, openSettings, clickListItem } from "../actions"
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
const command = page.locator('[data-slash-id="model.choose"]')
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const picker = page.getByRole("dialog")
await expect(picker).toBeVisible()
const target = picker.locator('[data-slot="list-item"]').first()
await expect(target).toBeVisible()
const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item")
const name = (await target.locator("span").first().innerText()).trim()
if (!name) throw new Error("Failed to resolve model name from list item")
await page.keyboard.press("Escape")
await expect(picker).toHaveCount(0)
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Models" }).click()
const search = settings.getByPlaceholder("Search models")
await expect(search).toBeVisible()
await search.fill(name)
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
const input = toggle.locator('[data-slot="switch-input"]')
await expect(toggle).toBeVisible()
await expect(input).toHaveAttribute("aria-checked", "true")
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "false")
await closeDialog(page, settings)
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const pickerAgain = page.getByRole("dialog")
await expect(pickerAgain).toBeVisible()
await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible()
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(pickerAgain).toHaveCount(0)
})
@@ -1,43 +0,0 @@
import { test, expect } from "../fixtures"
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug }) => {
await openSidebar(page)
const open = async () => {
const menu = await openProjectMenu(page, slug)
await clickMenuItem(menu, /^Edit$/i, { force: true })
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
return dialog
}
const name = `e2e project ${Date.now()}`
const startup = `echo e2e_${Date.now()}`
const dialog = await open()
const nameInput = dialog.getByLabel("Name")
await nameInput.fill(name)
const startupInput = dialog.getByLabel("Workspace startup script")
await startupInput.fill(startup)
await dialog.getByRole("button", { name: "Save" }).click()
await expect(dialog).toHaveCount(0)
const header = page.locator(".group\\/project").first()
await expect(header).toContainText(name)
const reopened = await open()
await expect(reopened.getByLabel("Name")).toHaveValue(name)
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
await reopened.getByRole("button", { name: "Cancel" }).click()
await expect(reopened).toHaveCount(0)
})
})
@@ -1,54 +0,0 @@
import { test, expect } from "../fixtures"
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("closing active project navigates to another open project", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
try {
await withProject(
async ({ slug }) => {
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const menu = await openProjectMenu(page, otherSlug)
await clickMenuItem(menu, /^Close$/i, { force: true })
await expect
.poll(
() => {
const pathname = new URL(page.url()).pathname
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
if (pathname === "/") return "home"
return ""
},
{ timeout: 15_000 },
)
.toMatch(/^(project|home)$/)
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
await expect
.poll(
async () => {
return await page.locator(projectSwitchSelector(otherSlug)).count()
},
{ timeout: 15_000 },
)
.toBe(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
@@ -1,116 +0,0 @@
import { base64Decode } from "@opencode-ai/util/encode"
import { test, expect } from "../fixtures"
import {
defocus,
createTestProject,
cleanupTestProject,
openSidebar,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils"
test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
try {
await withProject(
async ({ directory }) => {
await defocus(page)
const currentSlug = dirSlug(directory)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
await expect(currentButton).toBeVisible()
await currentButton.click()
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
try {
await withProject(
async ({ directory, slug, trackSession, trackDirectory }) => {
await defocus(page)
await setWorkspacesEnabled(page, slug, true)
await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
const raw = await waitSlug(page, [slug])
const dir = base64Decode(raw)
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
const space = await resolveDirectory(dir)
const next = dirSlug(space)
trackDirectory(space)
await openSidebar(page)
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
await expect(item).toBeVisible()
await item.hover()
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
await expect(btn).toBeVisible()
await btn.click({ force: true })
await waitSession(page, { directory: space })
// Create a session by sending a prompt
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill("test")
await page.keyboard.press("Enter")
// Wait for the URL to update with the new session ID
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
trackSession(created, space)
await waitSessionSaved(space, created)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click({ force: true })
await waitSession(page, { directory: other })
const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible()
await rootButton.click({ force: true })
await waitSession(page, { directory: space, sessionID: created })
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
@@ -1,94 +0,0 @@
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitDir,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
function item(space: { slug: string; raw: string }) {
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
}
function button(space: { slug: string; raw: string }) {
return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
}
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
await openSidebar(page)
await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
}
async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
await waitDir(page, next.directory)
return next
}
async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
await waitWorkspaceReady(page, space)
const row = page.locator(item(space)).first()
await row.hover()
const next = page.locator(button(space)).first()
await expect(next).toBeVisible()
await next.click({ force: true })
await waitSession(page, { directory: space.directory })
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
}
async function createSessionFromWorkspace(
page: Page,
space: { slug: string; raw: string; directory: string },
text: string,
) {
await openWorkspaceNewSession(page, space)
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill(text)
await page.keyboard.press("Enter")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await waitSessionSaved(space.directory, sessionID)
await createSdk(space.directory)
.session.abort({ sessionID })
.catch(() => undefined)
return sessionID
}
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
await openSidebar(page)
await setWorkspacesEnabled(page, root, true)
const first = await createWorkspace(page, root, [])
trackDirectory(first.directory)
await waitWorkspaceReady(page, first)
const second = await createWorkspace(page, root, [first.slug])
trackDirectory(second.directory)
await waitWorkspaceReady(page, second)
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
})
})
@@ -1,375 +0,0 @@
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
test.describe.configure({ mode: "serial" })
import {
cleanupTestProject,
clickMenuItem,
confirmDialog,
openSidebar,
openWorkspaceMenu,
resolveSlug,
setWorkspacesEnabled,
slugFromUrl,
waitDir,
waitSlug,
} from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
import { createSdk, dirSlug } from "../utils"
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
const rootSlug = project.slug
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
await waitDir(page, next.directory)
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(next.slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
return { rootSlug, slug: next.slug, directory: next.directory }
}
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug }) => {
await openSidebar(page)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
await setWorkspacesEnabled(page, slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
await setWorkspacesEnabled(page, slug, false)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
})
})
test("can create a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug }) => {
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [slug]))
await waitDir(page, next.directory)
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(next.slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
await cleanupTestProject(next.directory)
})
})
test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
const nonGitSlug = dirSlug(nonGit)
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
try {
await withProject(async () => {
await page.goto(`/${nonGitSlug}/session`)
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
const trigger = page.locator('[data-action="project-menu"]').first()
const hasMenu = await trigger
.isVisible()
.then((x) => x)
.catch(() => false)
if (!hasMenu) return
await trigger.click({ force: true })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
await expect(toggle).toBeVisible()
await expect(toggle).toBeDisabled()
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
})
} finally {
await cleanupTestProject(nonGit)
}
})
test("can rename a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => {
const { slug } = await setupWorkspaceTest(page, project)
const rename = `e2e workspace ${Date.now()}`
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Rename$/i, { force: true })
await expect(menu).toHaveCount(0)
const item = page.locator(workspaceItemSelector(slug)).first()
await expect(item).toBeVisible()
const input = item.locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await input.fill(rename)
await input.press("Enter")
await expect(item).toContainText(rename)
})
})
test("can reset a workspace", async ({ page, sdk, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => {
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
const readme = path.join(createdDir, "README.md")
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
const original = await fs.readFile(readme, "utf8")
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
await fs.writeFile(readme, dirty, "utf8")
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(true)
await expect
.poll(async () => {
const files = await sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
})
.toBeGreaterThan(0)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Reset$/i, { force: true })
await confirmDialog(page, /^Reset workspace$/i)
await expect
.poll(
async () => {
const files = await sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
},
{ timeout: 60_000 },
)
.toBe(0)
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(false)
})
})
test("can delete a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
await expect
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 30_000 },
)
.toBe(true)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i)
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
await expect
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 60_000 },
)
.toBe(false)
await project.gotoSession()
await openSidebar(page)
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
})
})
test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug: rootSlug }) => {
const workspaces = [] as { directory: string; slug: string }[]
const listSlugs = async () => {
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
const slugs = await nodes.evaluateAll((els) => {
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
})
return slugs
}
const waitReady = async (slug: string) => {
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
const drag = async (from: string, to: string) => {
const src = page.locator(workspaceItemSelector(from)).first()
const dst = page.locator(workspaceItemSelector(to)).first()
const a = await src.boundingBox()
const b = await dst.boundingBox()
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
await page.mouse.down()
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
await page.mouse.up()
}
try {
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
await waitDir(page, next.directory)
workspaces.push(next)
await openSidebar(page)
}
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
const a = workspaces[0].slug
const b = workspaces[1].slug
await waitReady(a)
await waitReady(b)
const list = async () => {
const slugs = await listSlugs()
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
}
await expect
.poll(async () => {
const slugs = await list()
return slugs.length === 2
})
.toBe(true)
const before = await list()
const from = before[1]
const to = before[0]
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
await drag(from, to)
await expect.poll(async () => await list()).toEqual([from, to])
} finally {
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
}
})
})
-95
View File
@@ -1,95 +0,0 @@
import { test, expect } from "../fixtures"
import type { Page } from "@playwright/test"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
function contextButton(page: Page) {
return page
.locator('[data-component="button"]')
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
.first()
}
async function seedContextSession(input: { sessionID: string; sdk: Parameters<typeof withSession>[0] }) {
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
noReply: true,
parts: [
{
type: "text",
text: "seed context",
},
],
})
await expect
.poll(async () => {
const messages = await input.sdk.session
.messages({ sessionID: input.sessionID, limit: 1 })
.then((r) => r.data ?? [])
return messages.length
})
.toBeGreaterThan(0)
}
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke context ${Date.now()}`
await withSession(sdk, title, async (session) => {
await seedContextSession({ sessionID: session.id, sdk })
await gotoSession(session.id)
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
})
})
test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => {
await seedContextSession({ sessionID: session.id, sdk })
await gotoSession(session.id)
await page.locator(promptSelector).click()
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
const context = tabs.getByRole("tab", { name: "Context" })
await expect(context).toBeVisible()
await page.getByRole("button", { name: "Close tab" }).first().click()
await expect(context).toHaveCount(0)
})
})
test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => {
await seedContextSession({ sessionID: session.id, sdk })
await gotoSession(session.id)
await page.locator(promptSelector).click()
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
await expect(page.getByRole("tab", { name: "Context" })).toBeVisible()
await page.getByRole("button", { name: "Open file" }).first().click()
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
})
@@ -1,76 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
// the connection open while the agent works, causing "Failed to fetch" over
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
// Simulate Tailscale/VPN killing the long-lived sync connection
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
await gotoSession()
const token = `E2E_ASYNC_${Date.now()}`
await page.locator(promptSelector).click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
try {
// Agent response arrives via SSE despite sync endpoint being dead
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
} finally {
await cleanupSession({ sdk, sessionID })
}
})
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
const prompt = page.locator(promptSelector)
const value = `restore ${Date.now()}`
await page.route(`**/session/${session.id}/prompt_async`, (route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ message: "e2e prompt failure" }),
}),
)
await gotoSession(session.id)
await prompt.click()
await page.keyboard.type(value)
await page.keyboard.press("Enter")
await expect.poll(async () => text(await prompt.textContent())).toBe(value)
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 15_000 },
)
.toBe(0)
})
})
@@ -1,22 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("dropping text/plain file: uri inserts a file pill", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await prompt.click()
const path = process.platform === "win32" ? "C:\\opencode-e2e-drop.txt" : "/tmp/opencode-e2e-drop.txt"
const dt = await page.evaluateHandle((text) => {
const dt = new DataTransfer()
dt.setData("text/plain", text)
return dt
}, `file:${path}`)
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
await expect(pill).toBeVisible()
await expect(pill).toHaveAttribute("data-path", path)
})
@@ -1,30 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("dropping an image file adds an attachment", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await prompt.click()
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3+4uQAAAAASUVORK5CYII="
const dt = await page.evaluateHandle((b64) => {
const dt = new DataTransfer()
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
const file = new File([bytes], "drop.png", { type: "image/png" })
dt.items.add(file)
return dt
}, png)
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
const img = page.locator('img[alt="drop.png"]').first()
await expect(img).toBeVisible()
const remove = page.getByRole("button", { name: "Remove attachment" }).first()
await expect(remove).toBeVisible()
await img.hover()
await remove.click()
await expect(page.locator('img[alt="drop.png"]')).toHaveCount(0)
})
@@ -1,184 +0,0 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import { promptSelector } from "../selectors"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
if (!("type" in part) || part.type !== "tool") return false
if (!("tool" in part) || part.tool !== "bash") return false
return "state" in part
}
async function edge(page: Page, pos: "start" | "end") {
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
const selection = window.getSelection()
if (!selection) return
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
const nodes: Text[] = []
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
nodes.push(node as Text)
}
if (nodes.length === 0) {
const node = document.createTextNode("")
el.appendChild(node)
nodes.push(node)
}
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
const range = document.createRange()
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}, pos)
}
async function wait(page: Page, value: string) {
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
}
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((item) => item.info.role === "assistant")
.flatMap((item) => item.parts)
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
}
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
const part = messages
.filter((item) => item.info.role === "assistant")
.flatMap((item) => item.parts)
.filter(isBash)
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
if (!part || part.state.status !== "completed") return
return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
},
{ timeout: 90_000 },
)
.toContain(token)
}
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
const first = `Reply with exactly: ${firstToken}`
const second = `Reply with exactly: ${secondToken}`
const draft = `draft ${Date.now()}`
await prompt.click()
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, firstToken)
await prompt.click()
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, secondToken)
await prompt.click()
await page.keyboard.type(draft)
await wait(page, draft)
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
await prompt.fill("")
await wait(page, "")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("ArrowUp")
await wait(page, first)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, "")
})
})
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
const normalToken = `E2E_NORMAL_${Date.now()}`
const first = `echo ${firstToken}`
const second = `echo ${secondToken}`
const normal = `Reply with exactly: ${normalToken}`
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, first, firstToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, second, secondToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("ArrowUp")
await wait(page, first)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, "")
await page.keyboard.press("Escape")
await wait(page, "")
await prompt.click()
await page.keyboard.type(normal)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, normalToken)
await prompt.click()
await page.keyboard.press("ArrowUp")
await wait(page, normal)
})
})
@@ -1,26 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
const sep = process.platform === "win32" ? "\\" : "/"
const file = ["packages", "app", "package.json"].join(sep)
const filePattern = /packages[\\/]+app[\\/]+\s*package\.json/
await page.keyboard.type(`@${file}`)
const suggestion = page.getByRole("button", { name: filePattern }).first()
await expect(suggestion).toBeVisible()
await suggestion.hover()
await page.keyboard.press("Tab")
const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
await expect(pill).toBeVisible()
await expect(pill).toHaveAttribute("data-path", filePattern)
await page.keyboard.type(" ok")
await expect(page.locator(promptSelector)).toContainText("ok")
})
@@ -1,24 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("shift+enter inserts a newline without submitting", async ({ page, gotoSession }) => {
await gotoSession()
await expect(page).toHaveURL(/\/session\/?$/)
const prompt = page.locator(promptSelector)
await prompt.focus()
await expect(prompt).toBeFocused()
await prompt.pressSequentially("line one")
await expect(prompt).toBeFocused()
await prompt.press("Shift+Enter")
await expect(page).toHaveURL(/\/session\/?$/)
await expect(prompt).toBeFocused()
await prompt.pressSequentially("line two")
await expect(page).toHaveURL(/\/session\/?$/)
await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two")
})
@@ -1,62 +0,0 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import { test, expect } from "../fixtures"
import { sessionIDFromUrl } from "../actions"
import { promptSelector } from "../selectors"
import { createSdk } from "../utils"
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
if (!("type" in part) || part.type !== "tool") return false
if (!("tool" in part) || part.tool !== "bash") return false
return "state" in part
}
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
test.setTimeout(120_000)
await withProject(async ({ directory, gotoSession, trackSession }) => {
const sdk = createSdk(directory)
const prompt = page.locator(promptSelector)
const cmd = process.platform === "win32" ? "dir" : "ls"
await gotoSession()
await prompt.click()
await page.keyboard.type("!")
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
await page.keyboard.type(cmd)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
trackSession(id, directory)
await expect
.poll(
async () => {
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
const msg = list.findLast(
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
)
if (!msg) return
const part = msg.parts
.filter(isBash)
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
if (!part || part.state.status !== "completed") return
const output =
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
if (!output.includes("README.md")) return
return { cwd: directory, output }
},
{ timeout: 90_000 },
)
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
await expect(prompt).toHaveText("")
})
})
@@ -1,22 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]')
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("textbox").first()).toBeVisible()
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
@@ -1,64 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
await sdk.session.promptAsync({
sessionID,
noReply: true,
parts: [{ type: "text", text: "e2e share seed" }],
})
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 30_000 },
)
.toBeGreaterThan(0)
}
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
const prompt = page.locator(promptSelector)
await seed(sdk, session.id)
await gotoSession(session.id)
await prompt.click()
await page.keyboard.type("/share")
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
await prompt.click()
await page.keyboard.type("/unshare")
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.toBeUndefined()
})
})
@@ -1,18 +0,0 @@
import { test, expect } from "../fixtures"
import { runPromptSlash, waitTerminalFocusIdle } from "../actions"
import { promptSelector, terminalSelector } from "../selectors"
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
const terminal = page.locator(terminalSelector)
await expect(terminal).not.toBeVisible()
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
await waitTerminalFocusIdle(page, { term: terminal })
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
await expect(terminal).not.toBeVisible()
})
-55
View File
@@ -1,55 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
const pageErrors: string[] = []
const onPageError = (err: Error) => {
pageErrors.push(err.message)
}
page.on("pageerror", onPageError)
await gotoSession()
const token = `E2E_OK_${Date.now()}`
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = (() => {
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
return id
})()
try {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
} finally {
page.off("pageerror", onPageError)
await cleanupSession({ sdk, sessionID })
}
if (pageErrors.length > 0) {
throw new Error(`Page error(s):\n${pageErrors.join("\n")}`)
}
})
-76
View File
@@ -1,76 +0,0 @@
export const promptSelector = '[data-component="prompt-input"]'
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
export const promptModelSelector = '[data-component="prompt-model-control"]'
export const promptVariantSelector = '[data-component="prompt-variant-control"]'
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
export const settingsThemeSelector = '[data-action="settings-theme"]'
export const settingsCodeFontSelector = '[data-action="settings-code-font"]'
export const settingsUIFontSelector = '[data-action="settings-ui-font"]'
export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
export const projectSwitchSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
export const projectMenuTriggerSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
export const projectClearNotificationsSelector = (slug: string) =>
`[data-action="project-clear-notifications"][data-project="${slug}"]`
export const projectWorkspacesToggleSelector = (slug: string) =>
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
export const titlebarRightSelector = "#opencode-titlebar-right"
export const popoverBodySelector = '[data-slot="popover-body"]'
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
export const inlineInputSelector = '[data-component="inline-input"]'
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
export const workspaceItemSelector = (slug: string) =>
`${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]`
export const workspaceMenuTriggerSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
export const workspaceNewSessionSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="workspace-new-session"][data-workspace="${slug}"]`
export const listItemSelector = '[data-slot="list-item"]'
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
export const listItemKeySelector = (key: string) => `${listItemSelector}[data-key="${key}"]`
export const keybindButtonSelector = (id: string) => `[data-keybind-id="${id}"]`
@@ -1,37 +0,0 @@
import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures"
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
const errs: string[] = []
const onError = (err: Error) => {
errs.push(err.message)
}
page.on("pageerror", onError)
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
const child = await seedSessionTask(sdk, {
sessionID: session.id,
description: "Open child session",
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
})
try {
await gotoSession(session.id)
const link = page
.locator("a.subagent-link")
.filter({ hasText: /open child session/i })
.first()
await expect(link).toBeVisible({ timeout: 30_000 })
await link.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await page.waitForTimeout(1000)
expect(errs).toEqual([])
} finally {
page.off("pageerror", onError)
}
})
})
@@ -1,537 +0,0 @@
import { test, expect } from "../fixtures"
import {
composerEvent,
type ComposerDriverState,
type ComposerProbeState,
type ComposerWindow,
} from "../../src/testing/session-composer"
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
import {
permissionDockSelector,
promptSelector,
questionDockSelector,
sessionComposerDockSelector,
sessionTodoToggleButtonSelector,
} from "../selectors"
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
async function withDockSession<T>(
sdk: Sdk,
title: string,
fn: (session: { id: string; title: string }) => Promise<T>,
opts?: { permission?: PermissionRule[] },
) {
const session = await sdk.session
.create(opts?.permission ? { title, permission: opts.permission } : { title })
.then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id")
try {
return await fn(session)
} finally {
await cleanupSession({ sdk, sessionID: session.id })
}
}
test.setTimeout(120_000)
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
try {
return await fn()
} finally {
await clearSessionDockSeed(sdk, sessionID).catch(() => undefined)
}
}
async function clearPermissionDock(page: any, label: RegExp) {
const dock = page.locator(permissionDockSelector)
await expect(dock).toBeVisible()
await dock.getByRole("button", { name: label }).click()
}
async function setAutoAccept(page: any, enabled: boolean) {
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
const pressed = (await button.getAttribute("aria-pressed")) === "true"
if (pressed === enabled) return
await button.click()
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
}
async function expectQuestionBlocked(page: any) {
await expect(page.locator(questionDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toHaveCount(0)
}
async function expectQuestionOpen(page: any) {
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(promptSelector)).toBeVisible()
}
async function expectPermissionBlocked(page: any) {
await expect(page.locator(permissionDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toHaveCount(0)
}
async function expectPermissionOpen(page: any) {
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await expect(page.locator(promptSelector)).toBeVisible()
}
async function todoDock(page: any, sessionID: string) {
await page.addInitScript(() => {
const win = window as ComposerWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
composer: {
enabled: true,
sessions: {},
},
}
})
const write = async (driver: ComposerDriverState | undefined) => {
await page.evaluate(
(input: { event: string; sessionID: string; driver: ComposerDriverState | undefined }) => {
const win = window as ComposerWindow
const composer = win.__opencode_e2e?.composer
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
composer.sessions ??= {}
const prev = composer.sessions[input.sessionID] ?? {}
if (!input.driver) {
if (!prev.probe) {
delete composer.sessions[input.sessionID]
} else {
composer.sessions[input.sessionID] = { probe: prev.probe }
}
} else {
composer.sessions[input.sessionID] = {
...prev,
driver: input.driver,
}
}
window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } }))
},
{ event: composerEvent, sessionID, driver },
)
}
const read = () =>
page.evaluate((sessionID: string) => {
const win = window as ComposerWindow
return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
}, sessionID) as Promise<ComposerProbeState | null>
const api = {
async clear() {
await write(undefined)
return api
},
async open(todos: NonNullable<ComposerDriverState["todos"]>) {
await write({ live: true, todos })
return api
},
async finish(todos: NonNullable<ComposerDriverState["todos"]>) {
await write({ live: false, todos })
return api
},
async expectOpen(states: ComposerProbeState["states"]) {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
mounted: true,
collapsed: false,
hidden: false,
count: states.length,
states,
})
return api
},
async expectCollapsed(states: ComposerProbeState["states"]) {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
mounted: true,
collapsed: true,
hidden: true,
count: states.length,
states,
})
return api
},
async expectClosed() {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({ mounted: false })
return api
},
async collapse() {
await page.locator(sessionTodoToggleButtonSelector).click()
return api
},
async expand() {
await page.locator(sessionTodoToggleButtonSelector).click()
return api
},
}
return api
}
async function withMockPermission<T>(
page: any,
request: {
id: string
sessionID: string
permission: string
patterns: string[]
metadata?: Record<string, unknown>
always?: string[]
},
opts: { child?: any } | undefined,
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
) {
const listUrl = /\/permission(?:\?.*)?$/
const replyUrls = [/\/session\/[^/]+\/permissions\/[^/?]+(?:\?.*)?$/, /\/permission\/[^/]+\/reply(?:\?.*)?$/]
let pending = [
{
...request,
always: request.always ?? ["*"],
metadata: request.metadata ?? {},
},
]
const list = async (route: any) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(pending),
})
}
const reply = async (route: any) => {
const url = new URL(route.request().url())
const parts = url.pathname.split("/").filter(Boolean)
const id = parts.at(-1) === "reply" ? parts.at(-2) : parts.at(-1)
pending = pending.filter((item) => item.id !== id)
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(true),
})
}
await page.route(listUrl, list)
for (const item of replyUrls) {
await page.route(item, reply)
}
const sessionList = opts?.child
? async (route: any) => {
const res = await route.fetch()
const json = await res.json()
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
await route.fulfill({
status: res.status(),
headers: res.headers(),
contentType: "application/json",
body: JSON.stringify(json),
})
}
: undefined
if (sessionList) await page.route("**/session?*", sessionList)
const state = {
async resolved() {
await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0)
},
}
try {
return await fn(state)
} finally {
await page.unroute(listUrl, list)
for (const item of replyUrls) {
await page.unroute(item, reply)
}
if (sessionList) await page.unroute("**/session?*", sessionList)
}
}
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock default", async (session) => {
await gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused()
})
})
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
await gotoSession()
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
await expect(button).toHaveAttribute("aria-pressed", "false")
await setAutoAccept(page, true)
await setAutoAccept(page, false)
})
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock question", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionQuestion(sdk, {
sessionID: session.id,
questions: [
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
})
const dock = page.locator(questionDockSelector)
await expectQuestionBlocked(page)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expectQuestionOpen(page)
})
})
})
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
await gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_once",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-once"],
metadata: { description: "Need permission for command" },
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
})
})
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
await gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_reject",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-reject"],
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /deny/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
})
})
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
await gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_always",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-always"],
metadata: { description: "Need permission for command" },
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow always/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
})
})
test("child session question request blocks parent dock and unblocks after submit", async ({
page,
sdk,
gotoSession,
}) => {
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
await gotoSession(session.id)
const child = await sdk.session
.create({
title: "e2e composer dock child question",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
try {
await withDockSeed(sdk, child.id, async () => {
await seedSessionQuestion(sdk, {
sessionID: child.id,
questions: [
{
header: "Child input",
question: "Pick one child option",
options: [
{ label: "Continue", description: "Continue child" },
{ label: "Stop", description: "Stop child" },
],
},
],
})
const dock = page.locator(questionDockSelector)
await expectQuestionBlocked(page)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expectQuestionOpen(page)
})
} finally {
await cleanupSession({ sdk, sessionID: child.id })
}
})
})
test("child session permission request blocks parent dock and supports allow once", async ({
page,
sdk,
gotoSession,
}) => {
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
await gotoSession(session.id)
await setAutoAccept(page, false)
const child = await sdk.session
.create({
title: "e2e composer dock child permission",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
try {
await withMockPermission(
page,
{
id: "per_e2e_child",
sessionID: child.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-child"],
metadata: { description: "Need child permission" },
},
{ child },
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
} finally {
await cleanupSession({ sdk, sessionID: child.id })
}
})
})
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
const dock = await todoDock(page, session.id)
await gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
try {
await dock.open([
{ content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" },
])
await dock.expectOpen(["pending", "in_progress"])
await dock.collapse()
await dock.expectCollapsed(["pending", "in_progress"])
await dock.expand()
await dock.expectOpen(["pending", "in_progress"])
await dock.finish([
{ content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" },
])
await dock.expectClosed()
} finally {
await dock.clear()
}
})
})
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionQuestion(sdk, {
sessionID: session.id,
questions: [
{
header: "Need input",
question: "Pick one option",
options: [{ label: "Continue", description: "Continue now" }],
},
],
})
await expectQuestionBlocked(page)
await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0)
})
})
})
@@ -1,405 +0,0 @@
import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionIdle,
waitSlug,
} from "../actions"
import {
promptAgentSelector,
promptModelSelector,
promptSelector,
promptVariantSelector,
workspaceItemSelector,
workspaceNewSessionSelector,
} from "../selectors"
import { createSdk, sessionPath } from "../utils"
type Footer = {
agent: string
model: string
variant: string
}
type Probe = {
dir?: string
sessionID?: string
agent?: string
model?: { providerID: string; modelID: string; name?: string }
variant?: string | null
pick?: {
agent?: string
model?: { providerID: string; modelID: string }
variant?: string | null
}
variants?: string[]
models?: Array<{ providerID: string; modelID: string; name: string }>
agents?: Array<{ name: string }>
}
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const text = async (locator: Locator) => ((await locator.textContent()) ?? "").trim()
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
async function probe(page: Page): Promise<Probe | null> {
return page.evaluate(() => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
current?: Probe
}
}
}
return win.__opencode_e2e?.model?.current ?? null
})
}
async function currentModel(page: Page) {
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).not.toBe(null)
const value = await probe(page).then(modelKey)
if (!value) throw new Error("Failed to resolve current model key")
return value
}
async function waitControl(page: Page, key: "setAgent" | "setModel" | "setVariant") {
await expect
.poll(
() =>
page.evaluate((key) => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
controls?: Record<string, unknown>
}
}
}
return !!win.__opencode_e2e?.model?.controls?.[key]
}, key),
{ timeout: 30_000 },
)
.toBe(true)
}
async function pickAgent(page: Page, value: string) {
await waitControl(page, "setAgent")
await page.evaluate((value) => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
controls?: {
setAgent?: (value: string | undefined) => void
}
}
}
}
const fn = win.__opencode_e2e?.model?.controls?.setAgent
if (!fn) throw new Error("Model e2e agent control is not enabled")
fn(value)
}, value)
}
async function pickModel(page: Page, value: { providerID: string; modelID: string }) {
await waitControl(page, "setModel")
await page.evaluate((value) => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
controls?: {
setModel?: (value: { providerID: string; modelID: string } | undefined) => void
}
}
}
}
const fn = win.__opencode_e2e?.model?.controls?.setModel
if (!fn) throw new Error("Model e2e model control is not enabled")
fn(value)
}, value)
}
async function pickVariant(page: Page, value: string) {
await waitControl(page, "setVariant")
await page.evaluate((value) => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
controls?: {
setVariant?: (value: string | undefined) => void
}
}
}
}
const fn = win.__opencode_e2e?.model?.controls?.setVariant
if (!fn) throw new Error("Model e2e variant control is not enabled")
fn(value)
}, value)
}
async function read(page: Page): Promise<Footer> {
return {
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
model: await text(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()),
variant: await text(page.locator(`${promptVariantSelector} [data-slot="select-select-trigger-value"]`).first()),
}
}
async function waitFooter(page: Page, expected: Partial<Footer>) {
let hit: Footer | null = null
await expect
.poll(
async () => {
const state = await read(page)
const ok = Object.entries(expected).every(([key, value]) => state[key as keyof Footer] === value)
if (ok) hit = state
return ok
},
{ timeout: 30_000 },
)
.toBe(true)
if (!hit) throw new Error("Failed to resolve prompt footer state")
return hit
}
async function waitModel(page: Page, value: string) {
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).toBe(value)
}
async function choose(page: Page, root: string, value: string) {
const select = page.locator(root)
await expect(select).toBeVisible()
await pickAgent(page, value)
}
async function variantCount(page: Page) {
return (await probe(page))?.variants?.length ?? 0
}
async function agents(page: Page) {
return ((await probe(page))?.agents ?? []).map((item) => item.name).filter(Boolean)
}
async function ensureVariant(page: Page, directory: string): Promise<Footer> {
const current = await read(page)
if ((await variantCount(page)) >= 2) return current
const cfg = await createSdk(directory)
.config.get()
.then((x) => x.data)
const visible = new Set(await agents(page))
const entry = Object.entries(cfg?.agent ?? {}).find((item) => {
const value = item[1]
return !!value && typeof value === "object" && "variant" in value && "model" in value && visible.has(item[0])
})
const name = entry?.[0]
test.skip(!name, "no agent with alternate variants available")
if (!name) return current
await choose(page, promptAgentSelector, name)
await expect.poll(() => variantCount(page), { timeout: 30_000 }).toBeGreaterThanOrEqual(2)
return waitFooter(page, { agent: name })
}
async function chooseDifferentVariant(page: Page): Promise<Footer> {
const current = await read(page)
const next = (await probe(page))?.variants?.find((item) => item !== current.variant)
if (!next) throw new Error("Current model has no alternate variant to select")
await pickVariant(page, next)
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
}
async function chooseOtherModel(page: Page, skip: string[] = []): Promise<Footer> {
const current = await currentModel(page)
const next = (await probe(page))?.models?.find((item) => {
const key = `${item.providerID}:${item.modelID}`
return key !== current && !skip.includes(key)
})
if (!next) throw new Error("Failed to choose a different model")
await pickModel(page, { providerID: next.providerID, modelID: next.modelID })
await expect.poll(async () => (await read(page)).model, { timeout: 30_000 }).toBe(next.name)
return read(page)
}
async function goto(page: Page, directory: string, sessionID?: string) {
await page.goto(sessionPath(directory, sessionID))
await waitSession(page, { directory, sessionID })
}
async function submit(page: Page, value: string) {
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.click()
await prompt.fill(value)
await prompt.press("Enter")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
return id
}
async function waitUser(directory: string, sessionID: string) {
const sdk = createSdk(directory)
await expect
.poll(
async () => {
const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
return items.some((item) => item.info.role === "user")
},
{ timeout: 30_000 },
)
.toBe(true)
await sdk.session.abort({ sessionID }).catch(() => undefined)
await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
}
async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
await waitSession(page, { directory: next.directory })
return next
}
async function waitWorkspace(page: Page, slug: string) {
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
async function newWorkspaceSession(page: Page, slug: string) {
await waitWorkspace(page, slug)
const item = page.locator(workspaceItemSelector(slug)).first()
await item.hover()
const button = page.locator(workspaceNewSessionSelector(slug)).first()
await expect(button).toBeVisible()
await button.click({ force: true })
const next = await resolveSlug(await waitSlug(page))
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
}
test("session model restore per session without leaking into new sessions", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory, gotoSession, trackSession }) => {
await gotoSession()
const firstState = await chooseOtherModel(page)
const firstKey = await currentModel(page)
const first = await submit(page, `session variant ${Date.now()}`)
trackSession(first)
await waitUser(directory, first)
await page.reload()
await waitSession(page, { directory, sessionID: first })
await waitFooter(page, firstState)
await gotoSession()
const fresh = await read(page)
expect(fresh.model).not.toBe(firstState.model)
const secondState = await chooseOtherModel(page, [firstKey])
const second = await submit(page, `session model ${Date.now()}`)
trackSession(second)
await waitUser(directory, second)
await goto(page, directory, first)
await waitFooter(page, firstState)
await goto(page, directory, second)
await waitFooter(page, secondState)
await gotoSession()
await waitFooter(page, fresh)
})
})
test("session model restore across workspaces", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
await gotoSession()
const firstState = await chooseOtherModel(page)
const firstKey = await currentModel(page)
const first = await submit(page, `root session ${Date.now()}`)
trackSession(first, root)
await waitUser(root, first)
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
const one = await createWorkspace(page, slug, [])
const oneDir = await newWorkspaceSession(page, one.slug)
trackDirectory(oneDir)
const secondState = await chooseOtherModel(page, [firstKey])
const secondKey = await currentModel(page)
const second = await submit(page, `workspace one ${Date.now()}`)
trackSession(second, oneDir)
await waitUser(oneDir, second)
const two = await createWorkspace(page, slug, [one.slug])
const twoDir = await newWorkspaceSession(page, two.slug)
trackDirectory(twoDir)
const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
const third = await submit(page, `workspace two ${Date.now()}`)
trackSession(third, twoDir)
await waitUser(twoDir, third)
await goto(page, root, first)
await waitFooter(page, firstState)
await goto(page, oneDir, second)
await waitFooter(page, secondState)
await goto(page, twoDir, third)
await waitFooter(page, thirdState)
await goto(page, root, first)
await waitFooter(page, firstState)
})
})
test("variant preserved when switching agent modes", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory, gotoSession }) => {
await gotoSession()
await ensureVariant(page, directory)
const updated = await chooseDifferentVariant(page)
const available = await agents(page)
const other = available.find((name) => name !== updated.agent)
test.skip(!other, "only one agent available")
if (!other) return
await choose(page, promptAgentSelector, other)
await waitFooter(page, { agent: other, variant: updated.variant })
await choose(page, promptAgentSelector, updated.agent)
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
})
})
@@ -1,426 +0,0 @@
import { waitSessionIdle, withSession } from "../actions"
import { test, expect } from "../fixtures"
import { createSdk } from "../utils"
const count = 14
function body(mark: string) {
return [
`title ${mark}`,
`mark ${mark}`,
...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
]
}
function files(tag: string) {
return Array.from({ length: count }, (_, i) => {
const id = String(i).padStart(2, "0")
return {
file: `review-scroll-${id}.txt`,
mark: `${tag}-${id}`,
}
})
}
function seed(list: ReturnType<typeof files>) {
const out = ["*** Begin Patch"]
for (const item of list) {
out.push(`*** Add File: ${item.file}`)
for (const line of body(item.mark)) out.push(`+${line}`)
}
out.push("*** End Patch")
return out.join("\n")
}
function edit(file: string, prev: string, next: string) {
return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
"\n",
)
}
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
await sdk.session.promptAsync({
sessionID,
agent: "build",
system: [
"You are seeding deterministic e2e UI state.",
"Your only valid response is one apply_patch tool call.",
`Use this JSON input: ${JSON.stringify({ patchText })}`,
"Do not call any other tools.",
"Do not output plain text.",
].join("\n"),
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
})
await waitSessionIdle(sdk, sessionID, 120_000)
}
async function show(page: Parameters<typeof test>[0]["page"]) {
const btn = page.getByRole("button", { name: "Toggle review" }).first()
await expect(btn).toBeVisible()
if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
await expect(btn).toHaveAttribute("aria-expanded", "true")
}
async function expand(page: Parameters<typeof test>[0]["page"]) {
const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
const open = await close
.isVisible()
.then((value) => value)
.catch(() => false)
const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
if (open) {
await close.click()
await expect(btn).toBeVisible()
}
await expect(btn).toBeVisible()
await btn.click()
await expect(close).toBeVisible()
}
async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
await page.waitForFunction(
({ file, mark }) => {
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
if (!(view instanceof HTMLElement)) return false
const head = Array.from(view.querySelectorAll("h3")).find(
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
)
if (!(head instanceof HTMLElement)) return false
return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
if (!(host instanceof HTMLElement)) return false
const root = host.shadowRoot
return root?.textContent?.includes(`mark ${mark}`) ?? false
})
},
{ file, mark },
{ timeout: 60_000 },
)
}
async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
return page.evaluate((file) => {
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
if (!(view instanceof HTMLElement)) return null
const row = Array.from(view.querySelectorAll("h3")).find(
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
)
if (!(row instanceof HTMLElement)) return null
const a = row.getBoundingClientRect()
const b = view.getBoundingClientRect()
return {
top: a.top - b.top,
y: view.scrollTop,
}
}, file)
}
async function comment(page: Parameters<typeof test>[0]["page"], file: string, note: string) {
const row = page.locator(`[data-file="${file}"]`).first()
await expect(row).toBeVisible()
const line = row.locator('diffs-container [data-line="2"]').first()
await expect(line).toBeVisible()
await line.hover()
const add = row.getByRole("button", { name: /^Comment$/ }).first()
await expect(add).toBeVisible()
await add.click()
const area = row.locator('[data-slot="line-comment-textarea"]').first()
await expect(area).toBeVisible()
await area.fill(note)
const submit = row.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
await expect(submit).toBeEnabled()
await submit.click()
await expect(row.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
await expect(row.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
}
async function overflow(page: Parameters<typeof test>[0]["page"], file: string) {
const row = page.locator(`[data-file="${file}"]`).first()
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
const pop = row.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
const tools = row.locator('[data-slot="line-comment-tools"]').first()
const [width, viewBox, popBox, toolsBox] = await Promise.all([
view.evaluate((el) => el.scrollWidth - el.clientWidth),
view.boundingBox(),
pop.boundingBox(),
tools.boundingBox(),
])
if (!viewBox || !popBox || !toolsBox) return null
return {
width,
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
}
}
async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
const row = page.locator(`[data-file="${file}"]`).first()
await expect(row).toBeVisible()
await row.hover()
const open = row.getByRole("button", { name: /^Open file$/i }).first()
await expect(open).toBeVisible()
await open.click()
const tab = page.getByRole("tab", { name: file }).first()
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
return viewer
}
async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
const line = viewer.locator('diffs-container [data-line="2"]').first()
await expect(line).toBeVisible()
await line.hover()
const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
await expect(add).toBeVisible()
await add.click()
const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
await expect(area).toBeVisible()
await area.fill(note)
const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
await expect(submit).toBeEnabled()
await submit.click()
await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
}
async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
const [width, viewBox, popBox, toolsBox] = await Promise.all([
view.evaluate((el) => el.scrollWidth - el.clientWidth),
view.boundingBox(),
pop.boundingBox(),
tools.boundingBox(),
])
if (!viewBox || !popBox || !toolsBox) return null
return {
width,
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
}
}
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.setTimeout(180_000)
const tag = `review-comment-${Date.now()}`
const file = `review-comment-${tag}.txt`
const note = `comment ${tag}`
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
await expand(page)
await waitMark(page, file, tag)
await comment(page, file, note)
await expect
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
})
})
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}`
const file = `review-file-comment-${tag}.txt`
const note = `comment ${tag}`
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
await expand(page)
await waitMark(page, file, tag)
await openReviewFile(page, file)
await fileComment(page, note)
await expect
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
})
})
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000)
const tag = `review-${Date.now()}`
const list = files(tag)
const hit = list[list.length - 4]!
const next = `${tag}-live`
await page.setViewportSize({ width: 1600, height: 1000 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review ${tag}`, async (session) => {
await patch(sdk, session.id, seed(list))
await expect
.poll(
async () => {
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
return info?.summary?.files ?? 0
},
{ timeout: 60_000 },
)
.toBe(list.length)
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(list.length)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
await expect(view).toBeVisible()
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
await expect(heads).toHaveCount(list.length, {
timeout: 60_000,
})
await expand(page)
await waitMark(page, hit.file, hit.mark)
const row = page
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
.first()
await expect(row).toBeVisible()
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
const prev = await spot(page, hit.file)
if (!prev) throw new Error(`missing review row for ${hit.file}`)
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const item = diff.find((item) => item.file === hit.file)
return typeof item?.after === "string" ? item.after : ""
},
{ timeout: 60_000 },
)
.toContain(`mark ${next}`)
await waitMark(page, hit.file, next)
await expect
.poll(
async () => {
const next = await spot(page, hit.file)
if (!next) return Number.POSITIVE_INFINITY
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
},
{ timeout: 60_000 },
)
.toBeLessThanOrEqual(32)
})
})
})
@@ -1,233 +0,0 @@
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import { createSdk, modKey } from "../utils"
import { promptSelector } from "../selectors"
async function seedConversation(input: {
page: Page
sdk: ReturnType<typeof createSdk>
sessionID: string
token: string
}) {
const messages = async () =>
await input.sdk.session.messages({ sessionID: input.sessionID, limit: 100 }).then((r) => r.data ?? [])
const seeded = await messages()
const userIDs = new Set(seeded.filter((m) => m.info.role === "user").map((m) => m.info.id))
const prompt = input.page.locator(promptSelector)
await expect(prompt).toBeVisible()
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
noReply: true,
parts: [{ type: "text", text: input.token }],
})
let userMessageID: string | undefined
await expect
.poll(
async () => {
const users = (await messages()).filter(
(m) =>
!userIDs.has(m.info.id) &&
m.info.role === "user" &&
m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
)
if (users.length === 0) return false
const user = users[users.length - 1]
if (!user) return false
userMessageID = user.info.id
return true
},
{ timeout: 90_000, intervals: [250, 500, 1_000] },
)
.toBe(true)
if (!userMessageID) throw new Error("Expected a user message id")
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`)).toHaveCount(1, { timeout: 30_000 })
return { prompt, userMessageID }
}
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
test.setTimeout(120_000)
const token = `undo_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
await seeded.prompt.click()
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(seeded.userMessageID)
await expect(seeded.prompt).toContainText(token)
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
})
})
})
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
test.setTimeout(120_000)
const token = `redo_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
await seeded.prompt.click()
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(seeded.userMessageID)
await seeded.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
const redo = page.locator('[data-slash-id="session.redo"]').first()
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBeUndefined()
await expect(seeded.prompt).not.toContainText(token)
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
})
})
})
test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
test.setTimeout(120_000)
const firstToken = `undo_redo_first_${Date.now()}`
const secondToken = `undo_redo_second_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
await project.gotoSession(session.id)
const first = await seedConversation({
page,
sdk,
sessionID: session.id,
token: firstToken,
})
const second = await seedConversation({
page,
sdk,
sessionID: session.id,
token: secondToken,
})
expect(first.userMessageID).not.toBe(second.userMessageID)
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(first.userMessageID)
await expect(firstMessage).toHaveCount(0)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
const redo = page.locator('[data-slash-id="session.redo"]').first()
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBeUndefined()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
})
})
})
-174
View File
@@ -1,174 +0,0 @@
import { test, expect } from "../fixtures"
import {
openSidebar,
openSessionMoreMenu,
clickMenuItem,
confirmDialog,
openSharePopover,
withSession,
} from "../actions"
import { sessionItemSelector, inlineInputSelector } from "../selectors"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
type Sdk = Parameters<typeof withSession>[0]
async function seedMessage(sdk: Sdk, sessionID: string) {
await sdk.session.promptAsync({
sessionID,
noReply: true,
parts: [{ type: "text", text: "e2e seed" }],
})
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 30_000 },
)
.toBeGreaterThan(0)
}
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const originalTitle = `e2e rename test ${stamp}`
const renamedTitle = `e2e renamed ${stamp}`
await withSession(sdk, originalTitle, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(renamedTitle)
await expect(input).toHaveValue(renamedTitle)
await input.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.title
},
{ timeout: 30_000 },
)
.toBe(renamedTitle)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
})
})
test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const title = `e2e archive test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /archive/i)
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.time?.archived
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
})
test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const title = `e2e delete test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /delete/i)
await confirmDialog(page, /delete/i)
await expect
.poll(
async () => {
const data = await sdk.session
.get({ sessionID: session.id })
.then((r) => r.data)
.catch(() => undefined)
return data?.id
},
{ timeout: 30_000 },
)
.toBeUndefined()
await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
})
test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
const stamp = Date.now()
const title = `e2e share test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const shared = await openSharePopover(page)
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
await expect(publish).toBeVisible({ timeout: 30_000 })
await publish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
timeout: 30_000,
})
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
await expect(unpublish).toBeVisible({ timeout: 30_000 })
await unpublish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000,
})
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.toBeUndefined()
const unshared = await openSharePopover(page)
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000,
})
})
})
@@ -1,389 +0,0 @@
import { test, expect } from "../fixtures"
import { openSettings, closeDialog, waitTerminalFocusIdle, withSession } from "../actions"
import { keybindButtonSelector, terminalSelector } from "../selectors"
import { modKey } from "../utils"
test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle")).first()
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("B")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyH`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("H")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["sidebar.toggle"]).toBe("mod+shift+h")
await closeDialog(page, dialog)
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true"
await page.keyboard.press(`${modKey}+Shift+H`)
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false")
const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true"
expect(afterToggleClosed).toBe(!initiallyClosed)
await page.keyboard.press(`${modKey}+Shift+H`)
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true")
const finalClosed = (await button.getAttribute("aria-expanded")) !== "true"
expect(finalClosed).toBe(initiallyClosed)
})
test("sidebar toggle keybind guards against shortcut conflicts", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("B")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyP`)
await page.waitForTimeout(100)
const toast = page.locator('[data-component="toast"]').last()
await expect(toast).toBeVisible()
await expect(toast).toContainText(/already/i)
await keybindButton.click()
await expect(keybindButton).toContainText("B")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
await closeDialog(page, dialog)
})
test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } }))
})
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
await expect(keybindButton).toBeVisible()
const customKeybind = await keybindButton.textContent()
expect(customKeybind).toContain("X")
const resetButton = dialog.getByRole("button", { name: "Reset to defaults" })
await expect(resetButton).toBeVisible()
await expect(resetButton).toBeEnabled()
await resetButton.click()
await page.waitForTimeout(100)
const restoredKeybind = await keybindButton.textContent()
expect(restoredKeybind).toContain("B")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
await closeDialog(page, dialog)
})
test("clearing a keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("B")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press("Delete")
await page.waitForTimeout(100)
const clearedKeybind = await keybindButton.textContent()
expect(clearedKeybind).toMatch(/unassigned|press/i)
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["sidebar.toggle"]).toBe("none")
await closeDialog(page, dialog)
await page.keyboard.press(`${modKey}+B`)
await page.waitForTimeout(100)
const stillOnSession = page.url().includes("/session")
expect(stillOnSession).toBe(true)
})
test("changing settings open keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("settings.open"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain(",")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Slash`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("/")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["settings.open"]).toBe("mod+/")
await closeDialog(page, dialog)
const settingsDialog = page.getByRole("dialog")
await expect(settingsDialog).toHaveCount(0)
await page.keyboard.press(`${modKey}+Slash`)
await page.waitForTimeout(100)
await expect(settingsDialog).toBeVisible()
await closeDialog(page, settingsDialog)
})
test("changing new session keybind works", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, "test session for keybind", async (session) => {
await gotoSession(session.id)
const initialUrl = page.url()
expect(initialUrl).toContain(`/session/${session.id}`)
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("session.new"))
await expect(keybindButton).toBeVisible()
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyN`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("N")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["session.new"]).toBe("mod+shift+n")
await closeDialog(page, dialog)
await page.keyboard.press(`${modKey}+Shift+N`)
await page.waitForTimeout(200)
const newUrl = page.url()
expect(newUrl).toMatch(/\/session\/?$/)
expect(newUrl).not.toContain(session.id)
})
})
test("changing file open keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("file.open"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("K")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyF`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("F")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["file.open"]).toBe("mod+shift+f")
await closeDialog(page, dialog)
const filePickerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder(/search files/i) })
await expect(filePickerDialog).toHaveCount(0)
await page.keyboard.press(`${modKey}+Shift+F`)
await page.waitForTimeout(100)
await expect(filePickerDialog).toBeVisible()
await page.keyboard.press("Escape")
await expect(filePickerDialog).toHaveCount(0)
})
test("changing terminal toggle keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
await expect(keybindButton).toBeVisible()
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+KeyY`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("Y")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["terminal.toggle"]).toBe("mod+y")
await closeDialog(page, dialog)
const terminal = page.locator(terminalSelector)
await expect(terminal).not.toBeVisible()
await page.keyboard.press(`${modKey}+Y`)
await waitTerminalFocusIdle(page, { term: terminal })
await page.keyboard.press(`${modKey}+Y`)
await expect(terminal).not.toBeVisible()
})
test("terminal toggle keybind persists after reload", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
await expect(keybindButton).toBeVisible()
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyY`)
await page.waitForTimeout(100)
await expect(keybindButton).toContainText("Y")
await closeDialog(page, dialog)
await page.reload()
await expect
.poll(async () => {
return await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
if (!raw) return
const parsed = JSON.parse(raw)
return parsed?.keybinds?.["terminal.toggle"]
})
})
.toBe("mod+shift+y")
const reloaded = await openSettings(page)
await reloaded.getByRole("tab", { name: "Shortcuts" }).click()
const reloadedKeybind = reloaded.locator(keybindButtonSelector("terminal.toggle")).first()
await expect(reloadedKeybind).toContainText("Y")
await closeDialog(page, reloaded)
})
test("changing command palette keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("command.palette"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("P")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyK`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("K")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["command.palette"]).toBe("mod+shift+k")
await closeDialog(page, dialog)
const palette = page.getByRole("dialog").filter({ has: page.getByRole("textbox").first() })
await expect(palette).toHaveCount(0)
await page.keyboard.press(`${modKey}+Shift+K`)
await page.waitForTimeout(100)
await expect(palette).toBeVisible()
await expect(palette.getByRole("textbox").first()).toBeVisible()
await page.keyboard.press("Escape")
await expect(palette).toHaveCount(0)
})
@@ -1,122 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { closeDialog, openSettings } from "../actions"
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
const command = page.locator('[data-slash-id="model.choose"]')
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const picker = page.getByRole("dialog")
await expect(picker).toBeVisible()
const target = picker.locator('[data-slot="list-item"]').first()
await expect(target).toBeVisible()
const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item")
const name = (await target.locator("span").first().innerText()).trim()
if (!name) throw new Error("Failed to resolve model name from list item")
await page.keyboard.press("Escape")
await expect(picker).toHaveCount(0)
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Models" }).click()
const search = settings.getByPlaceholder("Search models")
await expect(search).toBeVisible()
await search.fill(name)
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
const input = toggle.locator('[data-slot="switch-input"]')
await expect(toggle).toBeVisible()
await expect(input).toHaveAttribute("aria-checked", "true")
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "false")
await closeDialog(page, settings)
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const pickerAgain = page.getByRole("dialog")
await expect(pickerAgain).toBeVisible()
await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible()
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(pickerAgain).toHaveCount(0)
})
test("showing a hidden model restores it to the model picker", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
const command = page.locator('[data-slash-id="model.choose"]')
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const picker = page.getByRole("dialog")
await expect(picker).toBeVisible()
const target = picker.locator('[data-slot="list-item"]').first()
await expect(target).toBeVisible()
const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item")
const name = (await target.locator("span").first().innerText()).trim()
if (!name) throw new Error("Failed to resolve model name from list item")
await page.keyboard.press("Escape")
await expect(picker).toHaveCount(0)
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Models" }).click()
const search = settings.getByPlaceholder("Search models")
await expect(search).toBeVisible()
await search.fill(name)
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
const input = toggle.locator('[data-slot="switch-input"]')
await expect(toggle).toBeVisible()
await expect(input).toHaveAttribute("aria-checked", "true")
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "false")
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "true")
await closeDialog(page, settings)
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const pickerAgain = page.getByRole("dialog")
await expect(pickerAgain).toBeVisible()
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toBeVisible()
await page.keyboard.press("Escape")
await expect(pickerAgain).toHaveCount(0)
})
@@ -1,136 +0,0 @@
import { test, expect } from "../fixtures"
import { closeDialog, openSettings } from "../actions"
test("custom provider form can be filled and validates input", async ({ page, gotoSession }) => {
await gotoSession()
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Providers" }).click()
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
await expect(customProviderSection).toBeVisible()
const connectButton = customProviderSection.getByRole("button", { name: "Connect" })
await connectButton.click()
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
await expect(providerDialog).toBeVisible()
await providerDialog.getByLabel("Provider ID").fill("test-provider")
await providerDialog.getByLabel("Display name").fill("Test Provider")
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/fake")
await providerDialog.getByLabel("API key").fill("fake-key")
await providerDialog.getByPlaceholder("model-id").first().fill("test-model")
await providerDialog.getByPlaceholder("Display Name").first().fill("Test Model")
await expect(providerDialog.getByRole("textbox", { name: "Provider ID" })).toHaveValue("test-provider")
await expect(providerDialog.getByRole("textbox", { name: "Display name" })).toHaveValue("Test Provider")
await expect(providerDialog.getByRole("textbox", { name: "Base URL" })).toHaveValue("http://localhost:9999/fake")
await expect(providerDialog.getByRole("textbox", { name: "API key" })).toHaveValue("fake-key")
await expect(providerDialog.getByPlaceholder("model-id").first()).toHaveValue("test-model")
await expect(providerDialog.getByPlaceholder("Display Name").first()).toHaveValue("Test Model")
await page.keyboard.press("Escape")
await expect(providerDialog).toHaveCount(0)
await closeDialog(page, settings)
})
test("custom provider form shows validation errors", async ({ page, gotoSession }) => {
await gotoSession()
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Providers" }).click()
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
await customProviderSection.getByRole("button", { name: "Connect" }).click()
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
await expect(providerDialog).toBeVisible()
await providerDialog.getByLabel("Provider ID").fill("invalid provider id")
await providerDialog.getByLabel("Base URL").fill("not-a-url")
await providerDialog.getByRole("button", { name: /submit|save/i }).click()
await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /lowercase/i })).toBeVisible()
await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /http/i })).toBeVisible()
await page.keyboard.press("Escape")
await expect(providerDialog).toHaveCount(0)
await closeDialog(page, settings)
})
test("custom provider form can add and remove models", async ({ page, gotoSession }) => {
await gotoSession()
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Providers" }).click()
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
await customProviderSection.getByRole("button", { name: "Connect" }).click()
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
await expect(providerDialog).toBeVisible()
await providerDialog.getByLabel("Provider ID").fill("multi-model-test")
await providerDialog.getByLabel("Display name").fill("Multi Model Test")
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/multi")
await providerDialog.getByPlaceholder("model-id").first().fill("model-1")
await providerDialog.getByPlaceholder("Display Name").first().fill("Model 1")
const idInputsBefore = await providerDialog.getByPlaceholder("model-id").count()
await providerDialog.getByRole("button", { name: "Add model" }).click()
const idInputsAfter = await providerDialog.getByPlaceholder("model-id").count()
expect(idInputsAfter).toBe(idInputsBefore + 1)
await providerDialog.getByPlaceholder("model-id").nth(1).fill("model-2")
await providerDialog.getByPlaceholder("Display Name").nth(1).fill("Model 2")
await expect(providerDialog.getByPlaceholder("model-id").nth(1)).toHaveValue("model-2")
await expect(providerDialog.getByPlaceholder("Display Name").nth(1)).toHaveValue("Model 2")
await page.keyboard.press("Escape")
await expect(providerDialog).toHaveCount(0)
await closeDialog(page, settings)
})
test("custom provider form can add and remove headers", async ({ page, gotoSession }) => {
await gotoSession()
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Providers" }).click()
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
await customProviderSection.getByRole("button", { name: "Connect" }).click()
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
await expect(providerDialog).toBeVisible()
await providerDialog.getByLabel("Provider ID").fill("header-test")
await providerDialog.getByLabel("Display name").fill("Header Test")
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/headers")
await providerDialog.getByPlaceholder("model-id").first().fill("model-x")
await providerDialog.getByPlaceholder("Display Name").first().fill("Model X")
const headerInputsBefore = await providerDialog.getByPlaceholder("Header-Name").count()
await providerDialog.getByRole("button", { name: "Add header" }).click()
const headerInputsAfter = await providerDialog.getByPlaceholder("Header-Name").count()
expect(headerInputsAfter).toBe(headerInputsBefore + 1)
await providerDialog.getByPlaceholder("Header-Name").first().fill("Authorization")
await providerDialog.getByPlaceholder("value").first().fill("Bearer token123")
await expect(providerDialog.getByPlaceholder("Header-Name").first()).toHaveValue("Authorization")
await expect(providerDialog.getByPlaceholder("value").first()).toHaveValue("Bearer token123")
await page.keyboard.press("Escape")
await expect(providerDialog).toHaveCount(0)
await closeDialog(page, settings)
})
-708
View File
@@ -1,708 +0,0 @@
import { test, expect, settingsKey } from "../fixtures"
import { closeDialog, openSettings } from "../actions"
import {
settingsColorSchemeSelector,
settingsCodeFontSelector,
settingsLanguageSelectSelector,
settingsNotificationsAgentSelector,
settingsNotificationsErrorsSelector,
settingsNotificationsPermissionsSelector,
settingsReleaseNotesSelector,
settingsSoundsAgentSelector,
settingsSoundsErrorsSelector,
settingsSoundsPermissionsSelector,
settingsThemeSelector,
settingsUIFontSelector,
settingsUpdatesStartupSelector,
} from "../selectors"
test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible()
await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible()
await closeDialog(page, dialog)
})
test("changing language updates settings labels", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
})
await gotoSession()
const dialog = await openSettings(page)
const heading = dialog.getByRole("heading", { level: 2 })
await expect(heading).toHaveText("General")
const select = dialog.locator(settingsLanguageSelectSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
await expect(heading).toHaveText("Allgemein")
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
await expect(heading).toHaveText("General")
})
test("changing color scheme persists in localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsColorSchemeSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
const colorScheme = await page.evaluate(() => {
return document.documentElement.getAttribute("data-color-scheme")
})
expect(colorScheme).toBe("dark")
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Light" }).click()
const lightColorScheme = await page.evaluate(() => {
return document.documentElement.getAttribute("data-color-scheme")
})
expect(lightColorScheme).toBe("light")
})
test("changing theme persists in localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsThemeSelector)
await expect(select).toBeVisible()
const currentThemeId = await page.evaluate(() => {
return document.documentElement.getAttribute("data-theme")
})
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
const count = await items.count()
expect(count).toBeGreaterThan(1)
const nextTheme = (await items.locator('[data-slot="select-select-item-label"]').allTextContents())
.map((x) => x.trim())
.find((x) => x && x !== currentTheme)
expect(nextTheme).toBeTruthy()
await items.filter({ hasText: nextTheme! }).first().click()
await page.keyboard.press("Escape")
const storedThemeId = await page.evaluate(() => {
return localStorage.getItem("opencode-theme-id")
})
expect(storedThemeId).not.toBeNull()
expect(storedThemeId).not.toBe(currentThemeId)
const dataTheme = await page.evaluate(() => {
return document.documentElement.getAttribute("data-theme")
})
expect(dataTheme).toBe(storedThemeId)
})
test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
localStorage.setItem("opencode-theme-id", "oc-1")
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
})
await gotoSession()
await expect(page.locator("html")).toHaveAttribute("data-theme", "oc-2")
await expect
.poll(async () => {
return await page.evaluate(() => {
return localStorage.getItem("opencode-theme-id")
})
})
.toBe("oc-2")
await expect
.poll(async () => {
return await page.evaluate(() => {
return localStorage.getItem("opencode-theme-css-light")
})
})
.toBeNull()
await expect
.poll(async () => {
return await page.evaluate(() => {
return localStorage.getItem("opencode-theme-css-dark")
})
})
.toBeNull()
})
test("typing a code font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const input = dialog.locator(settingsCodeFontSelector)
await expect(input).toBeVisible()
await expect(input).toHaveAttribute("placeholder", "System Mono")
const initialFontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
const initialUIFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
expect(initialFontFamily).toContain("ui-monospace")
const next = "Test Mono"
await input.click()
await input.clear()
await input.pressSequentially(next)
await expect(input).toHaveValue(next)
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
mono: next,
},
})
const newFontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
const newUIFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
expect(newFontFamily).toContain(next)
expect(newFontFamily).not.toBe(initialFontFamily)
expect(newUIFamily).toBe(initialUIFamily)
})
test("typing a UI font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const input = dialog.locator(settingsUIFontSelector)
await expect(input).toBeVisible()
await expect(input).toHaveAttribute("placeholder", "System Sans")
const initialFontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
const initialCodeFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
expect(initialFontFamily).toContain("ui-sans-serif")
const next = "Test Sans"
await input.click()
await input.clear()
await input.pressSequentially(next)
await expect(input).toHaveValue(next)
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
sans: next,
},
})
const newFontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
const newCodeFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
expect(newFontFamily).toContain(next)
expect(newFontFamily).not.toBe(initialFontFamily)
expect(newCodeFamily).toBe(initialCodeFamily)
})
test("clearing the code font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const input = dialog.locator(settingsCodeFontSelector)
await expect(input).toBeVisible()
await input.click()
await input.clear()
await input.pressSequentially("Reset Mono")
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
mono: "Reset Mono",
},
})
await input.clear()
await input.press("Space")
await expect(input).toHaveValue("")
await expect(input).toHaveAttribute("placeholder", "System Mono")
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
mono: "",
},
})
const fontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
expect(fontFamily).toContain("ui-monospace")
expect(fontFamily).not.toContain("Reset Mono")
})
test("clearing the UI font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const input = dialog.locator(settingsUIFontSelector)
await expect(input).toBeVisible()
await input.click()
await input.clear()
await input.pressSequentially("Reset Sans")
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
sans: "Reset Sans",
},
})
await input.clear()
await input.press("Space")
await expect(input).toHaveValue("")
await expect(input).toHaveAttribute("placeholder", "System Sans")
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
sans: "",
},
})
const fontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
expect(fontFamily).toContain("ui-sans-serif")
expect(fontFamily).not.toContain("Reset Sans")
})
test("color scheme, code font, and UI font rehydrate after reload", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
await expect(colorSchemeSelect).toBeVisible()
await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
const code = dialog.locator(settingsCodeFontSelector)
const ui = dialog.locator(settingsUIFontSelector)
await expect(code).toBeVisible()
await expect(ui).toBeVisible()
const initialMono = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
const initialSans = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
const initialSettings = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
const mono = initialSettings?.appearance?.mono === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
const sans = initialSettings?.appearance?.sans === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
await code.click()
await code.clear()
await code.pressSequentially(mono)
await expect(code).toHaveValue(mono)
await ui.click()
await ui.clear()
await ui.pressSequentially(sans)
await expect(ui).toHaveValue(sans)
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
mono,
sans,
},
})
const updatedSettings = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
const updatedMono = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
const updatedSans = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
expect(updatedMono).toContain(mono)
expect(updatedMono).not.toBe(initialMono)
expect(updatedSans).toContain(sans)
expect(updatedSans).not.toBe(initialSans)
expect(updatedSettings?.appearance?.mono).toBe(mono)
expect(updatedSettings?.appearance?.sans).toBe(sans)
await closeDialog(page, dialog)
await page.reload()
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
mono,
sans,
},
})
const rehydratedSettings = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
await expect
.poll(async () => {
return await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
})
.toContain(mono)
await expect
.poll(async () => {
return await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
})
.toContain(sans)
const rehydratedMono = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
const rehydratedSans = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
expect(rehydratedMono).toContain(mono)
expect(rehydratedMono).not.toBe(initialMono)
expect(rehydratedSans).toContain(sans)
expect(rehydratedSans).not.toBe(initialSans)
expect(rehydratedSettings?.appearance?.mono).toBe(mono)
expect(rehydratedSettings?.appearance?.sans).toBe(sans)
})
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsNotificationsAgentSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(true)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(false)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.notifications?.agent).toBe(false)
})
test("toggling notification permissions switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsNotificationsPermissionsSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(true)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(false)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.notifications?.permissions).toBe(false)
})
test("toggling notification errors switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsNotificationsErrorsSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(false)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(true)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.notifications?.errors).toBe(true)
})
test("changing sound agent selection persists in localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsSoundsAgentSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
await items.nth(2).click()
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
})
test("selecting none disables agent sound", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsSoundsAgentSelector)
const trigger = select.locator('[data-slot="select-select-trigger"]')
await expect(select).toBeVisible()
await expect(trigger).toBeEnabled()
await trigger.click()
const items = page.locator('[data-slot="select-select-item"]')
await expect(items.first()).toBeVisible()
await items.first().click()
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.sounds?.agentEnabled).toBe(false)
})
test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const permissionsSelect = dialog.locator(settingsSoundsPermissionsSelector)
const errorsSelect = dialog.locator(settingsSoundsErrorsSelector)
await expect(permissionsSelect).toBeVisible()
await expect(errorsSelect).toBeVisible()
const initial = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
const permissionsCurrent =
(await permissionsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await permissionsSelect.locator('[data-slot="select-select-trigger"]').click()
const permissionItems = page.locator('[data-slot="select-select-item"]')
expect(await permissionItems.count()).toBeGreaterThan(1)
if (permissionsCurrent) {
await permissionItems.filter({ hasNotText: permissionsCurrent }).first().click()
}
if (!permissionsCurrent) {
await permissionItems.nth(1).click()
}
const errorsCurrent =
(await errorsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await errorsSelect.locator('[data-slot="select-select-trigger"]').click()
const errorItems = page.locator('[data-slot="select-select-item"]')
expect(await errorItems.count()).toBeGreaterThan(1)
if (errorsCurrent) {
await errorItems.filter({ hasNotText: errorsCurrent }).first().click()
}
if (!errorsCurrent) {
await errorItems.nth(1).click()
}
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
sounds: {
permissions: expect.any(String),
errors: expect.any(String),
},
})
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.sounds?.permissions).not.toBe(initial?.sounds?.permissions)
expect(stored?.sounds?.errors).not.toBe(initial?.sounds?.errors)
})
test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsUpdatesStartupSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const isDisabled = await toggleInput.evaluate((el: HTMLInputElement) => el.disabled)
if (isDisabled) {
test.skip()
return
}
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(true)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(false)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.updates?.startup).toBe(false)
})
test("toggling release notes switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsReleaseNotesSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(true)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(false)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.general?.releaseNotes).toBe(false)
})
@@ -1,118 +0,0 @@
import { test, expect } from "../fixtures"
import {
defocus,
cleanupSession,
cleanupTestProject,
closeSidebar,
createTestProject,
hoverSessionItem,
openSidebar,
waitSession,
} from "../actions"
import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
const one = await sdk.session.create({ title: `e2e sidebar popover archive 1 ${stamp}` }).then((r) => r.data)
const two = await sdk.session.create({ title: `e2e sidebar popover archive 2 ${stamp}` }).then((r) => r.data)
if (!one?.id) throw new Error("Session create did not return an id")
if (!two?.id) throw new Error("Session create did not return an id")
try {
await gotoSession(one.id)
await closeSidebar(page)
const oneItem = page.locator(`[data-session-id="${one.id}"]`).last()
const twoItem = page.locator(`[data-session-id="${two.id}"]`).last()
const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
await project.hover()
await expect(oneItem).toBeVisible()
await expect(twoItem).toBeVisible()
const item = await hoverSessionItem(page, one.id)
await item
.getByRole("button", { name: /archive/i })
.first()
.click()
await expect(twoItem).toBeVisible()
} finally {
await cleanupSession({ sdk, sessionID: one.id })
await cleanupSession({ sdk, sessionID: two.id })
}
})
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
const project = page.locator(projectSwitchSelector(slug)).first()
const card = page.locator('[data-component="hover-card-content"]')
await expect(project).toBeVisible()
await project.hover()
await expect(card.getByText(/recent sessions/i)).toBeVisible()
await page.mouse.down()
await expect(card).toHaveCount(0)
await page.mouse.up()
await waitSession(page, { directory: other })
await expect(card).toHaveCount(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
await defocus(page)
const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
let hit = false
for (let i = 0; i < 20; i++) {
hit = await project.evaluate((el) => {
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
})
if (hit) break
await page.keyboard.press("Tab")
}
expect(hit).toBe(true)
await page.keyboard.press("Enter")
await waitSession(page, { directory: other })
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
@@ -1,30 +0,0 @@
import { test, expect } from "../fixtures"
import { cleanupSession, openSidebar, withSession } from "../actions"
import { promptSelector } from "../selectors"
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
const one = await sdk.session.create({ title: `e2e sidebar nav 1 ${stamp}` }).then((r) => r.data)
const two = await sdk.session.create({ title: `e2e sidebar nav 2 ${stamp}` }).then((r) => r.data)
if (!one?.id) throw new Error("Session create did not return an id")
if (!two?.id) throw new Error("Session create did not return an id")
try {
await gotoSession(one.id)
await openSidebar(page)
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(target).toBeVisible()
await target.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
} finally {
await cleanupSession({ sdk, sessionID: one.id })
await cleanupSession({ sdk, sessionID: two.id })
}
})
-40
View File
@@ -1,40 +0,0 @@
import { test, expect } from "../fixtures"
import { openSidebar, toggleSidebar, withSession } from "../actions"
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
await gotoSession()
await openSidebar(page)
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await expect(button).toHaveAttribute("aria-expanded", "true")
await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "false")
await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "true")
})
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, "sidebar persist session 1", async (session1) => {
await withSession(sdk, "sidebar persist session 2", async (session2) => {
await gotoSession(session1.id)
await openSidebar(page)
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "false")
await gotoSession(session2.id)
await expect(button).toHaveAttribute("aria-expanded", "false")
await page.reload()
await expect(button).toHaveAttribute("aria-expanded", "false")
const opened = await page.evaluate(
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
)
await expect(opened).toBe(false)
})
})
})
@@ -1,94 +0,0 @@
import { test, expect } from "../fixtures"
import { openStatusPopover } from "../actions"
test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
await expect(popoverBody.getByRole("tab", { name: /servers/i })).toBeVisible()
await expect(popoverBody.getByRole("tab", { name: /mcp/i })).toBeVisible()
await expect(popoverBody.getByRole("tab", { name: /lsp/i })).toBeVisible()
await expect(popoverBody.getByRole("tab", { name: /plugins/i })).toBeVisible()
await page.keyboard.press("Escape")
await expect(popoverBody).toHaveCount(0)
})
test("status popover servers tab shows current server", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
const serversTab = popoverBody.getByRole("tab", { name: /servers/i })
await expect(serversTab).toHaveAttribute("aria-selected", "true")
const serverList = popoverBody.locator('[role="tabpanel"]').first()
await expect(serverList.locator("button").first()).toBeVisible()
})
test("status popover can switch to mcp tab", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
const mcpTab = popoverBody.getByRole("tab", { name: /mcp/i })
await mcpTab.click()
const ariaSelected = await mcpTab.getAttribute("aria-selected")
expect(ariaSelected).toBe("true")
const mcpContent = popoverBody.locator('[role="tabpanel"]:visible').first()
await expect(mcpContent).toBeVisible()
})
test("status popover can switch to lsp tab", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
const lspTab = popoverBody.getByRole("tab", { name: /lsp/i })
await lspTab.click()
const ariaSelected = await lspTab.getAttribute("aria-selected")
expect(ariaSelected).toBe("true")
const lspContent = popoverBody.locator('[role="tabpanel"]:visible').first()
await expect(lspContent).toBeVisible()
})
test("status popover can switch to plugins tab", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
const pluginsTab = popoverBody.getByRole("tab", { name: /plugins/i })
await pluginsTab.click()
const ariaSelected = await pluginsTab.getAttribute("aria-selected")
expect(ariaSelected).toBe("true")
const pluginsContent = popoverBody.locator('[role="tabpanel"]:visible').first()
await expect(pluginsContent).toBeVisible()
})
test("status popover closes on escape", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
await expect(popoverBody).toBeVisible()
await page.keyboard.press("Escape")
await expect(popoverBody).toHaveCount(0)
})
test("status popover closes when clicking outside", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
await expect(popoverBody).toBeVisible()
await page.getByRole("main").click({ position: { x: 5, y: 5 } })
await expect(popoverBody).toHaveCount(0)
})
@@ -1,28 +0,0 @@
import { test, expect } from "../fixtures"
import { waitTerminalFocusIdle, waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => {
await gotoSession()
const terminals = page.locator(terminalSelector)
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
const opened = await terminals.first().isVisible()
if (!opened) {
await page.keyboard.press(terminalToggleKey)
}
await waitTerminalFocusIdle(page, { term: terminals.first() })
await expect(terminals).toHaveCount(1)
// Ghostty captures a lot of keybinds when focused; move focus back
// to the app shell before triggering `terminal.new`.
await page.locator(promptSelector).click()
await page.keyboard.press("Control+Alt+T")
await expect(tabs).toHaveCount(2)
await expect(terminals).toHaveCount(1)
await waitTerminalReady(page, { term: terminals.first() })
})
@@ -1,46 +0,0 @@
import type { Page } from "@playwright/test"
import { disconnectTerminal, runTerminal, terminalConnects, waitTerminalReady } from "../actions"
import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
async function open(page: Page) {
const term = page.locator(terminalSelector).first()
const visible = await term.isVisible().catch(() => false)
if (!visible) await page.keyboard.press(terminalToggleKey)
await waitTerminalReady(page, { term })
return term
}
test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
await withProject(async ({ gotoSession }) => {
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
const token = `E2E_RECONNECT_${Date.now()}`
await gotoSession()
const term = await open(page)
const id = await term.getAttribute("data-pty-id")
if (!id) throw new Error("Active terminal missing data-pty-id")
const prev = await terminalConnects(page, { term })
await runTerminal(page, {
term,
cmd: `export ${name}=${token}; echo ${token}`,
token,
})
await disconnectTerminal(page, { term })
await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
await runTerminal(page, {
term,
cmd: `echo $${name}`,
token,
timeout: 15_000,
})
})
})
@@ -1,168 +0,0 @@
import type { Page } from "@playwright/test"
import { runTerminal, waitTerminalReady } from "../actions"
import { test, expect } from "../fixtures"
import { dropdownMenuContentSelector, terminalSelector } from "../selectors"
import { terminalToggleKey, workspacePersistKey } from "../utils"
type State = {
active?: string
all: Array<{
id: string
title: string
titleNumber: number
buffer?: string
}>
}
async function open(page: Page) {
const terminal = page.locator(terminalSelector)
const visible = await terminal.isVisible().catch(() => false)
if (!visible) await page.keyboard.press(terminalToggleKey)
await waitTerminalReady(page, { term: terminal })
}
async function store(page: Page, key: string) {
return page.evaluate((key) => {
const raw = localStorage.getItem(key)
if (raw) return JSON.parse(raw) as State
for (let i = 0; i < localStorage.length; i++) {
const next = localStorage.key(i)
if (!next?.endsWith(":workspace:terminal")) continue
const value = localStorage.getItem(next)
if (!value) continue
return JSON.parse(value) as State
}
}, key)
}
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const one = `E2E_TERM_ONE_${Date.now()}`
const two = `E2E_TERM_TWO_${Date.now()}`
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
await gotoSession()
await open(page)
await runTerminal(page, { cmd: `echo ${one}`, token: one })
await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2)
await runTerminal(page, { cmd: `echo ${two}`, token: two })
await first.click()
await expect(first).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
const state = await store(page, key)
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
return {
first: first.includes(one),
second: second.includes(two),
}
},
{ timeout: 5_000 },
)
.toEqual({ first: false, second: true })
await second.click()
await expect(second).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
const state = await store(page, key)
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
return {
first: first.includes(one),
second: second.includes(two),
}
},
{ timeout: 5_000 },
)
.toEqual({ first: true, second: false })
})
})
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
await gotoSession()
await open(page)
await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2)
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
await second.click()
await expect(second).toHaveAttribute("aria-selected", "true")
await second.hover()
await page
.getByRole("button", { name: /close terminal/i })
.nth(1)
.click({ force: true })
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
await expect(tabs).toHaveCount(1)
await expect(first).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
const state = await store(page, key)
return {
count: state?.all.length ?? 0,
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
}
},
{ timeout: 15_000 },
)
.toEqual({ count: 1, first: true })
})
})
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const rename = `E2E term ${Date.now()}`
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
await gotoSession()
await open(page)
await expect(tab).toContainText(/Terminal 1/)
await tab.click({ button: "right" })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
await expect(menu).toHaveCount(0)
const input = page.locator('#terminal-panel input[type="text"]').first()
await expect(input).toBeVisible()
await input.fill(rename)
await input.press("Enter")
await expect(input).toHaveCount(0)
await expect(tab).toContainText(rename)
await expect
.poll(
async () => {
const state = await store(page, key)
return state?.all[0]?.title
},
{ timeout: 5_000 },
)
.toBe(rename)
})
})
@@ -1,18 +0,0 @@
import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions"
import { terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
test("terminal panel can be toggled", async ({ page, gotoSession }) => {
await gotoSession()
const terminal = page.locator(terminalSelector)
const initiallyOpen = await terminal.isVisible()
if (initiallyOpen) {
await page.keyboard.press(terminalToggleKey)
await expect(terminal).toHaveCount(0)
}
await page.keyboard.press(terminalToggleKey)
await waitTerminalReady(page, { term: terminal })
})
-25
View File
@@ -1,25 +0,0 @@
import { test, expect } from "./fixtures"
import { modelVariantCycleSelector } from "./selectors"
test("smoke model variant cycle updates label", async ({ page, gotoSession }) => {
await gotoSession()
await page.addStyleTag({
content: `${modelVariantCycleSelector} { display: inline-block !important; }`,
})
const button = page.locator(modelVariantCycleSelector)
const exists = (await button.count()) > 0
test.skip(!exists, "current model has no variants")
if (!exists) return
await expect(button).toBeVisible()
const before = (await button.innerText()).trim()
await button.click()
await expect(button).not.toHaveText(before)
const after = (await button.innerText()).trim()
await button.click()
await expect(button).not.toHaveText(after)
})
+11
View File
@@ -0,0 +1,11 @@
import { test } from "@playwright/test"
test(
"test something cool",
{
annotation: { type: "todo" },
},
async () => {
test.fixme()
},
)
+1 -1
View File
@@ -5,5 +5,5 @@
"rootDir": "..",
"types": ["node", "bun"]
},
"include": ["./**/*.ts", "../src/testing/terminal.ts"]
"include": ["./**/*.ts"]
}
-63
View File
@@ -1,63 +0,0 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { base64Encode, checksum } from "@opencode-ai/util/encode"
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
export const serverUrl = `http://${serverHost}:${serverPort}`
export const serverName = `${serverHost}:${serverPort}`
const localHosts = ["127.0.0.1", "localhost"]
const serverLabels = (() => {
const url = new URL(serverUrl)
if (!localHosts.includes(url.hostname)) return [serverName]
return localHosts.map((host) => `${host}:${url.port}`)
})()
export const serverNames = [...new Set(serverLabels)]
export const serverUrls = serverNames.map((name) => `http://${name}`)
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("|")})`)
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
export const terminalToggleKey = "Control+Backquote"
export function createSdk(directory?: string) {
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
}
export async function resolveDirectory(directory: string) {
return createSdk(directory)
.path.get()
.then((x) => x.data?.directory ?? directory)
}
export async function getWorktree() {
const sdk = createSdk()
const result = await sdk.path.get()
const data = result.data
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`)
return data.worktree
}
export function dirSlug(directory: string) {
return base64Encode(directory)
}
export function dirPath(directory: string) {
return `/${dirSlug(directory)}`
}
export function sessionPath(directory: string, sessionID?: string) {
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
}
export function workspacePersistKey(directory: string, key: string) {
const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(directory) ?? "0"
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
}
+7 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.3.5",
"version": "1.14.19",
"description": "",
"type": "module",
"exports": {
@@ -15,17 +15,18 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit",
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "bun script/e2e-local.ts",
"test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report"
},
"license": "MIT",
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "1.57.0",
"@playwright/test": "catalog:",
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",
@@ -41,14 +42,15 @@
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opencode-ai/shared": "workspace:*",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
+6 -1
View File
@@ -7,6 +7,11 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
}
export default defineConfig({
testDir: "./e2e",
@@ -19,7 +24,7 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
reporter,
webServer: {
command,
url: baseURL,
-180
View File
@@ -1,180 +0,0 @@
import fs from "node:fs/promises"
import net from "node:net"
import os from "node:os"
import path from "node:path"
async function freePort() {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer()
server.once("error", reject)
server.listen(0, () => {
const address = server.address()
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire a free port")))
return
}
server.close((err) => {
if (err) {
reject(err)
return
}
resolve(address.port)
})
})
})
}
async function waitForHealth(url: string) {
const timeout = Date.now() + 120_000
const errors: string[] = []
while (Date.now() < timeout) {
const result = await fetch(url)
.then((r) => ({ ok: r.ok, error: undefined }))
.catch((error) => ({
ok: false,
error: error instanceof Error ? error.message : String(error),
}))
if (result.ok) return
if (result.error) errors.push(result.error)
await new Promise((r) => setTimeout(r, 250))
}
const last = errors.length ? ` (last error: ${errors[errors.length - 1]})` : ""
throw new Error(`Timed out waiting for server health: ${url}${last}`)
}
const appDir = process.cwd()
const repoDir = path.resolve(appDir, "../..")
const opencodeDir = path.join(repoDir, "packages", "opencode")
const extraArgs = (() => {
const args = process.argv.slice(2)
if (args[0] === "--") return args.slice(1)
return args
})()
const [serverPort, webPort] = await Promise.all([freePort(), freePort()])
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
const keepSandbox = process.env.OPENCODE_E2E_KEEP_SANDBOX === "1"
const serverEnv = {
...process.env,
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
XDG_DATA_HOME: path.join(sandbox, "share"),
XDG_CACHE_HOME: path.join(sandbox, "cache"),
XDG_CONFIG_HOME: path.join(sandbox, "config"),
XDG_STATE_HOME: path.join(sandbox, "state"),
OPENCODE_E2E_PROJECT_DIR: repoDir,
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
OPENCODE_CLIENT: "app",
OPENCODE_STRICT_CONFIG_DEPS: "true",
} satisfies Record<string, string>
const runnerEnv = {
...serverEnv,
PLAYWRIGHT_SERVER_HOST: "127.0.0.1",
PLAYWRIGHT_SERVER_PORT: String(serverPort),
VITE_OPENCODE_SERVER_HOST: "127.0.0.1",
VITE_OPENCODE_SERVER_PORT: String(serverPort),
PLAYWRIGHT_PORT: String(webPort),
} satisfies Record<string, string>
let seed: ReturnType<typeof Bun.spawn> | undefined
let runner: ReturnType<typeof Bun.spawn> | undefined
let server: { stop: () => Promise<void> | void } | undefined
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
let cleaned = false
const cleanup = async () => {
if (cleaned) return
cleaned = true
if (seed && seed.exitCode === null) seed.kill("SIGTERM")
if (runner && runner.exitCode === null) runner.kill("SIGTERM")
const jobs = [
inst?.Instance.disposeAll(),
server?.stop(),
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
].filter(Boolean)
await Promise.allSettled(jobs)
}
const shutdown = (code: number, reason: string) => {
process.exitCode = code
void cleanup().finally(() => {
console.error(`e2e-local shutdown: ${reason}`)
process.exit(code)
})
}
const reportInternalError = (reason: string, error: unknown) => {
console.warn(`e2e-local ignored server error: ${reason}`)
console.warn(error)
}
process.once("SIGINT", () => shutdown(130, "SIGINT"))
process.once("SIGTERM", () => shutdown(143, "SIGTERM"))
process.once("SIGHUP", () => shutdown(129, "SIGHUP"))
process.once("uncaughtException", (error) => {
reportInternalError("uncaughtException", error)
})
process.once("unhandledRejection", (error) => {
reportInternalError("unhandledRejection", error)
})
let code = 1
try {
seed = Bun.spawn(["bun", "script/seed-e2e.ts"], {
cwd: opencodeDir,
env: serverEnv,
stdout: "inherit",
stderr: "inherit",
})
const seedExit = await seed.exited
if (seedExit !== 0) {
code = seedExit
} else {
Object.assign(process.env, serverEnv)
process.env.AGENT = "1"
process.env.OPENCODE = "1"
process.env.OPENCODE_PID = String(process.pid)
const log = await import("../../opencode/src/util/log")
const install = await import("../../opencode/src/installation")
await log.Log.init({
print: true,
dev: install.Installation.isLocal(),
level: "WARN",
})
const servermod = await import("../../opencode/src/server/server")
inst = await import("../../opencode/src/project/instance")
server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], {
cwd: appDir,
env: runnerEnv,
stdout: "inherit",
stderr: "inherit",
})
code = await runner.exited
}
} catch (error) {
console.error(error)
code = 1
} finally {
await cleanup()
}
process.exit(code)
+2 -2
View File
@@ -180,8 +180,8 @@ describe("SerializeAddon", () => {
await writeAndWait(term, input)
const origLine = term.buffer.active.getLine(0)
const origFg = origLine!.getCell(0)!.getFgColor()
const origBg = origLine!.getCell(0)!.getBgColor()
const _origFg = origLine!.getCell(0)!.getFgColor()
const _origBg = origLine!.getCell(0)!.getBgColor()
expect(origLine!.getCell(0)!.isBold()).toBe(1)
const serialized = addon.serialize({ range: { start: 0, end: 0 } })
+2 -2
View File
@@ -258,8 +258,8 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
protected _beforeSerialize(rows: number, start: number, _end: number): void {
this._allRows = new Array<string>(rows)
this._allRowSeparators = new Array<string>(rows)
this._allRows = Array.from<string>({ length: rows })
this._allRowSeparators = Array.from<string>({ length: rows })
this._rowIndex = 0
this._currentRow = ""
+40 -37
View File
@@ -10,7 +10,7 @@ import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Duration, Effect } from "effect"
import { Effect } from "effect"
import {
type Component,
createMemo,
@@ -121,10 +121,10 @@ function SessionProviders(props: ParentProps) {
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
return (
<AppShellProviders>
<Suspense fallback={<Loading />}>
{props.appChildren}
{props.children}
</Suspense>
{/*<Suspense fallback={<Loading />}>*/}
{props.appChildren}
{props.children}
{/*</Suspense>*/}
</AppShellProviders>
)
}
@@ -141,13 +141,11 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
<LanguageProvider locale={props.locale}>
<UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<QueryProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
</QueryProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</UiI18nBridge>
</LanguageProvider>
@@ -156,11 +154,6 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
)
}
const effectMinDuration =
(duration: Duration.Input) =>
<A, E, R>(e: Effect.Effect<A, E, R>) =>
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
@@ -182,7 +175,6 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
if (checkMode() === "background" || type === "http") return false
}
}).pipe(
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise,
@@ -190,32 +182,41 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
)
return (
<Show
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
<Suspense
fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>
{/*<Show
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>*/}
{checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest}
<Show
when={startupHealthCheck()}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") healthCheckActions.refetch()
if (checkMode() === "background") void healthCheckActions.refetch()
}}
onServerSelected={(key) => {
setCheckMode("blocking")
server.setActive(key)
healthCheckActions.refetch()
void healthCheckActions.refetch()
}}
/>
}
>
{props.children}
</Show>
</Show>
{/*</Show>*/}
</Suspense>
)
}
@@ -290,20 +291,22 @@ export function AppInterface(props: {
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
<QueryProvider>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</QueryProvider>
</ServerKey>
</ConnectionGate>
</ServerProvider>
+2 -2
View File
@@ -1,6 +1,7 @@
import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/context/language"
@@ -349,13 +350,12 @@ export function DebugBar() {
syncHeap()
start()
document.addEventListener("visibilitychange", vis)
makeEventListener(document, "visibilitychange", vis)
onCleanup(() => {
if (one !== 0) cancelAnimationFrame(one)
if (two !== 0) cancelAnimationFrame(two)
stop()
document.removeEventListener("visibilitychange", vis)
for (const ob of obs) ob.disconnect()
})
})
@@ -327,7 +327,7 @@ export function DialogConnectProvider(props: { provider: string }) {
if (loading()) return
if (methods().length === 1) {
auto = true
selectMethod(0)
void selectMethod(0)
}
})
@@ -373,7 +373,7 @@ export function DialogConnectProvider(props: { provider: string }) {
key={(m) => m?.label}
onSelect={async (selected, index) => {
if (!selected) return
selectMethod(index)
void selectMethod(index)
}}
>
{(i) => (
@@ -9,7 +9,7 @@ import { createStore } from "solid-js/store"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/util/path"
import { getFilename } from "@opencode-ai/shared/util/path"
import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language"
+1 -1
View File
@@ -9,7 +9,7 @@ import { List } from "@opencode-ai/ui/list"
import { showToast } from "@opencode-ai/ui/toast"
import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/util/encode"
import { base64Encode } from "@opencode-ai/shared/util/encode"
import { useLanguage } from "@/context/language"
interface ForkableMessage {
@@ -3,7 +3,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list"
import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { getDirectory, getFilename } from "@opencode-ai/shared/util/path"
import fuzzysort from "fuzzysort"
import { createMemo, createResource, createSignal } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
@@ -4,8 +4,8 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
import { base64Encode } from "@opencode-ai/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { base64Encode } from "@opencode-ai/shared/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/shared/util/path"
import { useNavigate } from "@solidjs/router"
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
@@ -348,8 +348,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const open = (path: string) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
void tabs().open(value)
void file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all")
props.onOpenFile?.(path)
@@ -86,6 +86,7 @@ const ModelList: Component<{
}
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
export function ModelSelectorPopover(props: {
provider?: string
@@ -93,25 +94,31 @@ export function ModelSelectorPopover(props: {
children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
onClose?: (cause: "escape" | "select") => void
}) {
const [store, setStore] = createStore<{
open: boolean
dismiss: "escape" | "outside" | null
dismiss: Dismiss | null
}>({
open: false,
dismiss: null,
})
const dialog = useDialog()
const handleManage = () => {
const close = (dismiss: Dismiss) => {
setStore("dismiss", dismiss)
setStore("open", false)
}
const handleManage = () => {
close("manage")
void import("./dialog-manage-models").then((x) => {
dialog.show(() => <x.DialogManageModels />)
})
}
const handleConnectProvider = () => {
setStore("open", false)
close("provider")
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />)
})
@@ -136,21 +143,19 @@ export function ModelSelectorPopover(props: {
<Kobalte.Content
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
onEscapeKeyDown={(event) => {
setStore("dismiss", "escape")
setStore("open", false)
close("escape")
event.preventDefault()
event.stopPropagation()
}}
onPointerDownOutside={() => {
setStore("dismiss", "outside")
setStore("open", false)
}}
onFocusOutside={() => {
setStore("dismiss", "outside")
setStore("open", false)
}}
onPointerDownOutside={() => close("outside")}
onFocusOutside={() => close("outside")}
onCloseAutoFocus={(event) => {
if (store.dismiss === "outside") event.preventDefault()
const dismiss = store.dismiss
if (dismiss === "outside") event.preventDefault()
if (dismiss === "escape" || dismiss === "select") {
event.preventDefault()
props.onClose?.(dismiss)
}
setStore("dismiss", null)
}}
>
@@ -158,7 +163,7 @@ export function ModelSelectorPopover(props: {
<ModelList
provider={props.provider}
model={props.model}
onSelect={() => setStore("open", false)}
onSelect={() => close("select")}
class="p-1"
action={
<div class="flex items-center gap-1">
@@ -344,7 +344,7 @@ export function DialogSelectServer() {
createEffect(() => {
items()
refreshHealth()
void refreshHealth()
const interval = setInterval(refreshHealth, 10_000)
onCleanup(() => clearInterval(interval))
})
@@ -498,7 +498,7 @@ export function DialogSelectServer() {
async function handleRemove(url: ServerConnection.Key) {
server.remove(url)
if ((await platform.getDefaultServer?.()) === url) {
platform.setDefaultServer?.(null)
void platform.setDefaultServer?.(null)
}
}
@@ -536,7 +536,7 @@ export function DialogSelectServer() {
items={sortedItems}
key={(x) => x.http.url}
onSelect={(x) => {
if (x) select(x)
if (x) void select(x)
}}
divider={true}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
+1 -2
View File
@@ -14,7 +14,6 @@ import {
Switch,
untrack,
type ComponentProps,
type JSXElement,
type ParentProps,
} from "solid-js"
import { Dynamic } from "solid-js/web"
@@ -149,7 +148,7 @@ const FileTreeNode = (
classList={{
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
"bg-surface-base-active": local.node.path === local.active,
...(local.classList ?? {}),
...local.classList,
[local.class ?? ""]: !!local.class,
[local.nodeClass ?? ""]: !!local.nodeClass,
}}
+192 -198
View File
@@ -1,6 +1,6 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js"
import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
@@ -35,7 +35,6 @@ import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { promptEnabled, promptProbe } from "@/testing/prompt"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
@@ -55,6 +54,8 @@ import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useQueries, useQuery } from "@tanstack/solid-query"
import { loadAgentsQuery, loadProvidersQuery } from "@/context/global-sync/bootstrap"
interface PromptInputProps {
class?: string
@@ -101,6 +102,7 @@ const NON_EMPTY_TEXT = /[^\s\u200B]/
export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK()
const sync = useSync()
const local = useLocal()
const files = useFile()
@@ -213,9 +215,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all")
const tab = files.tab(item.path)
tabs().open(tab)
void tabs().open(tab)
tabs().setActive(tab)
Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus())
void Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus())
}
const recent = createMemo(() => {
@@ -243,23 +245,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
)
const working = createMemo(() => status()?.type !== "idle")
const tip = () => {
if (working()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
)
}
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
)
}
const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
@@ -297,6 +282,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (store.mode === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
})
const blank = createMemo(() => {
const text = prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
})
const stopping = createMemo(() => working() && blank())
const tip = () => {
if (stopping()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
)
}
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
)
}
const contextItems = createMemo(() => {
const items = prompt.context.items()
@@ -502,6 +512,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return getCursorPosition(editorRef)
}
const restoreFocus = () => {
requestAnimationFrame(() => {
const cursor = prompt.cursor() ?? promptLength(prompt.current())
editorRef.focus()
setCursorPosition(editorRef, cursor)
queueScroll()
})
}
const renderEditorWithCursor = (parts: Prompt) => {
const cursor = currentCursor()
renderEditor(parts)
@@ -622,19 +641,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
if (!cmd) return
promptProbe.select(cmd.id)
closePopover()
const images = imageAttachments()
if (cmd.type === "custom") {
const text = `/${cmd.trigger} `
setEditorText(text)
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
focusEditorEnd()
return
}
clearEditor()
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
prompt.set([...DEFAULT_PROMPT, ...images], 0)
command.trigger(cmd.id, "slash")
}
@@ -710,21 +729,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
})
})
if (promptEnabled()) {
createEffect(() => {
promptProbe.set({
popover: store.popover,
slash: {
active: slashActive() ?? null,
ids: slashFlat().map((cmd) => cmd.id),
},
})
})
onCleanup(() => promptProbe.clear())
}
const selectPopoverActive = () => {
if (store.popover === "at") {
const items = atFlat()
@@ -1061,17 +1065,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
return permission.isAutoAccepting(id, sdk.directory)
})
const acceptLabel = createMemo(() =>
language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"),
)
const toggleAccept = () => {
if (!params.id) {
permission.toggleAutoAcceptDirectory(sdk.directory)
return
}
permission.toggleAutoAccept(params.id, sdk.directory)
}
const { abort, handleSubmit } = createPromptSubmit({
info,
@@ -1149,7 +1142,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
if (working()) {
abort()
void abort()
event.preventDefault()
event.stopPropagation()
return
@@ -1215,7 +1208,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
if (working()) {
abort()
void abort()
event.preventDefault()
}
return
@@ -1255,12 +1248,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
) {
return
}
handleSubmit(event)
void handleSubmit(event)
}
}
const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({
queries: [loadAgentsQuery(sdk.directory), loadProvidersQuery(null), loadProvidersQuery(sdk.directory)],
}))
const agentsLoading = () => agentsQuery.isLoading
const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading
const [promptReady] = createResource(
() => prompt.ready().promise,
(p) => p,
)
return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
{(promptReady(), null)}
<PromptPopover
popover={store.popover}
setSlashPopoverRef={(el) => (slashPopoverRef = el)}
@@ -1315,11 +1321,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onMouseDown={(e) => {
const target = e.target
if (!(target instanceof HTMLElement)) return
if (
target.closest(
'[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]',
)
) {
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
return
}
editorRef?.focus()
@@ -1343,6 +1345,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
inputMode="text"
// @ts-expect-error
autocomplete="off"
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
@@ -1358,15 +1363,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
style={{ "padding-bottom": space }}
/>
<Show when={!prompt.dirty()}>
<div
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
classList={{ "font-mono!": store.mode === "shell" }}
style={{ "padding-bottom": space }}
>
{placeholder()}
</div>
</Show>
<div
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
classList={{ "font-mono!": store.mode === "shell" }}
style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }}
>
{placeholder()}
</div>
</div>
<div
@@ -1394,17 +1397,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>
<div class="flex items-center gap-1 pointer-events-auto">
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
disabled={store.mode !== "normal" || (!working() && blank())}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={working() ? "stop" : "arrow-up"}
icon={stopping() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={buttons()}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
@@ -1455,140 +1458,131 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
<div class="size-4 shrink-0" />
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<div data-component="prompt-agent-control">
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.agent.cycle")}
keybind={command.keybind("agent.cycle")}
>
<Select
size="normal"
options={agentNames()}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-agent" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
<div data-component="prompt-model-control">
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
<Show when={!agentsLoading()}>
<div data-component="prompt-agent-control" style={{ animation: "fade-in 0.3s" }}>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
title={language.t("command.agent.cycle")}
keybind={command.keybind("agent.cycle")}
>
<ModelSelectorPopover
model={local.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model",
<Select
size="normal"
options={agentNames()}
current={local.agent.current()?.name ?? ""}
onSelect={(value) => {
local.agent.set(value)
restoreFocus()
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-agent" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
</Show>
<Show when={!providersLoading()}>
<Show when={store.mode !== "shell"}>
<div data-component="prompt-model-control" style={{ animation: "fade-in 0.3s" }}>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
model={local.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model",
}}
onClose={restoreFocus}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
</div>
<div data-component="prompt-variant-control" style={{ animation: "fade-in 0.3s" }}>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
</Show>
</div>
<div data-component="prompt-variant-control">
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
<TooltipKeybind
placement="top"
gutter={8}
title={acceptLabel()}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
data-action="prompt-permissions"
variant="ghost"
onClick={toggleAccept}
classList={{
"h-7 w-7 p-0 shrink-0 flex items-center justify-center": true,
"text-text-base": !accepting(),
"hover:bg-surface-success-base": accepting(),
}}
style={control()}
aria-label={acceptLabel()}
aria-pressed={accepting()}
>
<Icon name="shield" size="small" classList={{ "text-icon-success-base": accepting() }} />
</Button>
</TooltipKeybind>
</Show>
</div>
</div>
</div>
@@ -1,4 +1,5 @@
import { onCleanup, onMount } from "solid-js"
import { onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@opencode-ai/ui/toast"
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
import { useLanguage } from "@/context/language"
@@ -181,15 +182,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
}
onMount(() => {
document.addEventListener("dragover", handleGlobalDragOver)
document.addEventListener("dragleave", handleGlobalDragLeave)
document.addEventListener("drop", handleGlobalDrop)
})
onCleanup(() => {
document.removeEventListener("dragover", handleGlobalDragOver)
document.removeEventListener("dragleave", handleGlobalDragLeave)
document.removeEventListener("drop", handleGlobalDrop)
makeEventListener(document, "dragover", handleGlobalDragOver)
makeEventListener(document, "dragleave", handleGlobalDragLeave)
makeEventListener(document, "drop", handleGlobalDrop)
})
return {
@@ -100,6 +100,30 @@ describe("buildRequestParts", () => {
expect(synthetic).toHaveLength(1)
})
test("adds file parts for @mentions inside comment text", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
context: [
{
key: "ctx:comment-mention",
type: "file",
path: "src/review.ts",
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
images: [],
text: "look",
messageID: "msg_comment_mentions",
sessionID: "ses_comment_mentions",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file")
expect(files).toHaveLength(2)
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
})
test("handles Windows paths correctly (simulated on macOS)", () => {
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]

Some files were not shown because too many files have changed in this diff Show More