Compare commits

...

207 Commits
kit-pr ... beta

Author SHA1 Message Date
opencode-agent[bot]
199011714e chore: update nix node_modules hashes 2026-03-09 15:35:33 +00:00
opencode-agent[bot]
47d47367b8 Apply PR #16366: fix(core): a chunk timeout when processing llm stream 2026-03-09 15:31:55 +00:00
opencode-agent[bot]
8859836e5b Apply PR #16296: fix(app): align same() helper usage across app and util 2026-03-09 15:31:55 +00:00
opencode-agent[bot]
8a3194e15b Apply PR #16286: refactor(opencode): replace Bun shell in core flows 2026-03-09 15:31:23 +00:00
opencode-agent[bot]
2f1cecbf64 Apply PR #16069: feat(windows): add first-class pwsh/powershell support 2026-03-09 15:30:37 +00:00
opencode-agent[bot]
b3777e2665 Apply PR #15697: tweak(ui): make questions popup collapsible 2026-03-09 15:30:36 +00:00
opencode-agent[bot]
53331f3ea0 Apply PR #15487: core: make account login upgrades safe while adding multi-account workspace auth 2026-03-09 15:30:36 +00:00
opencode-agent[bot]
040283b301 Apply PR #14677: feat: add experimental hashline edit mode with dual-schema support 2026-03-09 15:30:12 +00:00
opencode-agent[bot]
cdf03ed933 Apply PR #14471: [DO NOT MERGE]: beta badge for desktop app 2026-03-09 15:28:31 +00:00
opencode-agent[bot]
0184c84f31 Apply PR #14307: fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering 2026-03-09 15:28:30 +00:00
opencode-agent[bot]
96639b34ea Apply PR #12633: feat(tui): add auto-accept mode for permission requests 2026-03-09 15:28:30 +00:00
opencode-agent[bot]
0c53772b93 Apply PR #12022: feat: update tui model dialog to utilize model family to reduce noise in list 2026-03-09 15:28:29 +00: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
Dax Raad
e1dc03b7ba util: add shared module resolver for lsp
Use Node module resolution from the workspace root so LSP server discovery no longer depends on Bun. Add tests that lock down the TypeScript, ESLint, and Biome lookups used here.
2026-03-08 21:40:54 -04: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
0fdceb954c Merge branch 'dev' into chore/remove-bun-shell-opencode 2026-03-07 18:57:16 -05: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
opencode
a52d640c8c release: v1.2.21 2026-03-07 18:00:39 +00: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
Karan Handa
218869cf45 fix(storybook): restore build by mocking useLocation (#16472) 2026-03-07 09:55:43 -06:00
Eric Guo
e99d7a4292 fix(app): text-shimmer undefined length (#16475) 2026-03-07 09:53:32 -06:00
SANGWOO PARK
f0beb38f91 fix(app): guard session-header current() against undefined when options is empty (#16478) 2026-03-07 09:51:21 -06:00
Filip
66fcab7b08 fix(app): preserve file tree tab on reopen + fix e2e test regressions (#16482) 2026-03-07 09:47:45 -06:00
David Hill
641e1781a2 tui: remove close button from project hover popover (#16403)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-03-07 07:00:58 -06:00
Adam
490b95efe7 fix(app): new session uses agent model/variant 2026-03-07 07:00:38 -06:00
Adam
ba1edea0ab fix(app): model sticks to session 2026-03-07 06:57:00 -06:00
Adam
73c9b685a7 fix(app): all panels transition 2026-03-07 06:48:37 -06:00
Adam
99d8aab0ac fix(app): can't scroll files 2026-03-07 06:47:11 -06:00
Adam
7dd6369952 fix(app): task agent title 2026-03-07 06:03:30 -06:00
Adam
06f60af1e9 chore: update web stats 2026-03-07 05:47:47 -06:00
Adam
66d0beba6f fix(app): fix max-width on timeline 2026-03-07 05:45:30 -06:00
David Hill
6b99dd50b6 tui: align session empty states (#16412) 2026-03-07 05:39:43 -06:00
opencode-agent[bot]
c53c9d4e4e chore: generate 2026-03-07 11:26:12 +00:00
Kit Langton
bbd0f3a252 STUPID SEXY TIMELINE (#16420) 2026-03-07 05:25:22 -06:00
Luke Parker
b7e208b4f1 test(app): share workspace slug wait helper across e2e specs (#16446) 2026-03-07 07:48:30 +00:00
Quan Ran
be9b4d1bcd fix(opencode): preserve original line endings in 'edit' tool (#9443)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-03-07 07:42:54 +00:00
Nate Williams
5b5b791d75 fix(tui): fix broken /export toggling (#16443) 2026-03-07 12:52:17 +05:30
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
Luke Parker
0b7a5b1e7b test(app): abort sessions and wait for idle before e2e cleanup (#16439) 2026-03-07 16:33:12 +10:00
Karan Handa
28bb16ca2a fix(config): point GitHub PR search tool at current repository (#16441) 2026-03-07 05:53:28 +00:00
Luke Parker
8a95be492d fix(windows): git path resolution for modified files across Git Bash, MSYS2, and Cygwin (#16422) 2026-03-07 15:42:14 +10:00
opencode-agent[bot]
c42c5a0cc6 chore: generate 2026-03-07 04:54:05 +00:00
kikuchan
b2c2478d9d fix(pty): pty session handle leak (#15599) 2026-03-07 14:53:11 +10:00
Luke Parker
1a9af8acb6 feat(desktop): show skill issue when snapshotting is off (#16432) 2026-03-07 04:52:04 +00:00
Luke Parker
4c7fe60493 fix(opencode): sanitize preview database filenames (#16430) 2026-03-07 04:34:29 +00: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
opencode-agent[bot]
c108f304c6 chore: update nix node_modules hashes 2026-03-07 02:35:17 +00:00
David Hill
2b8acfa0e2 app: fix portal positioning for sidebar menus and tooltips by removing conditional mount logic 2026-03-07 00:30:51 +00:00
Jay V
b83282b940 docs: update legal policies for 2026 terms refresh 2026-03-06 18:31:02 -05:00
Filip
c4fd677785 tests(app): e2e tests part 67 (#16406) 2026-03-06 17:08:59 -06:00
opencode-agent[bot]
770cb66628 chore: generate 2026-03-06 22:34:25 +00:00
David Hill
b0bc3d87f5 feat(app): sidebar reveal animation, hover peek overlay, and weaker dividers (#16374)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-03-06 16:33:34 -06:00
James Long
a2634337b8 fix(core): log stack trace when schema validation fails (#16401) 2026-03-06 17:04:22 -05:00
Dax Raad
7417c869fc fix issue with migration 2026-03-06 16:44:49 -05:00
David Hill
091cf25de8 fix(app): better review/filetree empty states (#16221)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-03-06 15:39:31 -06:00
James Long
f5dde52cc4 Fix types 2026-03-06 14:22:10 -05:00
James Long
5df0c6e3c3 feat(core): add chunkTimeout option for sse timeouts in providers 2026-03-06 14:06:41 -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
Dax Raad
add16af117 fix(opencode): fail on brew repo lookup errors 2026-03-06 01:33:59 -05:00
Dax Raad
054f848307 fix(opencode): honor nothrow on spawn errors 2026-03-06 01:30:12 -05:00
Dax Raad
3b2e3afebd fix(opencode): address migration review feedback 2026-03-06 01:25:04 -05:00
Dax Raad
f1c7d4cefb Merge remote-tracking branch 'origin/dev' into chore/remove-bun-shell-opencode
# Conflicts:
#	packages/opencode/src/file/index.ts
#	packages/opencode/src/worktree/index.ts
2026-03-06 01:15:26 -05:00
Dax Raad
1454dd1dc1 refactor(opencode): remove remaining Bun shell usage 2026-03-06 01:09:00 -05:00
Dax Raad
5d68b1b148 refactor(opencode): replace Bun shell in core flows 2026-03-06 00:54:11 -05: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
Shoubhit Dash
5a7f03a384 refactor: remove legacy edit mode 2026-03-06 11:10:00 +05:30
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
Shoubhit Dash
9215af4465 Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-03-06 10:19:52 +05:30
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
Shoubhit Dash
ab44597018 Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-02-27 11:52:55 +05:30
Shoubhit Dash
aec95c4d10 stabilize hashline routing and anchors 2026-02-27 09:45:06 +05:30
Shoubhit Dash
b2c82cb897 Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-02-27 08:55:43 +05:30
Shoubhit Dash
52b42258fa Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-02-23 15:16:20 +05:30
Shoubhit Dash
3026a005b6 test: reorder hashline config test for beta merge 2026-02-23 10:05:12 +05:30
Shoubhit Dash
a6f802d7fe fix: align codex prompt with edit routing 2026-02-22 22:39:03 +05:30
Shoubhit Dash
9ef803be82 feat: enable hashline by default 2026-02-22 22:31:33 +05:30
Shoubhit Dash
ce5c827a6e chore: remove local opencode config flags 2026-02-22 19:46:59 +05:30
Shoubhit Dash
56decd79db feat: add experimental hashline edit mode 2026-02-22 19:40:34 +05:30
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
317 changed files with 13623 additions and 6839 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

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

View File

@@ -1,6 +1,6 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the sst/opencode repository and returns LLM-friendly results including:
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)

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.

1157
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-pBTIT8Pgdm3272YhBjiAZsmj0SSpHTklh6lGc8YcMoE=",
"aarch64-linux": "sha256-prt039++d5UZgtldAN6+RVOR557ifIeusiy5XpzN8QU=",
"aarch64-darwin": "sha256-Y3f+cXcIGLqz6oyc5fG22t6CLD4wGkvwqO6RNXjFriQ=",
"x86_64-darwin": "sha256-BjbBBhQUgGhrlP56skABcrObvutNUZSWnrnPCg1OTKE="
"x86_64-linux": "sha256-TnrYykX8Mf/Ugtkix6V",
"aarch64-linux": "sha256-TnrYykX8Mf/Ugtkix6V",
"aarch64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V",
"x86_64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V"
}
}

View File

@@ -43,6 +43,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"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

@@ -71,6 +71,12 @@ test("test description", async ({ page, sdk, gotoSession }) => {
- `closeDialog(page, dialog)` - Close any dialog
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
- `withSession(sdk, title, callback)` - Create temp session
- `withProject(...)` - Create temp project/workspace
- `sessionIDFromUrl(url)` - Read session ID from URL
- `slugFromUrl(url)` - Read workspace slug from URL
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
- `trackDirectory(directory)` - Register directory for fixture cleanup
- `clickListItem(container, filter)` - Click list item by key/text
**Selectors** (`selectors.ts`):
@@ -109,7 +115,7 @@ import { test, expect } from "@playwright/test"
### Error Handling
Tests should clean up after themselves:
Tests should clean up after themselves. Prefer fixture-managed cleanup:
```typescript
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
@@ -120,6 +126,11 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
})
```
- Prefer `withSession(...)` for temp sessions
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
- Avoid calling `sdk.session.delete(...)` directly
### Timeouts
Default: 60s per test, 10s per assertion. Override when needed:
@@ -161,9 +172,10 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
1. Choose appropriate folder or create new one
2. Import from `../fixtures`
3. Use helper functions from `../actions` and `../selectors`
4. Clean up any created resources
5. Use specific selectors (avoid CSS classes)
6. Test one feature per test file
4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs.
5. Clean up any created resources
6. Use specific selectors (avoid CSS classes)
7. Test one feature per test file
## Local Development

View File

@@ -3,12 +3,12 @@ import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { execSync } from "node:child_process"
import { modKey, serverUrl } from "./utils"
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
sessionItemSelector,
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
projectMenuTriggerSelector,
projectCloseMenuSelector,
projectWorkspacesToggleSelector,
titlebarRightSelector,
popoverBodySelector,
@@ -18,7 +18,6 @@ import {
workspaceItemSelector,
workspaceMenuTriggerSelector,
} from "./selectors"
import type { createSdk } from "./utils"
export async function defocus(page: Page) {
await page
@@ -61,9 +60,9 @@ export async function closeDialog(page: Page, dialog: Locator) {
}
export async function isSidebarClosed(page: Page) {
const main = page.locator("main")
const classes = (await main.getAttribute("class")) ?? ""
return classes.includes("xl:border-l")
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await expect(button).toBeVisible()
return (await button.getAttribute("aria-expanded")) !== "true"
}
export async function toggleSidebar(page: Page) {
@@ -75,48 +74,34 @@ export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const visible = await button
.isVisible()
.then((x) => x)
.catch(() => false)
await button.click()
if (visible) await button.click()
if (!visible) await toggleSidebar(page)
const main = page.locator("main")
const opened = await expect(main)
.not.toHaveClass(/xl:border-l/, { timeout: 1500 })
const opened = await expect(button)
.toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) return
await toggleSidebar(page)
await expect(main).not.toHaveClass(/xl:border-l/)
await expect(button).toHaveAttribute("aria-expanded", "true")
}
export async function closeSidebar(page: Page) {
if (await isSidebarClosed(page)) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const visible = await button
.isVisible()
.then((x) => x)
.catch(() => false)
await button.click()
if (visible) await button.click()
if (!visible) await toggleSidebar(page)
const main = page.locator("main")
const closed = await expect(main)
.toHaveClass(/xl:border-l/, { timeout: 1500 })
const closed = await expect(button)
.toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
.then(() => true)
.catch(() => false)
if (closed) return
await toggleSidebar(page)
await expect(main).toHaveClass(/xl:border-l/)
await expect(button).toHaveAttribute("aria-expanded", "false")
}
export async function openSettings(page: Page) {
@@ -204,7 +189,7 @@ export async function createTestProject() {
stdio: "ignore",
})
return root
return resolveDirectory(root)
}
export async function cleanupTestProject(directory: string) {
@@ -214,13 +199,40 @@ export async function cleanupTestProject(directory: string) {
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
}
export function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
}
export async function waitSlug(page: Page, skip: string[] = []) {
let prev = ""
let next = ""
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (skip.includes(slug)) return ""
if (slug !== prev) {
prev = slug
next = ""
return ""
}
next = slug
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
return next
}
export function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1]
}
export async function hoverSessionItem(page: Page, sessionID: string) {
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
await expect(sessionEl).toBeVisible()
await sessionEl.hover()
return sessionEl
@@ -321,6 +333,57 @@ export async function clickListItem(
return item
}
async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const data = await sdk.session
.status()
.then((x) => x.data ?? {})
.catch(() => undefined)
return data?.[sessionID]
}
async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
let prev = ""
await expect
.poll(
async () => {
const info = await sdk.session
.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!info) return true
const next = `${info.title}:${info.time.updated ?? info.time.created}`
if (next !== prev) {
prev = next
return false
}
return true
},
{ timeout },
)
.toBe(true)
}
export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
}
export async function cleanupSession(input: {
sessionID: string
directory?: string
sdk?: ReturnType<typeof createSdk>
}) {
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
const current = await status(sdk, input.sessionID).catch(() => undefined)
if (current && current.type !== "idle") {
await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
}
await stable(sdk, input.sessionID).catch(() => undefined)
await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
}
export async function withSession<T>(
sdk: ReturnType<typeof createSdk>,
title: string,
@@ -332,7 +395,7 @@ export async function withSession<T>(
try {
return await callback(session)
} finally {
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
await cleanupSession({ sdk, sessionID: session.id })
}
}
@@ -570,32 +633,42 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
await expect(trigger).toHaveCount(1)
const menu = page
.locator(dropdownMenuContentSelector)
.filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
.first()
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
const clicked = await trigger
.click({ timeout: 1500 })
.then(() => true)
.catch(() => false)
if (clicked) {
const opened = await menu
.waitFor({ state: "visible", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) {
await expect(close).toBeVisible()
return menu
}
}
await trigger.focus()
await page.keyboard.press("Enter")
const menu = page.locator(dropdownMenuContentSelector).first()
const opened = await menu
.waitFor({ state: "visible", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) {
const viewport = page.viewportSize()
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
await page.mouse.move(x, y)
await expect(close).toBeVisible()
return menu
}
await trigger.click({ force: true })
await expect(menu).toBeVisible()
const viewport = page.viewportSize()
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
await page.mouse.move(x, y)
return menu
throw new Error(`Failed to open project menu: ${projectSlug}`)
}
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
@@ -608,11 +681,18 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab
if (current === enabled) return
await openProjectMenu(page, projectSlug)
const flip = async (timeout?: number) => {
const menu = await openProjectMenu(page, projectSlug)
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
await expect(toggle).toBeVisible()
return toggle.click({ force: true, timeout })
}
const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
await expect(toggle).toBeVisible()
await toggle.click({ force: true })
const flipped = await flip(1500)
.then(() => true)
.catch(() => false)
if (!flipped) await flip()
const expected = enabled ? "New workspace" : "New session"
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()

View File

@@ -1,17 +1,17 @@
import { test, expect } from "../fixtures"
import { serverName } from "../utils"
import { serverNamePattern } from "../utils"
test("home renders and shows core entrypoints", async ({ page }) => {
await page.goto("/")
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
await expect(page.getByRole("button", { name: serverName })).toBeVisible()
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
})
test("server picker dialog opens from home", async ({ page }) => {
await page.goto("/")
const trigger = page.getByRole("button", { name: serverName })
const trigger = page.getByRole("button", { name: serverNamePattern })
await expect(trigger).toBeVisible()
await trigger.click()

View File

@@ -1,6 +1,6 @@
import { test, expect } from "../fixtures"
import { serverName, serverUrl } from "../utils"
import { clickListItem, closeDialog, clickMenuItem } from "../actions"
import { serverNamePattern, serverUrls } from "../utils"
import { closeDialog, clickMenuItem } from "../actions"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
@@ -31,10 +31,9 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
await expect(row).toBeVisible()
await expect(dialog.getByText(serverNamePattern).first()).toBeVisible()
const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click({ force: true })
@@ -42,14 +41,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
await expect(menu).toBeVisible()
await clickMenuItem(menu, /set as default/i)
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
await expect(row.getByText("Default", { exact: true })).toBeVisible()
await expect
.poll(async () =>
serverUrls.includes((await page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)) ?? ""),
)
.toBe(true)
await expect(dialog.getByText("Default", { exact: true })).toBeVisible()
await closeDialog(page, dialog)
await ensurePopoverOpen()
const serverRow = popover.locator("button").filter({ hasText: serverName }).first()
const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first()
await expect(serverRow).toBeVisible()
await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
})

View File

@@ -16,7 +16,6 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.scrollIntoViewIfNeeded()
await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
@@ -56,7 +55,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
await expect(second).toBeVisible()
await second.scrollIntoViewIfNeeded()
await second.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
@@ -76,7 +74,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
await expect(third).toBeVisible()
await third.scrollIntoViewIfNeeded()
await third.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
@@ -102,7 +99,6 @@ test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, g
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.scrollIntoViewIfNeeded()
await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))

View File

@@ -10,6 +10,8 @@ const expanded = async (el: { getAttribute: (name: string) => Promise<string | n
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
await gotoSession()
const reviewPanel = page.locator("#review-panel")
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
await expect(treeToggle).toBeVisible()
if (await expanded(treeToggle)) await treeToggle.click()
@@ -19,13 +21,13 @@ test("review panel can be toggled via keybind", async ({ page, gotoSession }) =>
await expect(reviewToggle).toBeVisible()
if (await expanded(reviewToggle)) await reviewToggle.click()
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(page.locator("#review-panel")).toHaveCount(0)
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
await expect(page.locator("#review-panel")).toBeVisible()
await expect(reviewPanel).toHaveAttribute("aria-hidden", "false")
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(page.locator("#review-panel")).toHaveCount(0)
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
})

View File

@@ -43,6 +43,13 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession
await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(allTab).toHaveAttribute("aria-selected", "true")
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await expect(viewer).toContainText("export default function FileTree")

View File

@@ -1,5 +1,5 @@
import { test as base, expect, type Page } from "@playwright/test"
import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
import { promptSelector } from "./selectors"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
@@ -13,6 +13,8 @@ type TestFixtures = {
directory: string
slug: string
gotoSession: (sessionID?: string) => Promise<void>
trackSession: (sessionID: string, directory?: string) => void
trackDirectory: (directory: string) => void
}) => Promise<T>,
options?: { extra?: string[] },
) => Promise<T>
@@ -51,20 +53,36 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
},
withProject: async ({ page }, use) => {
await use(async (callback, options) => {
const directory = await createTestProject()
const slug = dirSlug(directory)
await seedStorage(page, { directory, extra: options?.extra })
const root = await createTestProject()
const slug = dirSlug(root)
const sessions = new Map<string, string>()
const dirs = new Set<string>()
await seedStorage(page, { directory: root, extra: options?.extra })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
await page.goto(sessionPath(root, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
const current = sessionIDFromUrl(page.url())
if (current) trackSession(current)
}
const trackSession = (sessionID: string, directory?: string) => {
sessions.set(sessionID, directory ?? root)
}
const trackDirectory = (directory: string) => {
if (directory !== root) dirs.add(directory)
}
try {
await gotoSession()
return await callback({ directory, slug, gotoSession })
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
} finally {
await cleanupTestProject(directory)
await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
)
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root)
}
})
},

View File

@@ -1,25 +1,15 @@
import { test, expect } from "../fixtures"
import { openSidebar } from "../actions"
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async () => {
await withProject(async ({ slug }) => {
await openSidebar(page)
const open = async () => {
const header = page.locator(".group\\/project").first()
await header.hover()
const trigger = header.getByRole("button", { name: "More options" }).first()
await expect(trigger).toBeVisible()
await trigger.click({ force: true })
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
await expect(menu).toBeVisible()
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
await expect(editItem).toBeVisible()
await editItem.click({ force: true })
const menu = await openProjectMenu(page, slug)
await clickMenuItem(menu, /^Edit$/i, { force: true })
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()

View File

@@ -1,36 +1,8 @@
import { test, expect } from "../fixtures"
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors"
import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("can close a project via hover card close button", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.hover()
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
await expect(close).toBeVisible()
await close.click()
await expect(otherButton).toHaveCount(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
test("closing active project navigates to another open project", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
@@ -53,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

@@ -1,18 +1,39 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import {
defocus,
createTestProject,
cleanupTestProject,
openSidebar,
setWorkspacesEnabled,
sessionIDFromUrl,
} from "../actions"
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk, dirSlug, sessionPath } from "../utils"
import { dirSlug, resolveDirectory } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
async function workspaces(page: Page, directory: string, enabled: boolean) {
await page.evaluate(
({ directory, enabled }: { directory: string; enabled: boolean }) => {
const key = "opencode.global.dat:layout"
const raw = localStorage.getItem(key)
const data = raw ? JSON.parse(raw) : {}
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
const current =
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
? sidebar.workspaces
: {}
const next = { ...current }
if (enabled) next[directory] = true
if (!enabled) delete next[directory]
localStorage.setItem(
key,
JSON.stringify({
...data,
sidebar: {
...sidebar,
workspaces: next,
},
}),
)
},
{ directory, enabled },
)
}
test("can switch between projects from sidebar", async ({ page, withProject }) => {
@@ -51,46 +72,39 @@ test("switching back to a project opens the latest workspace session", async ({
const other = await createTestProject()
const otherSlug = dirSlug(other)
let rootDir: string | undefined
let workspaceDir: string | undefined
let sessionID: string | undefined
try {
await withProject(
async ({ directory, slug }) => {
rootDir = directory
async ({ directory, slug, trackSession, trackDirectory }) => {
await defocus(page)
await workspaces(page, directory, true)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const next = slugFromUrl(page.url())
if (!next) return ""
if (next === slug) return ""
return next
},
{ timeout: 45_000 },
)
.not.toBe("")
const workspaceSlug = slugFromUrl(page.url())
workspaceDir = base64Decode(workspaceSlug)
if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`)
const raw = await waitSlug(page, [slug])
const dir = base64Decode(raw)
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
const space = await resolveDirectory(dir)
const next = dirSlug(space)
trackDirectory(space)
await openSidebar(page)
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
await expect(workspace).toBeVisible()
await workspace.hover()
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
await expect(item).toBeVisible()
await item.hover()
const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first()
await expect(newSession).toBeVisible()
await newSession.click({ force: true })
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
await expect(btn).toBeVisible()
await btn.click({ force: true })
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
// A new workspace can be discovered via a transient slug before the route and sidebar
// settle to the canonical workspace path on Windows, so interact with either and assert
// against the resolved workspace slug.
await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
// Create a session by sending a prompt
const prompt = page.locator(promptSelector)
@@ -103,9 +117,9 @@ test("switching back to a project opens the latest workspace session", async ({
const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
sessionID = created
trackSession(created, space)
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
await openSidebar(page)
@@ -124,20 +138,6 @@ test("switching back to a project opens the latest workspace session", async ({
{ extra: [other] },
)
} finally {
if (sessionID) {
const id = sessionID
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
await Promise.all(
dirs.map((directory) =>
createSdk(directory)
.session.delete({ sessionID: id })
.catch(() => undefined),
),
)
}
if (workspaceDir) {
await cleanupTestProject(workspaceDir)
}
await cleanupTestProject(other)
}
})

View File

@@ -1,14 +1,10 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
async function waitWorkspaceReady(page: Page, slug: string) {
await openSidebar(page)
await expect
@@ -31,20 +27,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (slug === root) return ""
if (seen.includes(slug)) return ""
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
const slug = slugFromUrl(page.url())
const slug = await waitSlug(page, [root, ...seen])
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory }
@@ -60,12 +43,13 @@ async function openWorkspaceNewSession(page: Page, slug: string) {
await expect(button).toBeVisible()
await button.click({ force: true })
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
const next = await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
return next
}
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
await openWorkspaceNewSession(page, slug)
const next = await openWorkspaceNewSession(page, slug)
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
@@ -76,13 +60,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter")
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
return sessionID
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
return { sessionID, slug: next }
}
async function sessionDirectory(directory: string, sessionID: string) {
@@ -97,48 +81,29 @@ async function sessionDirectory(directory: string, sessionID: string) {
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ directory, slug: root }) => {
const workspaces = [] as { slug: string; directory: string }[]
const sessions = [] as string[]
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
await openSidebar(page)
await setWorkspacesEnabled(page, root, true)
try {
await openSidebar(page)
await setWorkspacesEnabled(page, root, true)
const first = await createWorkspace(page, root, [])
trackDirectory(first.directory)
await waitWorkspaceReady(page, first.slug)
const first = await createWorkspace(page, root, [])
workspaces.push(first)
await waitWorkspaceReady(page, first.slug)
const second = await createWorkspace(page, root, [first.slug])
trackDirectory(second.directory)
await waitWorkspaceReady(page, second.slug)
const second = await createWorkspace(page, root, [first.slug])
workspaces.push(second)
await waitWorkspaceReady(page, second.slug)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
trackSession(firstSession.sessionID, first.directory)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
sessions.push(firstSession)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
trackSession(secondSession.sessionID, second.directory)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
sessions.push(secondSession)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
trackSession(thirdSession.sessionID, first.directory)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
sessions.push(thirdSession)
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
} finally {
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
await Promise.all(
sessions.map((sessionID) =>
Promise.all(
dirs.map((dir) =>
createSdk(dir)
.session.delete({ sessionID })
.catch(() => undefined),
),
),
),
)
await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
}
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
})
})

View File

@@ -14,14 +14,12 @@ import {
openSidebar,
openWorkspaceMenu,
setWorkspacesEnabled,
slugFromUrl,
waitSlug,
} from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
import { createSdk, dirSlug } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
const rootSlug = project.slug
await openSidebar(page)
@@ -29,17 +27,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug
},
{ timeout: 45_000 },
)
.toBe(true)
const slug = slugFromUrl(page.url())
const slug = await waitSlug(page, [rootSlug])
const dir = base64Decode(slug)
await openSidebar(page)
@@ -91,18 +79,7 @@ test("can create a workspace", async ({ page, withProject }) => {
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const currentSlug = slugFromUrl(page.url())
return currentSlug.length > 0 && currentSlug !== slug
},
{ timeout: 45_000 },
)
.toBe(true)
const workspaceSlug = slugFromUrl(page.url())
const workspaceSlug = await waitSlug(page, [slug])
const workspaceDir = base64Decode(workspaceSlug)
await openSidebar(page)
@@ -279,7 +256,7 @@ test("can delete a workspace", async ({ page, withProject }) => {
await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i)
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
await expect
.poll(
@@ -336,9 +313,6 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
const src = page.locator(workspaceItemSelector(from)).first()
const dst = page.locator(workspaceItemSelector(to)).first()
await src.scrollIntoViewIfNeeded()
await dst.scrollIntoViewIfNeeded()
const a = await src.boundingBox()
const b = await dst.boundingBox()
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
@@ -357,17 +331,7 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug && slug !== prev
},
{ timeout: 45_000 },
)
.toBe(true)
const slug = slugFromUrl(page.url())
const slug = await waitSlug(page, [rootSlug, prev])
const dir = base64Decode(slug)
workspaces.push({ slug, directory: dir })

View File

@@ -1,6 +1,8 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { sessionIDFromUrl } from "../actions"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
// the connection open while the agent works, causing "Failed to fetch" over
@@ -38,6 +40,37 @@ test("prompt succeeds when sync message endpoint is unreachable", async ({ page,
)
.toContain(token)
} finally {
await sdk.session.delete({ sessionID }).catch(() => undefined)
await cleanupSession({ sdk, sessionID })
}
})
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
const prompt = page.locator(promptSelector)
const value = `restore ${Date.now()}`
await page.route(`**/session/${session.id}/prompt_async`, (route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ message: "e2e prompt failure" }),
}),
)
await gotoSession(session.id)
await prompt.click()
await page.keyboard.type(value)
await page.keyboard.press("Enter")
await expect.poll(async () => text(await prompt.textContent())).toBe(value)
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 15_000 },
)
.toBe(0)
})
})

View File

@@ -0,0 +1,181 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import { promptSelector } from "../selectors"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
if (!("type" in part) || part.type !== "tool") return false
if (!("tool" in part) || part.tool !== "bash") return false
return "state" in part
}
async function edge(page: Page, pos: "start" | "end") {
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
const selection = window.getSelection()
if (!selection) return
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
const nodes: Text[] = []
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
nodes.push(node as Text)
}
if (nodes.length === 0) {
const node = document.createTextNode("")
el.appendChild(node)
nodes.push(node)
}
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
const range = document.createRange()
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}, pos)
}
async function wait(page: Page, value: string) {
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
}
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((item) => item.info.role === "assistant")
.flatMap((item) => item.parts)
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
}
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
const part = messages
.filter((item) => item.info.role === "assistant")
.flatMap((item) => item.parts)
.filter(isBash)
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
if (!part || part.state.status !== "completed") return
return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
},
{ timeout: 90_000 },
)
.toContain(token)
}
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
const first = `Reply with exactly: ${firstToken}`
const second = `Reply with exactly: ${secondToken}`
const draft = `draft ${Date.now()}`
await prompt.click()
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, firstToken)
await prompt.click()
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, secondToken)
await prompt.click()
await page.keyboard.type(draft)
await wait(page, draft)
await edge(page, "start")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("ArrowUp")
await wait(page, first)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, draft)
})
})
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
const normalToken = `E2E_NORMAL_${Date.now()}`
const first = `echo ${firstToken}`
const second = `echo ${secondToken}`
const normal = `Reply with exactly: ${normalToken}`
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, first, firstToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, second, secondToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("ArrowUp")
await wait(page, first)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, "")
await page.keyboard.press("Escape")
await wait(page, "")
await prompt.click()
await page.keyboard.type(normal)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, normalToken)
await prompt.click()
await page.keyboard.press("ArrowUp")
await wait(page, normal)
})
})

View File

@@ -0,0 +1,62 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import { test, expect } from "../fixtures"
import { sessionIDFromUrl } from "../actions"
import { promptSelector } from "../selectors"
import { createSdk } from "../utils"
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
if (!("type" in part) || part.type !== "tool") return false
if (!("tool" in part) || part.tool !== "bash") return false
return "state" in part
}
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
test.setTimeout(120_000)
await withProject(async ({ directory, gotoSession, trackSession }) => {
const sdk = createSdk(directory)
const prompt = page.locator(promptSelector)
const cmd = process.platform === "win32" ? "dir" : "ls"
await gotoSession()
await prompt.click()
await page.keyboard.type("!")
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
await page.keyboard.type(cmd)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
trackSession(id, directory)
await expect
.poll(
async () => {
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
const msg = list.findLast(
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
)
if (!msg) return
const part = msg.parts
.filter(isBash)
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
if (!part || part.state.status !== "completed") return
const output =
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
if (!output.includes("README.md")) return
return { cwd: directory, output }
},
{ timeout: 90_000 },
)
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
await expect(prompt).toHaveText("")
})
})

View File

@@ -0,0 +1,64 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
await sdk.session.promptAsync({
sessionID,
noReply: true,
parts: [{ type: "text", text: "e2e share seed" }],
})
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 30_000 },
)
.toBeGreaterThan(0)
}
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
const prompt = page.locator(promptSelector)
await seed(sdk, session.id)
await gotoSession(session.id)
await prompt.click()
await page.keyboard.type("/share")
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
await prompt.click()
await page.keyboard.type("/unshare")
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.toBeUndefined()
})
})

View File

@@ -1,6 +1,6 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { sessionIDFromUrl, withSession } from "../actions"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
@@ -46,7 +46,7 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
.toContain(token)
} finally {
page.off("pageerror", onPageError)
await sdk.session.delete({ sessionID }).catch(() => undefined)
await cleanupSession({ sdk, sessionID })
}
if (pageErrors.length > 0) {

View File

@@ -30,8 +30,6 @@ export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
export const projectSwitchSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]`
export const projectMenuTriggerSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
import {
permissionDockSelector,
promptSelector,
@@ -26,7 +26,7 @@ async function withDockSession<T>(
try {
return await fn(session)
} finally {
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
await cleanupSession({ sdk, sessionID: session.id })
}
}
@@ -311,7 +311,7 @@ test("child session question request blocks parent dock and unblocks after submi
await expect(page.locator(promptSelector)).toBeVisible()
})
} finally {
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
await cleanupSession({ sdk, sessionID: child.id })
}
})
})
@@ -358,7 +358,7 @@ test("child session permission request blocks parent dock and supports allow onc
},
)
} finally {
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
await cleanupSession({ sdk, sessionID: child.id })
}
})
})

View File

@@ -45,7 +45,7 @@ async function seedConversation(input: {
.toBe(true)
if (!userMessageID) throw new Error("Expected a user message id")
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`)).toHaveCount(1, { timeout: 30_000 })
return { prompt, userMessageID }
}
@@ -123,7 +123,7 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
.toBeUndefined()
await expect(seeded.prompt).not.toContainText(token)
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`).first()).toBeVisible()
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
})
})
})
@@ -158,8 +158,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
await expect(firstMessage.first()).toBeVisible()
await expect(secondMessage.first()).toBeVisible()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
@@ -176,7 +176,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
})
.toBe(second.userMessageID)
await expect(firstMessage.first()).toBeVisible()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
@@ -210,7 +210,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
})
.toBe(second.userMessageID)
await expect(firstMessage.first()).toBeVisible()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
@@ -226,8 +226,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
})
.toBeUndefined()
await expect(firstMessage.first()).toBeVisible()
await expect(secondMessage.first()).toBeVisible()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
})
})
})

