Compare commits

..

249 Commits

Author SHA1 Message Date
opencode-agent[bot]
2c077e3b2d chore: update nix node_modules hashes 2026-03-10 16:39:30 +00:00
opencode-agent[bot]
55b058d735 Apply PR #16889: wip(app): colors 2026-03-10 16:35:15 +00:00
opencode-agent[bot]
e94527228b Apply PR #16888: feat(ui): restyle Card and improve tool error cards 2026-03-10 16:34:46 +00:00
opencode-agent[bot]
1c40088746 Apply PR #16825: feat(core): enable Node.js runtime alongside Bun 2026-03-10 16:34:45 +00:00
opencode-agent[bot]
8be13aec82 Apply PR #16296: fix(app): align same() helper usage across app and util 2026-03-10 16:33:32 +00:00
Dax Raad
a0b6e33edd core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-10 12:33:19 -04:00
opencode-agent[bot]
ffcdb7f260 Apply PR #16069: feat(windows): add first-class pwsh/powershell support 2026-03-10 16:33:02 +00:00
opencode-agent[bot]
a6f5dc8c25 Apply PR #15697: tweak(ui): make questions popup collapsible 2026-03-10 16:31:57 +00:00
opencode-agent[bot]
6f8f1d1d14 Apply PR #15487: core: make account login upgrades safe while adding multi-account workspace auth 2026-03-10 16:31:56 +00:00
opencode-agent[bot]
ef1709cf7c Apply PR #14471: [DO NOT MERGE]: beta badge for desktop app 2026-03-10 16:31:56 +00:00
opencode-agent[bot]
1a31572d87 Apply PR #14307: fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering 2026-03-10 16:31:55 +00:00
opencode-agent[bot]
83f71419a0 Apply PR #12633: feat(tui): add auto-accept mode for permission requests 2026-03-10 16:31:55 +00:00
opencode-agent[bot]
dc89d5586b Apply PR #12022: feat: update tui model dialog to utilize model family to reduce noise in list 2026-03-10 16:31:54 +00:00
Dax Raad
f7dd576a6e core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-10 12:30:46 -04:00
Dax Raad
41d0f18c59 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-10 12:29:21 -04:00
Adam
781104c1ec wip(app): colors 2026-03-10 11:27:32 -05:00
Dax Raad
7e5079da38 sync 2026-03-10 12:14:35 -04:00
Dax Raad
70359f1c07 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-10 11:50:14 -04:00
Kit Langton
635d30fddc core: rename login param from url to server to remove pointless alias 2026-03-10 11:47:20 -04:00
Dax Raad
61c83d7b99 Merge PR #16803 - refactor: node-server-adapter 2026-03-10 11:41:21 -04:00
Dax
0ea7e75e0b Merge branch 'dev' into refactor/node-server-adapter 2026-03-10 11:40:35 -04:00
Dax Raad
c3bd1efbd4 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-10 11:40:23 -04:00
Kit Langton
345772820a chore: remove commented-out legacy unique index 2026-03-10 11:33:59 -04:00
Kit Langton
cecfb62376 fix: use relative imports in .sql.ts files for drizzle-kit compatibility
drizzle-kit's jiti loader cannot resolve tsconfig @/ path aliases.
Switch all .sql.ts files to relative imports and add the missing
snapshot for the move_org_to_state custom migration.
2026-03-10 11:27:43 -04:00
Aiden Cline
ad08fd57df chore: rekram1-node is no longer on vacation (#16905) 2026-03-10 10:27:04 -05:00
Kit Langton
b3bd563ead test: update share-next test to match renamed error message 2026-03-10 11:19:16 -04:00
opencode-agent[bot]
54ba59d3e1 chore: generate 2026-03-10 15:14:46 +00:00
James Long
a4330a225d feat(core): allow passing workspaceID into session create endpoint (#16798) 2026-03-10 11:12:51 -04:00
James Long
69ddc91c35 fix(core): a chunk timeout when processing llm stream (#16366) 2026-03-10 11:12:14 -04:00
Kit Langton
c6322476b5 core: port missed upstream auth changes into providers
Restore --provider/-p and --method/-m flags, trailing slash
normalization, and Claude Max hint removal that were lost when
auth.ts was renamed to providers.ts before these features landed
on dev.
2026-03-10 11:11:53 -04:00
James Long
4c4aed5a87 fix(core): make worktrees read the project id from local workspace (#16795) 2026-03-10 11:11:28 -04:00
opencode-agent[bot]
5a40158abf chore: generate 2026-03-10 15:07:35 +00:00
Kit Langton
1568d73fb0 core: remove default server URL, require login URL argument 2026-03-10 11:06:15 -04:00
Jérôme Benoit
4dce485854 fix(opencode): add thinking variants support for SAP AI provider (#14958)
Co-authored-by: Test <test@test.com>
Co-authored-by: Stephen Collings <stevoland@gmail.com>
2026-03-10 10:05:45 -05:00
Kit Langton
cf40bf0f1b core: rename leftover control references to console/account 2026-03-10 11:04:21 -04:00
Kit Langton
0a05161da6 core: cleanup from simplify review
- Remove unused AccountRepoError import from service.ts
- Replace Bun.sleep with setTimeout in providers.ts
- Parallelize Account.config() + Account.token() in config startup
2026-03-10 10:56:16 -04:00
Kit Langton
531ba1dd69 test: truncate tables instead of deleting db file in account tests
File deletion is unreliable on Windows due to SQLite file locking.
Truncating via SQL avoids filesystem operations entirely and gives
each test a clean slate without cross-test state leakage.
2026-03-10 10:43:58 -04:00
Kit Langton
ae821ae15c core: hide account commands from help output
Commands still work when invoked directly but won't confuse
regular users who don't need multi-account management yet.
2026-03-10 10:34:03 -04:00
Kit Langton
f003c5d4f4 core: gracefully handle remote account config fetch failure
Wrap Account.config() in try/catch during config initialization so
unreachable servers don't crash unrelated code paths like tool registry.
2026-03-10 10:31:03 -04:00
Kit Langton
e59e104ad3 core: improve logout UX with account picker and warn on missing refresh token 2026-03-10 10:13:20 -04:00
David Hill
a87a6bfe60 tui: prevent Storybook preview from crashing when useTheme throws an error
Previously, the Storybook preview would crash entirely if useTheme threw an
error, leaving users with a broken interface. Now the preview gracefully handles
theme errors by returning null instead of crashing, ensuring Storybook remains
usable even when theme context is unavailable.
2026-03-10 13:49:52 +00:00
Adam
c24f440e94 wip(app): colors 2026-03-10 08:36:11 -05:00
David Hill
b06ed8b5f8 style(ui): update surface-critical-base for oc-2 2026-03-10 13:33:39 +00:00
David Hill
d4aa5fc4ed feat(ui): add copy affordance to ToolErrorCard 2026-03-10 13:29:15 +00:00
David Hill
109c17fb6a style(ui): add critical selection styling for ToolErrorCard 2026-03-10 13:07:25 +00:00
David Hill
3ddc9e251e fix(ui): allow selecting ToolErrorCard error text 2026-03-10 13:07:10 +00:00
David Hill
2afd29bc69 feat(ui): add ToolErrorCard trigger open state and icon stroke 2026-03-10 13:02:43 +00:00
David Hill
3f8ae4d695 style(ui): tune ToolErrorCard spacing and stripe inset 2026-03-10 13:02:35 +00:00
David Hill
0f91d27d17 refactor(ui): parameterize Card padding and stripe inset 2026-03-10 13:02:30 +00:00
Adam
5ec5d1dace chore(app): debug window 2026-03-10 07:05:54 -05:00
opencode-agent[bot]
d2c765e2b3 chore: generate 2026-03-10 11:01:22 +00:00
bhaktatejas922
d036c57d59 docs: update opencode-morph-plugin in all language ecosystem pages (#16869) 2026-03-10 06:00:13 -05:00
opencode-agent[bot]
e7493e2204 chore: update nix node_modules hashes 2026-03-10 10:16:15 +00:00
Sebastian
3500bf64b8 upgrade opentui to v0.1.87 (#16772) 2026-03-10 11:03:05 +01:00
Luke Parker
8b5033b8fa fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-10 17:42:41 +10:00
Dax Raad
d5c4e7fa0a tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-10 01:18:34 -04:00
Dax Raad
295a338f6c core: fix custom tool loading to properly resolve module paths 2026-03-10 01:16:51 -04:00
Dax Raad
1fb256886c sync 2026-03-10 01:02:17 -04:00
Dax Raad
26ed8cb60a sync 2026-03-10 01:00:28 -04:00
Dax Raad
917fe97926 sync 2026-03-10 00:58:15 -04:00
Dax Raad
42b8eab5b7 core: log npm install errors to console for debugging dependency failures 2026-03-10 00:52:21 -04:00
Dax
77d1b4118b Merge branch 'dev' into refactor/lsp-core-improvements 2026-03-10 00:49:07 -04:00
Dax Raad
0d9729d934 sync 2026-03-10 00:44:06 -04:00
Dax Raad
7478e0742e core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-10 00:11:18 -04:00
Dax Raad
ca8424c10e sync 2026-03-09 23:59:37 -04:00
Dax Raad
db2ce1ca6e tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-09 23:44:20 -04:00
Dax Raad
33f42a8d2a core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-09 23:39:47 -04:00
Dax Raad
68f3631004 core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-09 23:37:58 -04:00
Dax Raad
ea73d7db1e core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-09 23:32:28 -04:00
Dax Raad
e446964c05 core: disable npm bin links to fix package installation in sandboxed environments 2026-03-09 23:31:50 -04:00
opencode-agent[bot]
4f982ddb94 chore: generate 2026-03-10 02:02:18 +00:00
adam jones
ff3bb7424d fix(mcp): fix OAuth auto-connect failing on first connection (#15547)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-09 21:01:19 -05:00
Dax Raad
1882a5c219 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-09 21:58:25 -04:00
Dax Raad
a33fb445e9 sync 2026-03-09 21:42:08 -04:00
Dax
d4bfaac2af Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-09 21:41:12 -04:00
Dax
c37376c00f Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-09 21:39:50 -04:00
Dax Raad
64b5e7053a sync 2026-03-09 21:36:29 -04:00
Dax Raad
f694584fae refactor: lsp server and core improvements 2026-03-09 21:35:34 -04:00
Kit Langton
41114b1b6e Merge remote-tracking branch 'upstream/dev' into cli-auth-cloud
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	bun.lock
2026-03-09 20:08:07 -04:00
Kit Langton
30a501ba11 core: move org selection from account table to account_state
Single source of truth for current selection: account_state now owns
both active_account_id and active_org_id. Simplifies use() to a
single setState call and removes org state from the account table.

Also uses db() wrapper consistently in persistAccount and caps
orgsByAccount concurrency.
2026-03-09 19:44:38 -04:00
David Hill
f77a24ad87 feat(ui): use check icon for success cards 2026-03-09 22:30:30 +00:00
David Hill
0941a40953 refactor(ui): derive Card accent from theme icon tokens 2026-03-09 22:29:11 +00:00
David Hill
eaaf2c64db docs(ui): add ToolErrorCard story 2026-03-09 22:29:05 +00:00
Kit Langton
28f795a9a0 core: fix type error in account repo list mapping 2026-03-09 18:24:02 -04:00
Kit Langton
b4b24cf691 core: inline helpers and extract magic number in account repo
Address PR review comments: inline toAccountRepoError and fromRow
helpers, extract ACCOUNT_STATE_ID constant.
2026-03-09 18:23:51 -04:00
David Hill
269b6da131 feat(ui): render tool failures with collapsible ToolErrorCard 2026-03-09 22:14:33 +00:00
David Hill
16f50213a7 feat(ui): restyle Card and add title/description slots 2026-03-09 22:14:21 +00:00
Kit Langton
eda87569fe core: rename OPENCODE_CONTROL_TOKEN to OPENCODE_CONSOLE_TOKEN
Align env var name with opencontrol server's default CONSOLE_TOKEN_ENV.
2026-03-09 17:59:02 -04:00
Dax Raad
2724335b28 refactor(server): replace Bun serve with Hono node adapters 2026-03-09 17:57:00 -04:00
Dax Raad
89d6f60d25 refactor(server): extract createApp function for server initialization
- Replace Server.App() with Server.Default() for internal server access
- Extract server app creation into Server.createApp(opts) for testability
- Move CORS whitelist from module-level variable to function parameter
- Update all tests to use Server.Default() instead of Server.App()
2026-03-09 17:13:52 -04:00
Adam
ee18c9976e chore(app): dev stats 2026-03-09 15:57:24 -05:00
Adam
794532928f fix(app): terminal state corruption 2026-03-09 15:28:35 -05:00
Adam
7b773c65ec chore: cleanup 2026-03-09 15:28:35 -05:00
Adam
e53aa79dc6 chore: cleanup 2026-03-09 15:28:35 -05:00
opencode-agent[bot]
d9a97249c0 chore: generate 2026-03-09 20:15:31 +00:00
James Long
86cef16940 fix(core): put workspace routing behind OPENCODE_EXPERIMENTAL_WORKSPACES flag (#16775) 2026-03-09 16:14:19 -04:00
opencode-agent[bot]
ce38997c76 chore: update nix node_modules hashes 2026-03-09 19:51:58 +00:00
opencode-agent[bot]
7e10c728d4 chore: update nix node_modules hashes 2026-03-09 19:49:01 +00:00
bhaktatejas922
3627c67cf2 docs: update opencode-morph-fast-apply to opencode-morph-plugin in ecosystem (#16634) 2026-03-09 14:39:06 -05:00
opencode-agent[bot]
2518fd81f6 chore: generate 2026-03-09 19:31:33 +00:00
Dax Raad
39ef7fc90e Merge remote-tracking branch 'origin/dev' into dev 2026-03-09 15:30:15 -04:00
Dax Raad
37ae0a4051 refactor: replace bun semver with npm semver package 2026-03-09 15:29:55 -04:00
Kyle Altendorf
b312928e9f fix(tui): wait for model store before auto-submitting --prompt (#7476) 2026-03-09 14:22:38 -05:00
Dax
2f2856e20a refactor(opencode): replace Bun shell in core flows (#16286) 2026-03-09 15:19:50 -04:00
Dax Raad
831eb6881b refactor: change pathToFileURL imports from bun to url module 2026-03-09 14:52:25 -04:00
David Hill
bdc423d622 tui: fix ordered list indentation in markdown rendering 2026-03-09 16:15:48 +00:00
James Long
f20ee2fad2 fix(tui): handle error when creating a session (#16767) 2026-03-09 12:13:32 -04:00
Stephen Collings
8b9710e56c fix: Multiple jdtls LSPs eating memory in java monorepos (#12123) 2026-03-09 16:09:43 +00:00
opencode
c6262f9d40 release: v1.2.24 2026-03-09 16:09:34 +00:00
Adam
b749fa90f2 fix(app): scroll jitter/loop 2026-03-09 10:44:02 -05:00
Dax Raad
8a51cbd253 core: prevent accidental edits to migration files by restricting agent access 2026-03-09 11:25:58 -04:00
David Hill
399b8f0701 fix(app): session title turn spinner (#16764) 2026-03-09 09:46:15 -05:00
Filip
3742e42fdf fix(app): dismiss toast notifications when questions or permissions a… (#16758) 2026-03-09 09:36:57 -05:00
Karan Handa
0388ec6862 fix(storybook): add ci build workflow (#16760) 2026-03-09 09:33:19 -05:00
James Long
366b8a8034 feat(tui): add initial support for workspaces into the tui (#16230) 2026-03-09 10:28:04 -04:00
Armin Pašalić
ef9bc4ec9e feat(gitlab): send context-1m-2025-08-07 beta header to enable 1M context window (#16153)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-09 09:22:00 -05:00
Jack
5838b58913 add copilot gpt-5.4 xhigh support (#16294) 2026-03-09 22:07:12 +08:00
opencode
2712244ad3 release: v1.2.23 2026-03-09 13:50:43 +00:00
Adam
6388cbaf92 fix(app): remove oc-1 theme 2026-03-09 08:25:41 -05:00
David Hill
5cc61e1b53 tui: fix sidebar workspace container sizing by adding box-border class to prevent content overflow issues 2026-03-09 13:05:43 +00:00
Adam
0243be86a7 fix(app): don't animate review panel in/out 2026-03-09 07:49:11 -05:00
opencode-agent[bot]
9154cd64e7 chore: update nix node_modules hashes 2026-03-09 12:46:47 +00:00
Adam
c71d1bde5e revert(app): "STUPID SEXY TIMELINE (#16420)" (#16745) 2026-03-09 07:36:39 -05:00
Luke Parker
f27ef595f6 fix(app): sanitize workspace store filenames on Windows (#16703) 2026-03-09 20:26:53 +10:00
Yihui Khuu
34328828ae fix(app): fix issue with scroll jumping when pressing escape in comment text area (#15374) 2026-03-09 15:29:24 +05:30
Eric Clemmons
18fb19da3b fix(opencode): pass missing auth headers in run --attach (#16097)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-09 07:32:13 +00:00
opencode-agent[bot]
849e1ac543 docs(i18n): sync locale docs from english changes 2026-03-09 02:08:46 +00:00
Ariane Emory
656a8d8f55 docs: add session_child_first keybinding to documentation (#16631) 2026-03-08 21:03:52 -05:00
Adam
b976f339e8 feat(app): generate color palettes (#16232) 2026-03-08 19:28:58 -05:00
Dax Raad
7d7837e5b6 disable fallback to free nano for small model 2026-03-08 19:27:15 -04:00
opencode
1db292f4df release: v1.2.22 2026-03-08 22:34:59 +00:00
Sebastian
49a3a9fe36 guard tui exit (#16640) 2026-03-08 23:14:41 +01:00
Luke Parker
e51ed460a6 fix(tui): canonicalize cwd after chdir (#16641) 2026-03-09 07:57:48 +10:00
Kit Langton
49deb7207f chore: bump effect beta 2026-03-08 12:41:15 -04:00
Kit Langton
91b9a03e27 Merge upstream/dev into cli-auth-cloud 2026-03-08 12:14:40 -04:00
David Hill
d15c2ce349 tui: fix sidebar background color when collapsed
When the sidebar was collapsed (not on mobile), the background color was showing as the stronger variant instead of matching the base background. This fixes the hover state detection so users see a consistent lighter background when the sidebar is in collapsed mode.
2026-03-08 13:34:56 +00:00
David Hill
5cc4bb4089 app: suppress hover when opening project menu or right-clicking to prevent flickering 2026-03-08 13:31:18 +00:00
Shoubhit Dash
6e9e027886 fix: trim retained desktop terminal buffers (#16583) 2026-03-08 07:50:04 -05:00
opencode-agent[bot]
f9a3d129a4 chore: update nix node_modules hashes 2026-03-08 12:25:35 +00:00
Adam
c53d1d3ad8 fix(app): less auto-expand/collapse 2026-03-08 07:11:15 -05:00
Adam
f386137fba chore: refactoring ui hooks 2026-03-08 07:11:15 -05:00
Adam
c797b60069 fix(app): messages not loading reliably 2026-03-08 07:11:15 -05:00
Shoubhit Dash
a139e9297d fix: prune and evict stale app session caches (#16584) 2026-03-08 07:10:00 -05:00
Shoubhit Dash
050f99ec54 test: make process cwd check cross-platform (#16594) 2026-03-08 06:56:45 -05:00
Roy Bruschini
23ed652901 docs(zen.mdx): correct Italian grammar and punctuation errors (#16590) 2026-03-08 16:40:06 +05:30
tobwen
13a68f3de3 fix(opencode): avoid TTY corruption from double cleanup (#16565)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-08 13:55:33 +05:30
Nate Williams
fdad35aaa7 fix(tui): fix broken /mcp toggling (#16431)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-08 13:31:09 +05:30
Dax
a2ce4eb650 test: remove unused Ripgrep.search coverage (#16554) 2026-03-07 21:40:57 -05:00
David Hill
8fa04986cf Revert "tui: dock auto-accept after thinking and move Add file to bottom-left"
This reverts commit 69cb49f7cc.
2026-03-08 01:31:09 +00:00
David Hill
a5710ed3e1 Revert "tui: keep model + thinking selectors beside Add file"
This reverts commit 426dcfa3b0.
2026-03-08 01:31:06 +00:00
David Hill
2efdc9df93 Revert "tui: add more editor bottom padding for prompt controls"
This reverts commit 981353793d.
2026-03-08 01:31:03 +00:00
David Hill
0c245886fe Revert "tui: expose auto-accept as a permissions select"
This reverts commit 12d862dbd3.
2026-03-08 01:31:00 +00:00
David Hill
f03288b411 Revert "tui: use text-base color for prompt selects"
This reverts commit 207ebf4b8c.
2026-03-08 01:30:55 +00:00
David Hill
09388c98f3 Revert "tui: remove prompt model/thinking/permissions selectors on dev so the composer stays simple"
This reverts commit ae25c1e7b7.
2026-03-08 01:27:45 +00:00
David Hill
ae25c1e7b7 tui: remove prompt model/thinking/permissions selectors on dev so the composer stays simple 2026-03-08 01:21:45 +00:00
David Hill
0813c14cc6 tui: restore new-session logo on dev so users recognize OpenCode immediately 2026-03-08 01:18:42 +00:00
Kit Langton
a2b527ff29 core: simplify account error payloads 2026-03-07 20:12:16 -05:00
David Hill
b5151c421f tui: revert new-session logo on dev so this UI change only ships with auto-accept-permissions 2026-03-08 01:10:52 +00:00
David Hill
e66fd079db tui: add opencode logo to new session screen so users can immediately identify the app when starting a fresh session 2026-03-08 00:59:03 +00:00
David Hill
207ebf4b8c tui: use text-base color for prompt selects
Select triggers in the composer now use the normal text color so model/thinking/permissions controls read consistently with the rest of the input UI.
2026-03-08 00:53:57 +00:00
David Hill
12d862dbd3 tui: expose auto-accept as a permissions select
Lets people explicitly choose between normal permission prompts and auto-accept while composing, without relying on an ambiguous icon state.
2026-03-08 00:53:57 +00:00
David Hill
981353793d tui: add more editor bottom padding for prompt controls
Gives typed text more breathing room above the Add file/model/thinking row so the controls don’t visually crowd what you’re writing.
2026-03-08 00:53:57 +00:00
David Hill
426dcfa3b0 tui: keep model + thinking selectors beside Add file
People change models and thinking settings while composing, so keeping those controls next to the Add file button avoids hunting in the footer and reduces context switching mid-message.
2026-03-08 00:53:57 +00:00
David Hill
69cb49f7cc tui: dock auto-accept after thinking and move Add file to bottom-left
Auto-accept now lives in the footer dock beside the thinking control so it stays easy to find without crowding the text box.

The Add file button moves to the bottom-left of the editor and the input gets a bit more bottom padding so the control row doesn’t overlap what you’re typing.
2026-03-08 00:53:57 +00:00
Dax Raad
e30678a088 test: normalize ripgrep path assertion on windows 2026-03-07 19:47:57 -05:00
opencode-agent[bot]
771b29a857 chore: generate 2026-03-08 00:31:35 +00:00
Dax Raad
e6d1aae33a test: lock in process, ripgrep, and installation helpers 2026-03-07 19:30:32 -05:00
David Hill
9dc8ac4734 tui: revert prompt control docking
Restore the previous prompt control layout after the dock/position changes made the composer feel less familiar.

This brings auto-accept back to its prior spot and returns Add file to the previous placement.
2026-03-08 00:17:28 +00:00
David Hill
fdd037ba20 tui: dock auto-accept after thinking and move Add file to bottom-left
Auto-accept now lives in the footer dock beside the thinking control so it stays easy to find without crowding the text box.

The Add file button moves to the bottom-left of the editor and the input gets a bit more bottom padding so the control row doesn’t overlap what you’re typing.
2026-03-08 00:08:37 +00:00
Dax Raad
523f792b48 core: update database path test to verify correct channel-based filename
The test now validates that the database file is named according to the current installation channel (latest/beta get 'opencode.db', others get sanitized names). This ensures users' data is stored in the correct location based on their update channel.
2026-03-07 18:53:29 -05:00
Dax Raad
2230c3c401 core: allow beta channel to share database with stable channel 2026-03-07 18:53:29 -05:00
David Hill
1b494e5087 tui: balance titlebar columns so center content doesn't get squeezed by long side content 2026-03-07 23:50:23 +00:00
David Hill
9c43893a0f tui: align numeric displays consistently across tool outputs and diff counters using tabular numerals 2026-03-07 23:49:10 +00:00
David Hill
6dfe19b445 tui: center empty states vertically in session view and improve review panel messaging for projects without version control 2026-03-07 23:45:16 +00:00
LukeParkerDev
fb7dd0661a fix(e2e): wait for shell output before switching terminal tabs 2026-03-08 08:14:19 +10:00
Luke Parker
3e19a8dcba Merge branch 'dev' into experimental/windows-shells 2026-03-08 07:22:41 +10:00
Dax Raad
a965a06259 core: add OPENCODE_SKIP_MIGRATIONS flag to bypass database migrations
Allows users to skip automatic database migrations by setting the
OPENCODE_SKIP_MIGRATIONS environment variable. Useful for testing
scenarios or when manually managing database state.
2026-03-07 16:17:00 -05:00
Frank
0654f28c72 zen: fix graph legend 2026-03-07 14:28:36 -05:00
Adam
a32b76dee0 fix(app): review panel transition 2026-03-07 13:27:44 -06:00
Kit Langton
48cf609115 core: track active account separately from org selection 2026-03-07 14:02:04 -05:00
Kit Langton
a581493c13 test: cover account service flows 2026-03-07 12:46:49 -05:00
Kit Langton
e5b34076df core: remove dead auth command 2026-03-07 12:31:19 -05:00
Kit Langton
0e642ed885 core: separate account repo errors 2026-03-07 12:07:45 -05:00
Kit Langton
7af0546dc0 core: restore session workspace scope 2026-03-07 11:48:41 -05:00
Kit Langton
270f44f41d Merge upstream/dev into cli-auth-cloud 2026-03-07 11:40:15 -05:00
Kit Langton
2db9d317f9 core: use service shape helper for account api 2026-03-07 11:34:17 -05:00
LukeParkerDev
a61f9b01e0 Merge remote-tracking branch 'upstream/dev' into experimental/windows-shells
# Conflicts:
#	packages/opencode/src/util/filesystem.ts
2026-03-07 16:58:47 +10:00
Kit Langton
95279abbbc core: group org lookups by account 2026-03-06 23:05:20 -05:00
Kit Langton
1a2ddf9e0f core: tighten account service and CLI typing
Align the account CLI with the Effect-based service types so polling, org switching, and prompt cancellation narrow cleanly. This keeps the refactor type-safe and avoids leaking legacy wrapper shapes.
2026-03-06 22:11:04 -05:00
Kit Langton
f807875a99 core: effectify CLI account commands with tagged PollResult union
- Rewrite all account CLI commands (login, logout, switch, orgs) as
  Effect.fn with AccountService accessed directly via yield*
- Convert PollResult from plain object union to Schema.TaggedClass
  variants with Schema.Union and Match.valueTags for exhaustive matching
- Add recursive poll function for device auth flow (stack-safe via
  Effect trampolining)
- Add shared Effect runtime at src/effect/runtime.ts
- Add Effect wrappers for @clack/prompts at src/cli/effect/prompt.ts
- Add @effect/language-service TS plugin for editor support
- Refactor repo tests to use testEffect helper with Layer-based setup
- Parallelize org fetching in orgs command

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:34:03 -05:00
Kit Langton
48158ce97d core: simplify account repo/service after review
- persistToken: positional params → named object to prevent swaps
- persistAccount: wrap in Database.transaction for atomicity
- Internal response schemas: Schema.Class → Schema.Struct (lighter)
- fromRow: pass row directly to decoder (strips unknown keys)
- Test: add afterAll runtime disposal, use branded ID in assertion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:39:34 -05:00
Kit Langton
adc9536a16 core: refactor account module to Effect with repo/service split
Rewrite the account layer using Effect for typed error handling, schema
validation, and structured concurrency. Split into three concerns:

- schema.ts: branded types (AccountID, OrgID, AccessToken) and shared
  data classes
- repo.ts: AccountRepo service owning all DB operations via drizzle
- service.ts: AccountService owning HTTP orchestration (OAuth device
  flow, token refresh, org/config fetching) with AccountRepo as a
  dependency

Key improvements:
- Branded types enforce type safety at API boundaries
- Option used consistently instead of null/undefined in internal APIs
- User and orgs fetches parallelized during login poll
- Schema-validated HTTP request/response bodies
- Transient read retry with exponential backoff
- Clock.currentTimeMillis instead of Date.now() for testability
- 11 new repo tests covering all DB operations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:33:22 -05:00
LukeParkerDev
c0eb929465 fix(ci): pass full env to opencode test task in turbo
Turbo's strict env mode was stripping PATH and other system variables from nested shell processes, causing pwsh-spawned commands to produce empty output on Windows CI.
2026-03-06 17:31:23 +10:00
LukeParkerDev
f8810780cc Merge remote-tracking branch 'upstream/dev' into experimental/windows-shells 2026-03-06 17:04:20 +10:00
LukeParkerDev
6a9d1eea69 fix(app): use shared array.same helper for session memo equality 2026-03-06 16:40:28 +10:00
LukeParkerDev
68c435db79 fix(windows): widen PowerShell path permission detection
Handle wildcard path arguments, missing env expansion, and paths that appear after non-path PowerShell switches so external directory prompts stay accurate on Windows. Add focused regression coverage for those shell cases.
2026-03-06 15:45:37 +10:00
LukeParkerDev
4bcfb37567 fix(windows): tighten PowerShell shell handling
Centralize shell normalization and move bash permission inference onto native path handling so Windows shells behave consistently without Git Bash internals. Add focused shell, PTY, and bash tool coverage to keep Git Bash, pwsh, powershell, and cmd working on Windows.
2026-03-06 15:02:17 +10:00
LukeParkerDev
a6265531d6 Merge remote-tracking branch 'upstream/dev' into experimental/windows-shells 2026-03-06 14:11:01 +10:00
LukeParkerDev
2499be3622 Revert "refactor(shell): rename bash tool and migrate legacy config"
This reverts commit f6d989f836.
2026-03-06 14:06:03 +10:00
LukeParkerDev
f6d989f836 refactor(shell): rename bash tool and migrate legacy config
Rename the terminal tool around shell semantics instead of Bash while preserving backward compatibility for existing messages, permissions, and prompts. Automatically migrate file-based bash config to shell so Windows beta testing works without manual config edits.
2026-03-06 13:59:06 +10:00
Luke Parker
fd365f9d1f Merge branch 'dev' into experimental/windows-shells 2026-03-06 12:48:25 +10:00
LukeParkerDev
2241568a72 test(windows): stop bash permission checks before shell spawn 2026-03-06 11:05:55 +10:00
LukeParkerDev
2eb9f52fd1 test(windows): normalize temp dir permission glob 2026-03-06 10:17:25 +10:00
LukeParkerDev
eefe8a6d6d fix(windows): detect external paths for PowerShell cmdlets 2026-03-06 10:07:47 +10:00
LukeParkerDev
bd5f54887c fix(windows): normalize shell permission paths
Use the PowerShell parser for pwsh and collapse equivalent Windows path forms into canonical permission keys.
2026-03-06 09:34:40 +10:00
Kit Langton
fec8d5bcf1 core: prevent share auth headers from being sent cross-origin 2026-03-05 16:56:36 -05:00
Kit Langton
e923047219 core: route session sharing through org-scoped control APIs 2026-03-05 16:04:43 -05:00
Kit Langton
b19dc933a4 core: resolve account config env templates after loading control token 2026-03-05 16:03:10 -05:00
Kit Langton
902268e0d1 core: rename workspace scope to org across account and session surfaces 2026-03-05 16:02:17 -05:00
LukeParkerDev
6b00f4cb58 fix(windows): use typed powershell spawn options 2026-03-05 12:56:11 +10:00
LukeParkerDev
3392f89559 fix(windows): stabilize bash tool powershell execution 2026-03-05 12:54:06 +10:00
LukeParkerDev
81037a4e30 test(windows): cover bash tool shells 2026-03-05 11:45:29 +10:00
LukeParkerDev
807b8784e1 test(windows): use absolute fixture path in bash tests 2026-03-05 11:20:45 +10:00
LukeParkerDev
50188cdce3 test(windows): use absolute bun path in bash fixtures 2026-03-05 11:08:23 +10:00
LukeParkerDev
2a098f68ed test(windows): stabilize bash truncation fixtures 2026-03-05 11:00:47 +10:00
LukeParkerDev
be7e4f5813 fix(windows): resolve shell paths and adjust bash hints 2026-03-05 10:51:05 +10:00
LukeParkerDev
515687074e test(windows): avoid POSIX-only bash fixtures 2026-03-05 10:38:03 +10:00
LukeParkerDev
45adf54904 refactor(windows): inline shell blacklist check
Remove the one-off helper used for shell name normalization and keep the Windows blacklist check inline. This keeps the shell selection logic simpler without changing behavior.
2026-03-05 10:10:23 +10:00
LukeParkerDev
b384dac4b7 fix(windows): prefer PowerShell defaults for shell tools
Use pwsh and powershell before Git Bash when SHELL is unset on Windows so the default shell matches native expectations more closely. Surface the active OS and shell in the bash tool definition so agents can reason about the runtime they are executing in.
2026-03-05 10:04:09 +10:00
MakonnenMak
e993acec31 Merge remote-tracking branch 'upstream/dev' into fix/clock-skew-prompt-loop-exit
# Conflicts:
#	packages/ui/src/components/session-turn.tsx
2026-03-02 13:30:01 -05:00
David Hill
611e616010 tui: add right margin to question progress indicator so it doesn't touch the container edge 2026-03-02 14:27:43 +00:00
David Hill
b286c0ae3f tweak(ui): restore questions progress indicator 2026-03-02 12:08:37 +00:00
David Hill
81a61f8dbd tweak(ui): improve collapse area 2026-03-02 11:14:01 +00:00
David Hill
752e449e38 tweak(ui): improve collpase area 2026-03-02 11:11:52 +00:00
Dax Raad
a44f78c34a core: maintain backward compatibility with existing account data by restoring legacy ControlAccountTable alongside new AccountTable structure 2026-03-01 14:21:55 -05:00
Dax Raad
a5d727e7f9 core: enable workspace-aware configuration and account management commands
Switch from boolean active flag to workspace_id tracking so users can select which workspace context to operate in. Login now automatically selects the first available workspace and stores it on the account record.

Logout command now actually removes account records and supports targeting specific accounts by email. Switch command provides an interactive picker to change active workspace. Workspaces command lists all available workspaces across accounts.

Configuration now loads workspace-specific settings from the server when an active workspace is selected, enabling per-workspace customization of opencode behavior.
2026-03-01 14:21:55 -05:00
Dax Raad
7b5b665b4a core: support managing multiple authenticated accounts with individual workspace access
Enable users to authenticate with multiple accounts and switch between
them, accessing workspaces from each account separately.
2026-03-01 14:21:55 -05:00
Dax Raad
b5515dd2f7 core: add device flow authentication commands
Allow users to authenticate via browser-based OAuth device flow
with opencode login command. Includes login, logout, switch account,
and workspaces list commands for managing multiple accounts.
2026-03-01 14:21:55 -05:00
Dax Raad
d16e5b98dc core: rename control module to account for clearer authentication management
Refactor internal authentication system by renaming the control module to account,
making it easier to understand that this handles user account credentials and
tokens. Simplify database schema management by removing the centralized schema
exports and letting each module manage its own tables directly.
2026-03-01 14:21:55 -05:00
Dax Raad
9dbf3a2042 core: rename auth command to providers for clearer credential management
The auth command has been renamed to providers to better reflect its purpose of managing AI provider credentials. This makes it easier for users to discover and use the credential management features when configuring different AI providers.
2026-03-01 14:21:55 -05:00
David Hill
5d419a0211 tweak(ui): expand question dock toggle area 2026-02-27 21:30:49 +00:00
David Hill
8b168981aa tweak(ui): active state on type your own answer 2026-02-27 18:50:50 +00:00
David Hill
724dd665ec tweak(ui): collapse questions 2026-02-27 18:47:53 +00:00
MakonnenMak
fc258ea74f fix: remove as any type cast in processor exit logic 2026-02-20 13:20:10 -05:00
Makonnen
abd9e195ac fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering
When the client clock is ahead of the server, user message IDs (generated
client-side) sort after assistant message IDs (generated server-side).
This broke the prompt loop exit check and the UI message pairing logic.

- Extract shouldExitLoop() into a pure function that uses parentID matching
  instead of relying on ID ordering
- Extract findAssistantMessages() with forward+backward scan to handle
  messages sorted out of expected order due to clock skew
- Remove debug console.log statements added during investigation
- Add tests for both extracted functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:20:10 -05:00
Adam
9d78b69cd3 wip(app): beta badge 2026-02-20 10:59:59 -06:00
Dax
e31f00ad22 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-16 21:50:34 -05:00
LukeParkerDev
a90e8de050 add missing return 2026-02-11 13:24:17 +10:00
Aiden Cline
eabf770053 Merge branch 'dev' into utilize-family-in-dialog 2026-02-10 14:43:15 -06:00
Dax
86d7bdc542 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-09 10:55:01 -05:00
Dax
d3ab78bba0 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-09 10:04:40 -05:00
Dax Raad
a531f3f36d core: run command build agent now auto-accepts file edits to reduce workflow interruptions while still requiring confirmation for bash commands 2026-02-07 20:00:09 -05:00
Dax Raad
bb3382311d tui: standardize autoedit indicator text styling to match other status labels 2026-02-07 19:57:45 -05:00
Dax Raad
ad545d0cc9 tui: allow auto-accepting only edit permissions instead of all permissions 2026-02-07 19:52:53 -05:00
Dax Raad
ac244b1458 tui: add searchable 'toggle' keywords to command palette and show current state in toggle titles 2026-02-07 17:03:34 -05:00
Dax Raad
f202536b65 tui: show enable/disable state in permission toggle and make it searchable by 'toggle permissions' 2026-02-07 16:57:48 -05:00
Dax Raad
405cc3f610 tui: streamline permission toggle command naming and add keyboard shortcut support
Rename 'Toggle autoaccept permissions' to 'Toggle permissions' for clarity
and move the command to the Agent category for better discoverability.
Add permission_auto_accept_toggle keybind to enable keyboard shortcut
toggling of auto-accept mode for permission requests.
2026-02-07 16:51:55 -05:00
Dax Raad
878c1b8c2d feat(tui): add auto-accept mode for permission requests
Add a toggleable auto-accept mode that automatically accepts all incoming
permission requests with a 'once' reply. This is useful for users who want
to streamline their workflow when they trust the agent's actions.

Changes:
- Add permission_auto_accept keybind (default: shift+tab) to config
- Remove default for agent_cycle_reverse (was shift+tab)
- Add auto-accept logic in sync.tsx to auto-reply when enabled
- Add command bar action to toggle auto-accept mode (copy: "Toggle autoaccept permissions")
- Add visual indicator showing 'auto-accept' when active
- Store auto-accept state in KV for persistence across sessions
2026-02-07 16:44:39 -05:00
Aiden Cline
bb4d978684 feat: update tui model dialog to utilize model family to reduce noise in list 2026-02-03 15:48:40 -06:00
332 changed files with 15733 additions and 11554 deletions

38
.github/workflows/storybook.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: storybook
on:
push:
branches: [dev]
paths:
- ".github/workflows/storybook.yml"
- "package.json"
- "bun.lock"
- "packages/storybook/**"
- "packages/ui/**"
pull_request:
branches: [dev]
paths:
- ".github/workflows/storybook.yml"
- "package.json"
- "bun.lock"
- "packages/storybook/**"
- "packages/ui/**"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: storybook build
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Build Storybook
run: bun --cwd packages/storybook build

View File

@@ -1,3 +1,6 @@
plans/
bun.lock
node_modules
plans
package.json
bun.lock
.gitignore
package-lock.json

View File

@@ -5,6 +5,11 @@
"options": {},
},
},
"permission": {
"edit": {
"packages/opencode/migration/*": "deny",
},
},
"mcp": {},
"tools": {
"github-triage": false,

View File

@@ -5,16 +5,8 @@ import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
tui: [
"thdxr",
"kommander",
// "rekram1-node" (on vacation)
],
core: [
"thdxr",
// "rekram1-node", (on vacation)
"jlongster",
],
tui: ["thdxr", "kommander", "rekram1-node"],
core: ["thdxr", "rekram1-node", "jlongster"],
docs: ["R44VC0RP"],
windows: ["Hona"],
} as const
@@ -50,7 +42,10 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
export default tool({
description: DESCRIPTION,
args: {
assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"),
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee")
.default("rekram1-node"),
labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue")
@@ -73,8 +68,7 @@ export default tool({
results.push("Dropped label: nix (issue does not mention nix)")
}
// const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
const assignee = web ? pick(TEAM.desktop) : args.assignee
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")

View File

@@ -4,5 +4,3 @@ Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
(Note: rekram1-node is on vacation, do not assign issues to him.)

View File

@@ -122,3 +122,7 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
## Type Checking
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.

553
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
"aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
"aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
"x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
"x86_64-linux": "sha256-TnrYykX8Mf/Ugtkix6V",
"aarch64-linux": "sha256-TnrYykX8Mf/Ugtkix6V",
"aarch64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V",
"x86_64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V"
}
}

View File

@@ -41,8 +41,9 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"drizzle-kit": "1.0.0-beta.16-c2458b2",
"drizzle-orm": "1.0.0-beta.16-c2458b2",
"effect": "4.0.0-beta.29",
"ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -100,6 +101,7 @@
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron"
],

View File

@@ -7,7 +7,6 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
sessionTimelineHeaderSelector,
projectMenuTriggerSelector,
projectCloseMenuSelector,
projectWorkspacesToggleSelector,
@@ -244,9 +243,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
const scroller = page.locator(".scroll-view__viewport").first()
await expect(scroller).toBeVisible()
const header = page.locator(sessionTimelineHeaderSelector).first()
await expect(header).toBeVisible({ timeout: 30_000 })
await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
const menu = page
.locator(dropdownMenuContentSelector)
@@ -262,7 +259,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
if (opened) return menu
const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click()

View File

@@ -25,16 +25,26 @@ test("closing active project navigates to another open project", async ({ page,
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 ""
})
.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(otherButton).toHaveCount(0)
await expect
.poll(
async () => {
return await page.locator(projectSwitchSelector(otherSlug)).count()
},
{ timeout: 15_000 },
)
.toBe(0)
},
{ extra: [other] },
)

View File

@@ -51,8 +51,6 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte
export const inlineInputSelector = '[data-component="inline-input"]'
export const sessionTimelineHeaderSelector = "[data-session-title]"
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
export const workspaceItemSelector = (slug: string) =>

View File

@@ -7,7 +7,7 @@ import {
openSharePopover,
withSession,
} from "../actions"
import { sessionItemSelector, inlineInputSelector, sessionTimelineHeaderSelector } from "../selectors"
import { sessionItemSelector, inlineInputSelector } from "../selectors"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
@@ -39,14 +39,12 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
await withSession(sdk, originalTitle, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
originalTitle,
)
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(sessionTimelineHeaderSelector).locator(inlineInputSelector).first()
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(renamedTitle)
@@ -63,9 +61,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
)
.toBe(renamedTitle)
await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
renamedTitle,
)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
})
})

View File

@@ -83,16 +83,23 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
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 firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
expect(firstTheme).toBeTruthy()
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.nth(1).click()
await items.filter({ hasText: nextTheme! }).first().click()
await page.keyboard.press("Escape")
@@ -101,7 +108,7 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
})
expect(storedThemeId).not.toBeNull()
expect(storedThemeId).not.toBe("oc-1")
expect(storedThemeId).not.toBe(currentThemeId)
const dataTheme = await page.evaluate(() => {
return document.documentElement.getAttribute("data-theme")
@@ -109,6 +116,42 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
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("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
await gotoSession()

View File

@@ -27,6 +27,8 @@ async function run(page: Page, cmd: string) {
await terminal.click()
await page.keyboard.type(cmd)
await page.keyboard.press("Enter")
// powershell + windows just isnt that fast... we need to wait
await page.waitForTimeout(3_000)
}
async function store(page: Page, key: string) {
@@ -44,12 +46,14 @@ async function store(page: Page, key: string) {
}, key)
}
test("terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
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)
@@ -61,22 +65,39 @@ test("terminal tab buffers persist across tab switches", async ({ page, withProj
await run(page, `echo ${two}`)
await tabs
.filter({ hasText: /Terminal 1/ })
.first()
.click()
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.includes(one) && second.includes(two)
return {
first: first.includes(one),
second: second.includes(two),
}
},
{ timeout: 30_000 },
)
.toBe(true)
.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: 30_000 },
)
.toEqual({ first: true, second: false })
})
})

View File

@@ -57,7 +57,7 @@ export function sessionPath(directory: string, sessionID?: string) {
}
export function workspacePersistKey(directory: string, key: string) {
const head = directory.slice(0, 12) || "workspace"
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}`
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.21",
"version": "1.2.24",
"description": "",
"type": "module",
"exports": {

View File

@@ -1,6 +1,13 @@
;(function () {
var themeId = localStorage.getItem("opencode-theme-id")
if (!themeId) return
var key = "opencode-theme-id"
var themeId = localStorage.getItem(key) || "oc-2"
if (themeId === "oc-1") {
themeId = "oc-2"
localStorage.setItem(key, themeId)
localStorage.removeItem("opencode-theme-css-light")
localStorage.removeItem("opencode-theme-css-dark")
}
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
@@ -9,9 +16,9 @@
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
if (themeId === "oc-2") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
var css = localStorage.getItem("opencode-theme-css-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"

View File

@@ -0,0 +1,441 @@
import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { Tooltip } from "@opencode-ai/ui/tooltip"
type Mem = Performance & {
memory?: {
usedJSHeapSize: number
jsHeapSizeLimit: number
}
}
type Evt = PerformanceEntry & {
interactionId?: number
processingStart?: number
}
type Shift = PerformanceEntry & {
hadRecentInput: boolean
value: number
}
type Obs = PerformanceObserverInit & {
durationThreshold?: number
}
const span = 5000
const ms = (n?: number, d = 0) => {
if (n === undefined || Number.isNaN(n)) return "n/a"
return `${n.toFixed(d)}ms`
}
const time = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return "n/a"
return `${Math.round(n)}`
}
const mb = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return "n/a"
const v = n / 1024 / 1024
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
}
const bad = (n: number | undefined, limit: number, low = false) => {
if (n === undefined || Number.isNaN(n)) return false
return low ? n < limit : n > limit
}
const session = (path: string) => path.includes("/session")
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
return (
<Tooltip value={props.tip} placement="top">
<div
classList={{
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] bg-white/5 px-0.5 py-1 text-center": true,
"col-span-2": !!props.wide,
}}
>
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
<div
classList={{
"text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div>
</Tooltip>
)
}
export function DebugBar() {
const location = useLocation()
const routing = useIsRouting()
const [state, setState] = createStore({
cls: undefined as number | undefined,
delay: undefined as number | undefined,
fps: undefined as number | undefined,
gap: undefined as number | undefined,
heap: {
limit: undefined as number | undefined,
used: undefined as number | undefined,
},
inp: undefined as number | undefined,
jank: undefined as number | undefined,
long: {
block: undefined as number | undefined,
count: undefined as number | undefined,
max: undefined as number | undefined,
},
nav: {
dur: undefined as number | undefined,
pending: false,
},
})
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
const heapv = () => {
const value = heap()
if (value === undefined) return "n/a"
return `${Math.round(value * 100)}%`
}
const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`)
const navv = () => (state.nav.pending ? "..." : time(state.nav.dur))
let prev = ""
let start = 0
let init = false
let one = 0
let two = 0
createEffect(() => {
const busy = routing()
const next = `${location.pathname}${location.search}`
if (!init) {
init = true
prev = next
return
}
if (busy) {
if (one !== 0) cancelAnimationFrame(one)
if (two !== 0) cancelAnimationFrame(two)
one = 0
two = 0
if (start !== 0) return
start = performance.now()
if (session(prev)) setState("nav", { dur: undefined, pending: true })
return
}
if (start === 0) {
prev = next
return
}
const at = start
const from = prev
start = 0
prev = next
if (!(session(from) || session(next))) return
if (one !== 0) cancelAnimationFrame(one)
if (two !== 0) cancelAnimationFrame(two)
one = requestAnimationFrame(() => {
one = 0
two = requestAnimationFrame(() => {
two = 0
setState("nav", { dur: performance.now() - at, pending: false })
})
})
})
onMount(() => {
const obs: PerformanceObserver[] = []
const fps: Array<{ at: number; dur: number }> = []
const long: Array<{ at: number; dur: number }> = []
const seen = new Map<number | string, { at: number; delay: number; dur: number }>()
let hasLong = false
let poll: number | undefined
let raf = 0
let last = 0
let snap = 0
const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => {
while (list[0] && at - list[0].at > span) list.shift()
}
const syncFrame = (at: number) => {
trim(fps, span, at)
const total = fps.reduce((sum, entry) => sum + entry.dur, 0)
const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0)
const jank = fps.filter((entry) => entry.dur > 32).length
batch(() => {
setState("fps", total > 0 ? (fps.length * 1000) / total : undefined)
setState("gap", gap > 0 ? gap : undefined)
setState("jank", jank)
})
}
const syncLong = (at = performance.now()) => {
if (!hasLong) return
trim(long, span, at)
const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0)
const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0)
setState("long", { block, count: long.length, max })
}
const syncInp = (at = performance.now()) => {
for (const [key, entry] of seen) {
if (at - entry.at > span) seen.delete(key)
}
let delay = 0
let inp = 0
for (const entry of seen.values()) {
delay = Math.max(delay, entry.delay)
inp = Math.max(inp, entry.dur)
}
batch(() => {
setState("delay", delay > 0 ? delay : undefined)
setState("inp", inp > 0 ? inp : undefined)
})
}
const syncHeap = () => {
const mem = (performance as Mem).memory
if (!mem) return
setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize })
}
const reset = () => {
fps.length = 0
long.length = 0
seen.clear()
last = 0
snap = 0
batch(() => {
setState("fps", undefined)
setState("gap", undefined)
setState("jank", undefined)
setState("delay", undefined)
setState("inp", undefined)
if (hasLong) setState("long", { block: 0, count: 0, max: 0 })
})
}
const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => {
if (typeof PerformanceObserver === "undefined") return false
if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false
const ob = new PerformanceObserver((list) => fn(list.getEntries()))
try {
ob.observe(init)
obs.push(ob)
return true
} catch {
ob.disconnect()
return false
}
}
if (
watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => {
const add = entries.reduce((sum, entry) => {
const item = entry as Shift
if (item.hadRecentInput) return sum
return sum + item.value
}, 0)
if (add === 0) return
setState("cls", (value) => (value ?? 0) + add)
})
) {
setState("cls", 0)
}
if (
watch("longtask", { buffered: true, type: "longtask" }, (entries) => {
const at = performance.now()
long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration })))
syncLong(at)
})
) {
hasLong = true
setState("long", { block: 0, count: 0, max: 0 })
}
watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => {
for (const raw of entries) {
const entry = raw as Evt
if (entry.duration < 16) continue
const key =
entry.interactionId && entry.interactionId > 0
? entry.interactionId
: `${entry.name}:${Math.round(entry.startTime)}`
const prev = seen.get(key)
const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime)
seen.set(key, {
at: entry.startTime,
delay: Math.max(prev?.delay ?? 0, delay),
dur: Math.max(prev?.dur ?? 0, entry.duration),
})
if (seen.size <= 200) continue
const first = seen.keys().next().value
if (first !== undefined) seen.delete(first)
}
syncInp()
})
const loop = (at: number) => {
if (document.visibilityState !== "visible") {
raf = 0
return
}
if (last === 0) {
last = at
raf = requestAnimationFrame(loop)
return
}
fps.push({ at, dur: at - last })
last = at
if (at - snap >= 250) {
snap = at
syncFrame(at)
}
raf = requestAnimationFrame(loop)
}
const stop = () => {
if (raf !== 0) cancelAnimationFrame(raf)
raf = 0
if (poll === undefined) return
clearInterval(poll)
poll = undefined
}
const start = () => {
if (document.visibilityState !== "visible") return
if (poll === undefined) {
poll = window.setInterval(() => {
syncLong()
syncInp()
syncHeap()
}, 1000)
}
if (raf !== 0) return
raf = requestAnimationFrame(loop)
}
const vis = () => {
if (document.visibilityState !== "visible") {
stop()
return
}
reset()
start()
}
syncHeap()
start()
document.addEventListener("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()
})
})
return (
<aside
aria-label="Development performance diagnostics"
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
style={{
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
"border-color": "color-mix(in srgb, white 14%, transparent)",
}}
>
<div class="grid grid-cols-5 gap-px font-mono">
<Cell
label="NAV"
tip="Last completed route transition touching a session page, measured from router start until the first paint after it settles."
value={navv()}
bad={bad(state.nav.dur, 400)}
dim={state.nav.dur === undefined && !state.nav.pending}
/>
<Cell
label="FPS"
tip="Rolling frames per second over the last 5 seconds."
value={state.fps === undefined ? "n/a" : `${Math.round(state.fps)}`}
bad={bad(state.fps, 50, true)}
dim={state.fps === undefined}
/>
<Cell
label="FRM"
tip="Worst frame time over the last 5 seconds."
value={time(state.gap)}
bad={bad(state.gap, 50)}
dim={state.gap === undefined}
/>
<Cell
label="JNK"
tip="Frames over 32ms in the last 5 seconds."
value={state.jank === undefined ? "n/a" : `${state.jank}`}
bad={bad(state.jank, 8)}
dim={state.jank === undefined}
/>
<Cell
label="LNG"
tip={`Blocked time and long-task count in the last 5 seconds. Max task: ${ms(state.long.max)}.`}
value={longv()}
bad={bad(state.long.block, 200)}
dim={state.long.count === undefined}
/>
<Cell
label="DLY"
tip="Worst observed input delay in the last 5 seconds."
value={time(state.delay)}
bad={bad(state.delay, 100)}
dim={state.delay === undefined}
/>
<Cell
label="INP"
tip="Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP."
value={time(state.inp)}
bad={bad(state.inp, 200)}
dim={state.inp === undefined}
/>
<Cell
label="CLS"
tip="Cumulative layout shift for the current app lifetime."
value={state.cls === undefined ? "n/a" : state.cls.toFixed(2)}
bad={bad(state.cls, 0.1)}
dim={state.cls === undefined}
/>
<Cell
label="MEM"
tip={
state.heap.used === undefined
? "Used JS heap vs heap limit. Chromium only."
: `Used JS heap vs heap limit. ${mb(state.heap.used)} of ${mb(state.heap.limit)}.`
}
value={heapv()}
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
wide
/>
</div>
</aside>
)
}

View File

@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
function user(id: string): Message {
return {
id,
role: "user",
sessionID: "session-1",
time: { created: 1 },
} as unknown as Message
}
function assistant(id: string, parentID: string): Message {
return {
id,
role: "assistant",
sessionID: "session-1",
parentID,
time: { created: 1 },
} as unknown as Message
}
describe("findAssistantMessages", () => {
test("normal ordering: assistant after user in array → found via forward scan", () => {
const messages = [user("u1"), assistant("a1", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("clock skew: assistant before user in array → found via backward scan", () => {
// When client clock is ahead, user ID sorts after assistant ID,
// so assistant appears earlier in the ID-sorted message array
const messages = [assistant("a1", "u1"), user("u1")]
const result = findAssistantMessages(messages, 1, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("no assistant messages → returns empty array", () => {
const messages = [user("u1"), user("u2")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(0)
})
test("multiple assistant messages with matching parentID → all found", () => {
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(2)
expect(result[0].id).toBe("a1")
expect(result[1].id).toBe("a2")
})
test("does not return assistant messages with different parentID", () => {
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("stops forward scan at next user message", () => {
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("stops backward scan at previous user message", () => {
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
const result = findAssistantMessages(messages, 3, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("invalid index returns empty array", () => {
const messages = [user("u1")]
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
})
})

View File

@@ -4,6 +4,7 @@ import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon"
import { Mark } from "@opencode-ai/ui/logo"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
const MAIN_WORKTREE = "main"
@@ -52,7 +53,10 @@ export function NewSessionView(props: NewSessionViewProps) {
<div class="h-12 shrink-0" aria-hidden />
<div class="flex-1 px-6 pb-30 flex items-center justify-center text-center">
<div class="w-full max-w-200 flex flex-col items-center text-center gap-4">
<div class="text-20-medium text-text-strong">{language.t("session.new.title")}</div>
<div class="flex flex-col items-center gap-6">
<Mark class="w-10" />
<div class="text-20-medium text-text-strong">{language.t("session.new.title")}</div>
</div>
<div class="w-full flex flex-col gap-4 items-center">
<div class="flex items-start justify-center gap-3 min-h-5">
<div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">

View File

@@ -217,7 +217,7 @@ export const Terminal = (props: TerminalProps) => {
const currentTheme = theme.themes()[theme.themeId()]
if (!currentTheme) return fallback
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
if (!variant?.seeds) return fallback
if (!variant?.seeds && !variant?.palette) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-stronger"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background

View File

@@ -155,7 +155,7 @@ export function Titlebar() {
return (
<header
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
style={{ "min-height": minHeight() }}
data-tauri-drag-region
onMouseDown={drag}
@@ -266,10 +266,13 @@ export function Titlebar() {
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
BETA
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
<div id="opencode-titlebar-center" class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full" />
</div>
<div

View File

@@ -27,7 +27,7 @@ import type { InitError } from "../pages/error"
import { useGlobalSDK } from "./global-sdk"
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
import { createRefreshQueue } from "./global-sync/queue"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim"
@@ -189,6 +189,7 @@ function createGlobalSync() {
})
if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
}
children.unpin(directory)
return
@@ -220,6 +221,7 @@ function createGlobalSync() {
}),
)
setStore("session", reconcile(sessions, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
sessionMeta.set(directory, { limit })
})
.catch((err) => {

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { createStore } from "solid-js/store"
import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
({
@@ -248,6 +248,62 @@ describe("applyDirectoryEvent", () => {
}
})
test("cleans caches for trimmed sessions on session.created", () => {
const dropped = rootSession({ id: "ses_b" })
const kept = rootSession({ id: "ses_a" })
const message = userMessage("msg_1", dropped.id)
const todos: string[] = []
const [store, setStore] = createStore(
baseState({
limit: 1,
session: [dropped],
message: { [dropped.id]: [message] },
part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
session_diff: { [dropped.id]: [] },
todo: { [dropped.id]: [] },
permission: { [dropped.id]: [] },
question: { [dropped.id]: [] },
session_status: { [dropped.id]: { type: "busy" } },
}),
)
applyDirectoryEvent({
event: { type: "session.created", properties: { info: kept } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
setSessionTodo(sessionID, value) {
if (value !== undefined) return
todos.push(sessionID)
},
})
expect(store.session.map((x) => x.id)).toEqual([kept.id])
expect(store.message[dropped.id]).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff[dropped.id]).toBeUndefined()
expect(store.todo[dropped.id]).toBeUndefined()
expect(store.permission[dropped.id]).toBeUndefined()
expect(store.question[dropped.id]).toBeUndefined()
expect(store.session_status[dropped.id]).toBeUndefined()
expect(todos).toEqual([dropped.id])
})
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
const [store, setStore] = createStore(
baseState({
session: [rootSession({ id: "ses_keep" })],
part: { msg_1: [textPart("prt_1", "ses_drop", "msg_1")] },
}),
)
cleanupDroppedSessionCaches(store, setStore, store.session)
expect(store.part.msg_1).toBeUndefined()
})
test("upserts and removes messages while clearing orphaned parts", () => {
const sessionID = "ses_1"
const [store, setStore] = createStore(

View File

@@ -13,6 +13,7 @@ import type {
} from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
@@ -40,37 +41,44 @@ export function applyGlobalEvent(input: {
}
function cleanupSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
if (!sessionID) return
const hasAny =
store.message[sessionID] !== undefined ||
store.session_diff[sessionID] !== undefined ||
store.todo[sessionID] !== undefined ||
store.permission[sessionID] !== undefined ||
store.question[sessionID] !== undefined ||
store.session_status[sessionID] !== undefined
setSessionTodo?.(sessionID, undefined)
if (!hasAny) return
setStore(
produce((draft) => {
const messages = draft.message[sessionID]
if (messages) {
for (const message of messages) {
const id = message?.id
if (!id) continue
delete draft.part[id]
}
}
delete draft.message[sessionID]
delete draft.session_diff[sessionID]
delete draft.todo[sessionID]
delete draft.permission[sessionID]
delete draft.question[sessionID]
delete draft.session_status[sessionID]
dropSessionCaches(draft, [sessionID])
}),
)
}
export function cleanupDroppedSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
next: Session[],
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
const keep = new Set(next.map((item) => item.id))
const stale = [
...Object.keys(store.message),
...Object.keys(store.session_diff),
...Object.keys(store.todo),
...Object.keys(store.permission),
...Object.keys(store.question),
...Object.keys(store.session_status),
...Object.values(store.part)
.map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
.filter((sessionID): sessionID is string => !!sessionID),
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
if (stale.length === 0) return
for (const sessionID of stale) {
setSessionTodo?.(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, stale)
}),
)
}
@@ -102,6 +110,7 @@ export function applyDirectoryEvent(input: {
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
break
}
@@ -117,7 +126,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -130,6 +139,7 @@ export function applyDirectoryEvent(input: {
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
break
}
case "session.deleted": {
@@ -143,7 +153,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break

View File

@@ -0,0 +1,102 @@
import { describe, expect, test } from "bun:test"
import type {
FileDiff,
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
const msg = (id: string, sessionID: string) =>
({
id,
sessionID,
role: "user",
time: { created: 1 },
agent: "assistant",
model: { providerID: "openai", modelID: "gpt" },
}) as Message
const part = (id: string, sessionID: string, messageID: string) =>
({
id,
sessionID,
messageID,
type: "text",
text: id,
}) as Part
describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
} = {
session_status: { ses_1: { type: "busy" } as SessionStatus },
session_diff: { ses_1: [] },
todo: { ses_1: [] as Todo[] },
message: {},
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] },
question: { ses_1: [] as QuestionRequest[] },
}
dropSessionCaches(store, ["ses_1"])
expect(store.message.ses_1).toBeUndefined()
expect(store.part.msg_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined()
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined()
})
test("dropSessionCaches clears message-backed parts", () => {
const m = msg("msg_1", "ses_1")
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
} = {
session_status: {},
session_diff: {},
todo: {},
message: { ses_1: [m] },
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {},
question: {},
}
dropSessionCaches(store, ["ses_1"])
expect(store.message.ses_1).toBeUndefined()
expect(store.part[m.id]).toBeUndefined()
})
test("pickSessionCacheEvictions preserves requested sessions", () => {
const seen = new Set(["ses_1", "ses_2", "ses_3"])
const stale = pickSessionCacheEvictions({
seen,
keep: "ses_4",
limit: 2,
preserve: ["ses_1"],
})
expect(stale).toEqual(["ses_2", "ses_3"])
expect([...seen]).toEqual(["ses_1", "ses_4"])
})
})

View File

@@ -0,0 +1,62 @@
import type {
FileDiff,
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
Todo,
} from "@opencode-ai/sdk/v2/client"
export const SESSION_CACHE_LIMIT = 40
type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
}
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
const stale = new Set(Array.from(sessionIDs).filter(Boolean))
if (stale.size === 0) return
for (const key of Object.keys(store.part)) {
const parts = store.part[key]
if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
delete store.part[key]
}
for (const sessionID of stale) {
delete store.message[sessionID]
delete store.todo[sessionID]
delete store.session_diff[sessionID]
delete store.session_status[sessionID]
delete store.permission[sessionID]
delete store.question[sessionID]
}
}
export function pickSessionCacheEvictions(input: {
seen: Set<string>
keep: string
limit: number
preserve?: Iterable<string>
}) {
const stale: string[] = []
const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])])
if (input.seen.has(input.keep)) input.seen.delete(input.keep)
input.seen.add(input.keep)
for (const id of input.seen) {
if (input.seen.size - stale.length <= input.limit) break
if (keep.has(id)) continue
stale.push(id)
}
for (const id of stale) {
input.seen.delete(id)
}
return stale
}

View File

@@ -6,6 +6,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
@@ -108,6 +109,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const maxDirs = 30
const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
complete: {} as Record<string, boolean>,
@@ -121,6 +124,62 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return undefined
}
const seenFor = (directory: string) => {
const existing = seen.get(directory)
if (existing) {
seen.delete(directory)
seen.set(directory, existing)
return existing
}
const created = new Set<string>()
seen.set(directory, created)
while (seen.size > maxDirs) {
const first = seen.keys().next().value
if (!first) break
const stale = [...(seen.get(first) ?? [])]
seen.delete(first)
const [, setStore] = globalSync.child(first, { bootstrap: false })
evict(first, setStore, stale)
}
return created
}
const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
const key = keyFor(directory, sessionID)
delete draft.limit[key]
delete draft.complete[key]
delete draft.loading[key]
}
}),
)
}
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
for (const sessionID of sessionIDs) {
globalSync.todo.set(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, sessionIDs)
}),
)
clearMeta(directory, sessionIDs)
}
const touch = (directory: string, setStore: Setter, sessionID: string) => {
const stale = pickSessionCacheEvictions({
seen: seenFor(directory),
keep: sessionID,
limit: SESSION_CACHE_LIMIT,
})
evict(directory, setStore, stale)
}
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
@@ -135,6 +194,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
}
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
const loadMessages = async (input: {
directory: string
client: typeof sdk.client
@@ -148,6 +209,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
setMeta("loading", key, true)
await fetchMessages(input)
.then((next) => {
if (!tracked(input.directory, input.sessionID)) return
batch(() => {
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
for (const p of next.part) {
@@ -158,6 +220,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
})
.finally(() => {
if (!tracked(input.directory, input.sessionID)) return
setMeta("loading", key, false)
})
}
@@ -224,11 +287,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const key = keyFor(directory, sessionID)
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
touch(directory, setStore, sessionID)
if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return
const limit = meta.limit[key] ?? messagePageSize
const sessionReq = hasSession
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
@@ -258,11 +326,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined) return
const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
}),
)
@@ -271,6 +341,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = globalSync.data.session_todo[sessionID]
if (existing !== undefined) {
@@ -287,6 +358,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
if (!tracked(directory, sessionID)) return
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
@@ -310,6 +382,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? messagePageSize
if (meta.loading[key]) return
@@ -325,6 +398,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
},
},
evict(sessionID: string, directory = sdk.directory) {
const [, setStore] = globalSync.child(directory)
seenFor(directory).delete(sessionID)
evict(directory, setStore, [sessionID])
},
fetch: async (count = 10) => {
const directory = sdk.directory
const client = sdk.client

View File

@@ -2,6 +2,7 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
let getWorkspaceTerminalCacheKey: (dir: string) => string
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
let migrateTerminalState: (value: unknown) => unknown
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
@@ -17,6 +18,7 @@ beforeAll(async () => {
const mod = await import("./terminal")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
migrateTerminalState = mod.migrateTerminalState
})
describe("getWorkspaceTerminalCacheKey", () => {
@@ -37,3 +39,44 @@ describe("getLegacyTerminalStorageKeys", () => {
])
})
})
describe("migrateTerminalState", () => {
test("drops invalid terminals and restores a valid active terminal", () => {
expect(
migrateTerminalState({
active: "missing",
all: [
null,
{ id: "one", title: "Terminal 2" },
{ id: "one", title: "duplicate", titleNumber: 9 },
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
{ title: "no-id" },
],
}),
).toEqual({
active: "one",
all: [
{ id: "one", title: "Terminal 2", titleNumber: 2 },
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
],
})
})
test("keeps a valid active id", () => {
expect(
migrateTerminalState({
active: "two",
all: [
{ id: "one", title: "Terminal 1" },
{ id: "two", title: "shell", titleNumber: 7 },
],
}),
).toEqual({
active: "two",
all: [
{ id: "one", title: "Terminal 1", titleNumber: 1 },
{ id: "two", title: "shell", titleNumber: 7 },
],
})
})
})

View File

@@ -1,6 +1,6 @@
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import type { Platform } from "./platform"
@@ -20,6 +20,71 @@ export type LocalPTY = {
const WORKSPACE_KEY = "__workspace__"
const MAX_TERMINAL_SESSIONS = 20
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function text(value: unknown) {
return typeof value === "string" ? value : undefined
}
function num(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined
}
function numberFromTitle(title: string) {
const match = title.match(/^Terminal (\d+)$/)
if (!match) return
const value = Number(match[1])
if (!Number.isFinite(value) || value <= 0) return
return value
}
function pty(value: unknown): LocalPTY | undefined {
if (!record(value)) return
const id = text(value.id)
if (!id) return
const title = text(value.title) ?? ""
const number = num(value.titleNumber)
const rows = num(value.rows)
const cols = num(value.cols)
const buffer = text(value.buffer)
const scrollY = num(value.scrollY)
const cursor = num(value.cursor)
return {
id,
title,
titleNumber: number && number > 0 ? number : (numberFromTitle(title) ?? 0),
...(rows !== undefined ? { rows } : {}),
...(cols !== undefined ? { cols } : {}),
...(buffer !== undefined ? { buffer } : {}),
...(scrollY !== undefined ? { scrollY } : {}),
...(cursor !== undefined ? { cursor } : {}),
}
}
export function migrateTerminalState(value: unknown) {
if (!record(value)) return value
const seen = new Set<string>()
const all = (Array.isArray(value.all) ? value.all : []).flatMap((item) => {
const next = pty(item)
if (!next || seen.has(next.id)) return []
seen.add(next.id)
return [next]
})
const active = text(value.active)
return {
active: active && seen.has(active) ? active : all[0]?.id,
all,
}
}
export function getWorkspaceTerminalCacheKey(dir: string) {
return `${dir}:${WORKSPACE_KEY}`
}
@@ -38,6 +103,16 @@ type TerminalCacheEntry = {
const caches = new Set<Map<string, TerminalCacheEntry>>()
const trimTerminal = (pty: LocalPTY) => {
if (!pty.buffer && pty.cursor === undefined && pty.scrollY === undefined) return pty
return {
...pty,
buffer: undefined,
cursor: undefined,
scrollY: undefined,
}
}
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
const key = getWorkspaceTerminalCacheKey(dir)
for (const cache of caches) {
@@ -61,16 +136,11 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
const numberFromTitle = (title: string) => {
const match = title.match(/^Terminal (\d+)$/)
if (!match) return
const value = Number(match[1])
if (!Number.isFinite(value) || value <= 0) return
return value
}
const [store, setStore, _, ready] = persisted(
Persist.workspace(dir, "terminal", legacy),
{
...Persist.workspace(dir, "terminal", legacy),
migrate: migrateTerminalState,
},
createStore<{
active?: string
all: LocalPTY[]
@@ -118,26 +188,6 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
})
onCleanup(unsub)
const meta = { migrated: false }
createEffect(() => {
if (!ready()) return
if (meta.migrated) return
meta.migrated = true
setStore("all", (all) => {
const next = all.map((pty) => {
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
if (direct !== undefined) return pty
const parsed = numberFromTitle(pty.title)
if (parsed === undefined) return pty
return { ...pty, titleNumber: parsed }
})
if (next.every((pty, index) => pty === all[index])) return all
return next
})
})
return {
ready,
all: createMemo(() => store.all),
@@ -188,6 +238,18 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
console.error("Failed to update terminal", error)
})
},
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
if (index === -1) return
setStore("all", index, (pty) => trimTerminal(pty))
},
trimAll() {
setStore("all", (all) => {
const next = all.map(trimTerminal)
if (next.every((pty, index) => pty === all[index])) return all
return next
})
},
async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
@@ -322,12 +384,27 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
createEffect(
on(
() => ({ dir: params.dir, id: params.id }),
(next, prev) => {
if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id) return
if (next.dir === prev.dir && next.id) return
loadWorkspace(prev.dir, prev.id).trimAll()
},
{ defer: true },
),
)
return {
ready: () => workspace().ready(),
all: () => workspace().all(),
active: () => workspace().active(),
new: () => workspace().new(),
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
trim: (id: string) => workspace().trim(id),
trimAll: () => workspace().trimAll(),
clone: (id: string) => workspace().clone(id),
open: (id: string) => workspace().open(id),
close: (id: string) => workspace().close(id),

View File

@@ -34,6 +34,7 @@ import { useProviders } from "@/hooks/use-providers"
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
import { clearWorkspaceTerminals } from "@/context/terminal"
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary"
@@ -53,6 +54,7 @@ import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { DebugBar } from "@/components/debug-bar"
import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language"
@@ -423,6 +425,17 @@ export default function Layout(props: ParentProps) {
return
}
if (
e.details?.type === "question.replied" ||
e.details?.type === "question.rejected" ||
e.details?.type === "permission.replied"
) {
const props = e.details.properties as { sessionID: string }
const sessionKey = `${e.name}:${props.sessionID}`
dismissSessionAlert(sessionKey)
return
}
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
const title =
e.details.type === "permission.asked"
@@ -657,25 +670,24 @@ export default function Layout(props: ParentProps) {
const prefetchQueues = new Map<string, PrefetchQueue>()
const PREFETCH_MAX_SESSIONS_PER_DIR = 10
const prefetchedByDir = new Map<string, Map<string, true>>()
const prefetchedByDir = new Map<string, Set<string>>()
const lruFor = (directory: string) => {
const existing = prefetchedByDir.get(directory)
if (existing) return existing
const created = new Map<string, true>()
const created = new Set<string>()
prefetchedByDir.set(directory, created)
return created
}
const markPrefetched = (directory: string, sessionID: string) => {
const lru = lruFor(directory)
if (lru.has(sessionID)) lru.delete(sessionID)
lru.set(sessionID, true)
while (lru.size > PREFETCH_MAX_SESSIONS_PER_DIR) {
const oldest = lru.keys().next().value as string | undefined
if (!oldest) return
lru.delete(oldest)
}
return pickSessionCacheEvictions({
seen: lru,
keep: sessionID,
limit: PREFETCH_MAX_SESSIONS_PER_DIR,
preserve: directory === params.dir && params.id ? [params.id] : undefined,
})
}
createEffect(() => {
@@ -724,6 +736,7 @@ export default function Layout(props: ParentProps) {
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
.then((messages) => {
if (prefetchToken.value !== token) return
if (!lruFor(directory).has(sessionID)) return
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
@@ -787,7 +800,18 @@ export default function Layout(props: ParentProps) {
const lru = lruFor(directory)
const known = lru.has(session.id)
if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
markPrefetched(directory, session.id)
const stale = markPrefetched(directory, session.id)
if (stale.length > 0) {
const [, setStore] = globalSync.child(directory, { bootstrap: false })
for (const id of stale) {
globalSync.todo.set(id, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, stale)
}),
)
}
if (priority === "high") q.pending.unshift(session.id)
if (priority !== "high") q.pending.push(session.id)
@@ -1879,6 +1903,7 @@ export default function Layout(props: ParentProps) {
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
const projectName = createMemo(() => {
const project = panelProps.project
if (!project) return ""
@@ -1904,11 +1929,11 @@ export default function Layout(props: ParentProps) {
return (
<div
classList={{
"flex flex-col min-h-0 min-w-0 rounded-tl-[12px] px-2": true,
"flex flex-col min-h-0 min-w-0 box-border rounded-tl-[12px] px-2": true,
"border border-b-0 border-border-weak-base": !merged(),
"border-l border-t border-border-weaker-base": merged(),
"bg-background-base": merged(),
"bg-background-stronger": !merged(),
"bg-background-base": merged() || hover(),
"bg-background-stronger": !merged() && !hover(),
"flex-1 min-w-0": panelProps.mobile,
"max-w-full overflow-hidden": panelProps.mobile,
}}
@@ -2111,193 +2136,204 @@ export default function Layout(props: ParentProps) {
}
return (
<div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Titlebar />
<div class="flex-1 min-h-0 relative overflow-x-hidden">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
<div class="size-full relative overflow-x-hidden">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
arm()
}}
>
<div class="@container w-full h-full contain-strict">
<SidebarContent
opened={() => layout.sidebar.opened()}
aimMove={aim.move}
projects={() => layout.projects.list()}
renderProject={(project) => (
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
)}
handleDragStart={handleDragStart}
handleDragEnd={handleDragEnd}
handleDragOver={handleDragOver}
openProjectLabel={language.t("command.project.open")}
openProjectKeybind={() => command.keybind("project.open")}
onOpenProject={chooseProject}
renderProjectOverlay={() => (
<ProjectDragOverlay projects={() => layout.projects.list()} activeProject={() => store.activeProject} />
)}
settingsLabel={() => language.t("sidebar.settings")}
settingsKeybind={() => command.keybind("settings.open")}
onOpenSettings={openSettings}
helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() => (
<Show when={currentProject()} keyed>
{(project) => <SidebarPanel project={project} merged />}
</Show>
)}
arm()
}}
>
<div class="@container w-full h-full contain-strict">
<SidebarContent
opened={() => layout.sidebar.opened()}
aimMove={aim.move}
projects={() => layout.projects.list()}
renderProject={(project) => (
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
)}
handleDragStart={handleDragStart}
handleDragEnd={handleDragEnd}
handleDragOver={handleDragOver}
openProjectLabel={language.t("command.project.open")}
openProjectKeybind={() => command.keybind("project.open")}
onOpenProject={chooseProject}
renderProjectOverlay={() => (
<ProjectDragOverlay
projects={() => layout.projects.list()}
activeProject={() => store.activeProject}
/>
)}
settingsLabel={() => language.t("sidebar.settings")}
settingsKeybind={() => command.keybind("settings.open")}
onOpenSettings={openSettings}
helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() => (
<Show when={currentProject()} keyed>
{(project) => <SidebarPanel project={project} merged />}
</Show>
)}
/>
</div>
<Show when={layout.sidebar.opened()}>
<div onPointerDown={() => setSizing(true)}>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
collapseThreshold={244}
onResize={(w) => {
setSizing(true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setSizing(false), 120)
layout.sidebar.resize(w)
}}
onCollapse={layout.sidebar.close}
/>
</div>
</Show>
</nav>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
</div>
<Show when={layout.sidebar.opened()}>
<div onPointerDown={() => setSizing(true)}>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
collapseThreshold={244}
onResize={(w) => {
setSizing(true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setSizing(false), 120)
layout.sidebar.resize(w)
<div class="xl:hidden">
<div
classList={{
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
onCollapse={layout.sidebar.close}
/>
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
<SidebarContent
mobile
opened={() => layout.sidebar.opened()}
aimMove={aim.move}
projects={() => layout.projects.list()}
renderProject={(project) => (
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile />
)}
handleDragStart={handleDragStart}
handleDragEnd={handleDragEnd}
handleDragOver={handleDragOver}
openProjectLabel={language.t("command.project.open")}
openProjectKeybind={() => command.keybind("project.open")}
onOpenProject={chooseProject}
renderProjectOverlay={() => (
<ProjectDragOverlay
projects={() => layout.projects.list()}
activeProject={() => store.activeProject}
/>
)}
settingsLabel={() => language.t("sidebar.settings")}
settingsKeybind={() => command.keybind("settings.open")}
onOpenSettings={openSettings}
helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() => <SidebarPanel project={currentProject()} mobile />}
/>
</nav>
</div>
</Show>
</nav>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div
classList={{
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!sizing(),
}}
style={{
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
}}
>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
</div>
<div class="xl:hidden">
<div
classList={{
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
<SidebarContent
mobile
opened={() => layout.sidebar.opened()}
aimMove={aim.move}
projects={() => layout.projects.list()}
renderProject={(project) => (
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile />
)}
handleDragStart={handleDragStart}
handleDragEnd={handleDragEnd}
handleDragOver={handleDragOver}
openProjectLabel={language.t("command.project.open")}
openProjectKeybind={() => command.keybind("project.open")}
onOpenProject={chooseProject}
renderProjectOverlay={() => (
<ProjectDragOverlay projects={() => layout.projects.list()} activeProject={() => store.activeProject} />
)}
settingsLabel={() => language.t("sidebar.settings")}
settingsKeybind={() => command.keybind("settings.open")}
onOpenSettings={openSettings}
helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() => <SidebarPanel project={currentProject()} mobile />}
/>
</nav>
</div>
<div
classList={{
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!sizing(),
}}
style={{
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
}}
>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
</div>
<div
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peek()} keyed>
{(project) => <SidebarPanel project={project} merged={false} />}
</Show>
</div>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
<div
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peek()} keyed>
{(project) => <SidebarPanel project={project} merged={false} />}
</Show>
</div>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div>
</div>
</div>
{import.meta.env.DEV && <DebugBar />}
</div>
<Toast.Region />
</div>

View File

@@ -91,6 +91,7 @@ const ProjectTile = (props: {
modal={!props.sidebarHovering()}
onOpenChange={(value) => {
props.setMenu(value)
props.setSuppressHover(value)
if (value) props.setOpen(false)
}}
>
@@ -107,6 +108,12 @@ const ProjectTile = (props: {
!props.selected() && !props.active(),
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
}}
onPointerDown={(event) => {
if (!props.overlay()) return
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
props.setSuppressHover(true)
event.preventDefault()
}}
onMouseEnter={(event: MouseEvent) => {
if (!props.overlay()) return
if (props.suppressHover()) return

View File

@@ -37,13 +37,12 @@ import { createOpenReviewFile, createSizing } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
import { createScrollSpy } from "@/pages/session/scroll-spy"
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { same } from "@/utils/same"
import { same } from "@opencode-ai/util/array"
import { formatServerError } from "@/utils/server-errors"
const emptyUserMessages: UserMessage[] = []
@@ -122,9 +121,13 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
return
}
const beforeTop = el.scrollTop
const beforeHeight = el.scrollHeight
fn()
void el.scrollHeight
el.scrollTop = beforeTop
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (!delta) return
el.scrollTop = beforeTop + delta
})
}
const backfillTurns = () => {
@@ -207,7 +210,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
if (el.scrollTop >= turnScrollThreshold) return
const start = turnStart()
if (start > 0) {
@@ -281,6 +284,7 @@ export default function Page() {
const [ui, setUi] = createStore({
git: false,
pendingMessage: undefined as string | undefined,
reviewSnap: false,
scrollGesture: 0,
scroll: {
overflow: false,
@@ -426,10 +430,12 @@ export default function Page() {
createEffect(
on(
() => params.id,
(id, prev) => {
if (id || !prev) return
resetSessionModel(local)
() => ({ dir: params.dir, id: params.id }),
(next, prev) => {
if (!prev) return
if (next.dir === prev.dir && next.id === prev.id) return
if (prev.id) sync.session.evict(prev.id, prev.dir)
if (!next.id) resetSessionModel(local)
},
{ defer: true },
),
@@ -454,6 +460,21 @@ export default function Page() {
return key
}, sessionKey())
let reviewFrame: number | undefined
createComputed((prev) => {
const open = desktopReviewOpen()
if (prev === undefined || prev === open) return open
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
setUi("reviewSnap", true)
reviewFrame = requestAnimationFrame(() => {
reviewFrame = undefined
setUi("reviewSnap", false)
})
return open
}, desktopReviewOpen())
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
@@ -464,20 +485,49 @@ export default function Page() {
return "main"
})
const activeMessage = createMemo(() => {
if (!store.messageId) return lastUserMessage()
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
return found ?? lastUserMessage()
})
const setActiveMessage = (message: UserMessage | undefined) => {
messageMark = scrollMark
setStore("messageId", message?.id)
}
const anchor = (id: string) => `message-${id}`
const cursor = () => {
const root = scroller
if (!root) return store.messageId
const box = root.getBoundingClientRect()
const line = box.top + 100
const list = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.map((el) => {
const id = el.dataset.messageId
if (!id) return
const rect = el.getBoundingClientRect()
return { id, top: rect.top, bottom: rect.bottom }
})
.filter((item): item is { id: string; top: number; bottom: number } => !!item)
const shown = list.filter((item) => item.bottom > box.top && item.top < box.bottom)
const hit = shown.find((item) => item.top <= line && item.bottom >= line)
if (hit) return hit.id
const near = [...shown].sort((a, b) => {
const da = Math.abs(a.top - line)
const db = Math.abs(b.top - line)
if (da !== db) return da - db
return a.top - b.top
})[0]
if (near) return near.id
return list.filter((item) => item.top <= line).at(-1)?.id ?? list[0]?.id ?? store.messageId
}
function navigateMessageByOffset(offset: number) {
const msgs = visibleUserMessages()
if (msgs.length === 0) return
const current = store.messageId
const current = store.messageId && messageMark === scrollMark ? store.messageId : cursor()
const base = current ? msgs.findIndex((m) => m.id === current) : msgs.length
const currentIndex = base === -1 ? msgs.length : base
const targetIndex = currentIndex + offset
@@ -550,6 +600,8 @@ export default function Page() {
let dockHeight = 0
let scroller: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
let scrollMark = 0
let messageMark = 0
const scrollGestureWindowMs = 250
@@ -594,6 +646,7 @@ export default function Page() {
() => {
setStore("messageId", undefined)
setStore("changes", "session")
setUi("pendingMessage", undefined)
},
{ defer: true },
),
@@ -799,7 +852,7 @@ export default function Page() {
}
const emptyTurn = () => (
<div class="h-full pb-64 flex flex-col items-center justify-center text-center gap-6">
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
</div>
)
@@ -914,7 +967,7 @@ export default function Page() {
diffStyle: layout.review.diffStyle(),
onDiffStyleChange: layout.review.setDiffStyle,
loadingClass: "px-6 py-4 text-text-weak",
emptyClass: "h-full pb-64 flex flex-col items-center justify-center text-center gap-6",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</div>
@@ -1088,17 +1141,11 @@ export default function Page() {
let scrollStateFrame: number | undefined
let scrollStateTarget: HTMLDivElement | undefined
const scrollSpy = createScrollSpy({
onActive: (id) => {
if (id === store.messageId) return
setStore("messageId", id)
},
})
const updateScrollState = (el: HTMLDivElement) => {
const max = el.scrollHeight - el.clientHeight
const overflow = max > 1
const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
const bottom = !overflow || el.scrollTop >= max - 2
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
setUi("scroll", { overflow, bottom })
@@ -1121,7 +1168,7 @@ export default function Page() {
const resumeScroll = () => {
setStore("messageId", undefined)
autoScroll.smoothScrollToBottom()
autoScroll.forceScrollToBottom()
clearMessageHash()
const el = scroller
@@ -1141,31 +1188,21 @@ export default function Page() {
),
)
createEffect(
on(
sessionKey,
() => {
scrollSpy.clear()
},
{ defer: true },
),
)
const anchor = (id: string) => `message-${id}`
const setScrollRef = (el: HTMLDivElement | undefined) => {
scroller = el
autoScroll.scrollRef(el)
scrollSpy.setContainer(el)
if (el) scheduleScrollState(el)
}
const markUserScroll = () => {
scrollMark += 1
}
createResizeObserver(
() => content,
() => {
const el = scroller
if (el) scheduleScrollState(el)
scrollSpy.markDirty()
},
)
@@ -1189,14 +1226,15 @@ export default function Page() {
const el = scroller
const delta = next - dockHeight
const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
const stick = el
? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
: false
dockHeight = next
if (stick) autoScroll.smoothScrollToBottom()
if (stick) autoScroll.forceScrollToBottom()
if (el) scheduleScrollState(el)
scrollSpy.markDirty()
},
)
@@ -1224,7 +1262,7 @@ export default function Page() {
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
scrollSpy.destroy()
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
})
@@ -1246,7 +1284,7 @@ export default function Page() {
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!size.active(),
!size.active() && !ui.reviewSnap,
}}
style={{
width: sessionPanelWidth(),
@@ -1255,7 +1293,7 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id}>
<Show when={activeMessage()}>
<Show when={lastUserMessage()}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
@@ -1266,7 +1304,7 @@ export default function Page() {
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 flex flex-col items-center justify-center text-center gap-6",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
@@ -1275,11 +1313,9 @@ export default function Page() {
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
isDesktop={isDesktop()}
onScrollSpyScroll={scrollSpy.onScroll}
onUserScroll={markUserScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
onPreserveScrollAnchor={autoScroll.preserve}
centered={centered()}
setContentRef={(el) => {
content = el
@@ -1296,8 +1332,6 @@ export default function Page() {
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
anchor={anchor}
onRegisterMessage={scrollSpy.register}
onUnregisterMessage={scrollSpy.unregister}
/>
</Show>
</Match>
@@ -1362,6 +1396,7 @@ export default function Page() {
reviewPanel={reviewPanel}
activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
size={size}
/>
</div>

View File

@@ -140,7 +140,7 @@ export function SessionComposerRegion(props: {
<div
classList={{
"w-full px-3 pointer-events-auto": true,
"md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<Show when={props.state.questionRequest()} keyed>

View File

@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
@@ -25,6 +26,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
customOn: cached?.customOn ?? ([] as boolean[]),
editing: false,
sending: false,
collapsed: false,
})
let root: HTMLDivElement | undefined
@@ -35,6 +37,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const input = createMemo(() => store.custom[store.tab] ?? "")
const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true)
const picked = createMemo(() => store.answers[store.tab]?.length ?? 0)
const summary = createMemo(() => {
const n = Math.min(store.tab + 1, total())
@@ -43,6 +46,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const last = createMemo(() => store.tab >= total() - 1)
const fold = () => setStore("collapsed", (value) => !value)
const customUpdate = (value: string, selected: boolean = on()) => {
const prev = input().trim()
const next = value.trim()
@@ -257,9 +262,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
kind="question"
ref={(el) => (root = el)}
header={
<>
<div
data-action="session-question-toggle"
class="flex flex-1 min-w-0 items-center gap-2 cursor-default select-none"
role="button"
tabIndex={0}
style={{ margin: "0 -10px", padding: "0 0 0 10px" }}
onClick={fold}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
fold()
}}
>
<div data-slot="question-header-title">{summary()}</div>
<div data-slot="question-progress">
<div data-slot="question-progress" class="ml-auto mr-1">
<For each={questions()}>
{(_, i) => (
<button
@@ -271,13 +288,38 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
disabled={store.sending}
onClick={() => jump(i())}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
jump(i())
}}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/>
)}
</For>
</div>
</>
<div>
<IconButton
data-action="session-question-toggle-button"
icon="chevron-down"
size="normal"
variant="ghost"
classList={{ "rotate-180": store.collapsed }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
fold()
}}
aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
/>
</div>
</div>
}
footer={
<>
@@ -297,56 +339,121 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text">{question()?.question}</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
<div
data-slot="question-text"
class="cursor-default"
classList={{
"mb-6": store.collapsed && picked() === 0,
}}
role={store.collapsed ? "button" : undefined}
tabIndex={store.collapsed ? 0 : undefined}
onClick={fold}
onKeyDown={(event) => {
if (!store.collapsed) return
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
fold()
}}
>
{question()?.question}
</div>
<Show when={store.collapsed && picked() > 0}>
<div data-slot="question-hint" class="cursor-default mb-6">
{picked()} answer{picked() === 1 ? "" : "s"} selected
</div>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<div data-slot="question-answers" hidden={store.collapsed} aria-hidden={store.collapsed}>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<button
data-slot="question-option"
data-picked={picked()}
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
aria-checked={on()}
disabled={store.sending}
onClick={() => selectOption(i())}
onClick={customOpen}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<button
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={store.sending}
onClick={customOpen}
onMouseDown={(e) => {
if (store.sending) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<span
data-slot="question-option-check"
@@ -365,80 +472,39 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
</span>
</button>
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
onMouseDown={(e) => {
if (store.sending) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</form>
</Show>
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</form>
</Show>
</div>
</div>
</DockPrompt>
)

View File

@@ -446,9 +446,9 @@ export function FileTabContent(props: { tab: string }) {
)
return (
<Tabs.Content value={props.tab} class="mt-3 relative flex h-full min-h-0 flex-col overflow-hidden contain-strict">
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
<ScrollView
class="h-full min-h-0 flex-1"
class="h-full"
viewportRef={(el: HTMLDivElement) => {
scroll = el
restoreScroll()

View File

@@ -1,31 +1,28 @@
import {
For,
Index,
createEffect,
createMemo,
createSignal,
on,
onCleanup,
Show,
startTransition,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Spinner } from "@opencode-ai/ui/spinner"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { SessionTimelineHeader } from "@/pages/session/session-timeline-header"
type MessageComment = {
path: string
@@ -37,9 +34,7 @@ type MessageComment = {
}
const emptyMessages: MessageType[] = []
const isDefaultSessionTitle = (title?: string) =>
!!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
const idle = { type: "idle" as const }
const messageComments = (parts: Part[]): MessageComment[] =>
parts.flatMap((part) => {
@@ -116,8 +111,6 @@ function createTimelineStaging(input: TimelineStageInput) {
completedSession: "",
count: 0,
})
const [readySession, setReadySession] = createSignal("")
let active = ""
const stagedCount = createMemo(() => {
const total = input.messages().length
@@ -142,46 +135,23 @@ function createTimelineStaging(input: TimelineStageInput) {
cancelAnimationFrame(frame)
frame = undefined
}
const scheduleReady = (sessionKey: string) => {
if (input.sessionKey() !== sessionKey) return
if (readySession() === sessionKey) return
setReadySession(sessionKey)
}
createEffect(
on(
() => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
([sessionKey, isWindowed, total]) => {
const switched = active !== sessionKey
if (switched) {
active = sessionKey
setReadySession("")
}
const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey
const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey
if (staging && !switched && shouldStage && frame !== undefined) return
cancel()
if (shouldStage) setReadySession("")
const shouldStage =
isWindowed &&
total > input.config.init &&
state.completedSession !== sessionKey &&
state.activeSession !== sessionKey
if (!shouldStage) {
setState({
activeSession: "",
completedSession: isWindowed ? sessionKey : state.completedSession,
count: total,
})
if (total <= 0) {
setReadySession("")
return
}
if (readySession() !== sessionKey) scheduleReady(sessionKey)
setState({ activeSession: "", count: total })
return
}
let count = Math.min(total, input.config.init)
if (staging) count = Math.min(total, Math.max(count, state.count))
setState({ activeSession: sessionKey, count })
const step = () => {
@@ -191,11 +161,10 @@ function createTimelineStaging(input: TimelineStageInput) {
}
const currentTotal = input.messages().length
count = Math.min(currentTotal, count + input.config.batch)
startTransition(() => setState("count", count))
setState("count", count)
if (count >= currentTotal) {
setState({ completedSession: sessionKey, activeSession: "" })
frame = undefined
scheduleReady(sessionKey)
return
}
frame = requestAnimationFrame(step)
@@ -209,12 +178,9 @@ function createTimelineStaging(input: TimelineStageInput) {
const key = input.sessionKey()
return state.activeSession === key && state.completedSession !== key
})
const ready = createMemo(() => readySession() === input.sessionKey())
onCleanup(() => {
cancel()
})
return { messages: stagedUserMessages, isStaging, ready }
onCleanup(cancel)
return { messages: stagedUserMessages, isStaging }
}
export function MessageTimeline(props: {
@@ -227,11 +193,9 @@ export function MessageTimeline(props: {
onAutoScrollHandleScroll: () => void
onMarkScrollGesture: (target?: EventTarget | null) => void
hasScrollGesture: () => boolean
isDesktop: boolean
onScrollSpyScroll: () => void
onUserScroll: () => void
onTurnBackfillScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
onPreserveScrollAnchor: (target: HTMLElement) => void
centered: boolean
setContentRef: (el: HTMLDivElement) => void
turnStart: number
@@ -240,25 +204,18 @@ export function MessageTimeline(props: {
onLoadEarlier: () => void
renderedUserMessages: UserMessage[]
anchor: (id: string) => string
onRegisterMessage: (el: HTMLDivElement, id: string) => void
onUnregisterMessage: (id: string) => void
}) {
let touchGesture: number | undefined
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const dialog = useDialog()
const language = useLanguage()
const trigger = (target: EventTarget | null) => {
const next =
target instanceof Element
? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"], [data-scroll-preserve]')
: undefined
if (!(next instanceof HTMLElement)) return
return next
}
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const sessionID = createMemo(() => params.id)
const sessionMessages = createMemo(() => {
@@ -271,20 +228,62 @@ export function MessageTimeline(props: {
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
),
)
const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle")
const sessionStatus = createMemo(() => {
const id = sessionID()
if (!id) return idle
return sync.data.session_status[id] ?? idle
})
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
const [slot, setSlot] = createStore({
open: false,
show: false,
fade: false,
})
let f: number | undefined
const clear = () => {
if (f !== undefined) window.clearTimeout(f)
f = undefined
}
onCleanup(clear)
createEffect(
on(
working,
(on, prev) => {
clear()
if (on) {
setSlot({ open: true, show: true, fade: false })
return
}
if (prev) {
setSlot({ open: false, show: true, fade: true })
f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
return
}
setSlot({ open: false, show: false, fade: false })
},
{ defer: true },
),
)
const activeMessageID = createMemo(() => {
const messages = sessionMessages()
const message = pending()
if (message?.parentID) {
const result = Binary.search(messages, message.parentID, (item) => item.id)
const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID)
if (parent?.role === "user") return parent.id
const parentID = pending()?.parentID
if (parentID) {
const messages = sessionMessages()
const result = Binary.search(messages, parentID, (message) => message.id)
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
if (message && message.role === "user") return message.id
}
if (sessionStatus() === "idle") return undefined
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") return messages[i].id
const status = sessionStatus()
if (status.type !== "idle") {
const messages = sessionMessages()
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") return messages[i].id
}
}
return undefined
})
const info = createMemo(() => {
@@ -292,19 +291,9 @@ export function MessageTimeline(props: {
if (!id) return
return sync.session.get(id)
})
const titleValue = createMemo(() => {
const title = info()?.title
if (!title) return
if (isDefaultSessionTitle(title)) return language.t("command.session.new")
return title
})
const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title))
const headerTitle = createMemo(
() => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined),
)
const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0))
const titleValue = createMemo(() => info()?.title)
const parentID = createMemo(() => info()?.parentID)
const showHeader = createMemo(() => !!(headerTitle() || parentID()))
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const stageCfg = { init: 1, batch: 3 }
const staging = createTimelineStaging({
sessionKey,
@@ -312,7 +301,212 @@ export function MessageTimeline(props: {
messages: () => props.renderedUserMessages,
config: stageCfg,
})
const rendered = createMemo(() => staging.messages().map((message) => message.id))
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
let titleRef: HTMLInputElement | undefined
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
createEffect(
on(
sessionKey,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!sessionID()) return
setTitle({ editing: true, draft: titleValue() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const id = sessionID()
if (!id) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (titleValue() ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID: id, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === id)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
return
}
navigate(`/${params.dir}/session`)
}
const archiveSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const deleteSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
return true
}
const navigateParent = () => {
const id = parentID()
if (!id) return
navigate(`/${params.dir}/session/${id}`)
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
return (
<Show
@@ -336,18 +530,7 @@ export function MessageTimeline(props: {
<Icon name="arrow-down-to-line" />
</button>
</div>
<SessionTimelineHeader
centered={props.centered}
showHeader={showHeader}
sessionKey={sessionKey}
sessionID={sessionID}
parentID={parentID}
titleValue={titleValue}
headerTitle={headerTitle}
placeholderTitle={placeholderTitle}
/>
<ScrollView
reverse
viewportRef={props.setScrollRef}
onWheel={(e) => {
const root = e.currentTarget
@@ -381,44 +564,166 @@ export function MessageTimeline(props: {
touchGesture = undefined
}}
onPointerDown={(e) => {
const next = trigger(e.target)
if (next) props.onPreserveScrollAnchor(next)
if (e.target !== e.currentTarget) return
props.onMarkScrollGesture(e.currentTarget)
}}
onKeyDown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return
const next = trigger(e.target)
if (!next) return
props.onPreserveScrollAnchor(next)
}}
onScroll={(e) => {
props.onScheduleScrollState(e.currentTarget)
props.onTurnBackfillScroll()
if (!props.hasScrollGesture()) return
props.onUserScroll()
props.onAutoScrollHandleScroll()
props.onMarkScrollGesture(e.currentTarget)
if (props.isDesktop) props.onScrollSpyScroll()
}}
onClick={(e) => {
props.onAutoScrollInteraction(e)
}}
onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full"
style={{
"--session-title-height": showHeader() ? "72px" : "0px",
"--session-title-height": showHeader() ? "40px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
}}
>
<div>
<div ref={props.setContentRef} class="min-w-0 w-full">
<Show when={showHeader()}>
<div
data-session-title
classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true,
"pb-4": true,
"pl-2 pr-3 md:pl-4 md:pr-3": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
<Show when={parentID()}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={navigateParent}
aria-label={language.t("common.goBack")}
/>
</Show>
<div class="flex items-center min-w-0 grow-1">
<div
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{
width: slot.open ? "16px" : "0px",
"margin-right": slot.open ? "8px" : "0px",
}}
aria-hidden="true"
>
<Show when={slot.show}>
<div
class="transition-opacity duration-200 ease-out"
classList={{
"opacity-0": slot.fade,
}}
>
<Spinner class="size-4" style={{ color: "var(--icon-interactive-base)" }} />
</div>
</Show>
</div>
<Show when={titleValue() || title.editing}>
<Show
when={title.editing}
fallback={
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
onDblClick={openTitleEditor}
>
{titleValue()}
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
value={title.draft}
disabled={title.saving}
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={closeTitleEditor}
/>
</Show>
</Show>
</div>
</div>
<Show when={sessionID()}>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)}
</Show>
</div>
</div>
</Show>
<div
ref={props.setContentRef}
role="log"
class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
style={{ "padding-top": "var(--session-title-height)" }}
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
classList={{
"w-full": true,
"md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
"mt-0.5": props.centered,
"mt-0": !props.centered,
}}
@@ -440,15 +745,6 @@ export function MessageTimeline(props: {
</Show>
<For each={rendered()}>
{(messageID) => {
// Capture at creation time: animate only messages added after the
// timeline finishes its initial backfill staging, plus the first
// turn while a brand new session is still using its default title.
const isNew =
staging.ready() ||
(defaultTitle() &&
sessionStatus() !== "idle" &&
props.renderedUserMessages.length === 1 &&
messageID === props.renderedUserMessages[0]?.id)
const active = createMemo(() => activeMessageID() === messageID)
const queued = createMemo(() => {
if (active()) return false
@@ -457,23 +753,16 @@ export function MessageTimeline(props: {
return false
})
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
equals: (a, b) => {
if (a.length !== b.length) return false
return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment)
},
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
})
const commentCount = createMemo(() => comments().length)
return (
<div
id={props.anchor(messageID)}
data-message-id={messageID}
ref={(el) => {
props.onRegisterMessage(el, messageID)
onCleanup(() => props.onUnregisterMessage(messageID))
}}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-[500px] 2xl:max-w-[700px]": props.centered,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
}}
>
<Show when={commentCount() > 0}>
@@ -517,7 +806,7 @@ export function MessageTimeline(props: {
messageID={messageID}
active={active()}
queued={queued()}
animate={isNew || active()}
status={active() ? sessionStatus() : undefined}
showReasoningSummaries={settings.general.showReasoningSummaries()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
editToolDefaultOpen={settings.general.editToolPartsExpanded()}

View File

@@ -1,127 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createScrollSpy, pickOffsetId, pickVisibleId } from "./scroll-spy"
const rect = (top: number, height = 80): DOMRect =>
({
x: 0,
y: top,
top,
left: 0,
right: 800,
bottom: top + height,
width: 800,
height,
toJSON: () => ({}),
}) as DOMRect
const setRect = (el: Element, top: number, height = 80) => {
Object.defineProperty(el, "getBoundingClientRect", {
configurable: true,
value: () => rect(top, height),
})
}
describe("pickVisibleId", () => {
test("prefers higher intersection ratio", () => {
const id = pickVisibleId(
[
{ id: "a", ratio: 0.2, top: 100 },
{ id: "b", ratio: 0.8, top: 300 },
],
120,
)
expect(id).toBe("b")
})
test("breaks ratio ties by nearest line", () => {
const id = pickVisibleId(
[
{ id: "a", ratio: 0.5, top: 90 },
{ id: "b", ratio: 0.5, top: 140 },
],
130,
)
expect(id).toBe("b")
})
})
describe("pickOffsetId", () => {
test("uses binary search cutoff", () => {
const id = pickOffsetId(
[
{ id: "a", top: 0 },
{ id: "b", top: 200 },
{ id: "c", top: 400 },
],
350,
)
expect(id).toBe("b")
})
})
describe("createScrollSpy fallback", () => {
test("tracks active id from offsets and dirty refresh", () => {
const active: string[] = []
const root = document.createElement("div") as HTMLDivElement
const one = document.createElement("div")
const two = document.createElement("div")
const three = document.createElement("div")
root.append(one, two, three)
document.body.append(root)
Object.defineProperty(root, "scrollTop", { configurable: true, writable: true, value: 250 })
setRect(root, 0, 800)
setRect(one, -250)
setRect(two, -50)
setRect(three, 150)
const queue: FrameRequestCallback[] = []
const flush = () => {
const run = [...queue]
queue.length = 0
for (const cb of run) cb(0)
}
const spy = createScrollSpy({
onActive: (id) => active.push(id),
raf: (cb) => (queue.push(cb), queue.length),
caf: () => {},
IntersectionObserver: undefined,
ResizeObserver: undefined,
MutationObserver: undefined,
})
spy.setContainer(root)
spy.register(one, "a")
spy.register(two, "b")
spy.register(three, "c")
spy.onScroll()
flush()
expect(spy.getActiveId()).toBe("b")
expect(active.at(-1)).toBe("b")
root.scrollTop = 450
setRect(one, -450)
setRect(two, -250)
setRect(three, -50)
spy.onScroll()
flush()
expect(spy.getActiveId()).toBe("c")
root.scrollTop = 250
setRect(one, -250)
setRect(two, 250)
setRect(three, 150)
spy.markDirty()
spy.onScroll()
flush()
expect(spy.getActiveId()).toBe("a")
spy.destroy()
})
})

View File

@@ -1,275 +0,0 @@
type Visible = {
id: string
ratio: number
top: number
}
type Offset = {
id: string
top: number
}
type Input = {
onActive: (id: string) => void
raf?: (cb: FrameRequestCallback) => number
caf?: (id: number) => void
IntersectionObserver?: typeof globalThis.IntersectionObserver
ResizeObserver?: typeof globalThis.ResizeObserver
MutationObserver?: typeof globalThis.MutationObserver
}
export const pickVisibleId = (list: Visible[], line: number) => {
if (list.length === 0) return
const sorted = [...list].sort((a, b) => {
if (b.ratio !== a.ratio) return b.ratio - a.ratio
const da = Math.abs(a.top - line)
const db = Math.abs(b.top - line)
if (da !== db) return da - db
return a.top - b.top
})
return sorted[0]?.id
}
export const pickOffsetId = (list: Offset[], cutoff: number) => {
if (list.length === 0) return
let lo = 0
let hi = list.length - 1
let out = 0
while (lo <= hi) {
const mid = (lo + hi) >> 1
const top = list[mid]?.top
if (top === undefined) break
if (top <= cutoff) {
out = mid
lo = mid + 1
continue
}
hi = mid - 1
}
return list[out]?.id
}
export const createScrollSpy = (input: Input) => {
const raf = input.raf ?? requestAnimationFrame
const caf = input.caf ?? cancelAnimationFrame
const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver
const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver
const CtorMO = input.MutationObserver ?? globalThis.MutationObserver
let root: HTMLDivElement | undefined
let io: IntersectionObserver | undefined
let ro: ResizeObserver | undefined
let mo: MutationObserver | undefined
let frame: number | undefined
let active: string | undefined
let dirty = true
const node = new Map<string, HTMLElement>()
const id = new WeakMap<HTMLElement, string>()
const visible = new Map<string, { ratio: number; top: number }>()
let offset: Offset[] = []
const schedule = () => {
if (frame !== undefined) return
frame = raf(() => {
frame = undefined
update()
})
}
const refreshOffset = () => {
const el = root
if (!el) {
offset = []
dirty = false
return
}
const base = el.getBoundingClientRect().top
offset = [...node].map(([next, item]) => ({
id: next,
top: item.getBoundingClientRect().top - base + el.scrollTop,
}))
offset.sort((a, b) => a.top - b.top)
dirty = false
}
const update = () => {
const el = root
if (!el) return
const line = el.getBoundingClientRect().top + 100
const next =
pickVisibleId(
[...visible].map(([k, v]) => ({
id: k,
ratio: v.ratio,
top: v.top,
})),
line,
) ??
(() => {
if (dirty) refreshOffset()
return pickOffsetId(offset, el.scrollTop + 100)
})()
if (!next || next === active) return
active = next
input.onActive(next)
}
const observe = () => {
const el = root
if (!el) return
io?.disconnect()
io = undefined
if (CtorIO) {
try {
io = new CtorIO(
(entries) => {
for (const entry of entries) {
const item = entry.target
if (!(item instanceof HTMLElement)) continue
const key = id.get(item)
if (!key) continue
if (!entry.isIntersecting || entry.intersectionRatio <= 0) {
visible.delete(key)
continue
}
visible.set(key, {
ratio: entry.intersectionRatio,
top: entry.boundingClientRect.top,
})
}
schedule()
},
{
root: el,
threshold: [0, 0.25, 0.5, 0.75, 1],
},
)
} catch {
io = undefined
}
}
if (io) {
for (const item of node.values()) io.observe(item)
}
ro?.disconnect()
ro = undefined
if (CtorRO) {
ro = new CtorRO(() => {
dirty = true
schedule()
})
ro.observe(el)
for (const item of node.values()) ro.observe(item)
}
mo?.disconnect()
mo = undefined
if (CtorMO) {
mo = new CtorMO(() => {
dirty = true
schedule()
})
mo.observe(el, { subtree: true, childList: true, characterData: true })
}
dirty = true
schedule()
}
const setContainer = (el?: HTMLDivElement) => {
if (root === el) return
root = el
visible.clear()
active = undefined
observe()
}
const register = (el: HTMLElement, key: string) => {
const prev = node.get(key)
if (prev && prev !== el) {
io?.unobserve(prev)
ro?.unobserve(prev)
}
node.set(key, el)
id.set(el, key)
if (io) io.observe(el)
if (ro) ro.observe(el)
dirty = true
schedule()
}
const unregister = (key: string) => {
const item = node.get(key)
if (!item) return
io?.unobserve(item)
ro?.unobserve(item)
node.delete(key)
visible.delete(key)
dirty = true
schedule()
}
const markDirty = () => {
dirty = true
schedule()
}
const clear = () => {
for (const item of node.values()) {
io?.unobserve(item)
ro?.unobserve(item)
}
node.clear()
visible.clear()
offset = []
active = undefined
dirty = true
}
const destroy = () => {
if (frame !== undefined) caf(frame)
frame = undefined
clear()
io?.disconnect()
ro?.disconnect()
mo?.disconnect()
io = undefined
ro = undefined
mo = undefined
root = undefined
}
return {
setContainer,
register,
unregister,
onScroll: schedule,
markDirty,
clear,
destroy,
getActiveId: () => active,
}
}

View File

@@ -31,6 +31,7 @@ export function SessionSidePanel(props: {
reviewPanel: () => JSX.Element
activeDiff?: string
focusReviewDiff: (path: string) => void
reviewSnap: boolean
size: Sizing
}) {
const params = useParams()
@@ -55,11 +56,6 @@ export function SessionSidePanel(props: {
if (reviewOpen()) return `calc(100% - ${layout.session.width()}px)`
return `${layout.fileTree.width()}px`
})
const reviewWidth = createMemo(() => {
if (!reviewOpen()) return "0px"
if (!fileOpen()) return "100%"
return `calc(100% - ${layout.fileTree.width()}px)`
})
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
@@ -108,7 +104,7 @@ export function SessionSidePanel(props: {
const empty = (msg: string) => (
<div class="h-full flex flex-col">
<div class="h-12 shrink-0" aria-hidden />
<div class="h-6 shrink-0" aria-hidden />
<div class="flex-1 pb-64 flex items-center justify-center text-center">
<div class="text-12-regular text-text-weak">{msg}</div>
</div>
@@ -231,10 +227,9 @@ export function SessionSidePanel(props: {
inert={!open()}
class="relative min-w-0 h-full flex shrink-0 overflow-hidden bg-background-base"
classList={{
"opacity-100": open(),
"opacity-0 pointer-events-none": !open(),
"transition-[width,opacity] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
"pointer-events-none": !open(),
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active() && !props.reviewSnap,
}}
style={{ width: panelWidth() }}
>
@@ -242,14 +237,10 @@ export function SessionSidePanel(props: {
<div
aria-hidden={!reviewOpen()}
inert={!reviewOpen()}
class="relative min-w-0 h-full shrink-0 overflow-hidden bg-background-base"
class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base"
classList={{
"opacity-100": reviewOpen(),
"opacity-0 pointer-events-none": !reviewOpen(),
"transition-[width,opacity] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
"pointer-events-none": !reviewOpen(),
}}
style={{ width: reviewWidth() }}
>
<div class="size-full min-w-0 h-full bg-background-base">
<DragDropProvider
@@ -339,7 +330,7 @@ export function SessionSidePanel(props: {
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
@@ -385,9 +376,8 @@ export function SessionSidePanel(props: {
inert={!fileOpen()}
class="relative min-w-0 h-full shrink-0 overflow-hidden"
classList={{
"opacity-100": fileOpen(),
"opacity-0 pointer-events-none": !fileOpen(),
"transition-[width,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
"pointer-events-none": !fileOpen(),
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
}}
style={{ width: treeWidth() }}
@@ -435,7 +425,11 @@ export function SessionSidePanel(props: {
/>
</Show>
</Match>
<Match when={true}>{empty(language.t(reviewEmptyKey()))}</Match>
<Match when={true}>
{empty(
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
)}
</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">

View File

@@ -1,522 +0,0 @@
import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { prefersReducedMotion } from "@opencode-ai/ui/hooks"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
import { showToast } from "@opencode-ai/ui/toast"
import { errorMessage } from "@/pages/layout/helpers"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
export function SessionTimelineHeader(props: {
centered: boolean
showHeader: () => boolean
sessionKey: () => string
sessionID: () => string | undefined
parentID: () => string | undefined
titleValue: () => string | undefined
headerTitle: () => string | undefined
placeholderTitle: () => boolean
}) {
const navigate = useNavigate()
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const dialog = useDialog()
const language = useLanguage()
const reduce = prefersReducedMotion
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
const [headerText, setHeaderText] = createStore({
session: props.sessionKey(),
value: props.headerTitle(),
prev: undefined as string | undefined,
muted: props.placeholderTitle(),
prevMuted: false,
})
let headerAnim: AnimationPlaybackControls | undefined
let enterAnim: AnimationPlaybackControls | undefined
let leaveAnim: AnimationPlaybackControls | undefined
let titleRef: HTMLInputElement | undefined
let headerRef: HTMLDivElement | undefined
let enterRef: HTMLSpanElement | undefined
let leaveRef: HTMLSpanElement | undefined
const clearHeaderAnim = () => {
headerAnim?.stop()
headerAnim = undefined
}
const animateHeader = () => {
const el = headerRef
if (!el) return
clearHeaderAnim()
if (!headerText.muted || reduce()) {
el.style.opacity = "1"
return
}
headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 })
headerAnim.finished.then(() => {
if (headerRef !== el) return
clearFadeStyles(el)
})
}
const clearTitleAnims = () => {
enterAnim?.stop()
enterAnim = undefined
leaveAnim?.stop()
leaveAnim = undefined
}
const settleTitleEnter = () => {
if (enterRef) clearFadeStyles(enterRef)
}
const hideLeave = () => {
if (!leaveRef) return
leaveRef.style.opacity = "0"
leaveRef.style.filter = ""
leaveRef.style.transform = ""
}
const animateEnterSpan = () => {
if (!enterRef) return
if (reduce()) {
settleTitleEnter()
return
}
enterAnim = animate(
enterRef,
{ opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] },
FAST_SPRING,
)
enterAnim.finished.then(() => settleTitleEnter())
}
const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => {
clearTitleAnims()
setHeaderText({ prev: headerText.value, prevMuted: headerText.muted })
setHeaderText({ value: nextTitle, muted: nextMuted })
if (reduce()) {
setHeaderText({ prev: undefined, prevMuted: false })
hideLeave()
settleTitleEnter()
return
}
if (leaveRef) {
leaveAnim = animate(
leaveRef,
{ opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] },
FAST_SPRING,
)
leaveAnim.finished.then(() => {
setHeaderText({ prev: undefined, prevMuted: false })
hideLeave()
})
}
animateEnterSpan()
}
const fadeInTitle = (nextTitle: string, nextMuted: boolean) => {
clearTitleAnims()
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
animateEnterSpan()
}
const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => {
clearTitleAnims()
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
settleTitleEnter()
}
createEffect(
on(props.showHeader, (show, prev) => {
if (!show) {
clearHeaderAnim()
return
}
if (show === prev) return
animateHeader()
}),
)
createEffect(
on(
() => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const,
([nextSession, nextTitle, nextMuted]) => {
if (nextSession !== headerText.session) {
setHeaderText("session", nextSession)
if (nextTitle && nextMuted) {
fadeInTitle(nextTitle, nextMuted)
return
}
snapTitle(nextTitle, nextMuted)
return
}
if (nextTitle === headerText.value && nextMuted === headerText.muted) return
if (!nextTitle) {
snapTitle(undefined, false)
return
}
if (!headerText.value) {
fadeInTitle(nextTitle, nextMuted)
return
}
if (title.saving || title.editing) {
snapTitle(nextTitle, nextMuted)
return
}
crossfadeTitle(nextTitle, nextMuted)
},
),
)
onCleanup(() => {
clearHeaderAnim()
clearTitleAnims()
})
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
createEffect(
on(
props.sessionKey,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!props.sessionID()) return
setTitle({ editing: true, draft: props.titleValue() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const id = props.sessionID()
if (!id) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (props.titleValue() ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID: id, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((session) => session.id === id)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: toastError(err),
})
})
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
return
}
navigate(`/${params.dir}/session`)
}
const archiveSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((item) => item.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((item) => item.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: toastError(err),
})
})
}
const deleteSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived)
const index = sessions.findIndex((item) => item.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: toastError(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((item) => !removed.has(item.id))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
return true
}
const navigateParent = () => {
const id = props.parentID()
if (!id) return
navigate(`/${params.dir}/session/${id}`)
}
function DialogDeleteSession(input: { sessionID: string }) {
const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(input.sessionID)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
return (
<Show when={props.showHeader()}>
<div
data-session-title
ref={(el) => {
headerRef = el
el.style.opacity = "0"
}}
class="pointer-events-none absolute inset-x-0 top-0 z-30"
>
<div
classList={{
"bg-[linear-gradient(to_bottom,var(--background-stronger)_38px,transparent)]": true,
"w-full": true,
"pb-10": true,
"px-4 md:px-5": true,
"md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
}}
>
<div class="pointer-events-auto h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<Show when={props.parentID()}>
<div>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={navigateParent}
aria-label={language.t("common.goBack")}
/>
</div>
</Show>
<Show when={!!headerText.value || title.editing}>
<Show
when={title.editing}
fallback={
<h1 class="text-14-medium text-text-strong grow-1 min-w-0" onDblClick={openTitleEditor}>
<span class="grid min-w-0" style={{ overflow: "clip" }}>
<span ref={enterRef} class="col-start-1 row-start-1 min-w-0 truncate">
<span classList={{ "opacity-60": headerText.muted }}>{headerText.value}</span>
</span>
<span
ref={leaveRef}
class="col-start-1 row-start-1 min-w-0 truncate pointer-events-none"
style={{ opacity: "0" }}
>
<span classList={{ "opacity-60": headerText.prevMuted }}>{headerText.prev}</span>
</span>
</span>
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
value={title.draft}
disabled={title.saving}
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={closeTitleEditor}
/>
</Show>
</Show>
</div>
<Show when={props.sessionID()}>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)}
</Show>
</div>
</div>
</div>
</Show>
)
}

View File

@@ -250,6 +250,7 @@ export function TerminalPanel() {
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal
pty={pty()}
onConnect={() => terminal.trim(id)}
onCleanup={terminal.update}
onConnectError={() => terminal.clone(id)}
/>

View File

@@ -1,4 +1,5 @@
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { useLocation, useNavigate } from "@solidjs/router"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { messageIdFromHash } from "./message-id-from-hash"
@@ -15,7 +16,7 @@ export const useSessionHashScroll = (input: {
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: UserMessage | undefined) => void
setTurnStart: (value: number) => void
autoScroll: { pause: () => void; snapToBottom: () => void }
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
scheduleScrollState: (el: HTMLDivElement) => void
@@ -25,14 +26,40 @@ export const useSessionHashScroll = (input: {
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
let pendingKey = ""
let clearing = false
const location = useLocation()
const navigate = useNavigate()
const frames = new Set<number>()
const queue = (fn: () => void) => {
const id = requestAnimationFrame(() => {
frames.delete(id)
fn()
})
frames.add(id)
}
const cancel = () => {
for (const id of frames) cancelAnimationFrame(id)
frames.clear()
}
const clearMessageHash = () => {
if (!window.location.hash) return
window.history.replaceState(null, "", window.location.pathname + window.location.search)
cancel()
input.consumePendingMessage(input.sessionKey())
if (input.pendingMessage()) input.setPendingMessage(undefined)
if (!location.hash) return
clearing = true
navigate(location.pathname + location.search, { replace: true })
}
const updateHash = (id: string) => {
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`)
const hash = `#${input.anchor(id)}`
if (location.hash === hash) return
clearing = false
navigate(location.pathname + location.search + hash, {
replace: true,
})
}
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
@@ -41,65 +68,51 @@ export const useSessionHashScroll = (input: {
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
const inset = Number.isNaN(title) ? 0 : title
// With column-reverse, scrollTop is negative — don't clamp to 0
const top = a.top - b.top + root.scrollTop - inset
const sticky = root.querySelector("[data-session-title]")
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
root.scrollTo({ top, behavior })
return true
}
const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => {
const el = document.getElementById(input.anchor(id))
if (el) return scrollToElement(el, behavior)
if (left <= 0) return false
queue(() => {
seek(id, behavior, left - 1)
})
return false
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
cancel()
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
const index = messageIndex().get(message.id) ?? -1
if (index !== -1 && index < input.turnStart()) {
input.setTurnStart(index)
requestAnimationFrame(() => {
const el = document.getElementById(input.anchor(message.id))
if (!el) {
requestAnimationFrame(() => {
const next = document.getElementById(input.anchor(message.id))
if (!next) return
scrollToElement(next, behavior)
})
return
}
scrollToElement(el, behavior)
queue(() => {
seek(message.id, behavior)
})
updateHash(message.id)
return
}
const el = document.getElementById(input.anchor(message.id))
if (!el) {
updateHash(message.id)
requestAnimationFrame(() => {
const next = document.getElementById(input.anchor(message.id))
if (!next) return
if (!scrollToElement(next, behavior)) return
})
return
}
if (scrollToElement(el, behavior)) {
if (seek(message.id, behavior)) {
updateHash(message.id)
return
}
requestAnimationFrame(() => {
const next = document.getElementById(input.anchor(message.id))
if (!next) return
if (!scrollToElement(next, behavior)) return
})
updateHash(message.id)
}
const applyHash = (behavior: ScrollBehavior) => {
const hash = window.location.hash.slice(1)
const hash = location.hash.slice(1)
if (!hash) {
input.autoScroll.snapToBottom()
input.autoScroll.forceScrollToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
return
@@ -123,28 +136,17 @@ export const useSessionHashScroll = (input: {
return
}
input.autoScroll.snapToBottom()
input.autoScroll.forceScrollToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
}
onMount(() => {
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual"
}
const handler = () => {
if (!input.sessionID() || !input.messagesReady()) return
requestAnimationFrame(() => applyHash("auto"))
}
window.addEventListener("hashchange", handler)
onCleanup(() => window.removeEventListener("hashchange", handler))
})
createEffect(() => {
const hash = location.hash
if (!hash) clearing = false
if (!input.sessionID() || !input.messagesReady()) return
requestAnimationFrame(() => applyHash("auto"))
cancel()
queue(() => applyHash("auto"))
})
createEffect(() => {
@@ -166,17 +168,29 @@ export const useSessionHashScroll = (input: {
}
}
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
if (!targetId) return
if (input.currentMessageId() === targetId) return
const pending = input.pendingMessage() === targetId
const msg = messageById().get(targetId)
if (!msg) return
if (input.pendingMessage() === targetId) input.setPendingMessage(undefined)
if (pending) input.setPendingMessage(undefined)
if (input.currentMessageId() === targetId && !pending) return
input.autoScroll.pause()
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
cancel()
queue(() => scrollToMessage(msg, "auto"))
})
onMount(() => {
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual"
}
})
onCleanup(cancel)
return {
clearMessageHash,
scrollToMessage,

View File

@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, test } from "bun:test"
const src = await Bun.file(new URL("../public/oc-theme-preload.js", import.meta.url)).text()
const run = () => Function(src)()
beforeEach(() => {
document.head.innerHTML = ""
document.documentElement.removeAttribute("data-theme")
document.documentElement.removeAttribute("data-color-scheme")
localStorage.clear()
Object.defineProperty(window, "matchMedia", {
value: () =>
({
matches: false,
}) as MediaQueryList,
configurable: true,
})
})
describe("theme preload", () => {
test("migrates legacy oc-1 to oc-2 before mount", () => {
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;")
run()
expect(document.documentElement.dataset.theme).toBe("oc-2")
expect(document.documentElement.dataset.colorScheme).toBe("light")
expect(localStorage.getItem("opencode-theme-id")).toBe("oc-2")
expect(localStorage.getItem("opencode-theme-css-light")).toBeNull()
expect(localStorage.getItem("opencode-theme-css-dark")).toBeNull()
expect(document.getElementById("oc-theme-preload")).toBeNull()
})
test("keeps cached css for non-default themes", () => {
localStorage.setItem("opencode-theme-id", "nightowl")
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
run()
expect(document.documentElement.dataset.theme).toBe("nightowl")
expect(document.getElementById("oc-theme-preload")?.textContent).toContain("--background-base:#fff;")
})
})

View File

@@ -104,4 +104,12 @@ describe("persist localStorage resilience", () => {
const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}')
expect(result).toBeUndefined()
})
test("workspace storage sanitizes Windows filename characters", () => {
const result = persistTesting.workspaceStorage("C:\\Users\\foo")
expect(result).toStartWith("opencode.workspace.")
expect(result.endsWith(".dat")).toBeTrue()
expect(/[:\\/]/.test(result)).toBeFalse()
})
})

View File

@@ -204,7 +204,7 @@ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) =>
}
function workspaceStorage(dir: string) {
const head = dir.slice(0, 12) || "workspace"
const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(dir) ?? "0"
return `opencode.workspace.${head}.${sum}.dat`
}
@@ -300,6 +300,7 @@ export const PersistTesting = {
localStorageDirect,
localStorageWithPrefix,
normalize,
workspaceStorage,
}
export const Persist = {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.21",
"version": "1.2.24",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -480,7 +480,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(محذوف)",
"workspace.cost.empty": "لا توجد بيانات استخدام متاحة للفترة المحددة.",
"workspace.cost.subscriptionShort": "اشتراك",
"workspace.cost.liteShort": "lite",
"workspace.keys.title": "مفاتيح API",
"workspace.keys.subtitle": "إدارة مفاتيح API الخاصة بك للوصول إلى خدمات opencode.",

View File

@@ -488,7 +488,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(excluído)",
"workspace.cost.empty": "Nenhum dado de uso disponível para o período selecionado.",
"workspace.cost.subscriptionShort": "ass",
"workspace.cost.liteShort": "lite",
"workspace.keys.title": "Chaves de API",
"workspace.keys.subtitle": "Gerencie suas chaves de API para acessar os serviços opencode.",

View File

@@ -484,7 +484,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(slettet)",
"workspace.cost.empty": "Ingen brugsdata tilgængelige for den valgte periode.",
"workspace.cost.subscriptionShort": "sub",
"workspace.cost.liteShort": "lite",
"workspace.keys.title": "API-nøgler",
"workspace.keys.subtitle": "Administrer dine API-nøgler for at få adgang til opencode-tjenester.",

View File

@@ -487,7 +487,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(gelöscht)",
"workspace.cost.empty": "Keine Nutzungsdaten für den gewählten Zeitraum verfügbar.",
"workspace.cost.subscriptionShort": "Abo",
"workspace.cost.liteShort": "lite",
"workspace.keys.title": "API Keys",
"workspace.keys.subtitle": "Verwalte deine API Keys für den Zugriff auf OpenCode-Dienste.",

View File

@@ -480,7 +480,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(deleted)",
"workspace.cost.empty": "No usage data available for the selected period.",
"workspace.cost.subscriptionShort": "sub",
"workspace.cost.liteShort": "lite",
"workspace.keys.title": "API Keys",
"workspace.keys.subtitle": "Manage your API keys for accessing opencode services.",

View File

@@ -489,7 +489,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(eliminado)",
"workspace.cost.empty": "No hay datos de uso disponibles para el periodo seleccionado.",
"workspace.cost.subscriptionShort": "sub",
"workspace.cost.liteShort": "lite",
"workspace.keys.title": "Claves API",
"workspace.keys.subtitle": "Gestiona tus claves API para acceder a los servicios de opencode.",

View File

@@ -490,7 +490,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(supprimé)",
"workspace.cost.empty": "Aucune donnée d'utilisation disponible pour la période sélectionnée.",
"workspace.cost.subscriptionShort": "abo",
"workspace.cost.liteShort": "lite",
"workspace.keys.title": "Clés API",
"workspace.keys.subtitle": "Gérez vos clés API pour accéder aux services OpenCode.",

View File

@@ -487,7 +487,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(eliminato)",
"workspace.cost.empty": "Nessun dato di utilizzo disponibile per il periodo selezionato.",
"workspace.cost.subscriptionShort": "sub",
"workspace.cost.liteShort": "lite",
"workspace.keys.title": "Chiavi API",
"workspace.keys.subtitle": "Gestisci le tue chiavi API per accedere ai servizi opencode.",

View File

@@ -486,7 +486,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(削除済み)",
"workspace.cost.empty": "選択した期間の使用状況データはありません。",
"workspace.cost.subscriptionShort": "サブ",
"workspace.cost.liteShort": "lite",
"workspace.keys.title": "APIキー",
"workspace.keys.subtitle": "OpenCodeサービスにアクセスするためのAPIキーを管理します。",

View File

@@ -480,7 +480,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(삭제됨)",
"workspace.cost.empty": "선택한 기간에 사용 데이터가 없습니다.",
"workspace.cost.subscriptionShort": "구독",
"workspace.cost.liteShort": "lite",
"workspace.keys.title": "API 키",
"workspace.keys.subtitle": "OpenCode 서비스 액세스를 위한 API 키를 관리하세요.",

View File

@@ -485,7 +485,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(slettet)",
"workspace.cost.empty": "Ingen bruksdata tilgjengelig for den valgte perioden.",
"workspace.cost.subscriptionShort": "sub",
"workspace.cost.liteShort": "lite",
"workspace.keys.title": "API-nøkler",
"workspace.keys.subtitle": "Administrer API-nøklene dine for å få tilgang til opencode-tjenester.",

View File

@@ -486,7 +486,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(usunięte)",
"workspace.cost.empty": "Brak danych o użyciu dla wybranego okresu.",
"workspace.cost.subscriptionShort": "sub",
"workspace.cost.liteShort": "lite",
"workspace.keys.title": "Klucze API",
"workspace.keys.subtitle": "Zarządzaj kluczami API do usług opencode.",

View File

@@ -492,7 +492,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(удалено)",
"workspace.cost.empty": "Нет данных об использовании за выбранный период.",
"workspace.cost.subscriptionShort": "подписка",
"workspace.cost.liteShort": "lite",
"workspace.keys.title": "API Ключи",
"workspace.keys.subtitle": "Управляйте вашими API ключами для доступа к сервисам opencode.",

View File

@@ -483,7 +483,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(ลบแล้ว)",
"workspace.cost.empty": "ไม่มีข้อมูลการใช้งานในช่วงเวลาที่เลือก",
"workspace.cost.subscriptionShort": "sub",
"workspace.cost.liteShort": "lite",
"workspace.keys.title": "API Keys",
"workspace.keys.subtitle": "จัดการ API keys ของคุณสำหรับการเข้าถึงบริการ OpenCode",

View File

@@ -488,7 +488,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(silindi)",
"workspace.cost.empty": "Seçilen döneme ait kullanım verisi yok.",
"workspace.cost.subscriptionShort": "abonelik",
"workspace.cost.liteShort": "lite",
"workspace.keys.title": "API Anahtarları",
"workspace.keys.subtitle": "opencode hizmetlerine erişim için API anahtarlarınızı yönetin.",

View File

@@ -463,7 +463,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(已删除)",
"workspace.cost.empty": "所选期间无可用使用数据。",
"workspace.cost.subscriptionShort": "订阅",
"workspace.cost.liteShort": "lite",
"workspace.keys.title": "API 密钥",
"workspace.keys.subtitle": "管理访问 OpenCode 服务的 API 密钥。",

View File

@@ -464,7 +464,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(已刪除)",
"workspace.cost.empty": "所選期間沒有可用的使用資料。",
"workspace.cost.subscriptionShort": "訂",
"workspace.cost.liteShort": "lite",
"workspace.keys.title": "API 金鑰",
"workspace.keys.subtitle": "管理你的 API 金鑰以存取 OpenCode 服務。",

View File

@@ -218,7 +218,7 @@ export function GraphSection() {
const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim()
const colorBorder = styles.getPropertyValue("--color-border").trim()
const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})`
const liteSuffix = ` (${i18n.t("workspace.cost.liteShort")})`
const liteSuffix = " (go)"
const dailyDataRegular = new Map<string, Map<string, number>>()
const dailyDataSub = new Map<string, Map<string, number>>()

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.2.21",
"version": "1.2.24",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.21",
"version": "1.2.24",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.2.21",
"version": "1.2.24",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.2.21",
"version": "1.2.24",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.2.21",
"version": "1.2.24",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.21",
"version": "1.2.24",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.2.21"
version = "1.2.24"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.21",
"version": "1.2.24",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -0,0 +1,17 @@
CREATE TABLE `account` (
`id` text PRIMARY KEY,
`email` text NOT NULL,
`url` text NOT NULL,
`access_token` text NOT NULL,
`refresh_token` text NOT NULL,
`token_expiry` integer,
`selected_org_id` text,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `account_state` (
`id` integer PRIMARY KEY NOT NULL,
`active_account_id` text,
FOREIGN KEY (`active_account_id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE set null
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
ALTER TABLE `account_state` ADD `active_org_id` text;--> statement-breakpoint
UPDATE `account_state` SET `active_org_id` = (SELECT `selected_org_id` FROM `account` WHERE `account`.`id` = `account_state`.`active_account_id`);--> statement-breakpoint
ALTER TABLE `account` DROP COLUMN `selected_org_id`;

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.21",
"version": "1.2.24",
"name": "opencode",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000",
"test": "bun test --timeout 30000 registry",
"build": "bun run script/build.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
@@ -25,8 +25,16 @@
"exports": {
"./*": "./src/*.ts"
},
"imports": {
"#db": {
"bun": "./src/storage/db.bun.ts",
"node": "./src/storage/db.node.ts",
"default": "./src/storage/db.bun.ts"
}
},
"devDependencies": {
"@babel/core": "7.28.4",
"@effect/language-service": "0.79.0",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
@@ -41,12 +49,13 @@
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"drizzle-kit": "catalog:",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -80,8 +89,11 @@
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
"@npmcli/arborist": "9.4.0",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -90,8 +102,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.86",
"@opentui/solid": "0.1.86",
"@opentui/core": "0.1.87",
"@opentui/solid": "0.1.87",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -106,7 +118,8 @@
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
@@ -121,9 +134,11 @@
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
"semver": "^7.6.3",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bun
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
process.chdir(dir)
// Load migrations from migration directories
const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), {
withFileTypes: true,
})
)
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
.map((entry) => entry.name)
.sort()
const migrations = await Promise.all(
migrationDirs.map(async (name) => {
const file = path.join(dir, "migration", name, "migration.sql")
const sql = await Bun.file(file).text()
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
const timestamp = match
? Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6]),
)
: 0
return { sql, timestamp, name }
}),
)
console.log(`Loaded ${migrations.length} migrations`)
await Bun.build({
target: "node",
entrypoints: ["./src/node.ts"],
outdir: "./dist",
format: "esm",
external: ["jsonc-parser"],
define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
},
})
console.log("Build complete")

View File

@@ -0,0 +1,35 @@
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../storage/schema.sql"
export const AccountTable = sqliteTable("account", {
id: text().primaryKey(),
email: text().notNull(),
url: text().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
...Timestamps,
})
export const AccountStateTable = sqliteTable("account_state", {
id: integer().primaryKey(),
active_account_id: text().references(() => AccountTable.id, { onDelete: "set null" }),
active_org_id: text(),
})
// LEGACY
export const ControlAccountTable = sqliteTable(
"control_account",
{
email: text().notNull(),
url: text().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
.$default(() => false),
...Timestamps,
},
(table) => [primaryKey({ columns: [table.email, table.url] })],
)

View File

@@ -0,0 +1,43 @@
import { Effect, Option, ServiceMap } from "effect"
import {
Account as AccountSchema,
type AccountError,
type AccessToken,
AccountID,
AccountService,
OrgID,
} from "./service"
export { AccessToken, AccountID, OrgID } from "./service"
import { runtime } from "@/effect/runtime"
type AccountServiceShape = ServiceMap.Service.Shape<typeof AccountService>
function runSync<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
return runtime.runSync(AccountService.use(f))
}
function runPromise<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
return runtime.runPromise(AccountService.use(f))
}
export namespace Account {
export const Account = AccountSchema
export type Account = AccountSchema
export function active(): Account | undefined {
return Option.getOrUndefined(runSync((service) => service.active()))
}
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
const config = await runPromise((service) => service.config(accountID, orgID))
return Option.getOrUndefined(config)
}
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
const token = await runPromise((service) => service.token(accountID))
return Option.getOrUndefined(token)
}
}

View File

@@ -0,0 +1,138 @@
import { eq } from "drizzle-orm"
import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
import { Database } from "@/storage/db"
import { AccountStateTable, AccountTable } from "./account.sql"
import { Account, AccountID, AccountRepoError, OrgID } from "./schema"
export type AccountRow = (typeof AccountTable)["$inferSelect"]
const decodeAccount = Schema.decodeUnknownSync(Account)
type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
const ACCOUNT_STATE_ID = 1
const db = <A>(run: (db: DbClient) => A) =>
Effect.try({
try: () => Database.use(run),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const current = (db: DbClient) => {
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
if (!state?.active_account_id) return
const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
if (!account) return
return { ...account, active_org_id: state.active_org_id ?? null }
}
const setState = (db: DbClient, accountID: AccountID, orgID: string | null) =>
db
.insert(AccountStateTable)
.values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: orgID })
.onConflictDoUpdate({
target: AccountStateTable.id,
set: { active_account_id: accountID, active_org_id: orgID },
})
.run()
export class AccountRepo extends ServiceMap.Service<
AccountRepo,
{
readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError>
readonly list: () => Effect.Effect<Account[], AccountRepoError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
readonly persistToken: (input: {
accountID: AccountID
accessToken: string
refreshToken: string
expiry: Option.Option<number>
}) => Effect.Effect<void, AccountRepoError>
readonly persistAccount: (input: {
id: AccountID
email: string
url: string
accessToken: string
refreshToken: string
expiry: number
orgID: Option.Option<OrgID>
}) => Effect.Effect<void, AccountRepoError>
}
>()("@opencode/AccountRepo") {
static readonly layer: Layer.Layer<AccountRepo> = Layer.succeed(
AccountRepo,
AccountRepo.of({
active: Effect.fn("AccountRepo.active")(() =>
db((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decodeAccount(row)) : Option.none()))),
),
list: Effect.fn("AccountRepo.list")(() => db((db) => db.select().from(AccountTable).all().map((row) => decodeAccount({ ...row, active_org_id: null })))),
remove: Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
db((db) =>
Database.transaction((tx) => {
tx.update(AccountStateTable)
.set({ active_account_id: null, active_org_id: null })
.where(eq(AccountStateTable.active_account_id, accountID))
.run()
tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
}),
).pipe(Effect.asVoid),
),
use: Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
db((db) => setState(db, accountID, Option.getOrNull(orgID))).pipe(Effect.asVoid),
),
getRow: Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
db((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
Effect.map(Option.fromNullishOr),
),
),
persistToken: Effect.fn("AccountRepo.persistToken")((input) =>
db((db) =>
db
.update(AccountTable)
.set({
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: Option.getOrNull(input.expiry),
})
.where(eq(AccountTable.id, input.accountID))
.run(),
).pipe(Effect.asVoid),
),
persistAccount: Effect.fn("AccountRepo.persistAccount")((input) => {
const orgID = Option.getOrNull(input.orgID)
return db((db) =>
Database.transaction((tx) => {
tx.insert(AccountTable)
.values({
id: input.id,
email: input.email,
url: input.url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
})
.onConflictDoUpdate({
target: AccountTable.id,
set: {
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
},
})
.run()
setState(tx, input.id, orgID)
}),
).pipe(Effect.asVoid)
}),
}),
)
}

View File

@@ -0,0 +1,73 @@
import { Schema } from "effect"
import { withStatics } from "@/util/schema"
export const AccountID = Schema.String.pipe(
Schema.brand("AccountId"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export type AccountID = Schema.Schema.Type<typeof AccountID>
export const OrgID = Schema.String.pipe(
Schema.brand("OrgId"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export type OrgID = Schema.Schema.Type<typeof OrgID>
export const AccessToken = Schema.String.pipe(
Schema.brand("AccessToken"),
withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
)
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
export class Account extends Schema.Class<Account>("Account")({
id: AccountID,
email: Schema.String,
url: Schema.String,
active_org_id: Schema.NullOr(OrgID),
}) {}
export class Org extends Schema.Class<Org>("Org")({
id: OrgID,
name: Schema.String,
}) {}
export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export type AccountError = AccountRepoError | AccountServiceError
export class Login extends Schema.Class<Login>("Login")({
code: Schema.String,
user: Schema.String,
url: Schema.String,
server: Schema.String,
expiry: Schema.Number,
interval: Schema.Number,
}) {}
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
email: Schema.String,
}) {}
export class PollPending extends Schema.TaggedClass<PollPending>()("PollPending", {}) {}
export class PollSlow extends Schema.TaggedClass<PollSlow>()("PollSlow", {}) {}
export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired", {}) {}
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
cause: Schema.Defect,
}) {}
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
export type PollResult = Schema.Schema.Type<typeof PollResult>

View File

@@ -0,0 +1,384 @@
import { Clock, Effect, Layer, Option, Schema, ServiceMap } from "effect"
import {
FetchHttpClient,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import {
type AccountError,
AccessToken,
Account,
AccountID,
AccountServiceError,
Login,
Org,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
} from "./schema"
export * from "./schema"
export type AccountOrgs = {
account: Account
orgs: Org[]
}
const RemoteOrg = Schema.Struct({
id: Schema.optional(OrgID),
name: Schema.optional(Schema.String),
})
const RemoteOrgs = Schema.Array(RemoteOrg)
const RemoteConfig = Schema.Struct({
config: Schema.Record(Schema.String, Schema.Json),
})
const TokenRefresh = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
})
const DeviceCode = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
verification_uri_complete: Schema.String,
expires_in: Schema.Number,
interval: Schema.Number,
})
const DeviceToken = Schema.Struct({
access_token: Schema.optional(Schema.String),
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
error: Schema.optional(Schema.String),
error_description: Schema.optional(Schema.String),
})
const User = Schema.Struct({
id: Schema.optional(AccountID),
email: Schema.optional(Schema.String),
})
const ClientId = Schema.Struct({ client_id: Schema.String })
const DeviceTokenRequest = Schema.Struct({
grant_type: Schema.String,
device_code: Schema.String,
client_id: Schema.String,
})
const clientId = "opencode-cli"
const toAccountServiceError = (message: string, cause?: unknown) => new AccountServiceError({ message, cause })
const mapAccountServiceError =
(operation: string, message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
effect.pipe(
Effect.mapError((error) =>
error instanceof AccountServiceError ? error : toAccountServiceError(`${message} (${operation})`, error),
),
)
export class AccountService extends ServiceMap.Service<
AccountService,
{
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
readonly list: () => Effect.Effect<Account[], AccountError>
readonly orgsByAccount: () => Effect.Effect<AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
>()("@opencode/Account") {
static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
AccountService,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const execute = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
http.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
const executeRead = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
const executeEffect = <E>(operation: string, request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError(operation, "HTTP request failed"),
)
const okOrNone = (operation: string, response: HttpClientResponse.HttpClientResponse) =>
HttpClientResponse.filterStatusOk(response).pipe(
Effect.map(Option.some),
Effect.catch((error) =>
HttpClientError.isHttpClientError(error) && error.reason._tag === "StatusCodeError"
? Effect.succeed(Option.none<HttpClientResponse.HttpClientResponse>())
: Effect.fail(error),
),
mapAccountServiceError(operation),
)
const tokenForRow = Effect.fn("AccountService.tokenForRow")(function* (found: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (found.token_expiry && found.token_expiry > now) return Option.some(AccessToken.make(found.access_token))
const response = yield* execute(
"token.refresh",
HttpClientRequest.post(`${found.url}/oauth/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bodyUrlParams({
grant_type: "refresh_token",
refresh_token: found.refresh_token,
}),
),
)
const ok = yield* okOrNone("token.refresh", response)
if (Option.isNone(ok)) return Option.none()
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(ok.value).pipe(
mapAccountServiceError("token.refresh", "Failed to decode response"),
)
const expiry = Option.fromNullishOr(parsed.expires_in).pipe(Option.map((e) => now + e * 1000))
yield* repo.persistToken({
accountID: AccountID.make(found.id),
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token ?? found.refresh_token,
expiry,
})
return Option.some(AccessToken.make(parsed.access_token))
})
const resolveAccess = Effect.fn("AccountService.resolveAccess")(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
const account = maybeAccount.value
const accessToken = yield* tokenForRow(account)
if (Option.isNone(accessToken)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
return Option.some({ account, accessToken: accessToken.value })
})
const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
const accounts = yield* repo.list()
return yield* Effect.forEach(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
})
const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
const response = yield* executeRead(
"orgs",
HttpClientRequest.get(`${account.url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
const ok = yield* okOrNone("orgs", response)
if (Option.isNone(ok)) return []
const orgs = yield* HttpClientResponse.schemaBodyJson(RemoteOrgs)(ok.value).pipe(
mapAccountServiceError("orgs", "Failed to decode response"),
)
return orgs
.filter((org) => org.id !== undefined && org.name !== undefined)
.map((org) => new Org({ id: org.id!, name: org.name! }))
})
const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
"config",
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
const ok = yield* okOrNone("config", response)
if (Option.isNone(ok)) return Option.none()
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok.value).pipe(
mapAccountServiceError("config", "Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("AccountService.login")(function* (server: string) {
const response = yield* executeEffect(
"login",
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)({ client_id: clientId }),
),
)
const ok = yield* okOrNone("login", response)
if (Option.isNone(ok)) {
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
return yield* toAccountServiceError(`Failed to initiate device flow: ${body || response.status}`)
}
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceCode)(ok.value).pipe(
mapAccountServiceError("login", "Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`,
server,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
const response = yield* executeEffect(
"poll",
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("poll", "Failed to decode response"),
)
if (!parsed.access_token) {
if (parsed.error === "authorization_pending") return new PollPending()
if (parsed.error === "slow_down") return new PollSlow()
if (parsed.error === "expired_token") return new PollExpired()
if (parsed.error === "access_denied") return new PollDenied()
return new PollError({ cause: parsed.error })
}
const access = parsed.access_token
const fetchUser = executeRead(
"poll.user",
HttpClientRequest.get(`${input.server}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(access),
),
).pipe(
Effect.flatMap((r) =>
HttpClientResponse.schemaBodyJson(User)(r).pipe(
mapAccountServiceError("poll.user", "Failed to decode response"),
),
),
)
const fetchOrgs = executeRead(
"poll.orgs",
HttpClientRequest.get(`${input.server}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(access),
),
).pipe(
Effect.flatMap((r) =>
HttpClientResponse.schemaBodyJson(RemoteOrgs)(r).pipe(
mapAccountServiceError("poll.orgs", "Failed to decode response"),
),
),
)
const [user, remoteOrgs] = yield* Effect.all([fetchUser, fetchOrgs], { concurrency: 2 })
const userId = user.id
const userEmail = user.email
if (!userId || !userEmail) {
return new PollError({ cause: "No id or email in response" })
}
const firstOrgID = remoteOrgs.length > 0 ? Option.fromNullishOr(remoteOrgs[0].id) : Option.none()
const now = yield* Clock.currentTimeMillis
const expiry = now + (parsed.expires_in ?? 0) * 1000
const refresh = parsed.refresh_token ?? ""
if (!refresh) {
yield* Effect.logWarning("Server did not return a refresh token — session may expire without ability to refresh")
}
yield* repo.persistAccount({
id: userId,
email: userEmail,
url: input.server,
accessToken: access,
refreshToken: refresh,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: userEmail })
})
return AccountService.of({
active: repo.active,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
static readonly defaultLayer = AccountService.layer.pipe(
Layer.provide(AccountRepo.layer),
Layer.provide(FetchHttpClient.layer),
)
}

View File

@@ -29,7 +29,7 @@ import {
} from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
import { pathToFileURL } from "bun"
import { pathToFileURL } from "url"
import { Filesystem } from "../util/filesystem"
import { Hash } from "../util/hash"
import { ACPSessionManager } from "./session"

View File

@@ -63,6 +63,7 @@ export namespace Agent {
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
edit: "ask",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",

View File

@@ -1,13 +1,5 @@
import z from "zod"
import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error"
import { text } from "node:stream/consumers"
import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
import { proxied } from "@/util/proxied"
import { Process } from "../util/process"
export namespace BunProc {
@@ -45,87 +37,4 @@ export namespace BunProc {
export function which() {
return process.execPath
}
export const InstallFailedError = NamedError.create(
"BunInstallFailedError",
z.object({
pkg: z.string(),
version: z.string(),
}),
)
export async function install(pkg: string, version = "latest") {
// Use lock to ensure only one install at a time
using _ = await Lock.write("bun-install")
const mod = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
const result = { dependencies: {} as Record<string, string> }
await Filesystem.writeJson(pkgjsonPath, result)
return result
})
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
const dependencies = parsed.dependencies
const modExists = await Filesystem.exists(mod)
const cachedVersion = dependencies[pkg]
if (!modExists || !cachedVersion) {
// continue to install
} else if (version !== "latest" && cachedVersion === version) {
return mod
} else if (version === "latest") {
const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
if (!isOutdated) return mod
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
}
// Build command arguments
const args = [
"add",
"--force",
"--exact",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
"--cwd",
Global.Path.cache,
pkg + "@" + version,
]
// Let Bun handle registry resolution:
// - If .npmrc files exist, Bun will use them automatically
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
// - No need to pass --registry flag
log.info("installing package using Bun's default registry resolution", {
pkg,
version,
})
await BunProc.run(args, {
cwd: Global.Path.cache,
}).catch((e) => {
throw new InstallFailedError(
{ pkg, version },
{
cause: e,
},
)
})
// Resolve actual version from installed package when using "latest"
// This ensures subsequent starts use the cached version until explicitly updated
let resolvedVersion = version
if (version === "latest") {
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
() => null,
)
if (installedPkg?.version) {
resolvedVersion = installedPkg.version
}
}
parsed.dependencies[pkg] = resolvedVersion
await Filesystem.writeJson(pkgjsonPath, parsed)
return mod
}
}

View File

@@ -1,4 +1,3 @@
import { semver } from "bun"
import { text } from "node:stream/consumers"
import { Log } from "../util/log"
import { Process } from "../util/process"
@@ -34,17 +33,4 @@ export namespace PackageRegistry {
if (!value) return null
return value
}
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
const latestVersion = await info(pkg, "version", cwd)
if (!latestVersion) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.order(cachedVersion, latestVersion) === -1
}
}

View File

@@ -0,0 +1,196 @@
import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui"
import { runtime } from "@/effect/runtime"
import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service"
import { type AccountError } from "@/account/schema"
import * as Prompt from "../effect/prompt"
import open from "open"
const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => undefined))
const println = (msg: string) => Effect.sync(() => UI.println(msg))
const loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* AccountService
yield* Prompt.intro("Log in")
const login = yield* service.login(url)
yield* Prompt.log.info("Go to: " + login.url)
yield* Prompt.log.info("Enter code: " + login.user)
yield* openBrowser(login.url)
const s = Prompt.spinner()
yield* s.start("Waiting for authorization...")
const poll = (wait: number): Effect.Effect<PollResult, AccountError> =>
Effect.gen(function* () {
yield* Effect.sleep(wait)
const result = yield* service.poll(login)
if (result._tag === "PollPending") return yield* poll(wait)
if (result._tag === "PollSlow") return yield* poll(wait + 5000)
return result
})
const result = yield* poll(login.interval * 1000).pipe(
Effect.timeout(Duration.seconds(login.expiry)),
Effect.catchTag("TimeoutError", () => Effect.succeed(new PollExpired())),
)
yield* Match.valueTags(result, {
PollSuccess: (r) =>
Effect.gen(function* () {
yield* s.stop("Logged in as " + r.email)
yield* Prompt.outro("Done")
}),
PollExpired: () => s.stop("Device code expired", 1),
PollDenied: () => s.stop("Authorization denied", 1),
PollError: (r) => s.stop("Error: " + String(r.cause), 1),
PollPending: () => s.stop("Unexpected state", 1),
PollSlow: () => s.stop("Unexpected state", 1),
})
})
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
const service = yield* AccountService
const accounts = yield* service.list()
if (accounts.length === 0) return yield* println("Not logged in")
if (email) {
const match = accounts.find((a) => a.email === email)
if (!match) return yield* println("Account not found: " + email)
yield* service.remove(match.id)
yield* Prompt.outro("Logged out from " + email)
return
}
const active = yield* service.active()
const activeID = Option.map(active, (a) => a.id)
yield* Prompt.intro("Log out")
const opts = accounts.map((a) => {
const isActive = Option.isSome(activeID) && activeID.value === a.id
const server = UI.Style.TEXT_DIM + a.url + UI.Style.TEXT_NORMAL
return {
value: a,
label: isActive
? `${a.email} ${server}` + UI.Style.TEXT_DIM + " (active)"
: `${a.email} ${server}`,
}
})
const selected = yield* Prompt.select({ message: "Select account to log out", options: opts })
if (Option.isNone(selected)) return
yield* service.remove(selected.value.id)
yield* Prompt.outro("Logged out from " + selected.value.email)
})
interface OrgChoice {
orgID: OrgID
accountID: AccountID
label: string
}
const switchEffect = Effect.fn("switch")(function* () {
const service = yield* AccountService
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("Not logged in")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
const opts = groups.flatMap((group) =>
group.orgs.map((org) => {
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
return {
value: { orgID: org.id, accountID: group.account.id, label: org.name },
label: isActive
? `${org.name} (${group.account.email})` + UI.Style.TEXT_DIM + " (active)"
: `${org.name} (${group.account.email})`,
}
}),
)
if (opts.length === 0) return yield* println("No orgs found")
yield* Prompt.intro("Switch org")
const selected = yield* Prompt.select<OrgChoice>({ message: "Select org", options: opts })
if (Option.isNone(selected)) return
const choice = selected.value
yield* service.use(choice.accountID, Option.some(choice.orgID))
yield* Prompt.outro("Switched to " + choice.label)
})
const orgsEffect = Effect.fn("orgs")(function* () {
const service = yield* AccountService
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("No accounts found")
if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
for (const group of groups) {
for (const org of group.orgs) {
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL
const id = UI.Style.TEXT_DIM + org.id + UI.Style.TEXT_NORMAL
yield* println(` ${dot} ${name} ${email} ${id}`)
}
}
})
export const LoginCommand = cmd({
command: "login <url>",
describe: false,
builder: (yargs) =>
yargs.positional("url", {
describe: "server URL",
type: "string",
demandOption: true,
}),
async handler(args) {
UI.empty()
await runtime.runPromise(loginEffect(args.url))
},
})
export const LogoutCommand = cmd({
command: "logout [email]",
describe: false,
builder: (yargs) =>
yargs.positional("email", {
describe: "account email to log out from",
type: "string",
}),
async handler(args) {
UI.empty()
await runtime.runPromise(logoutEffect(args.email))
},
})
export const SwitchCommand = cmd({
command: "switch",
describe: false,
async handler() {
UI.empty()
await runtime.runPromise(switchEffect())
},
})
export const OrgsCommand = cmd({
command: "orgs",
describe: false,
async handler() {
UI.empty()
await runtime.runPromise(orgsEffect())
},
})

View File

@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
process.env.OPENCODE_CLIENT = "acp"
await bootstrap(process.cwd(), async () => {
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const server = await Server.listen(opts)
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,

View File

@@ -27,8 +27,9 @@ import { Provider } from "../../provider/provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { $ } from "bun"
import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process"
import { git } from "@/util/git"
type GitHubAuthor = {
login: string
@@ -255,7 +256,7 @@ export const GithubInstallCommand = cmd({
}
// Get repo info
const info = (await $`git remote get-url origin`.quiet().nothrow().text()).trim()
const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
const parsed = parseGitHubRemote(info)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -493,6 +494,26 @@ export const GithubRunCommand = cmd({
? "pr_review"
: "issue"
: undefined
const gitText = async (args: string[]) => {
const result = await git(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result.text().trim()
}
const gitRun = async (args: string[]) => {
const result = await git(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result
}
const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree })
const commitChanges = async (summary: string, actor?: string) => {
const args = ["commit", "-m", summary]
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
await gitRun(args)
}
try {
if (useGithubToken) {
@@ -553,7 +574,7 @@ export const GithubRunCommand = cmd({
}
const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule"
const branch = await checkoutNewBranch(branchPrefix)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
const response = await chat(userPrompt, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
if (switched) {
@@ -587,7 +608,7 @@ export const GithubRunCommand = cmd({
// Local PR
if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
await checkoutLocalBranch(prData)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, prData.headRefName)
@@ -605,7 +626,7 @@ export const GithubRunCommand = cmd({
// Fork PR
else {
const forkBranch = await checkoutForkBranch(prData)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, forkBranch)
@@ -624,7 +645,7 @@ export const GithubRunCommand = cmd({
// Issue
else {
const branch = await checkoutNewBranch("issue")
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
const issueData = await fetchIssue()
const dataPrompt = buildPromptDataForIssue(issueData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
@@ -658,7 +679,7 @@ export const GithubRunCommand = cmd({
exitCode = 1
console.error(e instanceof Error ? e.message : String(e))
let msg = e
if (e instanceof $.ShellError) {
if (e instanceof Process.RunFailedError) {
msg = e.stderr.toString()
} else if (e instanceof Error) {
msg = e.message
@@ -1049,29 +1070,29 @@ export const GithubRunCommand = cmd({
const config = "http.https://github.com/.extraheader"
// actions/checkout@v6 no longer stores credentials in .git/config,
// so this may not exist - use nothrow() to handle gracefully
const ret = await $`git config --local --get ${config}`.nothrow()
const ret = await gitStatus(["config", "--local", "--get", config])
if (ret.exitCode === 0) {
gitConfig = ret.stdout.toString().trim()
await $`git config --local --unset-all ${config}`
await gitRun(["config", "--local", "--unset-all", config])
}
const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64")
await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"`
await $`git config --global user.name "${AGENT_USERNAME}"`
await $`git config --global user.email "${AGENT_USERNAME}@users.noreply.github.com"`
await gitRun(["config", "--local", config, `AUTHORIZATION: basic ${newCredentials}`])
await gitRun(["config", "--global", "user.name", AGENT_USERNAME])
await gitRun(["config", "--global", "user.email", `${AGENT_USERNAME}@users.noreply.github.com`])
}
async function restoreGitConfig() {
if (gitConfig === undefined) return
const config = "http.https://github.com/.extraheader"
await $`git config --local ${config} "${gitConfig}"`
await gitRun(["config", "--local", config, gitConfig])
}
async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") {
console.log("Checking out new branch...")
const branch = generateBranchName(type)
await $`git checkout -b ${branch}`
await gitRun(["checkout", "-b", branch])
return branch
}
@@ -1081,8 +1102,8 @@ export const GithubRunCommand = cmd({
const branch = pr.headRefName
const depth = Math.max(pr.commits.totalCount, 20)
await $`git fetch origin --depth=${depth} ${branch}`
await $`git checkout ${branch}`
await gitRun(["fetch", "origin", `--depth=${depth}`, branch])
await gitRun(["checkout", branch])
}
async function checkoutForkBranch(pr: GitHubPullRequest) {
@@ -1092,9 +1113,9 @@ export const GithubRunCommand = cmd({
const localBranch = generateBranchName("pr")
const depth = Math.max(pr.commits.totalCount, 20)
await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git`
await $`git fetch fork --depth=${depth} ${remoteBranch}`
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
await gitRun(["remote", "add", "fork", `https://github.com/${pr.headRepository.nameWithOwner}.git`])
await gitRun(["fetch", "fork", `--depth=${depth}`, remoteBranch])
await gitRun(["checkout", "-b", localBranch, `fork/${remoteBranch}`])
return localBranch
}
@@ -1115,28 +1136,23 @@ export const GithubRunCommand = cmd({
async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) {
console.log("Pushing to new branch...")
if (commit) {
await $`git add .`
await gitRun(["add", "."])
if (isSchedule) {
// No co-author for scheduled events - the schedule is operating as the repo
await $`git commit -m "${summary}"`
await commitChanges(summary)
} else {
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await commitChanges(summary, actor)
}
}
await $`git push -u origin ${branch}`
await gitRun(["push", "-u", "origin", branch])
}
async function pushToLocalBranch(summary: string, commit: boolean) {
console.log("Pushing to local branch...")
if (commit) {
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await gitRun(["add", "."])
await commitChanges(summary, actor)
}
await $`git push`
await gitRun(["push"])
}
async function pushToForkBranch(summary: string, pr: GitHubPullRequest, commit: boolean) {
@@ -1145,30 +1161,28 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
const remoteBranch = pr.headRefName
if (commit) {
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await gitRun(["add", "."])
await commitChanges(summary, actor)
}
await $`git push fork HEAD:${remoteBranch}`
await gitRun(["push", "fork", `HEAD:${remoteBranch}`])
}
async function branchIsDirty(originalHead: string, expectedBranch: string) {
console.log("Checking if branch is dirty...")
// Detect if the agent switched branches during chat (e.g. created
// its own branch, committed, and possibly pushed/created a PR).
const current = (await $`git rev-parse --abbrev-ref HEAD`).stdout.toString().trim()
const current = await gitText(["rev-parse", "--abbrev-ref", "HEAD"])
if (current !== expectedBranch) {
console.log(`Branch changed during chat: expected ${expectedBranch}, now on ${current}`)
return { dirty: true, uncommittedChanges: false, switched: true }
}
const ret = await $`git status --porcelain`
const ret = await gitStatus(["status", "--porcelain"])
const status = ret.stdout.toString().trim()
if (status.length > 0) {
return { dirty: true, uncommittedChanges: true, switched: false }
}
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
return {
dirty: head !== originalHead,
uncommittedChanges: false,
@@ -1180,11 +1194,11 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
// Falls back to fetching from origin when local refs are missing
// (common in shallow clones from actions/checkout).
async function hasNewCommits(base: string, head: string) {
const result = await $`git rev-list --count ${base}..${head}`.nothrow()
const result = await gitStatus(["rev-list", "--count", `${base}..${head}`])
if (result.exitCode !== 0) {
console.log(`rev-list failed, fetching origin/${base}...`)
await $`git fetch origin ${base} --depth=1`.nothrow()
const retry = await $`git rev-list --count origin/${base}..${head}`.nothrow()
await gitStatus(["fetch", "origin", base, "--depth=1"])
const retry = await gitStatus(["rev-list", "--count", `origin/${base}..${head}`])
if (retry.exitCode !== 0) return true // assume dirty if we can't tell
return parseInt(retry.stdout.toString().trim()) > 0
}

View File

@@ -10,7 +10,7 @@ import { ShareNext } from "../../share/share-next"
import { EOL } from "os"
import { Filesystem } from "../../util/filesystem"
/** Discriminated union returned by the ShareNext API (GET /api/share/:id/data) */
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
export type ShareData =
| { type: "session"; data: SDKSession }
| { type: "message"; data: Message }
@@ -24,6 +24,14 @@ export function parseShareUrl(url: string): string | null {
return match ? match[1] : null
}
export function shouldAttachShareAuthHeaders(shareUrl: string, accountBaseUrl: string): boolean {
try {
return new URL(shareUrl).origin === new URL(accountBaseUrl).origin
} catch {
return false
}
}
/**
* Transform ShareNext API response (flat array) into the nested structure for local file storage.
*
@@ -97,8 +105,21 @@ export const ImportCommand = cmd({
return
}
const baseUrl = await ShareNext.url()
const response = await fetch(`${baseUrl}/api/share/${slug}/data`)
const parsed = new URL(args.file)
const baseUrl = parsed.origin
const req = await ShareNext.request()
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
const dataPath = req.api.data(slug)
let response = await fetch(`${baseUrl}${dataPath}`, {
headers,
})
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
response = await fetch(`${baseUrl}/api/share/${slug}/data`, {
headers,
})
}
if (!response.ok) {
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)

View File

@@ -1,7 +1,8 @@
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Instance } from "@/project/instance"
import { $ } from "bun"
import { Process } from "@/util/process"
import { git } from "@/util/git"
export const PrCommand = cmd({
command: "pr <number>",
@@ -27,21 +28,35 @@ export const PrCommand = cmd({
UI.println(`Fetching and checking out PR #${prNumber}...`)
// Use gh pr checkout with custom branch name
const result = await $`gh pr checkout ${prNumber} --branch ${localBranchName} --force`.nothrow()
const result = await Process.run(
["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"],
{
nothrow: true,
},
)
if (result.exitCode !== 0) {
if (result.code !== 0) {
UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
process.exit(1)
}
// Fetch PR info for fork handling and session link detection
const prInfoResult =
await $`gh pr view ${prNumber} --json headRepository,headRepositoryOwner,isCrossRepository,headRefName,body`.nothrow()
const prInfoResult = await Process.text(
[
"gh",
"pr",
"view",
`${prNumber}`,
"--json",
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
],
{ nothrow: true },
)
let sessionId: string | undefined
if (prInfoResult.exitCode === 0) {
const prInfoText = prInfoResult.text()
if (prInfoResult.code === 0) {
const prInfoText = prInfoResult.text
if (prInfoText.trim()) {
const prInfo = JSON.parse(prInfoText)
@@ -52,15 +67,19 @@ export const PrCommand = cmd({
const remoteName = forkOwner
// Check if remote already exists
const remotes = (await $`git remote`.nothrow().text()).trim()
const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
if (!remotes.split("\n").includes(remoteName)) {
await $`git remote add ${remoteName} https://github.com/${forkOwner}/${forkName}.git`.nothrow()
await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: Instance.worktree,
})
UI.println(`Added fork remote: ${remoteName}`)
}
// Set upstream to the fork so pushes go there
const headRefName = prInfo.headRefName
await $`git branch --set-upstream-to=${remoteName}/${headRefName} ${localBranchName}`.nothrow()
await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
cwd: Instance.worktree,
})
}
// Check for opencode session link in PR body
@@ -71,9 +90,11 @@ export const PrCommand = cmd({
UI.println(`Found opencode session: ${sessionUrl}`)
UI.println(`Importing session...`)
const importResult = await $`opencode import ${sessionUrl}`.nothrow()
if (importResult.exitCode === 0) {
const importOutput = importResult.text().trim()
const importResult = await Process.text(["opencode", "import", sessionUrl], {
nothrow: true,
})
if (importResult.code === 0) {
const importOutput = importResult.text.trim()
// Extract session ID from the output (format: "Imported session: <session-id>")
const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/)
if (sessionIdMatch) {

View File

@@ -13,14 +13,9 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
import { setTimeout as sleep } from "node:timers/promises"
type PluginAuth = NonNullable<Hooks["auth"]>
/**
* Handle plugin-based authentication flow.
* Returns true if auth was handled, false if it should fall through to default handling.
*/
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
let index = 0
if (methodName) {
@@ -33,7 +28,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}
index = match
} else if (plugin.auth.methods.length > 1) {
const selected = await prompts.select({
const method = await prompts.select({
message: "Login method",
options: [
...plugin.auth.methods.map((x, index) => ({
@@ -42,13 +37,12 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
})),
],
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
index = parseInt(selected)
if (prompts.isCancel(method)) throw new UI.CancelledError()
index = parseInt(method)
}
const method = plugin.auth.methods[index]
// Handle prompts for all auth types
await sleep(10)
await new Promise((r) => setTimeout(r, 10))
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {
@@ -171,11 +165,6 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
return false
}
/**
* Build a deduplicated list of plugin-registered auth providers that are not
* already present in models.dev, respecting enabled/disabled provider lists.
* Pure function with no side effects; safe to test without mocking.
*/
export function resolvePluginProviders(input: {
hooks: Hooks[]
existingProviders: Record<string, unknown>
@@ -203,19 +192,20 @@ export function resolvePluginProviders(input: {
return result
}
export const AuthCommand = cmd({
command: "auth",
describe: "manage credentials",
export const ProvidersCommand = cmd({
command: "providers",
aliases: ["auth"],
describe: "manage AI providers and credentials",
builder: (yargs) =>
yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
yargs.command(ProvidersListCommand).command(ProvidersLoginCommand).command(ProvidersLogoutCommand).demandCommand(),
async handler() {},
})
export const AuthListCommand = cmd({
export const ProvidersListCommand = cmd({
command: "list",
aliases: ["ls"],
describe: "list providers",
async handler() {
describe: "list providers and credentials",
async handler(_args) {
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
@@ -231,7 +221,6 @@ export const AuthListCommand = cmd({
prompts.outro(`${results.length} credentials`)
// Environment variables section
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
@@ -258,7 +247,7 @@ export const AuthListCommand = cmd({
},
})
export const AuthLoginCommand = cmd({
export const ProvidersLoginCommand = cmd({
command: "login [url]",
describe: "log in to a provider",
builder: (yargs) =>
@@ -356,7 +345,7 @@ export const AuthLoginCommand = cmd({
value: x.id,
hint: {
opencode: "recommended",
anthropic: "Claude Max or API key",
anthropic: "API key",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
@@ -409,7 +398,6 @@ export const AuthLoginCommand = cmd({
if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "")
// Check if a plugin provides auth for this custom provider
const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
@@ -461,10 +449,10 @@ export const AuthLoginCommand = cmd({
},
})
export const AuthLogoutCommand = cmd({
export const ProvidersLogoutCommand = cmd({
command: "logout",
describe: "log out from a configured provider",
async handler() {
async handler(_args) {
UI.empty()
const credentials = await Auth.all().then((x) => Object.entries(x))
prompts.intro("Remove credential")

View File

@@ -1,6 +1,6 @@
import type { Argv } from "yargs"
import path from "path"
import { pathToFileURL } from "bun"
import { pathToFileURL } from "url"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
@@ -280,6 +280,11 @@ export const RunCommand = cmd({
type: "string",
describe: "attach to a running opencode server (e.g., http://localhost:4096)",
})
.option("password", {
alias: ["p"],
type: "string",
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
})
.option("dir", {
type: "string",
describe: "directory to run in, path on remote server if attaching",
@@ -365,6 +370,11 @@ export const RunCommand = cmd({
action: "deny",
pattern: "*",
},
{
permission: "edit",
action: "allow",
pattern: "*",
},
]
function title() {
@@ -648,14 +658,21 @@ export const RunCommand = cmd({
}
if (args.attach) {
const sdk = createOpencodeClient({ baseUrl: args.attach, directory })
const headers = (() => {
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
return { Authorization: auth }
})()
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
return await execute(sdk)
}
await bootstrap(process.cwd(), async () => {
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
return Server.App().fetch(request)
return Server.Default().fetch(request)
}) as typeof globalThis.fetch
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
await execute(sdk)

View File

@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const server = await Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {})

View File

@@ -20,6 +20,7 @@ import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { KeybindProvider } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
@@ -111,7 +112,6 @@ export function tui(input: {
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
onExit?: () => Promise<void>
}) {
// promise to prevent immediate exit
return new Promise<void>(async (resolve) => {
@@ -126,7 +126,6 @@ export function tui(input: {
const onExit = async () => {
unguard?.()
await input.onExit?.()
resolve()
}
@@ -373,6 +372,22 @@ function App() {
dialog.replace(() => <DialogSessionList />)
},
},
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
? [
{
title: "Manage workspaces",
value: "workspace.list",
category: "Workspace",
suggested: true,
slash: {
name: "workspaces",
},
onSelect: () => {
dialog.replace(() => <DialogWorkspaceList />)
},
},
]
: []),
{
title: "New session",
suggested: route.data.type === "session",
@@ -387,9 +402,12 @@ function App() {
const current = promptRef.current
// Don't require focus - if there's any text, preserve it
const currentPrompt = current?.current?.input ? current.current : undefined
const workspaceID =
route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
route.navigate({
type: "home",
initialPrompt: currentPrompt,
workspaceID,
})
dialog.clear()
},
@@ -462,6 +480,7 @@ function App() {
{
title: "Toggle MCPs",
value: "mcp.list",
search: "toggle mcps",
category: "Agent",
slash: {
name: "mcps",
@@ -537,8 +556,9 @@ function App() {
category: "System",
},
{
title: "Toggle appearance",
title: mode() === "dark" ? "Light mode" : "Dark mode",
value: "theme.switch_mode",
search: "toggle appearance",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
@@ -577,6 +597,7 @@ function App() {
},
{
title: "Toggle debug panel",
search: "toggle debug",
category: "System",
value: "app.debug",
onSelect: (dialog) => {
@@ -586,6 +607,7 @@ function App() {
},
{
title: "Toggle console",
search: "toggle console",
category: "System",
value: "app.console",
onSelect: (dialog) => {
@@ -626,6 +648,7 @@ function App() {
{
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
value: "terminal.title.toggle",
search: "toggle terminal title",
keybind: "terminal_title_toggle",
category: "System",
onSelect: (dialog) => {
@@ -641,6 +664,7 @@ function App() {
{
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
value: "app.toggle.animations",
search: "toggle animations",
category: "System",
onSelect: (dialog) => {
kv.set("animations_enabled", !kv.get("animations_enabled", true))
@@ -650,6 +674,7 @@ function App() {
{
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.diffwrap",
search: "toggle diff wrapping",
category: "System",
onSelect: (dialog) => {
const current = kv.get("diff_wrap_mode", "word")

View File

@@ -7,6 +7,27 @@ import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
import type { Provider } from "@opencode-ai/sdk/v2"
function pickLatest(models: [string, Provider["models"][string]][]) {
const picks: Record<string, [string, Provider["models"][string]]> = {}
for (const item of models) {
const model = item[0]
const info = item[1]
const key = info.family ?? model
const prev = picks[key]
if (!prev) {
picks[key] = item
continue
}
if (info.release_date !== prev[1].release_date) {
if (info.release_date > prev[1].release_date) picks[key] = item
continue
}
if (model > prev[0]) picks[key] = item
}
return Object.values(picks)
}
export function useConnected() {
const sync = useSync()
@@ -21,6 +42,7 @@ export function DialogModel(props: { providerID?: string }) {
const dialog = useDialog()
const keybind = useKeybind()
const [query, setQuery] = createSignal("")
const [all, setAll] = createSignal(false)
const connected = useConnected()
const providers = createDialogProviderOptions()
@@ -72,8 +94,8 @@ export function DialogModel(props: { providerID?: string }) {
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
flatMap((provider) =>
pipe(
flatMap((provider) => {
const items = pipe(
provider.models,
entries(),
filter(([_, info]) => info.status !== "deprecated"),
@@ -104,8 +126,9 @@ export function DialogModel(props: { providerID?: string }) {
(x) => x.footer !== "Free",
(x) => x.title,
),
),
),
)
return items
}),
)
const popularProviders = !connected()
@@ -154,6 +177,13 @@ export function DialogModel(props: { providerID?: string }) {
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
},
},
{
keybind: keybind.all.model_show_all_toggle?.[0],
title: all() ? "Show latest only" : "Show all models",
onTrigger: () => {
setAll((value) => !value)
},
},
]}
onFilter={setQuery}
flat={true}

View File

@@ -36,7 +36,7 @@ export function createDialogProviderOptions() {
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(Claude Max or API key)",
anthropic: "(API key)",
openai: "(ChatGPT Plus/Pro or API key)",
"opencode-go": "Low cost subscription for everyone",
}[provider.id],

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