View File

@@ -32,22 +32,19 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
await closeDialog(page, dialog)
const main = page.locator("main")
const initialClasses = (await main.getAttribute("class")) ?? ""
const initiallyClosed = initialClasses.includes("xl:border-l")
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true"
await page.keyboard.press(`${modKey}+Shift+H`)
await page.waitForTimeout(100)
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false")
const afterToggleClasses = (await main.getAttribute("class")) ?? ""
const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true"
expect(afterToggleClosed).toBe(!initiallyClosed)
await page.keyboard.press(`${modKey}+Shift+H`)
await page.waitForTimeout(100)
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true")
const finalClasses = (await main.getAttribute("class")) ?? ""
const finalClosed = finalClasses.includes("xl:border-l")
const finalClosed = (await button.getAttribute("aria-expanded")) !== "true"
expect(finalClosed).toBe(initiallyClosed)
})

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

@@ -1,6 +1,6 @@
import { test, expect } from "../fixtures"
import { closeSidebar, hoverSessionItem } from "../actions"
import { projectSwitchSelector, sessionItemSelector } from "../selectors"
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
import { projectSwitchSelector } from "../selectors"
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
@@ -15,12 +15,15 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
await gotoSession(one.id)
await closeSidebar(page)
const oneItem = page.locator(`[data-session-id="${one.id}"]`).last()
const twoItem = page.locator(`[data-session-id="${two.id}"]`).last()
const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
await project.hover()
await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
await expect(oneItem).toBeVisible()
await expect(twoItem).toBeVisible()
const item = await hoverSessionItem(page, one.id)
await item
@@ -28,9 +31,9 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
.first()
.click()
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
await expect(twoItem).toBeVisible()
} finally {
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
await cleanupSession({ sdk, sessionID: one.id })
await cleanupSession({ sdk, sessionID: two.id })
}
})

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { openSidebar, withSession } from "../actions"
import { cleanupSession, openSidebar, withSession } from "../actions"
import { promptSelector } from "../selectors"
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
@@ -18,14 +18,13 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(target).toBeVisible()
await target.scrollIntoViewIfNeeded()
await target.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
} finally {
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
await cleanupSession({ sdk, sessionID: one.id })
await cleanupSession({ sdk, sessionID: two.id })
}
})

View File

@@ -5,12 +5,14 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
await gotoSession()
await openSidebar(page)
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await expect(button).toHaveAttribute("aria-expanded", "true")
await toggleSidebar(page)
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
await expect(button).toHaveAttribute("aria-expanded", "false")
await toggleSidebar(page)
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
await expect(button).toHaveAttribute("aria-expanded", "true")
})
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
@@ -19,14 +21,15 @@ test("sidebar collapsed state persists across navigation and reload", async ({ p
await gotoSession(session1.id)
await openSidebar(page)
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await toggleSidebar(page)
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
await expect(button).toHaveAttribute("aria-expanded", "false")
await gotoSession(session2.id)
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
await expect(button).toHaveAttribute("aria-expanded", "false")
await page.reload()
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
await expect(button).toHaveAttribute("aria-expanded", "false")
const opened = await page.evaluate(
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,

View File

@@ -0,0 +1,141 @@
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors"
import { terminalToggleKey, workspacePersistKey } from "../utils"
type State = {
active?: string
all: Array<{
id: string
title: string
titleNumber: number
buffer?: string
}>
}
async function open(page: Page) {
const terminal = page.locator(terminalSelector)
const visible = await terminal.isVisible().catch(() => false)
if (!visible) await page.keyboard.press(terminalToggleKey)
await expect(terminal).toBeVisible()
await expect(terminal.locator("textarea")).toHaveCount(1)
}
async function run(page: Page, cmd: string) {
const terminal = page.locator(terminalSelector)
await expect(terminal).toBeVisible()
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) {
return page.evaluate((key) => {
const raw = localStorage.getItem(key)
if (raw) return JSON.parse(raw) as State
for (let i = 0; i < localStorage.length; i++) {
const next = localStorage.key(i)
if (!next?.endsWith(":workspace:terminal")) continue
const value = localStorage.getItem(next)
if (!value) continue
return JSON.parse(value) as State
}
}, key)
}
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const one = `E2E_TERM_ONE_${Date.now()}`
const two = `E2E_TERM_TWO_${Date.now()}`
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
await gotoSession()
await open(page)
await run(page, `echo ${one}`)
await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2)
await run(page, `echo ${two}`)
await first.click()
await expect(first).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
const state = await store(page, key)
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
return {
first: first.includes(one),
second: second.includes(two),
}
},
{ timeout: 30_000 },
)
.toEqual({ first: false, second: true })
await second.click()
await expect(second).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
const state = await store(page, key)
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
return {
first: first.includes(one),
second: second.includes(two),
}
},
{ timeout: 30_000 },
)
.toEqual({ first: true, second: false })
})
})
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
await gotoSession()
await open(page)
await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2)
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
await second.click()
await expect(second).toHaveAttribute("aria-selected", "true")
await second.hover()
await page
.getByRole("button", { name: /close terminal/i })
.nth(1)
.click({ force: true })
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
await expect(tabs).toHaveCount(1)
await expect(first).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
const state = await store(page, key)
return {
count: state?.all.length ?? 0,
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
}
},
{ timeout: 15_000 },
)
.toEqual({ count: 1, first: true })
})
})

View File

@@ -1,5 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/util/encode"
import { base64Encode, checksum } from "@opencode-ai/util/encode"
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
@@ -7,6 +7,22 @@ export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
export const serverUrl = `http://${serverHost}:${serverPort}`
export const serverName = `${serverHost}:${serverPort}`
const localHosts = ["127.0.0.1", "localhost"]
const serverLabels = (() => {
const url = new URL(serverUrl)
if (!localHosts.includes(url.hostname)) return [serverName]
return localHosts.map((host) => `${host}:${url.port}`)
})()
export const serverNames = [...new Set(serverLabels)]
export const serverUrls = serverNames.map((name) => `http://${name}`)
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("|")})`)
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
export const terminalToggleKey = "Control+Backquote"
@@ -14,6 +30,12 @@ export function createSdk(directory?: string) {
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
}
export async function resolveDirectory(directory: string) {
return createSdk(directory)
.path.get()
.then((x) => x.data?.directory ?? directory)
}
export async function getWorktree() {
const sdk = createSdk()
const result = await sdk.path.get()
@@ -33,3 +55,9 @@ export function dirPath(directory: string) {
export function sessionPath(directory: string, sessionID?: string) {
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
}
export function workspacePersistKey(directory: string, key: string) {
const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(directory) ?? "0"
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.20",
"version": "1.2.23",
"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

@@ -6,10 +6,19 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = []
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
const optimistic: Array<{
message: {
agent: string
model: { providerID: string; modelID: string }
variant?: string
}
}> = []
const sentShell: string[] = []
const syncedDirectories: string[] = []
let params: { id?: string } = {}
let selected = "/repo/worktree-a"
let variant: string | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
@@ -26,6 +35,7 @@ const clientFor = (directory: string) => {
return { data: undefined }
},
prompt: async () => ({ data: undefined }),
promptAsync: async () => ({ data: undefined }),
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
@@ -40,7 +50,7 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
useParams: () => params,
}))
mock.module("@opencode-ai/sdk/v2/client", () => ({
@@ -62,7 +72,7 @@ beforeAll(async () => {
useLocal: () => ({
model: {
current: () => ({ id: "model", provider: { id: "provider" } }),
variant: { current: () => undefined },
variant: { current: () => variant },
},
agent: {
current: () => ({ name: "agent" }),
@@ -118,7 +128,11 @@ beforeAll(async () => {
data: { command: [] },
session: {
optimistic: {
add: () => undefined,
add: (value: {
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
}) => {
optimistic.push(value)
},
remove: () => undefined,
},
},
@@ -155,9 +169,12 @@ beforeEach(() => {
createdClients.length = 0
createdSessions.length = 0
enabledAutoAccept.length = 0
optimistic.length = 0
params = {}
sentShell.length = 0
syncedDirectories.length = 0
selected = "/repo/worktree-a"
variant = undefined
})
describe("prompt submit worktree selection", () => {
@@ -219,4 +236,39 @@ describe("prompt submit worktree selection", () => {
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
})
test("includes the selected variant on optimistic prompts", async () => {
params = { id: "session-1" }
variant = "high"
const submit = createPromptSubmit({
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
expect(optimistic).toHaveLength(1)
expect(optimistic[0]).toMatchObject({
message: {
agent: "agent",
model: { providerID: "provider", modelID: "model" },
variant: "high",
},
})
})
})

View File

@@ -316,6 +316,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
time: { created: Date.now() },
agent,
model,
variant,
}
const addOptimisticMessage = () =>

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

@@ -303,7 +303,12 @@ export function SessionHeader() {
})
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
const current = createMemo(
() =>
options().find((o) => o.id === prefs.app) ??
options()[0] ??
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
)
const opening = createMemo(() => openRequest.app !== undefined)
const selectApp = (app: OpenApp) => {

View File

@@ -4,12 +4,12 @@ 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"
const CREATE_WORKTREE = "create"
const ROOT_CLASS =
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-16"
const ROOT_CLASS = "size-full flex flex-col"
interface NewSessionViewProps {
worktree: string
@@ -50,33 +50,43 @@ export function NewSessionView(props: NewSessionViewProps) {
return (
<div class={ROOT_CLASS}>
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
<div class="flex justify-center items-start gap-3 min-h-5">
<Icon name="folder" size="small" class="mt-0.5 shrink-0" />
<div class="text-12-medium text-text-weak select-text leading-5">
{getDirectory(projectRoot())}
<span class="text-text-strong">{getFilename(projectRoot())}</span>
<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="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">
{getDirectory(projectRoot())}
<span class="text-text-strong">{getFilename(projectRoot())}</span>
</div>
</div>
<div class="flex items-start justify-center gap-1.5 min-h-5">
<Icon name="branch" size="small" class="mt-0.5 shrink-0" />
<div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">
{label(current())}
</div>
</div>
<Show when={sync.project}>
{(project) => (
<div class="flex items-start justify-center gap-3 min-h-5">
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
{language.t("session.new.lastModified")}&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(project().time.updated ?? project().time.created)
.setLocale(language.intl())
.toRelative()}
</span>
</div>
</div>
)}
</Show>
</div>
</div>
</div>
<div class="flex justify-center items-start gap-3 min-h-5">
<Icon name="branch" size="small" class="mt-0.5 shrink-0" />
<div class="text-12-medium text-text-weak select-text leading-5">{label(current())}</div>
</div>
<Show when={sync.project}>
{(project) => (
<div class="flex justify-center items-start gap-3 min-h-5">
<Icon name="pencil-line" size="small" class="mt-0.5 shrink-0" />
<div class="text-12-medium text-text-weak leading-5">
{language.t("session.new.lastModified")}&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(project().time.updated ?? project().time.created)
.setLocale(language.intl())
.toRelative()}
</span>
</div>
</div>
)}
</Show>
</div>
)
}

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)
})
}
@@ -199,6 +262,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}) {
const message: Message = {
id: input.messageID,
@@ -207,6 +271,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
time: { created: Date.now() },
agent: input.agent,
model: input.model,
variant: input.variant,
}
const [, setStore] = target()
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
@@ -222,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(
@@ -256,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" }))
}),
)
@@ -269,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) {
@@ -285,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)
@@ -308,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
@@ -323,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

@@ -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"
@@ -38,6 +38,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) {
@@ -188,6 +198,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 +344,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

@@ -456,6 +456,7 @@ export const dict = {
"session.todo.title": "المهام",
"session.todo.collapse": "طي",
"session.todo.expand": "توسيع",
"session.new.title": "ابنِ أي شيء",
"session.new.worktree.main": "الفرع الرئيسي",
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",

View File

@@ -459,6 +459,7 @@ export const dict = {
"session.todo.title": "Tarefas",
"session.todo.collapse": "Recolher",
"session.todo.expand": "Expandir",
"session.new.title": "Crie qualquer coisa",
"session.new.worktree.main": "Branch principal",
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
"session.new.worktree.create": "Criar novo worktree",

View File

@@ -515,6 +515,7 @@ export const dict = {
"session.todo.collapse": "Sažmi",
"session.todo.expand": "Proširi",
"session.new.title": "Napravi bilo šta",
"session.new.worktree.main": "Glavna grana",
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
"session.new.worktree.create": "Kreiraj novi worktree",

View File

@@ -510,6 +510,7 @@ export const dict = {
"session.todo.collapse": "Skjul",
"session.todo.expand": "Udvid",
"session.new.title": "Byg hvad som helst",
"session.new.worktree.main": "Hovedgren",
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
"session.new.worktree.create": "Opret nyt worktree",

View File

@@ -467,6 +467,7 @@ export const dict = {
"session.todo.title": "Aufgaben",
"session.todo.collapse": "Einklappen",
"session.todo.expand": "Ausklappen",
"session.new.title": "Baue, was du willst",
"session.new.worktree.main": "Haupt-Branch",
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
"session.new.worktree.create": "Neuen Worktree erstellen",

View File

@@ -511,11 +511,13 @@ export const dict = {
"session.review.change.other": "Changes",
"session.review.loadingChanges": "Loading changes...",
"session.review.empty": "No changes in this session yet",
"session.review.noVcs": "No git VCS detected, so session changes will not be detected",
"session.review.noVcs": "No Git Version Control System detected, changes not displayed",
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
"session.review.noChanges": "No changes",
"session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files",
"session.files.empty": "No files",
"session.files.binaryContent": "Binary file (content cannot be displayed)",
"session.messages.renderEarlier": "Render earlier messages",
@@ -529,6 +531,7 @@ export const dict = {
"session.todo.collapse": "Collapse",
"session.todo.expand": "Expand",
"session.new.title": "Build anything",
"session.new.worktree.main": "Main branch",
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
"session.new.worktree.create": "Create new worktree",

View File

@@ -516,6 +516,7 @@ export const dict = {
"session.todo.collapse": "Contraer",
"session.todo.expand": "Expandir",
"session.new.title": "Construye lo que quieras",
"session.new.worktree.main": "Rama principal",
"session.new.worktree.mainWithBranch": "Rama principal ({{branch}})",
"session.new.worktree.create": "Crear nuevo árbol de trabajo",

View File

@@ -463,6 +463,7 @@ export const dict = {
"session.todo.title": "Tâches",
"session.todo.collapse": "Réduire",
"session.todo.expand": "Développer",
"session.new.title": "Créez ce que vous voulez",
"session.new.worktree.main": "Branche principale",
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
"session.new.worktree.create": "Créer un nouvel arbre de travail",

View File

@@ -457,6 +457,7 @@ export const dict = {
"session.todo.title": "ToDo",
"session.todo.collapse": "折りたたむ",
"session.todo.expand": "展開",
"session.new.title": "何でも作る",
"session.new.worktree.main": "メインブランチ",
"session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
"session.new.worktree.create": "新しいワークツリーを作成",

View File

@@ -459,6 +459,7 @@ export const dict = {
"session.todo.title": "할 일",
"session.todo.collapse": "접기",
"session.todo.expand": "펼치기",
"session.new.title": "무엇이든 만들기",
"session.new.worktree.main": "메인 브랜치",
"session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
"session.new.worktree.create": "새 작업 트리 생성",

View File

@@ -516,6 +516,7 @@ export const dict = {
"session.todo.collapse": "Skjul",
"session.todo.expand": "Utvid",
"session.new.title": "Bygg hva som helst",
"session.new.worktree.main": "Hovedgren",
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
"session.new.worktree.create": "Opprett nytt worktree",

View File

@@ -458,6 +458,7 @@ export const dict = {
"session.todo.title": "Zadania",
"session.todo.collapse": "Zwiń",
"session.todo.expand": "Rozwiń",
"session.new.title": "Zbuduj cokolwiek",
"session.new.worktree.main": "Główna gałąź",
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
"session.new.worktree.create": "Utwórz nowe drzewo robocze",

View File

@@ -514,6 +514,7 @@ export const dict = {
"session.todo.collapse": "Свернуть",
"session.todo.expand": "Развернуть",
"session.new.title": "Создавайте что угодно",
"session.new.worktree.main": "Основная ветка",
"session.new.worktree.mainWithBranch": "Основная ветка ({{branch}})",
"session.new.worktree.create": "Создать новый worktree",

View File

@@ -511,6 +511,7 @@ export const dict = {
"session.todo.collapse": "ย่อ",
"session.todo.expand": "ขยาย",
"session.new.title": "สร้างอะไรก็ได้",
"session.new.worktree.main": "สาขาหลัก",
"session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})",
"session.new.worktree.create": "สร้าง worktree ใหม่",

View File

@@ -523,6 +523,7 @@ export const dict = {
"session.todo.collapse": "Daralt",
"session.todo.expand": "Genişlet",
"session.new.title": "İstediğini yap",
"session.new.worktree.main": "Ana dal",
"session.new.worktree.mainWithBranch": "Ana dal ({{branch}})",
"session.new.worktree.create": "Yeni çalışma ağacı oluştur",

View File

@@ -510,6 +510,7 @@ export const dict = {
"session.todo.title": "待办事项",
"session.todo.collapse": "折叠",
"session.todo.expand": "展开",
"session.new.title": "构建任何东西",
"session.new.worktree.main": "主分支",
"session.new.worktree.mainWithBranch": "主分支({{branch}}",
"session.new.worktree.create": "创建新的 worktree",

View File

@@ -507,6 +507,7 @@ export const dict = {
"session.todo.collapse": "折疊",
"session.todo.expand": "展開",
"session.new.title": "建構任何東西",
"session.new.worktree.main": "主分支",
"session.new.worktree.mainWithBranch": "主分支 ({{branch}})",
"session.new.worktree.create": "建立新的 worktree",

View File

@@ -1 +1,29 @@
@import "@opencode-ai/ui/styles/tailwind";
@layer components {
[data-component="getting-started"] {
container-type: inline-size;
container-name: getting-started;
}
[data-component="getting-started-actions"] {
display: flex;
flex-direction: column;
gap: 0.75rem; /* gap-3 */
}
[data-component="getting-started-actions"] > [data-component="button"] {
width: 100%;
}
@container getting-started (min-width: 17rem) {
[data-component="getting-started-actions"] {
flex-direction: row;
align-items: center;
}
[data-component="getting-started-actions"] > [data-component="button"] {
width: auto;
}
}
}

View File

@@ -1,26 +1,27 @@
import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { SDKProvider } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { useGlobalSDK } from "@/context/global-sdk"
import { DataProvider } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const params = useParams()
const navigate = useNavigate()
const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory))
return (
<DataProvider
data={sync.data}
directory={props.directory}
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
@@ -30,31 +31,63 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export default function Layout(props: ParentProps) {
const params = useParams()
const navigate = useNavigate()
const location = useLocation()
const language = useLanguage()
const [store, setStore] = createStore({ invalid: "" })
const directory = createMemo(() => {
return decode64(params.dir) ?? ""
})
const globalSDK = useGlobalSDK()
const directory = createMemo(() => decode64(params.dir) ?? "")
const [state, setState] = createStore({ invalid: "", resolved: "" })
createEffect(() => {
if (!params.dir) return
if (directory()) return
if (store.invalid === params.dir) return
setStore("invalid", params.dir)
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: language.t("directory.error.invalidUrl"),
})
navigate("/", { replace: true })
const raw = directory()
if (!raw) {
if (state.invalid === params.dir) return
setState("invalid", params.dir)
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: language.t("directory.error.invalidUrl"),
})
navigate("/", { replace: true })
return
}
const current = params.dir
globalSDK
.createClient({
directory: raw,
throwOnError: true,
})
.path.get()
.then((x) => {
if (params.dir !== current) return
const next = x.data?.directory ?? raw
batch(() => {
setState("invalid", "")
setState("resolved", next)
})
if (next === raw) return
const path = location.pathname.slice(current.length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
.catch(() => {
if (params.dir !== current) return
batch(() => {
setState("invalid", "")
setState("resolved", raw)
})
})
})
return (
<Show when={directory()}>
<SDKProvider directory={directory}>
<SyncProvider>
<DirectoryDataProvider directory={directory()}>{props.children}</DirectoryDataProvider>
</SyncProvider>
</SDKProvider>
<Show when={state.resolved}>
{(resolved) => (
<SDKProvider directory={resolved}>
<SyncProvider>
<DirectoryDataProvider directory={resolved()}>{props.children}</DirectoryDataProvider>
</SyncProvider>
</SDKProvider>
)}
</Show>
)
}

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"
@@ -93,6 +94,7 @@ export default function Layout(props: ParentProps) {
workspaceName: {} as Record<string, string>,
workspaceBranchName: {} as Record<string, Record<string, string>>,
workspaceExpanded: {} as Record<string, boolean>,
gettingStartedDismissed: false,
}),
)
@@ -154,6 +156,8 @@ export default function Layout(props: ParentProps) {
const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
const navLeave = { current: undefined as number | undefined }
const [sortNow, setSortNow] = createSignal(Date.now())
const [sizing, setSizing] = createSignal(false)
let sizet: number | undefined
let sortNowInterval: ReturnType<typeof setInterval> | undefined
const sortNowTimeout = setTimeout(
() => {
@@ -166,7 +170,7 @@ export default function Layout(props: ParentProps) {
const aim = createAim({
enabled: () => !layout.sidebar.opened(),
active: () => state.hoverProject,
el: () => state.nav,
el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
onActivate: (directory) => {
globalSync.child(directory)
setState("hoverProject", directory)
@@ -178,9 +182,23 @@ export default function Layout(props: ParentProps) {
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
clearTimeout(sortNowTimeout)
if (sortNowInterval) clearInterval(sortNowInterval)
if (sizet !== undefined) clearTimeout(sizet)
if (peekt !== undefined) clearTimeout(peekt)
aim.reset()
})
onMount(() => {
const stop = () => setSizing(false)
window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
})
})
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
const setHoverProject = (value: string | undefined) => {
@@ -191,12 +209,54 @@ export default function Layout(props: ParentProps) {
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
const disarm = () => {
if (navLeave.current === undefined) return
clearTimeout(navLeave.current)
navLeave.current = undefined
}
const arm = () => {
if (layout.sidebar.opened()) return
if (state.hoverProject === undefined) return
disarm()
navLeave.current = window.setTimeout(() => {
navLeave.current = undefined
setHoverProject(undefined)
setState("hoverSession", undefined)
}, 300)
}
const [peek, setPeek] = createSignal<LocalProject | undefined>(undefined)
const [peeked, setPeeked] = createSignal(false)
let peekt: number | undefined
const hoverProjectData = createMemo(() => {
const id = state.hoverProject
if (!id) return
return layout.projects.list().find((project) => project.worktree === id)
})
createEffect(() => {
const p = hoverProjectData()
if (p) {
if (peekt !== undefined) {
clearTimeout(peekt)
peekt = undefined
}
setPeek(p)
setPeeked(true)
return
}
setPeeked(false)
if (peek() === undefined) return
if (peekt !== undefined) clearTimeout(peekt)
peekt = window.setTimeout(() => {
peekt = undefined
setPeek(undefined)
}, 180)
})
createEffect(() => {
if (!layout.sidebar.opened()) return
setHoverProject(undefined)
@@ -364,6 +424,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"
@@ -598,25 +669,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(() => {
@@ -665,6 +735,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)
@@ -728,7 +799,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)
@@ -1122,6 +1204,12 @@ export default function Layout(props: ParentProps) {
}
const openSession = async (target: { directory: string; id: string }) => {
if (!canOpen(target.directory)) return false
const [data] = globalSync.child(target.directory, { bootstrap: false })
if (data.session.some((item) => item.id === target.id)) {
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
return true
}
const resolved = await globalSDK.client.session
.get({ sessionID: target.id })
.then((x) => x.data)
@@ -1812,7 +1900,9 @@ export default function Layout(props: ParentProps) {
setHoverSession,
}
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
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 ""
@@ -1838,10 +1928,17 @@ export default function Layout(props: ParentProps) {
return (
<div
classList={{
"flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-[12px]": 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() || hover(),
"bg-background-stronger": !merged() && !hover(),
"flex-1 min-w-0": panelProps.mobile,
"max-w-full overflow-hidden": panelProps.mobile,
}}
style={{
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
}}
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
>
<Show when={panelProps.project}>
{(p) => (
@@ -1887,7 +1984,7 @@ export default function Layout(props: ParentProps) {
}}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
@@ -2006,25 +2103,31 @@ export default function Layout(props: ParentProps) {
</Show>
<div
class="shrink-0 px-2 py-3 border-t border-border-weak-base"
class="shrink-0 px-3 py-3"
classList={{
hidden: !(providers.all().length > 0 && providers.paid().length === 0),
hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0),
}}
>
<div class="rounded-md bg-background-base shadow-xs-border-base">
<div class="p-3 flex flex-col gap-2">
<div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
<div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
<div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
<div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
<div class="p-3 flex flex-col gap-6">
<div class="flex flex-col gap-2">
<div class="text-14-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("sidebar.gettingStarted.line1")}
</div>
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("sidebar.gettingStarted.line2")}
</div>
</div>
<div data-component="getting-started-actions">
<Button size="large" icon="plus-small" onClick={connectProvider}>
{language.t("command.provider.connect")}
</Button>
<Button size="large" variant="ghost" onClick={() => setStore("gettingStartedDismissed", true)}>
Not yet
</Button>
</div>
</div>
<Button
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
size="large"
icon="plus"
onClick={connectProvider}
>
{language.t("command.provider.connect")}
</Button>
</div>
</div>
</div>
@@ -2034,33 +2137,27 @@ 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">
<Titlebar />
<div class="flex-1 min-h-0 flex">
<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,
"relative shrink-0": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "64px" }}
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
if (navLeave.current === undefined) return
clearTimeout(navLeave.current)
navLeave.current = undefined
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
navLeave.current = window.setTimeout(() => {
navLeave.current = undefined
setHoverProject(undefined)
setState("hoverSession", undefined)
}, 300)
arm()
}}
>
<div class="@container w-full h-full contain-strict">
@@ -2087,28 +2184,36 @@ export default function Layout(props: ParentProps) {
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() => (
<Show when={currentProject()} keyed>
{(project) => <SidebarPanel project={project} />}
{(project) => <SidebarPanel project={project} merged />}
</Show>
)}
/>
</div>
<Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>
<div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
<SidebarPanel project={hoverProjectData()} />
<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>
<Show when={layout.sidebar.opened()}>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
collapseThreshold={244}
onResize={layout.sidebar.resize}
onCollapse={layout.sidebar.close}
/>
</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 class="xl:hidden">
<div
classList={{
@@ -2124,7 +2229,7 @@ export default function Layout(props: ParentProps) {
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-72 bg-background-base transition-transform duration-200 ease-out": true,
"@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(),
}}
@@ -2157,16 +2262,66 @@ export default function Layout(props: ParentProps) {
</nav>
</div>
<main
<div
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
"xl:border-l xl:rounded-tl-[12px]": !layout.sidebar.opened(),
"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",
}}
>
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
{props.children}
<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>
</main>
</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>
<Toast.Region />
</div>

View File

@@ -163,7 +163,6 @@ const SessionHoverPreview = (props: {
gutter={16}
shift={-2}
trigger={props.trigger}
mount={!props.mobile ? props.nav() : undefined}
open={props.hoverSession() === props.session.id}
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
>

View File

@@ -5,8 +5,6 @@ import { Button } from "@opencode-ai/ui/button"
import { ContextMenu } from "@opencode-ai/ui/context-menu"
import { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { createSortable } from "@thisbeyond/solid-dnd"
import { useLayout, type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
@@ -93,6 +91,7 @@ const ProjectTile = (props: {
modal={!props.sidebarHovering()}
onOpenChange={(value) => {
props.setMenu(value)
props.setSuppressHover(value)
if (value) props.setOpen(false)
}}
>
@@ -109,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
@@ -137,7 +142,7 @@ const ProjectTile = (props: {
>
<ProjectIcon project={props.project} notify />
</ContextMenu.Trigger>
<ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
<ContextMenu.Portal>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
@@ -194,21 +199,6 @@ const ProjectPreviewPanel = (props: {
<div class="-m-3 p-2 flex flex-col w-72">
<div class="px-4 pt-2 pb-1 flex items-center gap-2">
<div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
<Tooltip value={props.language.t("common.close")} placement="top" gutter={6}>
<IconButton
icon="circle-x"
variant="ghost"
class="shrink-0"
data-action="project-close-hover"
data-project={base64Encode(props.project.worktree)}
aria-label={props.language.t("common.close")}
onClick={(event) => {
event.stopPropagation()
props.setOpen(false)
props.ctx.closeProject(props.project.worktree)
}}
/>
</Tooltip>
</div>
<div class="px-4 pb-2 text-12-medium text-text-weak">{props.language.t("sidebar.project.recentSessions")}</div>
<div class="px-2 pb-2 flex flex-col gap-2">

View File

@@ -1,4 +1,4 @@
import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import {
DragDropProvider,
DragDropSensors,
@@ -35,10 +35,22 @@ export const SidebarContent = (props: {
}): JSX.Element => {
const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
const placement = () => (props.mobile ? "bottom" : "right")
let panel: HTMLDivElement | undefined
createEffect(() => {
const el = panel
if (!el) return
if (expanded()) {
el.removeAttribute("inert")
return
}
el.setAttribute("inert", "")
})
return (
<div class="flex h-full w-full overflow-hidden">
<div class="flex h-full w-full min-w-0 overflow-hidden">
<div
data-component="sidebar-rail"
class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"
onMouseMove={props.aimMove}
>
@@ -100,7 +112,15 @@ export const SidebarContent = (props: {
</div>
</div>
<Show when={expanded()}>{props.renderPanel()}</Show>
<div
ref={(el) => {
panel = el
}}
classList={{ "flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
aria-hidden={!expanded()}
>
{props.renderPanel()}
</div>
</div>
)
}

View File

@@ -182,7 +182,7 @@ const WorkspaceActions = (props: {
aria-label={props.language.t("common.moreOptions")}
/>
</Tooltip>
<DropdownMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
<DropdownMenu.Portal>
<DropdownMenu.Content
onCloseAutoFocus={(event) => {
if (!props.pendingRename()) return
@@ -249,7 +249,7 @@ const WorkspaceSessionList = (props: {
loadMore: () => Promise<void>
language: ReturnType<typeof useLanguage>
}): JSX.Element => (
<nav class="flex flex-col gap-1 px-2">
<nav class="flex flex-col gap-1 px-3">
<Show when={props.showNew()}>
<NewSessionItem
slug={props.slug()}
@@ -490,7 +490,7 @@ export const LocalWorkspace = (props: {
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<nav class="flex flex-col gap-1 px-2">
<nav class="flex flex-col gap-1 px-3">
<Show when={loading()}>
<SessionSkeleton />
</Show>

View File

@@ -1,4 +1,4 @@
import type { UserMessage } from "@opencode-ai/sdk/v2"
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import {
onCleanup,
@@ -20,27 +20,31 @@ import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { Mark } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode, checksum } from "@opencode-ai/util/encode"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { createOpenReviewFile } from "@/pages/session/helpers"
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 { formatServerError } from "@/utils/server-errors"
import { same } from "@opencode-ai/util/array"
const emptyUserMessages: UserMessage[] = []
@@ -252,6 +256,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
}
export default function Page() {
const globalSync = useGlobalSync()
const layout = useLayout()
const local = useLocal()
const file = useFile()
@@ -278,7 +283,9 @@ export default function Page() {
})
const [ui, setUi] = createStore({
git: false,
pendingMessage: undefined as string | undefined,
reviewSnap: false,
scrollGesture: 0,
scroll: {
overflow: false,
@@ -331,6 +338,7 @@ export default function Page() {
)
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing()
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
@@ -416,15 +424,24 @@ export default function Page() {
() => {
const msg = lastUserMessage()
if (!msg) return
if (msg.agent) {
local.agent.set(msg.agent)
if (local.agent.current()?.model) return
}
if (msg.model) local.model.set(msg.model)
syncSessionModel(local, msg)
},
),
)
createEffect(
on(
() => ({ 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 },
),
)
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes",
@@ -444,6 +461,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()))
@@ -490,10 +522,51 @@ export default function Page() {
})
const reviewEmptyKey = createMemo(() => {
const project = sync.project
if (!project || project.vcs) return "session.review.empty"
return "session.review.noVcs"
if (project && !project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
return "session.review.empty"
})
function upsert(next: Project) {
const list = globalSync.data.project
sync.set("project", next.id)
const idx = list.findIndex((item) => item.id === next.id)
if (idx >= 0) {
globalSync.set(
"project",
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
)
return
}
const at = list.findIndex((item) => item.id > next.id)
if (at >= 0) {
globalSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
return
}
globalSync.set("project", [...list, next])
}
function initGit() {
if (ui.git) return
setUi("git", true)
void sdk.client.project
.initGit()
.then((x) => {
if (!x.data) return
upsert(x.data)
})
.catch((err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(err, language.t),
})
})
.finally(() => {
setUi("git", false)
})
}
let inputRef!: HTMLDivElement
let promptDock: HTMLDivElement | undefined
let dockHeight = 0
@@ -727,23 +800,28 @@ export default function Page() {
const changesOptions = ["session", "turn"] as const
const changesOptionsList = [...changesOptions]
const changesTitle = () => (
<Select
options={changesOptionsList}
current={store.changes}
label={(option) =>
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
}
onSelect={(option) => option && setStore("changes", option)}
variant="ghost"
size="small"
valueClass="text-14-medium"
/>
)
const changesTitle = () => {
if (!hasReview()) {
return null
}
return (
<Select
options={changesOptionsList}
current={store.changes}
label={(option) =>
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
}
onSelect={(option) => option && setStore("changes", option)}
variant="ghost"
size="small"
valueClass="text-14-medium"
/>
)
}
const emptyTurn = () => (
<div class="h-full pb-30 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<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>
)
@@ -809,9 +887,23 @@ export default function Page() {
empty={
store.changes === "turn" ? (
emptyTurn()
) : reviewEmptyKey() === "session.review.noVcs" ? (
<div class={input.emptyClass}>
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">Create a Git repository</div>
<div
class="text-14-regular text-text-base max-w-md"
style={{ "line-height": "var(--line-height-normal)" }}
>
Track, review, and undo changes in this project
</div>
</div>
<Button size="large" disabled={ui.git} onClick={initGit}>
{ui.git ? "Creating Git repository..." : "Create Git repository"}
</Button>
</div>
) : (
<div class={input.emptyClass}>
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
</div>
)
@@ -844,7 +936,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-30 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>
@@ -968,23 +1060,6 @@ export default function Page() {
tabs().setActive(next)
})
createEffect(
on(
() => layout.fileTree.opened(),
(opened, prev) => {
if (prev === undefined) return
if (!isDesktop()) return
if (opened) {
const active = tabs().active()
const tab = active === "review" || (!active && hasReview()) ? "changes" : "all"
layout.fileTree.setTab(tab)
}
},
{ defer: true },
),
)
createEffect(() => {
const id = params.id
if (!id) return
@@ -1174,6 +1249,7 @@ export default function Page() {
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
scrollSpy.destroy()
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
})
@@ -1193,9 +1269,9 @@ export default function Page() {
{/* Session panel */}
<div
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
"flex-1": true,
"md:flex-none": desktopSidePanelOpen(),
"@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() && !ui.reviewSnap,
}}
style={{
width: sessionPanelWidth(),
@@ -1215,7 +1291,7 @@ export default function Page() {
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-30 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}
@@ -1291,17 +1367,28 @@ export default function Page() {
/>
<Show when={desktopReviewOpen()}>
<ResizeHandle
direction="horizontal"
size={layout.session.width()}
min={450}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
onResize={layout.session.resize}
/>
<div onPointerDown={() => size.start()}>
<ResizeHandle
direction="horizontal"
size={layout.session.width()}
min={450}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
onResize={(width) => {
size.touch()
layout.session.resize(width)
}}
/>
</div>
</Show>
</div>
<SessionSidePanel reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} />
<SessionSidePanel
reviewPanel={reviewPanel}
activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
size={size}
/>
</div>
<TerminalPanel />

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

@@ -1,4 +1,5 @@
import { batch } from "solid-js"
import { batch, createEffect, on, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
export const focusTerminalById = (id: string) => {
const wrapper = document.getElementById(`terminal-wrapper-${id}`)
@@ -69,3 +70,104 @@ export const getTabReorderIndex = (tabs: readonly string[], from: string, to: st
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined
return toIndex
}
export const createSizing = () => {
const [state, setState] = createStore({ active: false })
let t: number | undefined
const stop = () => {
if (t !== undefined) {
clearTimeout(t)
t = undefined
}
setState("active", false)
}
const start = () => {
if (t !== undefined) {
clearTimeout(t)
t = undefined
}
setState("active", true)
}
onMount(() => {
window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
})
})
onCleanup(() => {
if (t !== undefined) clearTimeout(t)
})
return {
active: () => state.active,
start,
touch() {
start()
t = window.setTimeout(stop, 120)
},
}
}
export type Sizing = ReturnType<typeof createSizing>
export const createPresence = (open: Accessor<boolean>, wait = 200) => {
const [state, setState] = createStore({
show: open(),
open: open(),
})
let frame: number | undefined
let t: number | undefined
const clear = () => {
if (frame !== undefined) {
cancelAnimationFrame(frame)
frame = undefined
}
if (t !== undefined) {
clearTimeout(t)
t = undefined
}
}
createEffect(
on(open, (next) => {
clear()
if (next) {
if (state.show) {
setState("open", true)
return
}
setState({ show: true, open: false })
frame = requestAnimationFrame(() => {
frame = undefined
setState("open", true)
})
return
}
if (!state.show) return
setState("open", false)
t = window.setTimeout(() => {
t = undefined
setState("show", false)
}, wait)
}),
)
onCleanup(clear)
return {
show: () => state.show,
open: () => state.open,
}
}

View File

@@ -8,6 +8,7 @@ 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"
@@ -235,6 +236,40 @@ export function MessageTimeline(props: {
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 parentID = pending()?.parentID
if (parentID) {
@@ -573,43 +608,64 @@ export function MessageTimeline(props: {
aria-label={language.t("common.goBack")}
/>
</Show>
<Show when={titleValue() || title.editing}>
<Show
when={title.editing}
fallback={
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
onDblClick={openTitleEditor}
>
{titleValue()}
</h1>
}
<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"
>
<InlineInput
ref={(el) => {
titleRef = el
}}
value={title.draft}
disabled={title.saving}
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 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 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>
</Show>
</div>
</div>
<Show when={sessionID()}>
{(id) => (

View File

@@ -0,0 +1,158 @@
import { describe, expect, test } from "bun:test"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { resetSessionModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: Partial<Pick<UserMessage, "agent" | "model" | "variant">>) =>
({
id: "msg",
sessionID: "session",
role: "user",
time: { created: 1 },
agent: input?.agent ?? "build",
model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" },
variant: input?.variant,
}) as UserMessage
describe("syncSessionModel", () => {
test("restores the last message model and variant", () => {
const calls: unknown[] = []
syncSessionModel(
{
agent: {
current() {
return undefined
},
set(value) {
calls.push(["agent", value])
},
},
model: {
set(value) {
calls.push(["model", value])
},
current() {
return { id: "claude-sonnet-4", provider: { id: "anthropic" } }
},
variant: {
set(value) {
calls.push(["variant", value])
},
},
},
},
message({ variant: "high" }),
)
expect(calls).toEqual([
["agent", "build"],
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
["variant", "high"],
])
})
test("skips variant when the model falls back", () => {
const calls: unknown[] = []
syncSessionModel(
{
agent: {
current() {
return undefined
},
set(value) {
calls.push(["agent", value])
},
},
model: {
set(value) {
calls.push(["model", value])
},
current() {
return { id: "gpt-5", provider: { id: "openai" } }
},
variant: {
set(value) {
calls.push(["variant", value])
},
},
},
},
message({ variant: "high" }),
)
expect(calls).toEqual([
["agent", "build"],
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
])
})
})
describe("resetSessionModel", () => {
test("restores the current agent defaults", () => {
const calls: unknown[] = []
resetSessionModel({
agent: {
current() {
return {
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
variant: "high",
}
},
set() {},
},
model: {
set(value) {
calls.push(["model", value])
},
current() {
return undefined
},
variant: {
set(value) {
calls.push(["variant", value])
},
},
},
})
expect(calls).toEqual([
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
["variant", "high"],
])
})
test("clears the variant when the agent has none", () => {
const calls: unknown[] = []
resetSessionModel({
agent: {
current() {
return {
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
}
},
set() {},
},
model: {
set(value) {
calls.push(["model", value])
},
current() {
return undefined
},
variant: {
set(value) {
calls.push(["variant", value])
},
},
},
})
expect(calls).toEqual([
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
["variant", undefined],
])
})
})

View File

@@ -0,0 +1,48 @@
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { batch } from "solid-js"
type Local = {
agent: {
current():
| {
model?: UserMessage["model"]
variant?: string
}
| undefined
set(name: string | undefined): void
}
model: {
set(model: UserMessage["model"] | undefined): void
current():
| {
id: string
provider: { id: string }
}
| undefined
variant: {
set(value: string | undefined): void
}
}
}
export const resetSessionModel = (local: Local) => {
const agent = local.agent.current()
if (!agent) return
batch(() => {
local.model.set(agent.model)
local.model.variant.set(agent.variant)
})
}
export const syncSessionModel = (local: Local, msg: UserMessage) => {
batch(() => {
local.agent.set(msg.agent)
local.model.set(msg.model)
})
const model = local.model.current()
if (!model) return
if (model.provider.id !== msg.model.providerID) return
if (model.id !== msg.model.modelID) return
local.model.variant.set(msg.variant)
}

View File

@@ -23,7 +23,7 @@ import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, getTabReorderIndex } from "@/pages/session/helpers"
import { createOpenSessionFileTab, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
import { StickyAddButton } from "@/pages/session/review-tab"
import { setSessionHandoff } from "@/pages/session/handoff"
@@ -31,6 +31,8 @@ export function SessionSidePanel(props: {
reviewPanel: () => JSX.Element
activeDiff?: string
focusReviewDiff: (path: string) => void
reviewSnap: boolean
size: Sizing
}) {
const params = useParams()
const layout = useLayout()
@@ -46,8 +48,15 @@ export function SessionSidePanel(props: {
const view = createMemo(() => layout.view(sessionKey))
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened()))
const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
const open = createMemo(() => reviewOpen() || fileOpen())
const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => {
if (!open()) return "0px"
if (reviewOpen()) return `calc(100% - ${layout.session.width()}px)`
return `${layout.fileTree.width()}px`
})
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
@@ -60,6 +69,12 @@ export function SessionSidePanel(props: {
return sync.data.session_diff[id] !== undefined
})
const reviewEmptyKey = createMemo(() => {
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
return "session.review.noChanges"
})
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
@@ -87,6 +102,21 @@ export function SessionSidePanel(props: {
return out
})
const empty = (msg: string) => (
<div class="h-full flex flex-col">
<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>
</div>
)
const nofiles = createMemo(() => {
const state = file.tree.state("")
if (!state?.loaded) return false
return file.tree.children("").length === 0
})
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
@@ -145,17 +175,8 @@ export function SessionSidePanel(props: {
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
fileTreeScrolled: false,
})
let changesEl: HTMLDivElement | undefined
let allEl: HTMLDivElement | undefined
const syncFileTreeScrolled = (el?: HTMLDivElement) => {
const next = (el?.scrollTop ?? 0) > 0
setStore("fileTreeScrolled", (current) => (current === next ? current : next))
}
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
@@ -176,11 +197,6 @@ export function SessionSidePanel(props: {
setStore("activeDraggable", undefined)
}
createEffect(() => {
if (!layout.fileTree.opened()) return
syncFileTreeScrolled(fileTreeTab() === "changes" ? changesEl : allEl)
})
createEffect(() => {
if (!file.ready()) return
@@ -203,149 +219,172 @@ export function SessionSidePanel(props: {
})
return (
<Show when={open()}>
<Show when={isDesktop()}>
<aside
id="review-panel"
aria-label={language.t("session.panel.reviewAndFiles")}
class="relative min-w-0 h-full border-l border-border-weak-base flex"
aria-hidden={!open()}
inert={!open()}
class="relative min-w-0 h-full flex shrink-0 overflow-hidden bg-background-base"
classList={{
"flex-1": reviewOpen(),
"shrink-0": !reviewOpen(),
"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: reviewOpen() ? undefined : `${layout.fileTree.width()}px` }}
style={{ width: panelWidth() }}
>
<Show when={reviewOpen()}>
<div class="flex-1 min-w-0 h-full">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={reviewTab()}>
<Tabs.Trigger value="review">
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={hasReview()}>
<div>{reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
</SortableProvider>
<StickyAddButton>
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() => dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
</StickyAddButton>
</Tabs.List>
</div>
<div class="size-full flex border-l border-border-weaker-base">
<div
aria-hidden={!reviewOpen()}
inert={!reviewOpen()}
class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base"
classList={{
"pointer-events-none": !reviewOpen(),
}}
>
<div class="size-full min-w-0 h-full bg-background-base">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={reviewTab()}>
<Tabs.Trigger value="review">
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={hasReview()}>
<div>{reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
</SortableProvider>
<StickyAddButton>
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() =>
dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
</StickyAddButton>
</Tabs.List>
</div>
<Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
</Show>
<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">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</div>
<Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
</Show>
</Tabs.Content>
<Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "context"}>
<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">
<SessionContextTab />
<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")}
</div>
</div>
</div>
</Show>
</Tabs.Content>
</Show>
<Show when={activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = createMemo(() => file.pathFromTab(tab))
return (
<div data-component="tabs-drag-preview">
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
<Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "context"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Show>
</Tabs.Content>
</Show>
<Show when={activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = createMemo(() => file.pathFromTab(tab))
return (
<div data-component="tabs-drag-preview">
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</div>
</div>
</Show>
<Show when={layout.fileTree.opened()}>
<div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
<div
id="file-tree-panel"
aria-hidden={!fileOpen()}
inert={!fileOpen()}
class="relative min-w-0 h-full shrink-0 overflow-hidden"
classList={{
"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() }}
>
<div
class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weak-base": reviewOpen() }}
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
>
<Tabs
variant="pill"
@@ -354,7 +393,7 @@ export function SessionSidePanel(props: {
class="h-full"
data-scope="filetree"
>
<Tabs.List data-scrolled={store.fileTreeScrolled ? "" : undefined}>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{reviewCount()}{" "}
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
@@ -363,12 +402,7 @@ export function SessionSidePanel(props: {
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content
value="changes"
ref={(el: HTMLDivElement) => (changesEl = el)}
onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)}
class="bg-background-stronger px-3 py-0"
>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={hasReview()}>
<Show
@@ -382,6 +416,7 @@ export function SessionSidePanel(props: {
>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
@@ -391,39 +426,47 @@ export function SessionSidePanel(props: {
</Show>
</Match>
<Match when={true}>
<div class="mt-8 text-center text-12-regular text-text-weak">
{language.t("session.review.noChanges")}
</div>
{empty(
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
)}
</Match>
</Switch>
</Tabs.Content>
<Tabs.Content
value="all"
ref={(el: HTMLDivElement) => (allEl = el)}
onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)}
class="bg-background-stronger px-3 py-0"
>
<FileTree
path=""
modified={diffFiles()}
kinds={kinds()}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
modified={diffFiles()}
kinds={kinds()}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Match>
</Switch>
</Tabs.Content>
</Tabs>
</div>
<ResizeHandle
direction="horizontal"
edge="start"
size={layout.fileTree.width()}
min={200}
max={480}
collapseThreshold={160}
onResize={layout.fileTree.resize}
onCollapse={layout.fileTree.close}
/>
<Show when={fileOpen()}>
<div onPointerDown={() => props.size.start()}>
<ResizeHandle
direction="horizontal"
edge="start"
size={layout.fileTree.width()}
min={200}
max={480}
collapseThreshold={160}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
onCollapse={layout.fileTree.close}
/>
</div>
</Show>
</div>
</Show>
</div>
</aside>
</Show>
)

View File

@@ -17,7 +17,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { terminalTabLabel } from "@/pages/session/terminal-label"
import { focusTerminalById } from "@/pages/session/helpers"
import { createPresence, createSizing, focusTerminalById } from "@/pages/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
export function TerminalPanel() {
@@ -33,8 +33,11 @@ export function TerminalPanel() {
const opened = createMemo(() => view().terminal.opened())
const open = createMemo(() => isDesktop() && opened())
const panel = createPresence(open)
const size = createSizing()
const height = createMemo(() => layout.terminal.height())
const close = () => view().terminal.close()
let root: HTMLDivElement | undefined
const [store, setStore] = createStore({
autoCreated: false,
@@ -67,7 +70,7 @@ export function TerminalPanel() {
on(
() => terminal.active(),
(activeId) => {
if (!activeId || !open()) return
if (!activeId || !panel.open()) return
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
@@ -76,6 +79,14 @@ export function TerminalPanel() {
),
)
createEffect(() => {
if (panel.open()) return
const active = document.activeElement
if (!(active instanceof HTMLElement)) return
if (!root?.contains(active)) return
active.blur()
})
createEffect(() => {
const dir = params.dir
if (!dir) return
@@ -133,120 +144,143 @@ export function TerminalPanel() {
}
return (
<Show when={open()}>
<Show when={panel.show()}>
<div
ref={root}
id="terminal-panel"
role="region"
aria-label={language.t("terminal.title")}
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
style={{ height: `${height()}px` }}
aria-hidden={!panel.open()}
inert={!panel.open()}
class="relative w-full shrink-0 overflow-hidden"
classList={{
"opacity-100": panel.open(),
"opacity-0 pointer-events-none": !panel.open(),
"transition-[height,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
!size.active(),
}}
style={{ height: panel.open() ? `${height()}px` : "0px" }}
>
<ResizeHandle
direction="vertical"
size={height()}
min={100}
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={layout.terminal.resize}
onCollapse={close}
/>
<Show
when={terminal.ready()}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
<For each={handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title}
</div>
)}
</For>
<div class="flex-1" />
<div class="text-text-weak pr-2">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
<div class="size-full flex flex-col border-t border-border-weak-base">
<div onPointerDown={() => size.start()}>
<ResizeHandle
direction="vertical"
size={height()}
min={100}
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(next) => {
size.touch()
layout.terminal.resize(next)
}}
onCollapse={close}
/>
</div>
<Show
when={terminal.ready()}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
<For each={handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title}
</div>
)}
</For>
<div class="flex-1" />
<div class="text-text-weak pr-2">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
</div>
<div class="flex-1 flex items-center justify-center text-text-weak">
{language.t("terminal.loading")}
</div>
</div>
<div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
</div>
}
>
<DragDropProvider
onDragStart={handleTerminalDragStart}
onDragEnd={handleTerminalDragEnd}
onDragOver={handleTerminalDragOver}
collisionDetector={closestCenter}
}
>
<DragDropSensors />
<ConstrainDragYAxis />
<div class="flex flex-col h-full">
<Tabs
variant="alt"
value={terminal.active()}
onChange={(id) => terminal.open(id)}
class="!h-auto !flex-none"
>
<Tabs.List class="h-10">
<SortableProvider ids={ids()}>
<For each={ids()}>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
</Show>
)}
</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<TooltipKeybind
title={language.t("command.terminal.new")}
keybind={command.keybind("terminal.new")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={terminal.new}
aria-label={language.t("command.terminal.new")}
/>
</TooltipKeybind>
</div>
</Tabs.List>
</Tabs>
<div class="flex-1 min-h-0 relative">
<Show when={terminal.active()} keyed>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => (
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal pty={pty()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
<DragDropProvider
onDragStart={handleTerminalDragStart}
onDragEnd={handleTerminalDragEnd}
onDragOver={handleTerminalDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<div class="flex flex-col h-full">
<Tabs
variant="alt"
value={terminal.active()}
onChange={(id) => terminal.open(id)}
class="!h-auto !flex-none"
>
<Tabs.List class="h-10 border-b border-border-weaker-base">
<SortableProvider ids={ids()}>
<For each={ids()}>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
</Show>
)}
</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<TooltipKeybind
title={language.t("command.terminal.new")}
keybind={command.keybind("terminal.new")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={terminal.new}
aria-label={language.t("command.terminal.new")}
/>
</TooltipKeybind>
</div>
</Tabs.List>
</Tabs>
<div class="flex-1 min-h-0 relative">
<Show when={terminal.active()} keyed>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => (
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal
pty={pty()}
onConnect={() => terminal.trim(id)}
onCleanup={terminal.update}
onConnectError={() => terminal.clone(id)}
/>
</div>
)}
</Show>
)}
</Show>
</div>
</div>
<DragOverlay>
<Show when={store.activeDraggable}>
{(draggedId) => (
<Show when={byId().get(draggedId())}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({
title: t().title,
titleNumber: t().titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
})}
</div>
)}
</Show>
)}
</Show>
</div>
</div>
<DragOverlay>
<Show when={store.activeDraggable}>
{(draggedId) => (
<Show when={byId().get(draggedId())}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({
title: t().title,
titleNumber: t().titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
})}
</div>
)}
</Show>
)}
</Show>
</DragOverlay>
</DragDropProvider>
</Show>
</DragOverlay>
</DragDropProvider>
</Show>
</div>
</div>
</Show>
)

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.20",
"version": "1.2.23",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "100K",
full: "100,000",
compact: "120K",
full: "120,000",
},
},
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "700",
commits: "9,000",
monthlyUsers: "2.5M",
contributors: "800",
commits: "10,000",
monthlyUsers: "5M",
},
} as const

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.",

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