Compare commits

..

252 Commits

Author SHA1 Message Date
opencode-agent[bot]
30d43cd2c1 Fix beta integration 2026-04-04 03:25:48 +00:00
opencode-agent[bot]
f8cea2c61e Apply PR #20956: feat(tui): show console-managed providers 2026-04-04 03:24:00 +00:00
opencode-agent[bot]
1bb8c5b743 Apply PR #20793: refactor(effect): move read tool onto defineEffect 2026-04-04 03:23:17 +00:00
opencode-agent[bot]
67fddd18c1 Apply PR #19545: feat: opencode remote control + opencode serve dependencies 2026-04-04 03:23:16 +00:00
opencode-agent[bot]
9b170d8f3c Apply PR #19029: feat(app): hide desktop titlebar tools behind settings 2026-04-04 03:22:01 +00:00
opencode-agent[bot]
04b5fa4082 Apply PR #18335: refactor(server): replace Bun serve with Hono node adapters 2026-04-04 03:20:09 +00:00
opencode-agent[bot]
4181ebdbaa Apply PR #18327: refactor: replace Bun.serve with Node http.createServer in OAuth handlers 2026-04-04 03:18:32 +00:00
opencode-agent[bot]
b20be1f735 Apply PR #17803: Remove CLI from electron app 2026-04-04 03:18:32 +00:00
opencode-agent[bot]
2eacbddf19 Apply PR #15697: tweak(ui): make questions popup collapsible 2026-04-04 03:15:17 +00:00
opencode-agent[bot]
b38ea906e7 Apply PR #14471: [DO NOT MERGE]: beta badge for desktop app 2026-04-04 03:13:22 +00:00
opencode-agent[bot]
19e6362c02 Apply PR #14307: fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering 2026-04-04 03:13:22 +00:00
opencode-agent[bot]
4920fffcb8 Apply PR #12633: feat(tui): add auto-accept mode for permission requests 2026-04-04 03:12:37 +00:00
Kit Langton
00fa68b3a7 fix(ci): create JUnit output dirs before tests (#20959) 2026-04-03 22:47:20 -04:00
Kit Langton
ba86434c78 refactor(effect): simplify read tool boundaries
Keep LSP warmup off the read critical path and use AppFileSystem helpers directly in the read tool. Update the migration note to describe the single-bridge pattern that the tool now follows.
2026-04-03 22:44:51 -04:00
Kit Langton
f5b30cdf39 test(config): mock active console org 2026-04-03 22:20:53 -04:00
Kit Langton
cbfe6709e3 refactor(effect): effectify read tool execution
Move the read tool body onto a named Effect path and keep a single Promise bridge at execute(). This keeps the service graph wiring from the previous commit while reducing runPromise islands inside the tool implementation.
2026-04-03 22:00:59 -04:00
Kit Langton
8675f51751 feat(tui): show console-managed providers 2026-04-03 22:00:22 -04:00
Kit Langton
e549174c87 refactor(effect): wire read tool through services
Yield AppFileSystem, Instruction, LSP, and FileTime from the read tool effect so the tool closes over real services instead of static facades. Rewrite read tool tests to use the effect test harness and document the migration pattern for effectified tools.
2026-04-03 21:46:29 -04:00
Kit Langton
288eb044cb perf(opencode): batch snapshot diffFull blob reads (#20752)
Co-authored-by: Nate Williams <50088025+natewill@users.noreply.github.com>
2026-04-04 01:05:23 +00:00
Kit Langton
59ca4543d8 refactor(provider): stop custom loaders using facades (#20776)
Co-authored-by: luanweslley77 <213105503+luanweslley77@users.noreply.github.com>
2026-04-04 00:24:24 +00:00
opencode-agent[bot]
650d0dbe54 chore: generate 2026-04-03 22:55:05 +00:00
Sebastian
a5ec741cff notes on v2 (#20941) 2026-04-04 00:54:03 +02:00
Aiden Cline
fff98636f7 chore: rm models snapshot (#20929) 2026-04-03 19:44:50 +00:00
Kit Langton
c72642dd35 test(ci): publish unit reports in actions (#20547) 2026-04-03 16:12:01 +00:00
Kit Langton
63168d35e8 refactor(effect): move read tool onto defineEffect 2026-04-03 12:05:47 -04:00
Kit Langton
74a2abe973 refactor(effect): build task tool from agent services 2026-04-03 12:05:44 -04:00
Kit Langton
f2d4ced8ea refactor(effect): build todowrite tool from Todo service (#20789)
Co-authored-by: Juan Pablo Carranza Hurtado <52012198+jpcarranza94@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 12:05:40 -04:00
Adam
ae7e2eb3fb chore(app): remove queued follow-ups for now 2026-04-03 10:56:40 -05:00
Adam
a32ffaba35 fix(app): show correct submit icon when typing follow up 2026-04-03 10:51:07 -05:00
opencode-agent[bot]
a4e75a0794 chore: generate 2026-04-03 14:56:44 +00:00
Shoubhit Dash
35350b1d25 feat: restore git-backed review modes (#20845) 2026-04-03 09:54:57 -05:00
Shoubhit Dash
263dcf75b5 fix: restore prompt focus after footer selection (#20841) 2026-04-03 14:36:33 +00:00
Kit Langton
7994dce0f2 refactor(effect): resolve built tools through the registry (#20787) 2026-04-03 10:31:00 -04:00
Shoubhit Dash
fbfa148e4e fix(app): hide default session timestamps (#20892) 2026-04-03 13:51:54 +00:00
Shoubhit Dash
9d57f21f9f feat(ui): redesign modified files section in session turn (#20348)
Co-authored-by: David Hill <iamdavidhill@gmail.com>
2026-04-03 08:32:53 -05:00
Shoubhit Dash
3deee3a02b fix(session): disable todo dock auto-scroll (#20840) 2026-04-03 08:32:30 -05:00
Ryan Vogel
cd3a58a4c2 Merge remote-tracking branch 'origin/dev' into opencode-remote-voice
# Conflicts:
#	bun.lock
2026-04-03 13:26:01 +00:00
Ryan Vogel
a5614f988f fix: unblock mobile EAS installs with Bun
Refresh the workspace lockfile and pin EAS to Bun 1.3.11 so frozen installs match local resolution and development builds stop failing in CI.
2026-04-03 13:18:13 +00:00
Shoubhit Dash
2002f08f2e fix(prompt): unmount model controls in shell mode (#20886) 2026-04-03 13:16:26 +00:00
Shoubhit Dash
c307505f8b fix(session): delay jump-to-bottom button (#20853) 2026-04-03 08:12:01 -05:00
Ryan Vogel
0f58efe030 fix: advertise MagicDNS hosts for Tailscale pairing
Prefer .ts.net names and allow Tailscale CGNAT HTTP on iOS so mobile pairing avoids ATS-blocked raw tailnet IPs.
2026-04-03 13:02:58 +00:00
Nate Williams
6359d00fb4 fix(core): fix restoring earlier messages in a reverted chain (#20780) 2026-04-03 18:23:00 +05:30
Brendan Allan
b969066a20 electron: better menus (#20878) 2026-04-03 12:41:30 +00:00
opencode-agent[bot]
500dcfc586 chore: update nix node_modules hashes 2026-04-03 03:53:46 +00:00
Luke Parker
7b8dc8065e fix(sdk): handle Windows opencode spawn and shutdown (#20772) 2026-04-03 13:18:50 +10:00
Kevin Flansburg
e89527c9f0 feat: Send x-session-affinity and x-parent-session-id headers (#20744) 2026-04-02 22:09:53 -05:00
Dax
aa2239d5de add automatic heap snapshots for high-memory cli processes (#20788) 2026-04-03 02:34:33 +00:00
opencode-agent[bot]
8daeacc989 chore: generate 2026-04-03 02:10:50 +00:00
Juan Pablo Carranza Hurtado
81d3ac3bf0 fix: prevent Tool.define() wrapper accumulation on object-defined tools (#16952)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 22:09:53 -04:00
Luke Parker
eb6f1dada8 fix: call models.dev once instead of twice on start (#20765) 2026-04-03 11:26:53 +10:00
Kit Langton
8e9e79d276 refactor(share): effectify share next (#20596) 2026-04-03 00:56:56 +00:00
Aiden Cline
38014fe448 fix: rm dynamic part from bash tool description again to restore cache hits across projects (#20771) 2026-04-03 00:16:40 +00:00
Kit Langton
8942fc21aa refactor(effect): prune unused facades (#20748) 2026-04-02 20:15:09 -04:00
ykswang
7f45943a9e fix(opencode): honor model limit.input overrides (#16306)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-03 00:13:10 +00:00
Sebastian
6e1400fc45 dialog aware prompt cursor (#20753) 2026-04-02 23:20:56 +02:00
opencode-agent[bot]
bf26c08d51 chore: update nix node_modules hashes 2026-04-02 20:47:24 +00:00
Ryan Vogel
4abb464345 feat: refine mobile voice reader interactions
Make Reader open and close feel like one continuous control instead of a view swap. Render assistant markdown consistently in both the compact card and Reader so formatted responses stay readable in either mode.
2026-04-02 20:38:49 +00:00
Sebastian
29f7dc073b Adds TUI prompt traits, refs, and plugin slots (#20741) 2026-04-02 22:11:17 +02:00
Ryan Vogel
d1d3d420bf fix: suppress subagent APN completion and error events 2026-04-02 20:11:12 +00:00
Ryan Vogel
1d68cd288c fix: resolve dev merge conflicts in opencode deps 2026-04-02 19:41:33 +00:00
Kit Langton
5e1b513527 refactor(todo): effectify session todo (#20595) 2026-04-02 19:11:23 +00:00
Kit Langton
f549fde874 test(app): emit junit artifacts for playwright (#20732) 2026-04-02 15:07:46 -04:00
Kit Langton
6dfb30448c refactor(app): unexport internal e2e helpers (#20730) 2026-04-02 18:53:51 +00:00
Kit Langton
b5b5f7e019 test(opencode): remove temporary e2e url repro (#20729) 2026-04-02 14:35:21 -04:00
Kit Langton
ae7b49b034 docs(effect): refresh migration status (#20665) 2026-04-02 14:33:58 -04:00
Ryan Vogel
0b8f8bc196 fix: reduce noisy push relay notifications
Only send completion pushes from session.status idle and suppress aborted/overflow errors. Avoid emitting redundant idle state on no-op cancel so users don't get duplicate notifications.
2026-04-02 15:48:02 +00:00
Ryan Vogel
4d30ad1e7c feat: enhance mobile voice build and UI layout
- Updated AGENTS.md with new build commands for development and production.
- Added a new script in package.json for starting Expo with a specific hostname.
- Improved layout handling in DictationScreen by adding dynamic height calculations for dropdown menus and footers.
- Introduced new state variables to manage menu list heights and footer heights for better UI responsiveness.
- Enhanced QR code generation for mobile pairing in the serve command, allowing for a connect QR option without starting the server.
2026-04-02 13:01:17 +00:00
Ryan Vogel
c90640e0e1 feat: expose push pairing QR endpoints
Return both JSON metadata and a PNG QR so clients can consume mobile pairing without rebuilding the payload themselves.
2026-04-02 13:00:08 +00:00
Makonnen
21e7f3f5c1 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-04-01 20:25:54 -04:00
Luke Parker
529a4d5e72 fix(build-node): preserve Bun linker for node-pty install (#20364) 2026-04-02 08:07:17 +10:00
Brendan Allan
9010bb6c14 fix web ui in electron 2026-04-01 16:40:44 +08:00
Brendan Allan
4d8ac02b87 build-node in prepare 2026-04-01 16:37:19 +08:00
Brendan Allan
d87ea42dab electron: remove cli and use server code directly 2026-04-01 16:37:18 +08:00
Brendan Allan
751d42d6a6 cleanup 2026-04-01 14:52:29 +08:00
Brendan Allan
0b93d2d8fb Merge branch 'dev' into refactor/hono-server 2026-04-01 12:23:29 +08:00
Ryan Vogel
36b51cad33 Merge branch 'dev' into opencode-remote-voice 2026-03-31 13:59:13 -04:00
Ryan Vogel
776e61d1ec update to build proc 2026-03-31 13:58:57 -04:00
Ryan Vogel
28aebb2772 update mobile voice iOS tracking
Stop tracking generated iOS native project files so EAS builds use app config prebuild output and avoid mixed native/CNG state.
2026-03-31 10:21:02 -04:00
Ryan Vogel
6494f48136 update 2026-03-30 17:05:49 -04:00
Ryan Vogel
15fae6cb60 update mobile pairing flow and audio session handling
Improve pairing reliability and UX by letting users choose among scanned hosts with health checks and cleaner row styling while shrinking QR payloads. Handle iOS call-time audio session conflicts more gracefully with user-friendly messaging and lower-noise logs.
2026-03-30 16:53:35 -04:00
Ryan Vogel
aacf1d20d3 update app hanlding 2026-03-30 13:07:30 -04:00
Ryan Vogel
bcf7817127 update mobile dictation controls
Add mobile permission approval flow, simplify dictation settings into toggles, and remove oversized Whisper models while syncing the iOS project with the current runtime configuration.
2026-03-30 13:01:14 -04:00
Ryan Vogel
abf79ae24c refactor mobile screen orchestration
Extract server/session and monitoring workflows into focused hooks so DictationScreen no longer owns every network and notification path. Add a dedicated mobile typecheck config so TypeScript checks pass without breaking Expo export resolution.
2026-03-30 08:57:35 -04:00
Ryan Vogel
922633ea9d refactor mobile derived ui state
Rewrite a focused cluster of nested ternaries in the mobile screen into straight-line derived logic so the render state is easier to read without changing behavior.
2026-03-30 08:31:46 -04:00
Ryan Vogel
49b40e3c90 refactor mobile fire-and-forget calls
Mark intentional async work in the mobile screen with the void operator so lint can distinguish real promise bugs from deliberate fire-and-forget behavior.
2026-03-30 08:30:28 -04:00
Ryan Vogel
df3276fc87 refactor mobile web color hydration
Replace the hydration state effect with useSyncExternalStore so the web color-scheme hook keeps its static-render fallback without triggering the set-state-in-effect lint warning.
2026-03-30 08:28:25 -04:00
Ryan Vogel
f8f986536b refactor mobile session payload parsing
Move server session response parsing into a typed helper so the mobile screen no longer relies on inline any-based mapping in the refresh path.
2026-03-30 08:27:16 -04:00
Ryan Vogel
785635caef refactor mobile onboarding config
Replace the onboarding step ternary chain with a typed step config so the screen is easier to read and lint can highlight the remaining hotspots more clearly.
2026-03-30 08:18:51 -04:00
Ryan Vogel
ec27518eca update mobile voice quality guardrails
Document package-specific React Native best practices and add lint warnings so state, effect, and complexity issues surface earlier during mobile-voice work.
2026-03-30 08:15:29 -04:00
Ryan Vogel
8ee4ada38e update for onboarding 2026-03-30 07:45:21 -04:00
Ryan Vogel
ab7b1d78bf Update settings 2026-03-30 07:33:30 -04:00
Ryan Vogel
2f44d1900e feat: support deep-link QR pairing in mobile
Generate mobilevoice deep links in serve QR output and let mobile parse both raw payloads and pair query links, while keeping advertised-host ordering and removing QR name overrides.
2026-03-29 19:38:19 -04:00
Ryan Vogel
cb535eef9d feat: support advertised QR hosts for mobile pairing
Allow serve to publish preferred host/domain entries in QR payloads and make mobile choose the first reachable host by QR order so preferred addresses like .ts.net are selected consistently.
2026-03-29 18:32:21 -04:00
Ryan Vogel
d3ec6f75f4 feat: route push notifications by server and session
Include serverID in relay event payloads and prefer server+session matching in mobile notification handling so taps reliably open the correct context and stale state is refreshed.
2026-03-29 17:52:07 -04:00
Ryan Vogel
9a8b2ae0b1 update apn server 2026-03-29 16:26:16 -04:00
Ryan Vogel
eadb0e25da update to the apn and server management 2026-03-29 16:17:57 -04:00
Ryan Vogel
ddd30ef304 update 2026-03-28 21:38:21 -04:00
Ryan Vogel
2abf1100ee update for whisper 2026-03-28 21:12:24 -04:00
Ryan Vogel
bd2e34f3bd update 2026-03-28 19:03:13 -04:00
Ryan Vogel
a45c3a0049 feat: harden mobile server flow and enrich push alerts
Persist scanned servers across reloads, smooth server/session UI states, and make recording feel immediate. Add session-aware push notification title/body metadata from the OpenCode server.
2026-03-28 18:10:35 -04:00
Ryan Vogel
52d1ee70a0 feat: use new mobile app icon and QR-only server add flow
Replace Expo icon/adaptive icon assets with the provided image and simplify the server dropdown so adding a server is done by scanning the setup QR code only.
2026-03-28 17:30:13 -04:00
Ryan Vogel
0a9fcab56f chore: update dependencies and enhance mobile-voice functionality
- Updated package dependencies in bun.lock and package.json for mobile-voice and opencode.
- Added expo-camera and improved camera permission handling in mobile-voice.
- Introduced QR code generation for relay setup in opencode serve command.
- Enhanced server management and logging in DictationScreen component.
2026-03-28 17:05:35 -04:00
Ryan Vogel
62fae6d182 fix: auto-recover APNs env mismatch in relay
Retry sends on BadEnvironmentKeyInToken with the opposite APNs environment, persist the corrected env, and add request/send logs for register/unregister/event delivery debugging.
2026-03-28 16:58:36 -04:00
Ryan Vogel
3a5be7ad33 update index.ts 2026-03-28 14:31:16 -04:00
Ryan Vogel
f1e88d35ba update for the db.ts 2026-03-28 14:28:44 -04:00
Ryan Vogel
b737e87d9a update env again 2026-03-28 14:16:57 -04:00
Ryan Vogel
bd6e81f30b update for env checks 2026-03-28 14:11:02 -04:00
Ryan Vogel
f080147363 update for app and bun 2026-03-28 14:03:57 -04:00
Ryan Vogel
0051b605ae feat: improve mobile model download UX and relay defaults
Add in-button model download progress plus a model reset control in mobile-voice, and switch APN relay defaults to apn.dev.opencode.ai in serve and docs.
2026-03-28 14:03:57 -04:00
Ryan Vogel
56e0e5ce65 Update packages json for the porter stuff 2026-03-28 14:03:57 -04:00
porter-deployment-app[bot]
d065d5a8ec Add Porter workflow files for APN relay project (#19547)
Co-authored-by: porter-deployment-app[bot] <87230664+porter-deployment-app[bot]@users.noreply.github.com>
2026-03-28 13:59:37 -04:00
Ryan Vogel
cf79208055 mobile-voice commit 2026-03-28 13:30:21 -04:00
Ryan Vogel
f276a8db42 feat: add APN relay MVP and experimental push bridge 2026-03-28 13:28:24 -04:00
LukeParkerDev
a722045ee8 Merge branch 'dev' into refactor/oauth-node-http 2026-03-28 11:41:48 +10:00
Dax Raad
cd49dc74f6 Merge remote-tracking branch 'origin/refactor/npm-over-bunproc' into refactor/npm-over-bunproc 2026-03-27 11:21:40 -04:00
Dax Raad
ec9c9960a3 refactor: migrate from BunProc to Npm module
- Replace BunProc.install with Npm.add for plugin resolution

- Remove needsInstall check from installDependencies

- Update test files to use Npm module instead of BunProc

- Delete bun/index.ts and bun/registry.ts (no longer needed)
2026-03-27 11:19:38 -04:00
Dax Raad
4d079b34f4 Merge dev into refactor/npm-over-bunproc 2026-03-27 11:03:05 -04:00
Dax
c5e288c1fa Merge branch 'dev' into refactor/npm-over-bunproc 2026-03-26 20:42:26 -04:00
Dax Raad
5079bf863e expect error 2026-03-26 20:41:12 -04:00
Dax Raad
57f7d39281 sync 2026-03-26 20:40:28 -04:00
Dax Raad
282ab0f67d Merge branch 'node-pty' into pr-18335 2026-03-26 19:04:00 -04:00
Dax Raad
ada7b11fd6 Merge branch 'dev' into pr-18335
Bring the PR up to date with the latest server, workspace, and UI changes from dev while keeping the PR's GitLab dependency and PTY merge resolutions.
2026-03-26 18:40:41 -04:00
Dax Raad
011bccd4d8 fix types 2026-03-26 18:22:35 -04:00
Dax Raad
c55b0a1261 Merge branch 'dev' into refactor/npm-over-bunproc 2026-03-26 18:20:04 -04:00
Brendan Allan
1359aa40af Merge branch 'dev' into refactor/npm-over-bunproc 2026-03-26 16:04:20 +08:00
Jay V
fa9674edf9 feat(app): add more titlebar visibility settings 2026-03-24 18:44:29 -04:00
Jay V
f45e084b3e feat(app): hide desktop titlebar tools behind settings 2026-03-24 18:21:57 -04:00
Jay V
700d0fe3cc tweak: use theme tokens for debug bar surface 2026-03-24 17:43:42 -04:00
Dax
a6bff14a78 Merge branch 'dev' into refactor/hono-server 2026-03-20 11:05:04 -04:00
Dax
5792a80a8c Merge branch 'dev' into feat/auto-accept-permissions 2026-03-20 10:46:31 -04:00
Dax Raad
df44b41aaa fix: handle undefined from Npm.which in all callers
Npm.which now returns undefined instead of throwing when binary not found.
Update all callers to check for undefined and return early.
Avoid explicit union types by using a resolved variable pattern.
2026-03-20 10:33:53 -04:00
Dax Raad
db039db7f5 regen js sdk 2026-03-20 10:21:10 -04:00
Dax Raad
c1a3936b61 Merge remote-tracking branch 'origin/dev' into feat/auto-accept-permissions
# Conflicts:
#	packages/sdk/js/src/v2/gen/types.gen.ts
2026-03-20 10:20:26 -04:00
Dax
b1d537b49a Merge branch 'dev' into refactor/npm-over-bunproc 2026-03-20 10:02:14 -04:00
Dax
9f5713e802 Merge branch 'dev' into refactor/oauth-node-http 2026-03-20 10:02:12 -04:00
Dax
37ff5aaa5c Merge branch 'dev' into refactor/hono-server 2026-03-20 10:02:09 -04:00
Dax
b77c797c0f Merge branch 'dev' into refactor/hono-server 2026-03-20 00:00:24 -04:00
Dax Raad
bc43bf378d sync 2026-03-19 23:38:54 -04:00
Dax Raad
3b669e7f2b fix type 2026-03-19 23:37:00 -04:00
Dax
e48da96886 Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-19 23:32:30 -04:00
Dax
20249bb723 Apply suggestion from @greptile-apps[bot]
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-19 23:29:18 -04:00
Dax Raad
205affa0eb fix: type error in mcp env and add missing @npmcli/arborist dependency 2026-03-19 23:09:27 -04:00
Dax Raad
3eeeec359a chore: extract misc fixes into #18328 2026-03-19 22:13:38 -04:00
Dax Raad
7409ce8aec fix: remove BUN_BE_BUN workaround, use node for ESLint server
Replace tsx dependency with explicit 'node' invocation for the
compiled ESLint server. Remove all BUN_BE_BUN env var references
which are no longer needed after the Bun-to-Node migration.
2026-03-19 22:05:30 -04:00
Dax Raad
c075a5d694 fix: force reinstall in which() when .bin is missing
When the lockfile exists but .bin is empty or absent, add() would
read the lockfile via loadVirtual() and return early without calling
reify(). Delete the lockfile before calling add() so it proceeds
with a full install.
2026-03-19 21:54:53 -04:00
Dax Raad
34c676ba7a fix: scope Npm.add() lock to per-package key
Use npm-install:${pkg} instead of a global npm-install lock so
concurrent installs of different packages can run in parallel.
2026-03-19 21:49:22 -04:00
Dax Raad
65e786258a chore: extract OAuth changes into #18327 2026-03-19 21:48:23 -04:00
Dax Raad
816a5f793f refactor: replace Bun.serve with Node http.createServer in OAuth handlers
Use Node's createServer for MCP OAuth callback and Codex plugin OAuth
servers instead of Bun.serve, making them work under Node.js.
2026-03-19 21:47:38 -04:00
Dax Raad
cbc40a5981 chore: extract node entry point into #18324 2026-03-19 21:30:35 -04:00
Dax Raad
b9b210a864 Merge branch 'dev' into opencode-2-0 2026-03-19 21:22:28 -04:00
Dax Raad
d473b7e971 chore: extract which/global changes into #18320 2026-03-19 21:22:06 -04:00
Dax Raad
f5783c4313 chore: extract portable process changes into #18318 2026-03-19 21:15:31 -04:00
Dax Raad
9439a5647e chore: revert drizzle upgrade (extracted to sqlite PR) 2026-03-19 21:10:53 -04:00
Dax Raad
2bfe81ee5c chore: extract SQLite abstraction into separate PR (#refactor/sqlite-abstraction) 2026-03-19 21:02:58 -04:00
Dax Raad
fcf1bb010c chore: update lockfile and package.json 2026-03-19 20:53:04 -04:00
Dax Raad
08b6d9c6dc sync 2026-03-19 20:48:44 -04:00
Dax Raad
0293a8bb80 chore: revert changes overlapping with #18308 2026-03-19 20:48:23 -04:00
Dax Raad
ca2099e69d refactor: replace BunProc with Npm module using @npmcli/arborist 2026-03-19 20:17:03 -04:00
Dax Raad
850dbb93eb Merge remote-tracking branch 'origin/dev' into opencode-2-0 2026-03-19 19:33:19 -04:00
Dax Raad
48e867ee20 Merge remote-tracking branch 'origin/dev' into opencode-2-0
# Conflicts:
#	.opencode/tool/github-pr-search.ts
#	.opencode/tool/github-triage.ts
2026-03-19 19:03:48 -04:00
Dax Raad
b5ebc541b9 Merge remote-tracking branch 'origin/dev' into opencode-2-0 2026-03-19 18:52:18 -04:00
Dax Raad
bd7a4cec90 sync 2026-03-19 17:53:35 -04:00
Dax Raad
63af295a17 Merge origin/dev into opencode-2-0 2026-03-19 16:07:13 -04:00
Adam
0a53f8e084 chore: cleanup 2026-03-11 13:56:16 -05:00
Adam
6f5b2f786e wip: node-pty 2026-03-11 13:47:20 -05:00
Dax Raad
04954a9620 Merge remote-tracking branch 'origin/opencode-2-0' into opencode-2-0 2026-03-11 14:32:38 -04:00
Dax Raad
fb63fd79a3 cleanup 2026-03-11 14:29:35 -04:00
Dax Raad
2e04b66eab sync 2026-03-11 14:29:03 -04:00
Dax Raad
f0b7c8c374 refactor(npm): inline pkgPath and lockPath variables 2026-03-11 14:29:03 -04:00
Dax Raad
be6f59035a unbreak 2026-03-11 14:29:03 -04:00
Dax Raad
27ab51f490 sync 2026-03-11 14:29:03 -04:00
Dax Raad
bca723e8fe core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-11 14:29:03 -04:00
Dax Raad
1ac39718d8 sync 2026-03-11 14:29:03 -04:00
Dax Raad
190319fb56 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-11 14:29:03 -04:00
Dax Raad
3154f0a61c core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-11 14:29:03 -04:00
Dax Raad
0b686b8178 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-11 14:29:03 -04:00
Dax Raad
4cba56171b sync 2026-03-11 14:29:03 -04:00
Dax Raad
66342acd31 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-11 14:29:03 -04:00
Dax Raad
88dae67549 refactor(server): replace Bun serve with Hono node adapters 2026-03-11 14:29:03 -04:00
Dax Raad
0ec42582f3 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-11 14:29:02 -04:00
Luke Parker
4f82248a68 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-11 14:29:02 -04:00
Dax Raad
5e069aab97 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-11 14:29:02 -04:00
Dax Raad
5325b2ec99 core: fix custom tool loading to properly resolve module paths 2026-03-11 14:29:02 -04:00
Dax Raad
2a98920922 sync 2026-03-11 14:29:02 -04:00
Dax Raad
5ea92ea6cb sync 2026-03-11 14:29:02 -04:00
Dax Raad
a18528a7ee sync 2026-03-11 14:29:02 -04:00
Dax Raad
ced125a974 core: log npm install errors to console for debugging dependency failures 2026-03-11 14:29:02 -04:00
Dax Raad
655fe20beb sync 2026-03-11 14:29:02 -04:00
Dax Raad
dd0c258e23 core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-11 14:29:02 -04:00
Dax Raad
791e27d289 sync 2026-03-11 14:29:02 -04:00
Dax Raad
fac0aec69f tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-11 14:29:02 -04:00
Dax Raad
ca26e639f6 core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-11 14:29:02 -04:00
Dax Raad
0b5d54f2cb core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-11 14:29:02 -04:00
Dax Raad
1b408cf06b core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-11 14:29:02 -04:00
Dax Raad
8e102d19ed core: disable npm bin links to fix package installation in sandboxed environments 2026-03-11 14:29:02 -04:00
Dax Raad
721b2406e9 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-11 14:29:01 -04:00
Dax Raad
4a6a18cd79 sync 2026-03-11 14:28:37 -04:00
Dax
c10b5880cc Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 14:28:37 -04:00
Dax
e6bf83084c Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 14:28:37 -04:00
Dax Raad
6722ee22ee sync 2026-03-11 14:28:36 -04:00
Dax Raad
870a5731ac refactor: lsp server and core improvements 2026-03-11 14:28:24 -04:00
Luke Parker
7910ce5d36 fix: guard Npm.which() against infinite loop when .bin is empty (#16961) 2026-03-11 09:34:58 -04:00
Dax
6ad171dba9 Merge branch 'dev' into opencode-2-0 2026-03-10 17:20:19 -04:00
Dax Raad
cb5674edc7 sync 2026-03-10 17:00:15 -04:00
Dax Raad
b99de4118e refactor(npm): inline pkgPath and lockPath variables 2026-03-10 16:59:01 -04:00
Dax Raad
040700dbc4 unbreak 2026-03-10 16:07:25 -04:00
Dax Raad
4d5da9697e sync 2026-03-10 16:02:40 -04:00
Dax Raad
a28648f530 core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-10 16:02:40 -04:00
Dax Raad
4d81e2d4d9 sync 2026-03-10 16:02:40 -04:00
Dax Raad
21e72cbf42 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-10 16:02:40 -04:00
Dax Raad
5f277d1e62 core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-10 16:02:40 -04:00
Dax Raad
d67e877e28 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-10 16:02:40 -04:00
Dax Raad
d4e51e04b3 sync 2026-03-10 16:02:40 -04:00
Dax Raad
070c1679e4 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-10 16:02:40 -04:00
Dax Raad
406d216cd2 refactor(server): replace Bun serve with Hono node adapters 2026-03-10 16:02:40 -04:00
Dax Raad
5dc8b4ef29 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-10 16:02:39 -04:00
Luke Parker
2f41d89163 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-10 16:02:39 -04:00
Dax Raad
b2eae867a1 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-10 16:02:39 -04:00
Dax Raad
3c2fda4d91 core: fix custom tool loading to properly resolve module paths 2026-03-10 16:02:39 -04:00
Dax Raad
2678ceb45e sync 2026-03-10 16:02:39 -04:00
Dax Raad
58a4cd00b6 sync 2026-03-10 16:02:39 -04:00
Dax Raad
0faa191b6d sync 2026-03-10 16:02:39 -04:00
Dax Raad
58cf092105 core: log npm install errors to console for debugging dependency failures 2026-03-10 16:02:39 -04:00
Dax Raad
0ff8bfe1d9 sync 2026-03-10 16:02:39 -04:00
Dax Raad
ceb79c786a core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-10 16:02:39 -04:00
Dax Raad
b1a15d559b sync 2026-03-10 16:02:39 -04:00
Dax Raad
124a8abf9b tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-10 16:02:39 -04:00
Dax Raad
85c2bb342b core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad
4c57e39466 core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad
0cdd4e4e16 core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-10 16:02:39 -04:00
Dax Raad
a9b01be0c2 core: disable npm bin links to fix package installation in sandboxed environments 2026-03-10 16:02:39 -04:00
Dax Raad
528daf5490 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-10 16:02:39 -04:00
Dax Raad
0e176d3ac3 sync 2026-03-10 16:02:39 -04:00
Dax
27f359852e Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Dax
173128d431 Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Dax Raad
e8ee1e239f sync 2026-03-10 16:02:39 -04:00
Dax Raad
656fa191c1 refactor: lsp server and core improvements 2026-03-10 16:02:39 -04: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
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
Adam
9d78b69cd3 wip(app): beta badge 2026-02-20 10:59:59 -06: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
268 changed files with 30145 additions and 64993 deletions

View File

@@ -0,0 +1,27 @@
"on":
push:
branches:
- opencode-remote-voice
name: Deploy to apn-relay
jobs:
porter-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set Github tag
id: vars
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Setup porter
uses: porter-dev/setup-porter@v0.1.0
- name: Deploy stack
timeout-minutes: 30
run: porter apply
env:
PORTER_APP_NAME: apn-relay
PORTER_CLUSTER: "5534"
PORTER_DEPLOYMENT_TARGET_ID: d60e67f5-b0a6-4275-8ed6-3cebaf092147
PORTER_HOST: https://dashboard.porter.run
PORTER_PROJECT: "18525"
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
PORTER_TOKEN: ${{ secrets.PORTER_APP_18525_975734319 }}

View File

@@ -15,6 +15,7 @@ concurrency:
permissions:
contents: read
checks: write
jobs:
unit:
@@ -45,14 +46,40 @@ jobs:
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
- name: Cache Turbo
uses: actions/cache@v4
with:
path: node_modules/.cache/turbo
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
turbo-${{ runner.os }}-
- name: Run unit tests
run: bun turbo test
run: bun turbo test:ci
env:
# Bun 1.3.11 intermittently crashes on Windows during test teardown
# inside the native @parcel/watcher binding. Unit CI does not rely on
# the live watcher backend there, so disable it for that platform.
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Publish unit reports
if: always()
uses: mikepenz/action-junit-report@v6
with:
report_paths: packages/*/.artifacts/unit/junit.xml
check_name: "unit results (${{ matrix.settings.name }})"
detailed_summary: true
include_time_in_summary: true
fail_on_failure: false
- name: Upload unit artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/*/.artifacts/unit/junit.xml
e2e:
name: e2e (${{ matrix.settings.name }})
strategy:
@@ -105,15 +132,17 @@ jobs:
run: bun --cwd packages/app test:e2e:local
env:
CI: true
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
timeout-minutes: 30
- name: Upload Playwright artifacts
if: failure()
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
if-no-files-found: ignore
retention-days: 7
path: |
packages/app/e2e/junit-*.xml
packages/app/e2e/test-results
packages/app/e2e/playwright-report

View File

@@ -653,23 +653,30 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
const skin = look(ctx.theme.current)
type Prompt = (props: {
workspaceID?: string
visible?: boolean
disabled?: boolean
onSubmit?: () => void
hint?: JSX.Element
right?: JSX.Element
showPlaceholder?: boolean
placeholders?: {
normal?: string[]
shell?: string[]
}
}) => JSX.Element
if (!("Prompt" in api.ui)) return null
const view = api.ui.Prompt
if (typeof view !== "function") return null
const Prompt = view as Prompt
type Slot = (
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
) => JSX.Element | null
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
const Prompt = ui.Prompt
const Slot = ui.Slot
const normal = [
`[SMOKE] route check for ${input.label}`,
"[SMOKE] confirm home_prompt slot override",
"[SMOKE] verify api.ui.Prompt rendering",
"[SMOKE] verify prompt-right slot passthrough",
]
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
const Hint = (
const hint = (
<box flexShrink={0} flexDirection="row" gap={1}>
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}></span> smoke home prompt
@@ -677,7 +684,46 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
</box>
)
return <Prompt workspaceID={value.workspace_id} hint={Hint} placeholders={{ normal, shell }} />
return (
<Prompt
workspaceID={value.workspace_id}
hint={hint}
right={
<box flexDirection="row" gap={1}>
<Slot name="home_prompt_right" workspace_id={value.workspace_id} />
<Slot name="smoke_prompt_right" workspace_id={value.workspace_id} label={input.label} />
</box>
}
placeholders={{ normal, shell }}
/>
)
},
home_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
const id = value.workspace_id?.slice(0, 8) ?? "none"
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> home:{id}
</text>
)
},
session_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> session:{value.session_id.slice(0, 8)}
</text>
)
},
smoke_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
const id = typeof value.workspace_id === "string" ? value.workspace_id.slice(0, 8) : "none"
const label = typeof value.label === "string" ? value.label : input.label
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{label}</span> custom:{id}
</text>
)
},
home_bottom(ctx) {
const skin = look(ctx.theme.current)

252
AGENTS.md
View File

@@ -1,128 +1,162 @@
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
# OpenCode Monorepo Agent Guide
## Style Guide
This file is for coding agents working in `/Users/ryanvogel/dev/opencode`.
### General Principles
## Scope And Precedence
- Keep things in one function unless composable or reusable
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Prefer single word variable names where possible
- Use Bun APIs when possible, like `Bun.file()`
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
- Start with this file for repo-wide defaults.
- Then check package-local `AGENTS.md` files for stricter rules.
- Existing local guides include `packages/opencode/AGENTS.md` and `packages/app/AGENTS.md`.
- Package-specific guides override this file when they conflict.
## Repo Facts
- Package manager: `bun` (`bun@1.3.11`).
- Monorepo tool: `turbo`.
- Default branch: `dev`.
- Root test script intentionally fails; do not run tests from root.
## Cursor / Copilot Rules
- No `.cursor/rules/` directory found.
- No `.cursorrules` file found.
- No `.github/copilot-instructions.md` file found.
- If these files are added later, treat them as mandatory project policy.
## High-Value Commands
Run commands from the correct package directory unless noted.
### Root
- Install deps: `bun install`
- Run all typechecks via turbo: `bun run typecheck`
- OpenCode dev CLI entry: `bun run dev`
- OpenCode serve (common): `bun run dev serve --hostname 0.0.0.0 --port 4096`
### `packages/opencode`
- Dev CLI: `bun run dev`
- Typecheck: `bun run typecheck`
- Tests (all): `bun test --timeout 30000`
- Tests (single file): `bun test test/path/to/file.test.ts --timeout 30000`
- Tests (single test name): `bun test test/path/to/file.test.ts -t "name fragment" --timeout 30000`
- Build: `bun run build`
- Drizzle helper: `bun run db`
### `packages/app`
- Dev server: `bun dev`
- Build: `bun run build`
- Typecheck: `bun run typecheck`
- Unit tests (all): `bun run test:unit`
- Unit tests (single file): `bun test --preload ./happydom.ts ./src/path/to/file.test.ts`
- Unit tests (single test name): `bun test --preload ./happydom.ts ./src/path/to/file.test.ts -t "name fragment"`
- E2E tests: `bun run test:e2e`
### `packages/mobile-voice`
- Start Expo: `bun run start`
- Start Expo dev client: `bunx expo start --dev-client --clear --host lan`
- iOS native run: `bun run ios`
- Android native run: `bun run android`
- Lint: `bun run lint`
- Expo doctor: `bunx expo-doctor`
- Dependency compatibility check: `bunx expo install --check`
### `packages/apn-relay`
- Start relay: `bun run dev`
- Typecheck: `bun run typecheck`
- DB connectivity check: `bun run db:check`
## Build / Lint / Test Expectations
- Always run the narrowest checks that prove your change.
- For backend changes: run package typecheck + relevant tests.
- For mobile changes: run `expo lint` and at least one `expo` compile-style command if possible.
- Never claim tests passed unless you ran them in this workspace.
## Single-Test Guidance
- Prefer running one file first, then broaden scope.
- For Bun tests, pass the file path directly.
- For name filtering, use `-t "..."`.
- Keep original timeouts when scripts define them.
## Code Style Guidelines
These conventions are already used heavily in this repo and should be preserved.
### Formatting
- Use Prettier defaults configured in root: `semi: false`, `printWidth: 120`.
- Keep imports grouped and stable; avoid noisy reorder-only edits.
- Avoid unrelated formatting churn in touched files.
### Imports
- Prefer explicit imports over dynamic imports unless runtime gating is required.
- Prefer existing alias patterns (for example `@/...`) where already configured.
- Do not introduce new dependency layers when a local util already exists.
### Types
- Avoid `any`.
- Prefer inference for local variables.
- Add explicit annotations for exported APIs and complex boundaries.
- Prefer `zod` schemas for request/response validation and parsing.
### Naming
Prefer single word names for variables and functions. Only use multiple words if necessary.
### Naming Enforcement (Read This)
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
- Use single word names by default for new locals, params, and helper functions.
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
```ts
// Good
const foo = 1
function journal(dir: string) {}
// Bad
const fooBar = 1
function prepareJournal(dir: string) {}
```
Reduce total variable count by inlining when a value is only used once.
```ts
// Good
const journal = await Bun.file(path.join(dir, "journal.json")).json()
// Bad
const journalPath = path.join(dir, "journal.json")
const journal = await Bun.file(journalPath).json()
```
### Destructuring
Avoid unnecessary destructuring. Use dot notation to preserve context.
```ts
// Good
obj.a
obj.b
// Bad
const { a, b } = obj
```
### Variables
Prefer `const` over `let`. Use ternaries or early returns instead of reassignment.
```ts
// Good
const foo = condition ? 1 : 2
// Bad
let foo
if (condition) foo = 1
else foo = 2
```
- Follow existing repo preference for short, clear names.
- Use single-word names when readable; use multi-word only for clarity.
- Keep naming consistent with nearby code.
### Control Flow
Avoid `else` statements. Prefer early returns.
- Prefer early returns over nested `else` blocks.
- Keep functions focused; split only when it improves reuse or readability.
```ts
// Good
function foo() {
if (condition) return 1
return 2
}
### Error Handling
// Bad
function foo() {
if (condition) return 1
else return 2
}
```
- Fail with actionable messages.
- Avoid swallowing errors silently.
- Log enough context to debug production issues (IDs, env, status), but never secrets.
- In UI code, degrade gracefully for missing capabilities.
### Schema Definitions (Drizzle)
### Data / DB
Use snake_case for field names so column names don't need to be redefined as strings.
- For Drizzle schema, use snake_case fields and columns.
- Keep migration and schema changes minimal and explicit.
- Follow package-specific DB guidance in `packages/opencode/AGENTS.md`.
```ts
// Good
const table = sqliteTable("session", {
id: text().primaryKey(),
project_id: text().notNull(),
created_at: integer().notNull(),
})
### Testing Philosophy
// Bad
const table = sqliteTable("session", {
id: text("id").primaryKey(),
projectID: text("project_id").notNull(),
createdAt: integer("created_at").notNull(),
})
```
- Prefer testing real behavior over mocks.
- Add regression tests for bug fixes where practical.
- Keep fixtures small and focused.
## Testing
## Agent Workflow Tips
- 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`.
- Read existing code paths before introducing new abstractions.
- Match local patterns first; do not impose a new style per file.
- If a package has its own `AGENTS.md`, review it before editing.
- For OpenCode Effect services, follow `packages/opencode/AGENTS.md` strictly.
## Type Checking
## Known Operational Notes
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
- `packages/app/AGENTS.md` says: never restart app/server processes during that package's debugging workflow.
- `packages/app/AGENTS.md` also documents local backend+web split for UI work.
- `packages/opencode/AGENTS.md` contains mandatory Effect and database conventions.
## Regeneration / Special Scripts
- Regenerate JS SDK with: `./packages/sdk/js/script/build.ts`
## Quick Checklist Before Finishing
- Ran relevant package checks.
- Updated docs/config when behavior changed.
- Avoided committing unrelated files.
- Kept edits minimal and aligned with local conventions.

1766
bun.lock

File diff suppressed because it is too large Load Diff

0
eas.json Normal file
View File

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-cMIblNlBgq3fJonaFywzT/VrusmFhrHThOKa5p6vIlw=",
"aarch64-linux": "sha256-ougfUo4oqyyW2fBUK/i8U0//tqEvYnhNhnG2SR0s3B8=",
"aarch64-darwin": "sha256-3n0X0GfEydQgbRTmXnFpnQTKFFE9bOjmHXaJpHji4JE=",
"x86_64-darwin": "sha256-8KEV+Gy+UedqW25ene7O3M0aRPk8LdV8bAKrWCNfeLw="
"x86_64-linux": "sha256-0jwPCu2Lod433GPQLHN8eEkhfpPviDFfkFJmuvkRdlE=",
"aarch64-linux": "sha256-Qi0IkGkaIBKZsPLTO8kaTbCVL0cEfVOm/Y/6VUVI9TY=",
"aarch64-darwin": "sha256-1eZBBLgYVkjg5RYN/etR1Mb5UjU3VelElBB5ug5hQdc=",
"x86_64-darwin": "sha256-jdXgA+kZb/foFHR40UiPif6rsA2GDVCCVHnJR3jBUGI="
}
}

View File

@@ -12,6 +12,7 @@
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck",
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'",
@@ -27,6 +28,7 @@
"catalog": {
"@effect/platform-node": "4.0.0-beta.43",
"@types/bun": "1.3.11",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
@@ -47,6 +49,7 @@
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.43",
"ai": "6.0.138",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
@@ -101,6 +104,7 @@
},
"trustedDependencies": [
"esbuild",
"node-pty",
"protobufjs",
"tree-sitter",
"tree-sitter-bash",

View File

@@ -0,0 +1,11 @@
PORT=8787
DATABASE_HOST=
DATABASE_USERNAME=
DATABASE_PASSWORD=
DATABASE_NAME=main
APNS_TEAM_ID=
APNS_KEY_ID=
APNS_PRIVATE_KEY=
APNS_DEFAULT_BUNDLE_ID=com.anomalyco.mobilevoice

View File

@@ -0,0 +1,106 @@
# apn-relay Agent Guide
This file defines package-specific guidance for agents working in `packages/apn-relay`.
## Scope And Precedence
- Follow root `AGENTS.md` first.
- This file provides stricter package-level conventions for relay service work.
- If future local guides are added, closest guide wins.
## Project Overview
- Minimal APNs relay service (Hono + Bun + PlanetScale via Drizzle).
- Core routes:
- `GET /health`
- `GET /`
- `POST /v1/device/register`
- `POST /v1/device/unregister`
- `POST /v1/event`
## Commands
Run all commands from `packages/apn-relay`.
- Install deps: `bun install`
- Start relay locally: `bun run dev`
- Typecheck: `bun run typecheck`
- DB connectivity check: `bun run db:check`
## Build / Test Expectations
- There is no dedicated package test script currently.
- Required validation for behavior changes:
- `bun run typecheck`
- `bun run db:check` when DB/env changes are involved
- manual endpoint verification against `/health`, `/v1/device/register`, `/v1/event`
## Single-Test Guidance
- No single-test command exists for this package today.
- For focused checks, run endpoint-level manual tests against a local dev server.
## Code Style Guidelines
### Formatting / Structure
- Keep handlers compact and explicit.
- Prefer small local helpers for repeated route logic.
- Avoid broad refactors when a targeted fix is enough.
### Types / Validation
- Validate request bodies with `zod` at route boundaries.
- Keep payload and DB row shapes explicit and close to usage.
- Avoid `any`; narrow unknown input immediately after parsing.
### Naming
- Follow existing concise naming in this package (`reg`, `unreg`, `evt`, `row`, `key`).
- For DB columns, keep snake_case alignment with schema.
### Error Handling
- Return clear JSON errors for invalid input.
- Keep handler failures observable via `app.onError` and structured logs.
- Do not leak secrets in responses or logs.
### Logging
- Log delivery lifecycle at key checkpoints:
- registration/unregistration attempts
- event fanout start/end
- APNs send failures and retries
- Mask sensitive values; prefer token suffixes and metadata.
### APNs Environment Rules
- Keep APNs env explicit per registration (`sandbox` / `production`).
- For `BadEnvironmentKeyInToken`, retry once with flipped env and persist correction.
- Avoid infinite retry loops; one retry max per delivery attempt.
## Database Conventions
- Schema is in `src/schema.sql.ts`.
- Keep table/column names snake_case.
- Maintain index naming consistency with existing schema.
- For upserts, update only fields required by current behavior.
## API Behavior Expectations
- `register`/`unregister` must be idempotent.
- `event` should return success envelope even when no devices are registered.
- Delivery logs should capture per-attempt result and error payload.
## Operational Notes
- Ensure `APNS_PRIVATE_KEY` supports escaped newline format (`\n`) and raw multiline.
- Validate that `APNS_DEFAULT_BUNDLE_ID` matches mobile app bundle identifier.
- Avoid coupling route behavior to deployment platform specifics.
## Before Finishing
- Run `bun run typecheck`.
- If DB/env behavior changed, run `bun run db:check`.
- Manually exercise affected endpoints.
- Confirm logs are useful and secret-safe.

View File

@@ -0,0 +1,14 @@
FROM oven/bun:1.3.11-alpine
WORKDIR /app
COPY package.json ./
COPY tsconfig.json ./
COPY drizzle.config.ts ./
RUN bun install --production
COPY src ./src
EXPOSE 8787
CMD ["bun", "run", "src/index.ts"]

View File

@@ -0,0 +1,46 @@
# APN Relay
Minimal APNs relay for OpenCode mobile background notifications.
## What it does
- Registers iOS device tokens for a shared secret.
- Receives OpenCode event posts (`complete`, `permission`, `error`).
- Sends APNs notifications to mapped devices.
- Stores delivery rows in PlanetScale.
## Routes
- `GET /health`
- `GET /` (simple dashboard)
- `POST /v1/device/register`
- `POST /v1/device/unregister`
- `POST /v1/event`
## Environment
Use `.env.example` as a starting point.
- `DATABASE_HOST`
- `DATABASE_USERNAME`
- `DATABASE_PASSWORD`
- `APNS_TEAM_ID`
- `APNS_KEY_ID`
- `APNS_PRIVATE_KEY`
- `APNS_DEFAULT_BUNDLE_ID`
## Run locally
```bash
bun install
bun run src/index.ts
```
## Docker
Build from this directory:
```bash
docker build -t apn-relay .
docker run --rm -p 8787:8787 --env-file .env apn-relay
```

View File

@@ -0,0 +1,17 @@
import { defineConfig } from "drizzle-kit"
export default defineConfig({
out: "./migration",
strict: true,
schema: ["./src/**/*.sql.ts"],
dialect: "mysql",
dbCredentials: {
host: process.env.DATABASE_HOST ?? "",
user: process.env.DATABASE_USERNAME ?? "",
password: process.env.DATABASE_PASSWORD ?? "",
database: process.env.DATABASE_NAME ?? "main",
ssl: {
rejectUnauthorized: false,
},
},
})

View File

@@ -0,0 +1,27 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/apn-relay",
"version": "0.0.0",
"private": true,
"type": "module",
"license": "MIT",
"scripts": {
"dev": "bun run src/index.ts",
"db:check": "bun run --env-file .env src/check.ts",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@planetscale/database": "1.19.0",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"hono": "4.10.7",
"jose": "6.0.11",
"zod": "4.1.8"
},
"devDependencies": {
"@tsconfig/bun": "1.0.9",
"@types/bun": "1.3.11",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"typescript": "5.8.2"
}
}

View File

@@ -0,0 +1,185 @@
import { connect } from "node:http2"
import { SignJWT, importPKCS8 } from "jose"
import { env } from "./env"
export type PushEnv = "sandbox" | "production"
type PushInput = {
token: string
bundle: string
env: PushEnv
title: string
body: string
data: Record<string, unknown>
}
type PushResult = {
ok: boolean
code: number
error?: string
}
function tokenSuffix(input: string) {
return input.length > 8 ? input.slice(-8) : input
}
let jwt = ""
let exp = 0
let pk: Awaited<ReturnType<typeof importPKCS8>> | undefined
function host(input: PushEnv) {
if (input === "sandbox") return "api.sandbox.push.apple.com"
return "api.push.apple.com"
}
function key() {
if (env.APNS_PRIVATE_KEY.includes("\\n")) return env.APNS_PRIVATE_KEY.replace(/\\n/g, "\n")
return env.APNS_PRIVATE_KEY
}
async function sign() {
if (!pk) pk = await importPKCS8(key(), "ES256")
const now = Math.floor(Date.now() / 1000)
if (jwt && now < exp) return jwt
jwt = await new SignJWT({})
.setProtectedHeader({ alg: "ES256", kid: env.APNS_KEY_ID })
.setIssuer(env.APNS_TEAM_ID)
.setIssuedAt(now)
.sign(pk)
exp = now + 50 * 60
return jwt
}
function post(input: {
host: string
token: string
auth: string
bundle: string
payload: string
}): Promise<{ code: number; body: string }> {
return new Promise((resolve, reject) => {
const cli = connect(`https://${input.host}`)
let done = false
let code = 0
let body = ""
const stop = (fn: () => void) => {
if (done) return
done = true
fn()
}
cli.on("error", (err) => {
stop(() => reject(err))
cli.close()
})
const req = cli.request({
":method": "POST",
":path": `/3/device/${input.token}`,
authorization: `bearer ${input.auth}`,
"apns-topic": input.bundle,
"apns-push-type": "alert",
"apns-priority": "10",
"content-type": "application/json",
})
req.setEncoding("utf8")
req.on("response", (headers) => {
code = Number(headers[":status"] ?? 0)
})
req.on("data", (chunk) => {
body += chunk
})
req.on("end", () => {
stop(() => resolve({ code, body }))
cli.close()
})
req.on("error", (err) => {
stop(() => reject(err))
cli.close()
})
req.end(input.payload)
})
}
export async function send(input: PushInput): Promise<PushResult> {
const apnsHost = host(input.env)
const suffix = tokenSuffix(input.token)
console.log("[ APN RELAY ] push:start", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
})
const auth = await sign().catch((err) => {
return `error:${String(err)}`
})
if (auth.startsWith("error:")) {
console.log("[ APN RELAY ] push:auth-failed", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
error: auth,
})
return {
ok: false,
code: 0,
error: auth,
}
}
const payload = JSON.stringify({
aps: {
alert: {
title: input.title,
body: input.body,
},
sound: "alert.wav",
},
...input.data,
})
const out = await post({
host: apnsHost,
token: input.token,
auth,
bundle: input.bundle,
payload,
}).catch((err) => ({
code: 0,
body: String(err),
}))
if (out.code === 200) {
console.log("[ APN RELAY ] push:sent", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
code: out.code,
})
return {
ok: true,
code: 200,
}
}
console.log("[ APN RELAY ] push:failed", {
env: input.env,
host: apnsHost,
bundle: input.bundle,
tokenSuffix: suffix,
code: out.code,
error: out.body,
})
return {
ok: false,
code: out.code,
error: out.body,
}
}

View File

@@ -0,0 +1,28 @@
import { sql } from "drizzle-orm"
import { db } from "./db"
import { env } from "./env"
import { delivery_log, device_registration } from "./schema.sql"
import { setup } from "./setup"
async function run() {
console.log(`[apn-relay] DB host: ${env.DATABASE_HOST}`)
await db.execute(sql`SELECT 1`)
console.log("[apn-relay] DB connection OK")
await setup()
console.log("[apn-relay] Setup migration OK")
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
console.log(`[apn-relay] device_registration rows: ${Number(a?.value ?? 0)}`)
console.log(`[apn-relay] delivery_log rows: ${Number(b?.value ?? 0)}`)
console.log("[apn-relay] DB check passed")
}
run().catch((err) => {
console.error("[apn-relay] DB check failed")
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,11 @@
import { Client } from "@planetscale/database"
import { drizzle } from "drizzle-orm/planetscale-serverless"
import { env } from "./env"
const client = new Client({
host: env.DATABASE_HOST,
username: env.DATABASE_USERNAME,
password: env.DATABASE_PASSWORD,
})
export const db = drizzle({ client })

View File

@@ -0,0 +1,47 @@
import { z } from "zod"
const bad = new Set(["undefined", "null"])
const txt = z
.string()
.transform((input) => input.trim())
.refine((input) => input.length > 0 && !bad.has(input.toLowerCase()))
const schema = z.object({
PORT: z.coerce.number().int().positive().default(8787),
DATABASE_HOST: txt,
DATABASE_USERNAME: txt,
DATABASE_PASSWORD: txt,
APNS_TEAM_ID: txt,
APNS_KEY_ID: txt,
APNS_PRIVATE_KEY: txt,
APNS_DEFAULT_BUNDLE_ID: txt,
})
const req = [
"DATABASE_HOST",
"DATABASE_USERNAME",
"DATABASE_PASSWORD",
"APNS_TEAM_ID",
"APNS_KEY_ID",
"APNS_PRIVATE_KEY",
"APNS_DEFAULT_BUNDLE_ID",
] as const
const out = schema.safeParse(process.env)
if (!out.success) {
const miss = req.filter((key) => !process.env[key]?.trim())
const bad = out.error.issues
.map((item) => item.path[0])
.filter((key): key is string => typeof key === "string")
.filter((key) => !miss.includes(key as (typeof req)[number]))
console.error("[apn-relay] Invalid startup configuration")
if (miss.length) console.error(`[apn-relay] Missing required env vars: ${miss.join(", ")}`)
if (bad.length) console.error(`[apn-relay] Invalid env vars: ${Array.from(new Set(bad)).join(", ")}`)
console.error("[apn-relay] Check .env.example and restart")
throw new Error("Startup configuration invalid")
}
export const env = out.data

View File

@@ -0,0 +1,5 @@
import { createHash } from "node:crypto"
export function hash(input: string) {
return createHash("sha256").update(input).digest("hex")
}

View File

@@ -0,0 +1,448 @@
import { randomUUID } from "node:crypto"
import { and, desc, eq, sql } from "drizzle-orm"
import { Hono } from "hono"
import { z } from "zod"
import { send } from "./apns"
import { db } from "./db"
import { env } from "./env"
import { hash } from "./hash"
import { delivery_log, device_registration } from "./schema.sql"
import { setup } from "./setup"
function bad(input?: string) {
if (!input) return false
return input.includes("BadEnvironmentKeyInToken")
}
function flip(input: "sandbox" | "production") {
if (input === "sandbox") return "production"
return "sandbox"
}
function tail(input: string) {
return input.slice(-8)
}
function esc(input: unknown) {
return String(input ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}
function fmt(input: number) {
return new Date(input).toISOString()
}
const reg = z.object({
secret: z.string().min(1),
deviceToken: z.string().min(1),
bundleId: z.string().min(1).optional(),
apnsEnv: z.enum(["sandbox", "production"]).default("production"),
})
const unreg = z.object({
secret: z.string().min(1),
deviceToken: z.string().min(1),
})
const evt = z.object({
secret: z.string().min(1),
serverID: z.string().min(1).optional(),
eventType: z.enum(["complete", "permission", "error"]),
sessionID: z.string().min(1),
title: z.string().min(1).optional(),
body: z.string().min(1).optional(),
})
function title(input: z.infer<typeof evt>["eventType"]) {
if (input === "complete") return "Session complete"
if (input === "permission") return "Action needed"
return "Session error"
}
function body(input: z.infer<typeof evt>["eventType"]) {
if (input === "complete") return "OpenCode finished your session."
if (input === "permission") return "OpenCode needs your permission decision."
return "OpenCode reported an error for your session."
}
const app = new Hono()
app.onError((err, c) => {
return c.json(
{
ok: false,
error: err.message,
},
500,
)
})
app.notFound((c) => {
return c.json(
{
ok: false,
error: "Not found",
},
404,
)
})
app.get("/health", async (c) => {
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
return c.json({
ok: true,
devices: Number(a?.value ?? 0),
deliveries: Number(b?.value ?? 0),
})
})
app.get("/", async (c) => {
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
const devices = await db.select().from(device_registration).orderBy(desc(device_registration.updated_at)).limit(100)
const byBundle = await db
.select({
bundle: device_registration.bundle_id,
env: device_registration.apns_env,
value: sql<number>`count(*)`,
})
.from(device_registration)
.groupBy(device_registration.bundle_id, device_registration.apns_env)
.orderBy(desc(sql<number>`count(*)`))
const rows = await db.select().from(delivery_log).orderBy(desc(delivery_log.created_at)).limit(20)
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>APN Relay</title>
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 24px; color: #111827; }
h1 { margin: 0 0 12px 0; }
h2 { margin: 22px 0 10px 0; font-size: 16px; }
.stats { display: flex; gap: 16px; margin: 0 0 18px 0; }
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 12px; min-width: 160px; }
.muted { color: #6b7280; font-size: 12px; }
.small { font-size: 11px; color: #6b7280; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #e5e7eb; text-align: left; padding: 8px; font-size: 12px; }
th { background: #f9fafb; }
</style>
</head>
<body>
<h1>APN Relay</h1>
<p class="muted">MVP dashboard</p>
<div class="stats">
<div class="card">
<div class="muted">Registered devices</div>
<div>${Number(a?.value ?? 0)}</div>
</div>
<div class="card">
<div class="muted">Delivery log rows</div>
<div>${Number(b?.value ?? 0)}</div>
</div>
</div>
<h2>Registered devices</h2>
<p class="small">Most recent 100 registrations. Token values are masked to suffix only.</p>
<table>
<thead>
<tr>
<th>updated</th>
<th>created</th>
<th>token suffix</th>
<th>env</th>
<th>bundle</th>
<th>secret hash</th>
</tr>
</thead>
<tbody>
${
devices.length
? devices
.map(
(row) => `<tr>
<td>${esc(fmt(row.updated_at))}</td>
<td>${esc(fmt(row.created_at))}</td>
<td>${esc(tail(row.device_token))}</td>
<td>${esc(row.apns_env)}</td>
<td>${esc(row.bundle_id)}</td>
<td>${esc(`${row.secret_hash.slice(0, 12)}`)}</td>
</tr>`,
)
.join("")
: `<tr><td colspan="6" class="muted">No devices registered.</td></tr>`
}
</tbody>
</table>
<h2>Bundle breakdown</h2>
<table>
<thead>
<tr>
<th>bundle</th>
<th>env</th>
<th>count</th>
</tr>
</thead>
<tbody>
${
byBundle.length
? byBundle
.map(
(row) => `<tr>
<td>${esc(row.bundle)}</td>
<td>${esc(row.env)}</td>
<td>${esc(Number(row.value ?? 0))}</td>
</tr>`,
)
.join("")
: `<tr><td colspan="3" class="muted">No device data.</td></tr>`
}
</tbody>
</table>
<h2>Recent deliveries</h2>
<table>
<thead>
<tr>
<th>time</th>
<th>event</th>
<th>session</th>
<th>status</th>
<th>error</th>
</tr>
</thead>
<tbody>
${rows
.map(
(row) => `<tr>
<td>${esc(fmt(row.created_at))}</td>
<td>${esc(row.event_type)}</td>
<td>${esc(row.session_id)}</td>
<td>${esc(row.status)}</td>
<td>${esc(row.error ?? "")}</td>
</tr>`,
)
.join("")}
</tbody>
</table>
</body>
</html>`
return c.html(html)
})
app.post("/v1/device/register", async (c) => {
const raw = await c.req.json().catch(() => undefined)
const check = reg.safeParse(raw)
if (!check.success) {
return c.json(
{
ok: false,
error: "Invalid request body",
},
400,
)
}
const now = Date.now()
const key = hash(check.data.secret)
const row = {
id: randomUUID(),
secret_hash: key,
device_token: check.data.deviceToken,
bundle_id: check.data.bundleId ?? env.APNS_DEFAULT_BUNDLE_ID,
apns_env: check.data.apnsEnv,
created_at: now,
updated_at: now,
}
console.log("[relay] register", {
token: tail(row.device_token),
env: row.apns_env,
bundle: row.bundle_id,
secretHash: `${key.slice(0, 12)}...`,
})
await db
.insert(device_registration)
.values(row)
.onDuplicateKeyUpdate({
set: {
bundle_id: row.bundle_id,
apns_env: row.apns_env,
updated_at: now,
},
})
return c.json({ ok: true })
})
app.post("/v1/device/unregister", async (c) => {
const raw = await c.req.json().catch(() => undefined)
const check = unreg.safeParse(raw)
if (!check.success) {
return c.json(
{
ok: false,
error: "Invalid request body",
},
400,
)
}
const key = hash(check.data.secret)
console.log("[relay] unregister", {
token: tail(check.data.deviceToken),
secretHash: `${key.slice(0, 12)}...`,
})
await db
.delete(device_registration)
.where(and(eq(device_registration.secret_hash, key), eq(device_registration.device_token, check.data.deviceToken)))
return c.json({ ok: true })
})
app.post("/v1/event", async (c) => {
const raw = await c.req.json().catch(() => undefined)
const check = evt.safeParse(raw)
if (!check.success) {
return c.json(
{
ok: false,
error: "Invalid request body",
},
400,
)
}
const key = hash(check.data.secret)
const list = await db.select().from(device_registration).where(eq(device_registration.secret_hash, key))
console.log("[relay] event", {
type: check.data.eventType,
serverID: check.data.serverID,
session: check.data.sessionID,
secretHash: `${key.slice(0, 12)}...`,
devices: list.length,
})
if (!list.length) {
const [total] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
console.log("[relay] event:no-matching-devices", {
type: check.data.eventType,
serverID: check.data.serverID,
session: check.data.sessionID,
secretHash: `${key.slice(0, 12)}...`,
totalDevices: Number(total?.value ?? 0),
})
return c.json({
ok: true,
sent: 0,
failed: 0,
})
}
const out = await Promise.all(
list.map(async (row) => {
const env = row.apns_env === "sandbox" ? "sandbox" : "production"
const payload = {
token: row.device_token,
bundle: row.bundle_id,
title: check.data.title ?? title(check.data.eventType),
body: check.data.body ?? body(check.data.eventType),
data: {
serverID: check.data.serverID,
eventType: check.data.eventType,
sessionID: check.data.sessionID,
},
}
const first = await send({ ...payload, env })
if (first.ok || !bad(first.error)) {
if (!first.ok) {
console.log("[relay] send:error", {
token: tail(row.device_token),
env,
error: first.error,
})
}
return first
}
const alt = flip(env)
console.log("[relay] send:retry-env", {
token: tail(row.device_token),
from: env,
to: alt,
})
const second = await send({ ...payload, env: alt })
if (!second.ok) {
console.log("[relay] send:error", {
token: tail(row.device_token),
env: alt,
error: second.error,
})
return second
}
await db
.update(device_registration)
.set({ apns_env: alt, updated_at: Date.now() })
.where(
and(
eq(device_registration.secret_hash, row.secret_hash),
eq(device_registration.device_token, row.device_token),
),
)
console.log("[relay] send:env-updated", {
token: tail(row.device_token),
env: alt,
})
return second
}),
)
const now = Date.now()
await db.insert(delivery_log).values(
out.map((item) => ({
id: randomUUID(),
secret_hash: key,
event_type: check.data.eventType,
session_id: check.data.sessionID,
status: item.ok ? "sent" : "failed",
error: item.error,
created_at: now,
})),
)
const sent = out.filter((item) => item.ok).length
console.log("[relay] event:done", {
type: check.data.eventType,
session: check.data.sessionID,
sent,
failed: out.length - sent,
})
return c.json({
ok: true,
sent,
failed: out.length - sent,
})
})
await setup()
if (import.meta.main) {
Bun.serve({
port: env.PORT,
fetch: app.fetch,
})
console.log(`apn-relay listening on http://0.0.0.0:${env.PORT}`)
}
export { app }

View File

@@ -0,0 +1,35 @@
import { bigint, index, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
export const device_registration = mysqlTable(
"device_registration",
{
id: varchar("id", { length: 36 }).primaryKey(),
secret_hash: varchar("secret_hash", { length: 64 }).notNull(),
device_token: varchar("device_token", { length: 255 }).notNull(),
bundle_id: varchar("bundle_id", { length: 255 }).notNull(),
apns_env: varchar("apns_env", { length: 16 }).notNull().default("production"),
created_at: bigint("created_at", { mode: "number" }).notNull(),
updated_at: bigint("updated_at", { mode: "number" }).notNull(),
},
(table) => [
uniqueIndex("device_registration_secret_token_idx").on(table.secret_hash, table.device_token),
index("device_registration_secret_hash_idx").on(table.secret_hash),
],
)
export const delivery_log = mysqlTable(
"delivery_log",
{
id: varchar("id", { length: 36 }).primaryKey(),
secret_hash: varchar("secret_hash", { length: 64 }).notNull(),
event_type: varchar("event_type", { length: 32 }).notNull(),
session_id: varchar("session_id", { length: 255 }).notNull(),
status: varchar("status", { length: 16 }).notNull(),
error: varchar("error", { length: 1024 }),
created_at: bigint("created_at", { mode: "number" }).notNull(),
},
(table) => [
index("delivery_log_secret_hash_idx").on(table.secret_hash),
index("delivery_log_created_at_idx").on(table.created_at),
],
)

View File

@@ -0,0 +1,34 @@
import { sql } from "drizzle-orm"
import { db } from "./db"
export async function setup() {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS device_registration (
id varchar(36) NOT NULL,
secret_hash varchar(64) NOT NULL,
device_token varchar(255) NOT NULL,
bundle_id varchar(255) NOT NULL,
apns_env varchar(16) NOT NULL DEFAULT 'production',
created_at bigint NOT NULL,
updated_at bigint NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY device_registration_secret_token_idx (secret_hash, device_token),
KEY device_registration_secret_hash_idx (secret_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS delivery_log (
id varchar(36) NOT NULL,
secret_hash varchar(64) NOT NULL,
event_type varchar(32) NOT NULL,
session_id varchar(255) NOT NULL,
status varchar(16) NOT NULL,
error varchar(1024) NULL,
created_at bigint NOT NULL,
PRIMARY KEY (id),
KEY delivery_log_secret_hash_idx (secret_hash),
KEY delivery_log_created_at_idx (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`)
}

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}

View File

@@ -205,7 +205,7 @@ export async function closeDialog(page: Page, dialog: Locator) {
await expect(dialog).toHaveCount(0)
}
export async function isSidebarClosed(page: Page) {
async function isSidebarClosed(page: Page) {
const button = await waitSidebarButton(page, "isSidebarClosed")
return (await button.getAttribute("aria-expanded")) !== "true"
}
@@ -236,7 +236,7 @@ async function errorBoundaryText(page: Page) {
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
}
export async function assertHealthy(page: Page, context: string) {
async function assertHealthy(page: Page, context: string) {
const text = await errorBoundaryText(page)
if (!text) return
console.log(`[e2e:error-boundary][${context}]\n${text}`)

View File

@@ -0,0 +1,88 @@
import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { promptAgentSelector, promptModelSelector, promptSelector } from "../selectors"
type Probe = {
agent?: string
model?: { providerID: string; modelID: string; name?: string }
models?: Array<{ providerID: string; modelID: string; name: string }>
agents?: Array<{ name: string }>
}
async function probe(page: Page): Promise<Probe | null> {
return page.evaluate(() => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
current?: Probe
}
}
}
return win.__opencode_e2e?.model?.current ?? null
})
}
async function state(page: Page) {
const value = await probe(page)
if (!value) throw new Error("Failed to resolve model selection probe")
return value
}
async function ready(page: Page) {
const prompt = page.locator(promptSelector)
await prompt.click()
await expect(prompt).toBeFocused()
await prompt.pressSequentially("focus")
return prompt
}
async function body(prompt: Locator) {
return prompt.evaluate((el) => (el as HTMLElement).innerText)
}
test("agent select returns focus to the prompt", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = await ready(page)
const info = await state(page)
const next = info.agents?.map((item) => item.name).find((name) => name !== info.agent)
test.skip(!next, "only one agent available")
if (!next) return
await page.locator(`${promptAgentSelector} [data-slot="select-select-trigger"]`).first().click()
const item = page.locator('[data-slot="select-select-item"]').filter({ hasText: next }).first()
await expect(item).toBeVisible()
await item.click({ force: true })
await expect(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()).toHaveText(
next,
)
await expect(prompt).toBeFocused()
await prompt.pressSequentially(" agent")
await expect.poll(() => body(prompt)).toContain("focus agent")
})
test("model select returns focus to the prompt", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = await ready(page)
const info = await state(page)
const key = info.model ? `${info.model.providerID}:${info.model.modelID}` : null
const next = info.models?.find((item) => `${item.providerID}:${item.modelID}` !== key)
test.skip(!next, "only one model available")
if (!next) return
await page.locator(`${promptModelSelector} [data-action="prompt-model"]`).first().click()
const item = page.locator(`[data-slot="list-item"][data-key="${next.providerID}:${next.modelID}"]`).first()
await expect(item).toBeVisible()
await item.click({ force: true })
await expect(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()).toHaveText(next.name)
await expect(prompt).toBeFocused()
await prompt.pressSequentially(" model")
await expect.poll(() => body(prompt)).toContain("focus model")
})

View File

@@ -1,6 +1,7 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import { promptModelSelector, promptSelector, promptVariantSelector } from "../selectors"
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
@@ -9,15 +10,6 @@ const isBash = (part: unknown): part is ToolPart => {
return "state" in part
}
async function setAutoAccept(page: Parameters<typeof test>[0]["page"], enabled: boolean) {
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
const pressed = (await button.getAttribute("aria-pressed")) === "true"
if (pressed === enabled) return
await button.click()
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
}
test("shell mode runs a command in the project directory", async ({ page, project }) => {
test.setTimeout(120_000)
@@ -27,7 +19,12 @@ test("shell mode runs a command in the project directory", async ({ page, projec
await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id)
await setAutoAccept(page, true)
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
if ((await button.getAttribute("aria-pressed")) !== "true") {
await button.click()
await expect(button).toHaveAttribute("aria-pressed", "true")
}
await project.shell(cmd)
await expect
@@ -57,3 +54,18 @@ test("shell mode runs a command in the project directory", async ({ page, projec
.toEqual(expect.objectContaining({ cwd: project.directory, output: expect.stringContaining("README.md") }))
})
})
test("shell mode unmounts model and variant controls", async ({ page, project }) => {
await project.open()
const prompt = page.locator(promptSelector).first()
await expect(page.locator(promptModelSelector)).toHaveCount(1)
await expect(page.locator(promptVariantSelector)).toHaveCount(1)
await prompt.click()
await page.keyboard.type("!")
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
await expect(page.locator(promptModelSelector)).toHaveCount(0)
await expect(page.locator(promptVariantSelector)).toHaveCount(0)
})

View File

@@ -1,5 +1,5 @@
export const promptSelector = '[data-component="prompt-input"]'
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
@@ -24,7 +24,7 @@ export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-error
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
export const projectSwitchSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`

View File

@@ -15,6 +15,7 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit",
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",

View File

@@ -7,6 +7,11 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
}
export default defineConfig({
testDir: "./e2e",
@@ -19,7 +24,7 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
reporter,
webServer: {
command,
url: baseURL,

View File

@@ -86,6 +86,7 @@ const ModelList: Component<{
}
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
export function ModelSelectorPopover(props: {
provider?: string
@@ -93,25 +94,31 @@ export function ModelSelectorPopover(props: {
children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
onClose?: (cause: "escape" | "select") => void
}) {
const [store, setStore] = createStore<{
open: boolean
dismiss: "escape" | "outside" | null
dismiss: Dismiss | null
}>({
open: false,
dismiss: null,
})
const dialog = useDialog()
const handleManage = () => {
const close = (dismiss: Dismiss) => {
setStore("dismiss", dismiss)
setStore("open", false)
}
const handleManage = () => {
close("manage")
void import("./dialog-manage-models").then((x) => {
dialog.show(() => <x.DialogManageModels />)
})
}
const handleConnectProvider = () => {
setStore("open", false)
close("provider")
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />)
})
@@ -136,21 +143,19 @@ export function ModelSelectorPopover(props: {
<Kobalte.Content
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
onEscapeKeyDown={(event) => {
setStore("dismiss", "escape")
setStore("open", false)
close("escape")
event.preventDefault()
event.stopPropagation()
}}
onPointerDownOutside={() => {
setStore("dismiss", "outside")
setStore("open", false)
}}
onFocusOutside={() => {
setStore("dismiss", "outside")
setStore("open", false)
}}
onPointerDownOutside={() => close("outside")}
onFocusOutside={() => close("outside")}
onCloseAutoFocus={(event) => {
if (store.dismiss === "outside") event.preventDefault()
const dismiss = store.dismiss
if (dismiss === "outside") event.preventDefault()
if (dismiss === "escape" || dismiss === "select") {
event.preventDefault()
props.onClose?.(dismiss)
}
setStore("dismiss", null)
}}
>
@@ -158,7 +163,7 @@ export function ModelSelectorPopover(props: {
<ModelList
provider={props.provider}
model={props.model}
onSelect={() => setStore("open", false)}
onSelect={() => close("select")}
class="p-1"
action={
<div class="flex items-center gap-1">

View File

@@ -243,23 +243,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
)
const working = createMemo(() => status()?.type !== "idle")
const tip = () => {
if (working()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
)
}
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
)
}
const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
@@ -297,6 +280,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (store.mode === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
})
const blank = createMemo(() => {
const text = prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
})
const stopping = createMemo(() => working() && blank())
const tip = () => {
if (stopping()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
)
}
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
)
}
const contextItems = createMemo(() => {
const items = prompt.context.items()
@@ -502,6 +510,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return getCursorPosition(editorRef)
}
const restoreFocus = () => {
requestAnimationFrame(() => {
const cursor = prompt.cursor() ?? promptLength(prompt.current())
editorRef.focus()
setCursorPosition(editorRef, cursor)
queueScroll()
})
}
const renderEditorWithCursor = (parts: Prompt) => {
const cursor = currentCursor()
renderEditor(parts)
@@ -1398,17 +1415,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>
<div class="flex items-center gap-1 pointer-events-auto">
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
disabled={store.mode !== "normal" || (!working() && blank())}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={working() ? "stop" : "arrow-up"}
icon={stopping() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={buttons()}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
@@ -1471,7 +1488,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
size="normal"
options={agentNames()}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
onSelect={(value) => {
local.agent.set(value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
@@ -1480,28 +1500,62 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>
</TooltipKeybind>
</div>
<div data-component="prompt-model-control">
<Show
when={providers.paid().length > 0}
fallback={
<Show when={store.mode !== "shell"}>
<div data-component="prompt-model-control">
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
<ModelSelectorPopover
model={local.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model",
}}
onClose={restoreFocus}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
@@ -1514,63 +1568,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</ModelSelectorPopover>
</TooltipKeybind>
}
>
</Show>
</div>
<div data-component="prompt-variant-control">
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<ModelSelectorPopover
model={local.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model",
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipKeybind>
</Show>
</div>
<div data-component="prompt-variant-control">
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
</div>
</Show>
<TooltipKeybind
placement="top"
gutter={8}

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

@@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers"
@@ -134,6 +135,7 @@ export function SessionHeader() {
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
const settings = useSettings()
const sync = useSync()
const terminal = useTerminal()
const { params, view } = useSessionLayout()
@@ -151,6 +153,10 @@ export function SessionHeader() {
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const search = createMemo(() => platform.platform !== "desktop" || settings.general.showSearch())
const tree = createMemo(() => platform.platform !== "desktop" || settings.general.showFileTree())
const term = createMemo(() => platform.platform !== "desktop" || settings.general.showTerminal())
const status = createMemo(() => platform.platform !== "desktop" || settings.general.showStatus())
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
@@ -267,35 +273,37 @@ export function SessionHeader() {
return (
<>
<Show when={centerMount()}>
{(mount) => (
<Portal mount={mount()}>
<Button
type="button"
variant="ghost"
size="small"
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")}
>
<div class="flex min-w-0 flex-1 items-center overflow-visible">
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
{language.t("session.header.search.placeholder", {
project: name(),
})}
</span>
</div>
<Show when={search()}>
<Show when={centerMount()}>
{(mount) => (
<Portal mount={mount()}>
<Button
type="button"
variant="ghost"
size="small"
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")}
>
<div class="flex min-w-0 flex-1 items-center overflow-visible">
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
{language.t("session.header.search.placeholder", {
project: name(),
})}
</span>
</div>
<Show when={hotkey()}>
{(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
{keybind()}
</Keybind>
)}
</Show>
</Button>
</Portal>
)}
<Show when={hotkey()}>
{(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
{keybind()}
</Keybind>
)}
</Show>
</Button>
</Portal>
)}
</Show>
</Show>
<Show when={rightMount()}>
{(mount) => (
@@ -415,24 +423,28 @@ export function SessionHeader() {
</div>
</Show>
<div class="flex items-center gap-1">
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
<Show when={status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
<Show when={term()}>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
</Show>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
@@ -451,30 +463,32 @@ export function SessionHeader() {
</Button>
</TooltipKeybind>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
<Show when={tree()}>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</Show>
</div>
</div>
</div>

View File

@@ -76,6 +76,7 @@ export const SettingsGeneral: Component = () => {
})
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const desktop = createMemo(() => platform.platform === "desktop")
const check = () => {
if (!platform.checkUpdate) return
@@ -139,11 +140,6 @@ export const SettingsGeneral: Component = () => {
{ value: "dark", label: language.t("theme.scheme.dark") },
])
const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [
{ value: "queue", label: language.t("settings.general.row.followup.option.queue") },
{ value: "steer", label: language.t("settings.general.row.followup.option.steer") },
])
const languageOptions = createMemo(() =>
language.locales.map((locale) => ({
value: locale,
@@ -241,23 +237,73 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const AdvancedSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.showFileTree.title")}
description={language.t("settings.general.row.showFileTree.description")}
>
<div data-action="settings-show-file-tree">
<Switch
checked={settings.general.showFileTree()}
onChange={(checked) => settings.general.setShowFileTree(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.followup.title")}
description={language.t("settings.general.row.followup.description")}
title={language.t("settings.general.row.showNavigation.title")}
description={language.t("settings.general.row.showNavigation.description")}
>
<Select
data-action="settings-followup"
options={followupOptions()}
current={followupOptions().find((o) => o.value === settings.general.followup())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && settings.general.setFollowup(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
<div data-action="settings-show-navigation">
<Switch
checked={settings.general.showNavigation()}
onChange={(checked) => settings.general.setShowNavigation(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}
>
<div data-action="settings-show-search">
<Switch
checked={settings.general.showSearch()}
onChange={(checked) => settings.general.setShowSearch(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showTerminal.title")}
description={language.t("settings.general.row.showTerminal.description")}
>
<div data-action="settings-show-terminal">
<Switch
checked={settings.general.showTerminal()}
onChange={(checked) => settings.general.setShowTerminal(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
>
<div data-action="settings-show-status">
<Switch
checked={settings.general.showStatus()}
onChange={(checked) => settings.general.setShowStatus(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
@@ -593,6 +639,10 @@ export const SettingsGeneral: Component = () => {
)
}}
</Show>
<Show when={desktop()}>
<AdvancedSection />
</Show>
</div>
</div>
)

View File

@@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
type TauriDesktopWindow = {
@@ -40,6 +41,7 @@ export function Titlebar() {
const platform = usePlatform()
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const theme = useTheme()
const navigate = useNavigate()
const location = useLocation()
@@ -78,6 +80,7 @@ export function Titlebar() {
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => platform.platform !== "desktop" || settings.general.showNavigation())
const back = () => {
const next = backPath(history)
@@ -252,7 +255,7 @@ export function Titlebar() {
</div>
</div>
</Show>
<Show when={hasProjects()}>
<Show when={hasProjects() && nav()}>
<div
class="flex items-center gap-0 transition-transform"
classList={{
@@ -287,6 +290,9 @@ 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">

View File

@@ -248,7 +248,7 @@ export async function bootstrapDirectory(input: {
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next?.branch) input.vcsCache.setStore("value", next)
if (next) input.vcsCache.setStore("value", next)
}),
),
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),

View File

@@ -494,8 +494,10 @@ describe("applyDirectoryEvent", () => {
})
test("updates vcs branch in store and cache", () => {
const [store, setStore] = createStore(baseState())
const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
const [cacheStore, setCacheStore] = createStore({
value: { branch: "main", default_branch: "main" } as State["vcs"],
})
applyDirectoryEvent({
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
@@ -511,8 +513,8 @@ describe("applyDirectoryEvent", () => {
},
})
expect(store.vcs).toEqual({ branch: "feature/test" })
expect(cacheStore.value).toEqual({ branch: "feature/test" })
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
})
test("routes disposal and lsp events to side-effect handlers", () => {

View File

@@ -271,9 +271,9 @@ export function applyDirectoryEvent(input: {
break
}
case "vcs.branch.updated": {
const props = event.properties as { branch: string }
const props = event.properties as { branch?: string }
if (input.store.vcs?.branch === props.branch) break
const next = { branch: props.branch }
const next = { ...input.store.vcs, branch: props.branch }
input.setStore("vcs", next)
if (input.vcsCache) input.vcsCache.setStore("value", next)
break

View File

@@ -23,6 +23,11 @@ export interface Settings {
autoSave: boolean
releaseNotes: boolean
followup: "queue" | "steer"
showFileTree: boolean
showNavigation: boolean
showSearch: boolean
showStatus: boolean
showTerminal: boolean
showReasoningSummaries: boolean
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
@@ -89,6 +94,11 @@ const defaultSettings: Settings = {
autoSave: true,
releaseNotes: true,
followup: "steer",
showFileTree: false,
showNavigation: false,
showSearch: false,
showStatus: false,
showTerminal: false,
showReasoningSummaries: false,
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
@@ -136,6 +146,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
})
createEffect(() => {
if (store.general?.followup !== "queue") return
setStore("general", "followup", "steer")
})
return {
ready,
get current() {
@@ -150,9 +165,32 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setReleaseNotes(value: boolean) {
setStore("general", "releaseNotes", value)
},
followup: withFallback(() => store.general?.followup, defaultSettings.general.followup),
followup: withFallback(
() => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
defaultSettings.general.followup,
),
setFollowup(value: "queue" | "steer") {
setStore("general", "followup", value)
setStore("general", "followup", value === "queue" ? "steer" : value)
},
showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree),
setShowFileTree(value: boolean) {
setStore("general", "showFileTree", value)
},
showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation),
setShowNavigation(value: boolean) {
setStore("general", "showNavigation", value)
},
showSearch: withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch),
setShowSearch(value: boolean) {
setStore("general", "showSearch", value)
},
showStatus: withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus),
setShowStatus(value: boolean) {
setStore("general", "showStatus", value)
},
showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
setShowTerminal(value: boolean) {
setStore("general", "showTerminal", value)
},
showReasoningSummaries: withFallback(
() => store.general?.showReasoningSummaries,

View File

@@ -535,6 +535,8 @@ export const dict = {
"session.review.noVcs.createGit.action": "Create Git repository",
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
"session.review.noChanges": "No changes",
"session.review.noUncommittedChanges": "No uncommitted changes yet",
"session.review.noBranchChanges": "No branch changes yet",
"session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files",
@@ -715,6 +717,7 @@ export const dict = {
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Appearance",
"settings.general.section.advanced": "Advanced",
"settings.general.section.notifications": "System notifications",
"settings.general.section.updates": "Updates",
"settings.general.section.sounds": "Sound effects",
@@ -737,6 +740,16 @@ export const dict = {
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
"settings.general.row.followup.option.queue": "Queue",
"settings.general.row.followup.option.steer": "Steer",
"settings.general.row.showFileTree.title": "File tree",
"settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions",
"settings.general.row.showNavigation.title": "Navigation controls",
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
"settings.general.row.showSearch.title": "Command palette",
"settings.general.row.showSearch.description": "Show the search and command palette button in the desktop title bar",
"settings.general.row.showTerminal.title": "Terminal",
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",

View File

@@ -16,6 +16,7 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { hasProjectPermissions } from "./helpers"
@@ -101,42 +102,46 @@ const SessionRow = (props: {
warmPress: () => void
warmFocus: () => void
cancelHoverPrefetch: () => void
}): JSX.Element => (
<A
href={`/${props.slug}/session/${props.session.id}`}
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress}
onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch}
onFocus={props.warmFocus}
onClick={() => {
props.setHoverSession(undefined)
if (props.sidebarOpened()) return
props.clearHoverProjectSoon()
}}
>
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
}) => {
const title = () => sessionTitle(props.session.title)
return (
<A
href={`/${props.slug}/session/${props.session.id}`}
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress}
onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch}
onFocus={props.warmFocus}
onClick={() => {
props.setHoverSession(undefined)
if (props.sidebarOpened()) return
props.clearHoverProjectSoon()
}}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
</A>
)
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span>
</A>
)
}
const SessionHoverPreview = (props: {
mobile?: boolean
@@ -319,7 +324,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
fallback={
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={props.session.title}
value={sessionTitle(props.session.title)}
gutter={10}
class="min-w-0 w-full"
>

View File

@@ -1,4 +1,4 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import {
@@ -68,6 +68,9 @@ type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "session" | "turn"
type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
messagesReady: () => boolean
@@ -347,6 +350,7 @@ export default function Page() {
scroll: {
overflow: false,
bottom: true,
jump: false,
},
})
@@ -426,15 +430,16 @@ export default function Page() {
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasSessionReview = createMemo(() => sessionCount() > 0)
const canReview = createMemo(() => !!sync.project)
const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview,
hasReview: canReview,
})
const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs
@@ -457,6 +462,12 @@ export default function Page() {
if (!id) return false
return sync.session.history.loading(id)
})
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasSessionReview()) return true
return sync.data.session_diff[id] !== undefined
})
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
@@ -510,11 +521,22 @@ export default function Page() {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes",
changes: "session" as "session" | "turn",
changes: "git" as ChangeMode,
newSessionWorktree: "main",
deferRender: false,
})
const [vcs, setVcs] = createStore({
diff: {
git: [] as FileDiff[],
branch: [] as FileDiff[],
},
ready: {
git: false,
branch: false,
},
})
const [followup, setFollowup] = persisted(
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
createStore<{
@@ -548,6 +570,68 @@ export default function Page() {
let todoTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
const vcsTask = new Map<VcsMode, Promise<void>>()
const vcsRun = new Map<VcsMode, number>()
const bumpVcs = (mode: VcsMode) => {
const next = (vcsRun.get(mode) ?? 0) + 1
vcsRun.set(mode, next)
return next
}
const resetVcs = (mode?: VcsMode) => {
const list = mode ? [mode] : (["git", "branch"] as const)
list.forEach((item) => {
bumpVcs(item)
vcsTask.delete(item)
setVcs("diff", item, [])
setVcs("ready", item, false)
})
}
const loadVcs = (mode: VcsMode, force = false) => {
if (sync.project?.vcs !== "git") return Promise.resolve()
if (!force && vcs.ready[mode]) return Promise.resolve()
if (force) {
if (vcsTask.has(mode)) bumpVcs(mode)
vcsTask.delete(mode)
setVcs("ready", mode, false)
}
const current = vcsTask.get(mode)
if (current) return current
const run = bumpVcs(mode)
const task = sdk.client.vcs
.diff({ mode })
.then((result) => {
if (vcsRun.get(mode) !== run) return
setVcs("diff", mode, result.data ?? [])
setVcs("ready", mode, true)
})
.catch((error) => {
if (vcsRun.get(mode) !== run) return
console.debug("[session-review] failed to load vcs diff", { mode, error })
setVcs("diff", mode, [])
setVcs("ready", mode, true)
})
.finally(() => {
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
})
vcsTask.set(mode, task)
return task
}
const refreshVcs = () => {
resetVcs()
const mode = untrack(vcsMode)
if (!mode) return
if (!untrack(wantsReview)) return
void loadVcs(mode, true)
}
createComputed((prev) => {
const open = desktopReviewOpen()
@@ -563,7 +647,42 @@ export default function Page() {
}, desktopReviewOpen())
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
const changesOptions = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
if (sync.project?.vcs === "git") list.push("git")
if (
sync.project?.vcs === "git" &&
sync.data.vcs?.branch &&
sync.data.vcs?.default_branch &&
sync.data.vcs.branch !== sync.data.vcs.default_branch
) {
list.push("branch")
}
list.push("session", "turn")
return list
})
const vcsMode = createMemo<VcsMode | undefined>(() => {
if (store.changes === "git" || store.changes === "branch") return store.changes
})
const reviewDiffs = createMemo(() => {
if (store.changes === "git") return vcs.diff.git
if (store.changes === "branch") return vcs.diff.branch
if (store.changes === "session") return diffs()
return turnDiffs()
})
const reviewCount = createMemo(() => {
if (store.changes === "git") return vcs.diff.git.length
if (store.changes === "branch") return vcs.diff.branch.length
if (store.changes === "session") return sessionCount()
return turnDiffs().length
})
const hasReview = createMemo(() => reviewCount() > 0)
const reviewReady = createMemo(() => {
if (store.changes === "git") return vcs.ready.git
if (store.changes === "branch") return vcs.ready.branch
if (store.changes === "session") return !hasSessionReview() || diffsReady()
return true
})
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
@@ -629,13 +748,7 @@ export default function Page() {
scrollToMessage(msgs[targetIndex], "auto")
}
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const reviewEmptyKey = createMemo(() => {
const sessionEmptyKey = createMemo(() => {
const project = sync.project
if (project && !project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
@@ -789,13 +902,46 @@ export default function Page() {
sessionKey,
() => {
setStore("messageId", undefined)
setStore("changes", "session")
setStore("changes", "git")
setUi("pendingMessage", undefined)
},
{ defer: true },
),
)
createEffect(
on(
() => sdk.directory,
() => {
resetVcs()
},
{ defer: true },
),
)
createEffect(
on(
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
(next, prev) => {
if (prev === undefined || same(next, prev)) return
refreshVcs()
},
{ defer: true },
),
)
const stopVcs = sdk.event.listen((evt) => {
if (evt.details.type !== "file.watcher.updated") return
const props =
typeof evt.details.properties === "object" && evt.details.properties
? (evt.details.properties as Record<string, unknown>)
: undefined
const file = typeof props?.file === "string" ? props.file : undefined
if (!file || file.startsWith(".git/")) return
refreshVcs()
})
onCleanup(stopVcs)
createEffect(
on(
() => params.dir,
@@ -918,6 +1064,40 @@ export default function Page() {
}
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const wantsReview = createMemo(() =>
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
)
createEffect(() => {
const list = changesOptions()
if (list.includes(store.changes)) return
const next = list[0]
if (!next) return
setStore("changes", next)
})
createEffect(() => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
void loadVcs(mode)
})
createEffect(
on(
() => sync.data.session_status[params.id ?? ""]?.type,
(next, prev) => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
if (next !== "idle" || prev === undefined || prev === "idle") return
void loadVcs(mode, true)
},
{ defer: true },
),
)
const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
@@ -964,21 +1144,23 @@ export default function Page() {
loadFile: file.load,
})
const changesOptions = ["session", "turn"] as const
const changesOptionsList = [...changesOptions]
const changesTitle = () => {
if (!hasReview()) {
if (!canReview()) {
return null
}
const label = (option: ChangeMode) => {
if (option === "git") return language.t("ui.sessionReview.title.git")
if (option === "branch") return language.t("ui.sessionReview.title.branch")
if (option === "session") return language.t("ui.sessionReview.title")
return language.t("ui.sessionReview.title.lastTurn")
}
return (
<Select
options={changesOptionsList}
options={changesOptions()}
current={store.changes}
label={(option) =>
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
}
label={label}
onSelect={(option) => option && setStore("changes", option)}
variant="ghost"
size="small"
@@ -987,20 +1169,34 @@ export default function Page() {
)
}
const emptyTurn = () => (
const empty = (text: string) => (
<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 class="text-14-regular text-text-weak max-w-56">{text}</div>
</div>
)
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "turn") return emptyTurn()
const reviewEmptyText = createMemo(() => {
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
if (store.changes === "turn") return language.t("session.review.noChanges")
return language.t(sessionEmptyKey())
})
if (hasReview() && !diffsReady()) {
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "git" || store.changes === "branch") {
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
return empty(reviewEmptyText())
}
if (store.changes === "turn") {
return empty(reviewEmptyText())
}
if (hasSessionReview() && !diffsReady()) {
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
}
if (reviewEmptyKey() === "session.review.noVcs") {
if (sessionEmptyKey() === "session.review.noVcs") {
return (
<div class={input.emptyClass}>
<div class="flex flex-col gap-3">
@@ -1020,7 +1216,7 @@ export default function Page() {
return (
<div class={input.emptyClass}>
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
</div>
)
}
@@ -1127,7 +1323,7 @@ export default function Page() {
const pending = tree.pendingDiff
if (!pending) return
if (!tree.reviewScroll) return
if (!diffsReady()) return
if (!reviewReady()) return
const attempt = (count: number) => {
if (tree.pendingDiff !== pending) return
@@ -1168,10 +1364,7 @@ export default function Page() {
const id = params.id
if (!id) return
const wants = isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes"
if (!wants) return
if (!wantsReview()) return
if (sync.data.session_diff[id] !== undefined) return
if (sync.status === "loading") return
@@ -1180,13 +1373,7 @@ export default function Page() {
createEffect(
on(
() =>
[
sessionKey(),
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
] as const,
() => [sessionKey(), wantsReview()] as const,
([key, wants]) => {
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
@@ -1247,13 +1434,17 @@ export default function Page() {
let scrollStateTarget: HTMLDivElement | undefined
let fillFrame: number | undefined
const jumpThreshold = (el: HTMLDivElement) => Math.max(400, el.clientHeight)
const updateScrollState = (el: HTMLDivElement) => {
const max = el.scrollHeight - el.clientHeight
const distance = max - el.scrollTop
const overflow = max > 1
const bottom = !overflow || el.scrollTop >= max - 2
const bottom = !overflow || distance <= 2
const jump = overflow && distance > jumpThreshold(el)
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
setUi("scroll", { overflow, bottom })
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom && ui.scroll.jump === jump) return
setUi("scroll", { overflow, bottom, jump })
}
const scheduleScrollState = (el: HTMLDivElement) => {
@@ -1862,6 +2053,12 @@ export default function Page() {
</div>
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel}
activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff}

View File

@@ -4,6 +4,7 @@ import { useMutation } from "@tanstack/solid-query"
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"
@@ -73,6 +74,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
customOn: cached?.customOn ?? ([] as boolean[]),
editing: false,
focus: 0,
collapsed: false,
})
let root: HTMLDivElement | undefined
@@ -87,6 +89,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true)
const count = createMemo(() => options().length + 1)
const picked = createMemo(() => store.answers[store.tab]?.length ?? 0)
const summary = createMemo(() => {
const n = Math.min(store.tab + 1, total())
@@ -98,6 +101,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()
@@ -247,7 +252,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
}
const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
const has = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
const pick = (answer: string, custom: boolean = false) => {
setStore("answers", store.tab, [answer])
@@ -426,9 +431,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
ref={(el) => (root = el)}
onKeyDown={nav}
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
@@ -437,13 +454,38 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
data-active={i() === store.tab}
data-answered={answered(i())}
disabled={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={
<>
@@ -469,99 +511,123 @@ 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) => (
<Option
multi={multi()}
picked={picked(opt.label)}
label={opt.label}
description={opt.description}
disabled={sending()}
ref={(el) => (optsRef[i()] = el)}
onFocus={() => setStore("focus", i())}
onClick={() => selectOption(i())}
/>
)}
</For>
<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) => (
<Option
multi={multi()}
picked={has(opt.label)}
label={opt.label}
description={opt.description}
disabled={sending()}
ref={(el) => (optsRef[i()] = el)}
onFocus={() => setStore("focus", i())}
onClick={() => selectOption(i())}
/>
)}
</For>
<Show
when={store.editing}
fallback={
<button
type="button"
ref={customRef}
<Show
when={store.editing}
fallback={
<button
type="button"
ref={customRef}
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={sending()}
onFocus={() => setStore("focus", options().length)}
onClick={customOpen}
>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
<span data-slot="question-option-main">
<span data-slot="option-label">{customLabel()}</span>
<span data-slot="option-description">{input() || customPlaceholder()}</span>
</span>
</button>
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={sending()}
onFocus={() => setStore("focus", options().length)}
onClick={customOpen}
onMouseDown={(e) => {
if (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()
}}
>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
<span data-slot="question-option-main">
<span data-slot="option-label">{customLabel()}</span>
<span data-slot="option-description">{input() || customPlaceholder()}</span>
</span>
</button>
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
onMouseDown={(e) => {
if (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()
}}
>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
<span data-slot="question-option-main">
<span data-slot="option-label">{customLabel()}</span>
<textarea
ref={focusCustom}
data-slot="question-custom-input"
placeholder={customPlaceholder()}
value={input()}
rows={1}
disabled={sending()}
onKeyDown={(e) => {
if (e.key === "Escape") {
<textarea
ref={focusCustom}
data-slot="question-custom-input"
placeholder={customPlaceholder()}
value={input()}
rows={1}
disabled={sending()}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
focus(options().length)
return
}
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
setStore("editing", false)
focus(options().length)
return
}
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
resizeInput(e.currentTarget)
}}
/>
</span>
</form>
</Show>
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
resizeInput(e.currentTarget)
}}
/>
</span>
</form>
</Show>
</div>
</div>
</DockPrompt>
)

View File

@@ -7,7 +7,7 @@ import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Index, createEffect, createMemo, on, onCleanup } from "solid-js"
import { Index, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { composerEnabled, composerProbe } from "@/testing/session-composer"
import { useLanguage } from "@/context/language"
@@ -210,76 +210,25 @@ export function SessionTodoDock(props: {
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
}}
>
<TodoList todos={props.todos} open={!store.collapsed} />
<TodoList todos={props.todos} />
</div>
</div>
</DockTray>
)
}
function TodoList(props: { todos: Todo[]; open: boolean }) {
function TodoList(props: { todos: Todo[] }) {
const [store, setStore] = createStore({
stuck: false,
scrolling: false,
})
let scrollRef!: HTMLDivElement
let timer: number | undefined
const inProgress = createMemo(() => props.todos.findIndex((todo) => todo.status === "in_progress"))
const ensure = () => {
if (!props.open) return
if (store.scrolling) return
if (!scrollRef || scrollRef.offsetParent === null) return
const el = scrollRef.querySelector("[data-in-progress]")
if (!(el instanceof HTMLElement)) return
const topFade = 16
const bottomFade = 44
const container = scrollRef.getBoundingClientRect()
const rect = el.getBoundingClientRect()
const top = rect.top - container.top + scrollRef.scrollTop
const bottom = rect.bottom - container.top + scrollRef.scrollTop
const viewTop = scrollRef.scrollTop + topFade
const viewBottom = scrollRef.scrollTop + scrollRef.clientHeight - bottomFade
if (top < viewTop) {
scrollRef.scrollTop = Math.max(0, top - topFade)
} else if (bottom > viewBottom) {
scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade)
}
setStore("stuck", scrollRef.scrollTop > 0)
}
createEffect(
on([() => props.open, inProgress], () => {
if (!props.open || inProgress() < 0) return
requestAnimationFrame(ensure)
}),
)
onCleanup(() => {
if (!timer) return
window.clearTimeout(timer)
})
return (
<div class="relative">
<div
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
ref={scrollRef}
style={{ "overflow-anchor": "none" }}
onScroll={(e) => {
setStore("stuck", e.currentTarget.scrollTop > 0)
setStore("scrolling", true)
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setStore("scrolling", false)
if (inProgress() < 0) return
requestAnimationFrame(ensure)
}, 250)
}}
>
<Index each={props.todos}>

View File

@@ -29,6 +29,7 @@ import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { makeTimer } from "@solid-primitives/timer"
@@ -43,7 +44,6 @@ type MessageComment = {
const emptyMessages: MessageType[] = []
const idle = { type: "idle" as const }
type UserActions = {
fork?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
revert?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
@@ -200,7 +200,7 @@ export function MessageTimeline(props: {
mobileChanges: boolean
mobileFallback: JSX.Element
actions?: UserActions
scroll: { overflow: boolean; bottom: boolean }
scroll: { overflow: boolean; bottom: boolean; jump: boolean }
onResumeScroll: () => void
setScrollRef: (el: HTMLDivElement | undefined) => void
onScheduleScrollState: (el: HTMLDivElement) => void
@@ -291,6 +291,7 @@ export function MessageTimeline(props: {
return sync.session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
@@ -399,7 +400,7 @@ export function MessageTimeline(props: {
const openTitleEditor = () => {
if (!sessionID()) return
setTitle({ editing: true, draft: titleValue() ?? "" })
setTitle({ editing: true, draft: titleLabel() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
@@ -417,7 +418,7 @@ export function MessageTimeline(props: {
if (titleMutation.isPending) return
const next = title.draft.trim()
if (!next || next === (titleValue() ?? "")) {
if (!next || next === (titleLabel() ?? "")) {
setTitle("editing", false)
return
}
@@ -532,7 +533,9 @@ export function MessageTimeline(props: {
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const name = createMemo(
() => sessionTitle(sync.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
@@ -568,10 +571,9 @@ export function MessageTimeline(props: {
<div
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
"opacity-100 translate-y-0 scale-100":
props.scroll.overflow && !props.scroll.bottom && !staging.isStaging(),
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump && !staging.isStaging(),
"opacity-0 translate-y-2 scale-95 pointer-events-none":
!props.scroll.overflow || props.scroll.bottom || staging.isStaging(),
!props.scroll.overflow || !props.scroll.jump || staging.isStaging(),
}}
>
<button
@@ -674,7 +676,7 @@ export function MessageTimeline(props: {
</div>
</Show>
</div>
<Show when={titleValue() || title.editing}>
<Show when={titleLabel() || title.editing}>
<Show
when={title.editing}
fallback={
@@ -682,7 +684,7 @@ export function MessageTimeline(props: {
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
onDblClick={openTitleEditor}
>
{titleValue()}
{titleLabel()}
</h1>
}
>

View File

@@ -8,6 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import type { FileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -18,7 +19,8 @@ import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
@@ -26,6 +28,12 @@ import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
export function SessionSidePanel(props: {
canReview: () => boolean
diffs: () => FileDiff[]
diffsReady: () => boolean
empty: () => string
hasReview: () => boolean
reviewCount: () => number
reviewPanel: () => JSX.Element
activeDiff?: string
focusReviewDiff: (path: string) => void
@@ -33,17 +41,19 @@ export function SessionSidePanel(props: {
size: Sizing
}) {
const layout = useLayout()
const sync = useSync()
const platform = usePlatform()
const settings = useSettings()
const file = useFile()
const language = useLanguage()
const command = useCommand()
const dialog = useDialog()
const { params, sessionKey, tabs, view } = useSessionLayout()
const { sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = createMemo(() => platform.platform !== "desktop" || settings.general.showFileTree())
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
const open = createMemo(() => reviewOpen() || fileOpen())
const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => {
@@ -53,24 +63,7 @@ export function SessionSidePanel(props: {
})
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] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
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 diffFiles = createMemo(() => props.diffs().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b
@@ -81,7 +74,7 @@ export function SessionSidePanel(props: {
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
for (const diff of props.diffs()) {
const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
@@ -135,7 +128,7 @@ export function SessionSidePanel(props: {
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview,
hasReview: props.canReview,
})
const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs
@@ -240,12 +233,12 @@ export function SessionSidePanel(props: {
onCleanup(stop)
}}
>
<Show when={reviewTab()}>
<Show when={reviewTab() && props.canReview()}>
<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 when={props.hasReview()}>
<div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
@@ -304,7 +297,7 @@ export function SessionSidePanel(props: {
</Tabs.List>
</div>
<Show when={reviewTab()}>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
@@ -353,100 +346,100 @@ export function SessionSidePanel(props: {
</div>
</div>
<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() }}
>
<Show when={shown()}>
<div
class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
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() }}
>
<Tabs
variant="pill"
value={fileTreeTab()}
onChange={setFileTreeTabValue}
class="h-full"
data-scope="filetree"
<div
class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
>
<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")}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<Tabs
variant="pill"
value={fileTreeTab()}
onChange={setFileTreeTabValue}
class="h-full"
data-scope="filetree"
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{props.reviewCount()}{" "}
{language.t(
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
)}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={props.hasReview() || !props.diffsReady()}>
<Show
when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
/>
</Show>
</Match>
<Match when={true}>{empty(props.empty())}</Match>
</Switch>
</Tabs.Content>
<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"
allowed={diffFiles()}
modified={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Show>
</Match>
<Match when={true}>
{empty(
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
)}
</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<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>
<Show when={fileOpen()}>
<div onPointerDown={() => props.size.start()}>
<ResizeHandle
direction="horizontal"
edge="start"
size={layout.fileTree.width()}
min={200}
max={480}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
/>
</Match>
</Switch>
</Tabs.Content>
</Tabs>
</div>
</Show>
</div>
<Show when={fileOpen()}>
<div onPointerDown={() => props.size.start()}>
<ResizeHandle
direction="horizontal"
edge="start"
size={layout.fileTree.width()}
min={200}
max={480}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
/>
</div>
</Show>
</div>
</Show>
</div>
</aside>
</Show>

View File

@@ -7,8 +7,10 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { showToast } from "@opencode-ai/ui/toast"
@@ -39,8 +41,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const language = useLanguage()
const local = useLocal()
const permission = usePermission()
const platform = usePlatform()
const prompt = usePrompt()
const sdk = useSDK()
const settings = useSettings()
const sync = useSync()
const terminal = useTerminal()
const layout = useLayout()
@@ -52,11 +56,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!id) return
return sync.session.get(id)
}
const hasReview = () => {
const id = params.id
if (!id) return false
return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
}
const hasReview = () => !!params.id
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
@@ -70,6 +70,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const shown = () => platform.platform !== "desktop" || settings.general.showFileTree()
const idle = { type: "idle" as const }
const status = () => sync.data.session_status[params.id ?? ""] ?? idle
@@ -461,12 +462,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
}),
...(shown()
? [
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
}),
]
: []),
viewCommand({
id: "input.focus",
title: language.t("command.input.focus"),

View File

@@ -0,0 +1,7 @@
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
export function sessionTitle(title?: string) {
if (!title) return title
const match = title.match(pattern)
return match?.[1] ?? title
}

View File

@@ -34,11 +34,6 @@ const getBase = (): Configuration => ({
},
files: ["out/**/*", "resources/**/*"],
extraResources: [
{
from: "resources/",
to: "",
filter: ["opencode-cli*"],
},
{
from: "native/",
to: "native/",

View File

@@ -1,5 +1,6 @@
import { defineConfig } from "electron-vite"
import appPlugin from "@opencode-ai/app/vite"
import * as fs from "node:fs/promises"
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
@@ -7,6 +8,8 @@ const channel = (() => {
return "dev"
})()
const OPENCODE_SERVER_DIST = "../opencode/dist/node"
export default defineConfig({
main: {
define: {
@@ -17,6 +20,25 @@ export default defineConfig({
input: { index: "src/main/index.ts" },
},
},
plugins: [
{
name: "opencode:virtual-server-module",
enforce: "pre",
resolveId(id) {
if (id === "virtual:opencode-server") return this.resolve(`${OPENCODE_SERVER_DIST}/node.js`)
},
},
{
name: "opencode:copy-server-assets",
enforce: "post",
async closeBundle() {
for (const l of await fs.readdir(OPENCODE_SERVER_DIST)) {
if (l.endsWith(".js")) continue
await fs.writeFile(`./out/main/${l}`, await fs.readFile(`${OPENCODE_SERVER_DIST}/${l}`))
}
},
},
],
},
preload: {
build: {

View File

@@ -30,6 +30,7 @@
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"@valibot/to-json-schema": "1.6.0",
"effect": "catalog:",
"electron-context-menu": "4.1.2",
"electron-log": "^5",
@@ -38,7 +39,9 @@
"electron-window-state": "^5.0.3",
"marked": "^15",
"solid-js": "catalog:",
"tree-kill": "^1.2.2"
"sury": "11.0.0-alpha.4",
"tree-kill": "^1.2.2",
"zod-openapi": "5.4.6"
},
"devDependencies": {
"@actions/artifact": "4.0.0",

View File

@@ -1,17 +1,5 @@
import { $ } from "bun"
import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
const RUST_TARGET = Bun.env.RUST_TARGET
const sidecarConfig = getCurrentSidecar(RUST_TARGET)
const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
await (sidecarConfig.ocBinary.includes("-baseline")
? $`cd ../opencode && bun run build --single --baseline`
: $`cd ../opencode && bun run build --single`)
await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET)
await $`cd ../opencode && bun script/build-node.ts`

View File

@@ -2,7 +2,7 @@
import { $ } from "bun"
import { Script } from "@opencode-ai/script"
import { copyBinaryToSidecarFolder, getCurrentSidecar, resolveChannel, windowsify } from "./utils"
import { resolveChannel } from "./utils"
const channel = resolveChannel()
await $`bun ./scripts/copy-icons.ts ${channel}`
@@ -12,14 +12,4 @@ pkg.version = Script.version
await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
console.log(`Updated package.json version to ${Script.version}`)
const sidecarConfig = getCurrentSidecar()
const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli"
const dir = "resources/opencode-binaries"
await $`mkdir -p ${dir}`
await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir)
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
await $`rm -rf ${dir}`
await $`cd ../opencode && bun script/build-node.ts`

View File

@@ -1,283 +0,0 @@
import { execFileSync, spawn } from "node:child_process"
import { EventEmitter } from "node:events"
import { chmodSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import readline from "node:readline"
import { fileURLToPath } from "node:url"
import { app } from "electron"
import treeKill from "tree-kill"
import { WSL_ENABLED_KEY } from "./constants"
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
import { store } from "./store"
const CLI_INSTALL_DIR = ".opencode/bin"
const CLI_BINARY_NAME = "opencode"
export type ServerConfig = {
hostname?: string
port?: number
}
export type Config = {
server?: ServerConfig
}
export type TerminatedPayload = { code: number | null; signal: number | null }
export type CommandEvent =
| { type: "stdout"; value: string }
| { type: "stderr"; value: string }
| { type: "error"; value: string }
| { type: "terminated"; value: TerminatedPayload }
| { type: "sqlite"; value: SqliteMigrationProgress }
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
export type CommandChild = {
pid: number | undefined
kill: () => void
}
const root = dirname(fileURLToPath(import.meta.url))
export function getSidecarPath() {
const suffix = process.platform === "win32" ? ".exe" : ""
const path = app.isPackaged
? join(process.resourcesPath, `opencode-cli${suffix}`)
: join(root, "../../resources", `opencode-cli${suffix}`)
console.log(`[cli] Sidecar path resolved: ${path} (isPackaged: ${app.isPackaged})`)
return path
}
export async function getConfig(): Promise<Config | null> {
const { events } = spawnCommand("debug config", {})
let output = ""
await new Promise<void>((resolve) => {
events.on("stdout", (line: string) => {
output += line
})
events.on("stderr", (line: string) => {
output += line
})
events.on("terminated", () => resolve())
events.on("error", () => resolve())
})
try {
return JSON.parse(output) as Config
} catch {
return null
}
}
export async function installCli(): Promise<string> {
if (process.platform === "win32") {
throw new Error("CLI installation is only supported on macOS & Linux")
}
const sidecar = getSidecarPath()
const scriptPath = join(app.getAppPath(), "install")
const script = readFileSync(scriptPath, "utf8")
const tempScript = join(tmpdir(), "opencode-install.sh")
writeFileSync(tempScript, script, "utf8")
chmodSync(tempScript, 0o755)
const cmd = spawn(tempScript, ["--binary", sidecar], { stdio: "pipe" })
return await new Promise<string>((resolve, reject) => {
cmd.on("exit", (code: number | null) => {
try {
unlinkSync(tempScript)
} catch {}
if (code === 0) {
const installPath = getCliInstallPath()
if (installPath) return resolve(installPath)
return reject(new Error("Could not determine install path"))
}
reject(new Error("Install script failed"))
})
})
}
export function syncCli() {
if (!app.isPackaged) return
const installPath = getCliInstallPath()
if (!installPath) return
let version = ""
try {
version = execFileSync(installPath, ["--version"], { windowsHide: true }).toString().trim()
} catch {
return
}
const cli = parseVersion(version)
const appVersion = parseVersion(app.getVersion())
if (!cli || !appVersion) return
if (compareVersions(cli, appVersion) >= 0) return
void installCli().catch(() => undefined)
}
export function serve(hostname: string, port: number, password: string) {
const args = `--print-logs --log-level WARN serve --hostname ${hostname} --port ${port}`
const env = {
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
}
return spawnCommand(args, env)
}
export function spawnCommand(args: string, extraEnv: Record<string, string>) {
console.log(`[cli] Spawning command with args: ${args}`)
const base = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
)
const env = {
...base,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
XDG_STATE_HOME: app.getPath("userData"),
...extraEnv,
}
const shell = process.platform === "win32" ? null : getUserShell()
const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env
const { cmd, cmdArgs } = buildCommand(args, envs, shell)
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
const child = spawn(cmd, cmdArgs, {
env: envs,
detached: process.platform !== "win32",
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
})
console.log(`[cli] Spawned process with PID: ${child.pid}`)
const events = new EventEmitter()
const exit = new Promise<TerminatedPayload>((resolve) => {
child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
console.log(`[cli] Process exited with code: ${code}, signal: ${signal}`)
resolve({ code: code ?? null, signal: null })
})
child.on("error", (error: Error) => {
console.error(`[cli] Process error: ${error.message}`)
events.emit("error", error.message)
})
})
const stdout = child.stdout
const stderr = child.stderr
if (stdout) {
readline.createInterface({ input: stdout }).on("line", (line: string) => {
if (handleSqliteProgress(events, line)) return
events.emit("stdout", `${line}\n`)
})
}
if (stderr) {
readline.createInterface({ input: stderr }).on("line", (line: string) => {
if (handleSqliteProgress(events, line)) return
events.emit("stderr", `${line}\n`)
})
}
exit.then((payload) => {
events.emit("terminated", payload)
})
const kill = () => {
if (!child.pid) return
treeKill(child.pid)
}
return { events, child: { pid: child.pid, kill }, exit }
}
function handleSqliteProgress(events: EventEmitter, line: string) {
const stripped = line.startsWith("sqlite-migration:") ? line.slice("sqlite-migration:".length).trim() : null
if (!stripped) return false
if (stripped === "done") {
events.emit("sqlite", { type: "Done" })
return true
}
const value = Number.parseInt(stripped, 10)
if (!Number.isNaN(value)) {
events.emit("sqlite", { type: "InProgress", value })
return true
}
return false
}
function buildCommand(args: string, env: Record<string, string>, shell: string | null) {
if (process.platform === "win32" && isWslEnabled()) {
console.log(`[cli] Using WSL mode`)
const version = app.getVersion()
const script = [
"set -e",
'BIN="$HOME/.opencode/bin/opencode"',
'if [ ! -x "$BIN" ]; then',
` curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)} --no-modify-path`,
"fi",
`${envPrefix(env)} exec "$BIN" ${args}`,
].join("\n")
return { cmd: "wsl", cmdArgs: ["-e", "bash", "-lc", script] }
}
if (process.platform === "win32") {
const sidecar = getSidecarPath()
console.log(`[cli] Windows direct mode, sidecar: ${sidecar}`)
return { cmd: sidecar, cmdArgs: args.split(" ") }
}
const sidecar = getSidecarPath()
const user = shell || getUserShell()
const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`)
return { cmd: user, cmdArgs: ["-l", "-c", line] }
}
function envPrefix(env: Record<string, string>) {
const entries = Object.entries(env).map(([key, value]) => `${key}=${shellEscape(value)}`)
return entries.join(" ")
}
function shellEscape(input: string) {
if (!input) return "''"
return `'${input.replace(/'/g, `'"'"'`)}'`
}
function getCliInstallPath() {
const home = process.env.HOME
if (!home) return null
return join(home, CLI_INSTALL_DIR, CLI_BINARY_NAME)
}
function isWslEnabled() {
return store.get(WSL_ENABLED_KEY) === true
}
function parseVersion(value: string) {
const parts = value
.replace(/^v/, "")
.split(".")
.map((part) => Number.parseInt(part, 10))
if (parts.some((part) => Number.isNaN(part))) return null
return parts
}
function compareVersions(a: number[], b: number[]) {
const len = Math.max(a.length, b.length)
for (let i = 0; i < len; i += 1) {
const left = a[i] ?? 0
const right = b[i] ?? 0
if (left > right) return 1
if (left < right) return -1
}
return 0
}

View File

@@ -5,3 +5,25 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module "virtual:opencode-server" {
export namespace Server {
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
}
export namespace Config {
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
export type Info = import("../../../opencode/dist/types/src/node").Config.Info
}
export namespace Log {
export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
}
export namespace Database {
export const Path: typeof import("../../../opencode/dist/types/src/node").Database.Path
export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
}
export namespace JsonMigration {
export type Progress = import("../../../opencode/dist/types/src/node").JsonMigration.Progress
export const run: typeof import("../../../opencode/dist/types/src/node").JsonMigration.run
}
export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
}

View File

@@ -10,6 +10,7 @@ import pkg from "electron-updater"
import contextMenu from "electron-context-menu"
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
const APP_NAMES: Record<string, string> = {
dev: "OpenCode Dev",
@@ -27,8 +28,6 @@ const { autoUpdater } = pkg
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
import type { CommandChild } from "./cli"
import { installCli, syncCli } from "./cli"
import { CHANNEL, UPDATER_ENABLED } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
import { initLogging } from "./logging"
@@ -36,12 +35,13 @@ import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
import { Server } from "virtual:opencode-server"
const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }
let mainWindow: BrowserWindow | null = null
let sidecar: CommandChild | null = null
let server: Server.Listener | null = null
const loadingComplete = defer<void>()
const pendingDeepLinks: string[] = []
@@ -96,11 +96,9 @@ function setupApp() {
}
void app.whenReady().then(async () => {
// migrate()
app.setAsDefaultProtocolClient("opencode")
setDockIcon()
setupAutoUpdater()
syncCli()
await initialize()
})
}
@@ -134,8 +132,8 @@ async function initialize() {
const password = randomUUID()
logger.log("spawning sidecar", { url })
const { child, health, events } = spawnLocalServer(hostname, port, password)
sidecar = child
const { listener, health } = await spawnLocalServer(hostname, port, password)
server = listener
serverReady.resolve({
url,
username: "opencode",
@@ -145,7 +143,7 @@ async function initialize() {
const loadingTask = (async () => {
logger.log("sidecar connection started", { url })
events.on("sqlite", (progress: SqliteMigrationProgress) => {
initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
setInitStep({ phase: "sqlite_waiting" })
if (overlay) sendSqliteMigrationProgress(overlay, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
@@ -198,9 +196,6 @@ function wireMenu() {
if (!mainWindow) return
createMenu({
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
installCli: () => {
void installCli()
},
checkForUpdates: () => {
void checkForUpdates(true)
},
@@ -215,7 +210,6 @@ function wireMenu() {
registerIpcHandlers({
killSidecar: () => killSidecar(),
installCli: async () => installCli(),
awaitInitialization: async (sendStep) => {
sendStep(initStep)
const listener = (step: InitStep) => sendStep(step)
@@ -247,16 +241,9 @@ registerIpcHandlers({
})
function killSidecar() {
if (!sidecar) return
const pid = sidecar.pid
sidecar.kill()
sidecar = null
// tree-kill is async; also send process group signal as immediate fallback
if (pid && process.platform !== "win32") {
try {
process.kill(-pid, "SIGTERM")
} catch {}
}
if (!server) return
server.stop()
server = null
}
function ensureLoopbackNoProxy() {

View File

@@ -13,7 +13,6 @@ const pickerFilters = (ext?: string[]) => {
type Deps = {
killSidecar: () => void
installCli: () => Promise<string>
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
@@ -34,7 +33,6 @@ type Deps = {
export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
ipcMain.handle("install-cli", () => deps.installCli())
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
const send = (step: InitStep) => event.sender.send("init-step", step)
return deps.awaitInitialization(send)

View File

@@ -1,11 +1,10 @@
import { BrowserWindow, Menu, shell } from "electron"
import { Menu, shell } from "electron"
import { UPDATER_ENABLED } from "./constants"
import { createMainWindow } from "./windows"
type Deps = {
trigger: (id: string) => void
installCli: () => void
checkForUpdates: () => void
reload: () => void
relaunch: () => void
@@ -24,10 +23,6 @@ export function createMenu(deps: Deps) {
enabled: UPDATER_ENABLED,
click: () => deps.checkForUpdates(),
},
{
label: "Install CLI...",
click: () => deps.installCli(),
},
{
label: "Reload Webview",
click: () => deps.reload(),
@@ -77,27 +72,46 @@ export function createMenu(deps: Deps) {
{ label: "Toggle Terminal", accelerator: "Ctrl+`", click: () => deps.trigger("terminal.toggle") },
{ label: "Toggle File Tree", click: () => deps.trigger("fileTree.toggle") },
{ type: "separator" },
{ label: "Back", click: () => deps.trigger("common.goBack") },
{ label: "Forward", click: () => deps.trigger("common.goForward") },
{ role: "reload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{ role: "togglefullscreen" },
],
},
{
label: "Go",
submenu: [
{ label: "Back", accelerator: "Cmd+[", click: () => deps.trigger("common.goBack") },
{ label: "Forward", accelerator: "Cmd+]", click: () => deps.trigger("common.goForward") },
{ type: "separator" },
{
label: "Previous Session",
accelerator: "Option+ArrowUp",
accelerator: "Option+Up",
click: () => deps.trigger("session.previous"),
},
{
label: "Next Session",
accelerator: "Option+ArrowDown",
accelerator: "Option+Down",
click: () => deps.trigger("session.next"),
},
{ type: "separator" },
{
label: "Toggle Developer Tools",
accelerator: "Alt+Cmd+I",
click: () => BrowserWindow.getFocusedWindow()?.webContents.toggleDevTools(),
label: "Previous Project",
accelerator: "Cmd+Option+Up",
click: () => deps.trigger("project.previous"),
},
{
label: "Next Project",
accelerator: "Cmd+Option+Down",
click: () => deps.trigger("project.next"),
},
],
},
{ role: "windowMenu" },
{
label: "Help",
submenu: [

View File

@@ -1,4 +1,4 @@
import { serve, type CommandChild } from "./cli"
import { Server, Log } from "virtual:opencode-server"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { store } from "./store"
@@ -29,8 +29,14 @@ export function setWslConfig(config: WslConfig) {
store.set(WSL_ENABLED_KEY, config.enabled)
}
export function spawnLocalServer(hostname: string, port: number, password: string) {
const { child, exit, events } = serve(hostname, port, password)
export async function spawnLocalServer(hostname: string, port: number, password: string) {
await Log.init({ level: "WARN" })
const listener = await Server.listen({
port,
hostname,
username: "opencode",
password,
})
const wait = (async () => {
const url = `http://${hostname}:${port}`
@@ -42,19 +48,10 @@ export function spawnLocalServer(hostname: string, port: number, password: strin
}
}
const terminated = async () => {
const payload = await exit
throw new Error(
`Sidecar terminated before becoming healthy (code=${payload.code ?? "unknown"} signal=${
payload.signal ?? "unknown"
})`,
)
}
await Promise.race([ready(), terminated()])
await ready()
})()
return { child, health: { wait }, events }
return { listener, health: { wait } }
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
@@ -82,5 +79,3 @@ export async function checkHealth(url: string, password?: string | null): Promis
return false
}
}
export type { CommandChild }

View File

@@ -0,0 +1,7 @@
{
"plugins": {
"figma": {
"enabled": true
}
}
}

43
packages/mobile-voice/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
# generated native folders
/ios
/android

View File

@@ -0,0 +1,183 @@
# mobile-voice Agent Guide
This file defines package-specific guidance for agents working in `packages/mobile-voice`.
## Scope And Precedence
- Follow root `AGENTS.md` first.
- This file overrides root guidance for this package when rules conflict.
- If additional local guides are added later, treat the closest guide as highest priority.
## Project Overview
- Expo + React Native app for voice dictation and OpenCode session monitoring.
- Uses native/device-heavy modules such as `whisper.rn`, `react-native-audio-api`, `expo-notifications`, and `expo-camera`.
- Development builds are required for native module changes.
## Commands
Run all commands from `packages/mobile-voice`.
- Install deps: `bun install`
- Start Metro: `bun run start`
- Start dev client server (recommended): `bunx expo start --dev-client --clear --host lan`
- iOS run: `bun run ios`
- Android run: `bun run android`
- Lint: `bun run lint`
- Typecheck: `bun run typecheck`
- Expo doctor: `bunx expo-doctor`
- Dependency compatibility check: `bunx expo install --check`
- Export bundle smoke test: `bunx expo export --platform ios --clear`
## Build / Verification Expectations
- For JS-only changes: run `bun run lint` and verify app behavior via dev client.
- For TS-heavy refactors: run `bun run typecheck` in addition to lint.
- For native dependency/config/plugin changes: rebuild dev client via EAS before validation.
- If notifications, camera, microphone, or audio-session behavior changes, verify on a physical iOS device.
- Do not claim a fix unless you validated in Metro logs and app runtime behavior.
## Single-Test Guidance
- This package currently has no dedicated unit test script.
- Use targeted validation commands instead:
- `bun run lint`
- `bun run typecheck`
- `bunx expo export --platform ios --clear`
- manual runtime test in dev client
## Architecture Priorities
- Keep screens focused on composition and orchestration. Once a screen owns multiple workflows, extract hooks/components before adding more local state.
- Prefer extracting pure helpers and config objects before introducing new stores or abstractions.
- Treat `src/app/index.tsx` as a composition root, not as the permanent home for onboarding, dictation, monitoring, pairing, persistence, and all UI details.
- Avoid mirrored `state + ref` pairs unless they are needed for imperative native APIs, race cancellation, or subscription callbacks.
## Code Style And Patterns
### Formatting / Structure
- Preserve existing style (`semi: false`, concise JSX, stable import grouping).
- Keep UI changes localized and behavior-preserving; avoid unrelated formatting churn.
- Prefer feature-adjacent hooks/components over growing a single screen file.
### React State / Effects
- Effects are for subscriptions, timers, persistence, network I/O, and native bridge setup/cleanup.
- Do not add `useEffect` just to derive render data from props or state. Derive during render instead.
- Prefer one source of truth. If a value can be computed from existing state, do not store it separately.
- Use `useMemo` only when computation is expensive or stable identity actually matters.
- Use `useCallback` only when stable function identity matters for dependencies, cleanup, or memoized children.
- When UI branches are driven by a small finite state, prefer config tables/objects over long nested ternaries.
### Types
- Avoid `any`; prefer local type aliases for component state and network payloads.
- Keep exported/shared boundaries typed explicitly.
- Parse persisted and network payloads as `unknown` first, then validate before use.
- Use discriminated unions for UI modes/status where practical.
### Naming
- Prefer short, readable names consistent with nearby code.
- Keep naming aligned with existing app state keys (`monitorStatus`, `activeSessionId`, etc.).
### Error Handling / Logging
- Fail gracefully in UI (alerts, disabled actions, fallback text).
- Avoid bare `catch {}` or `.catch(() => {})` for meaningful work. If failure is intentionally best-effort, leave a short comment or use a helper that makes that explicit.
- Log actionable diagnostics for runtime workflows such as server health checks, relay registration, and notification token lifecycle.
- Never log secrets or full APNs tokens.
- Keep hot-path logging behind `__DEV__` when possible.
### Network / Relay Integration
- Normalize and validate URLs before storing server configs.
- Use `AbortController` or request IDs for overlapping requests, streams, and polling.
- Keep relay registration idempotent.
- Guard duplicate scan/add flows to avoid repeated server entries.
### Notifications / APNs
- This package currently assumes APNs relay registration uses the `production` environment only. Do not add environment switching unless explicitly requested.
- On registration changes, ensure old token unregister flow remains intact.
- Treat permission failures as non-fatal and degrade to foreground monitoring when needed.
### Performance / RN
- Validate performance-sensitive changes in a dev client or release build, not only Metro dev mode.
- During recording and monitoring flows, keep JS-thread work light.
- Prefer Reanimated/native-thread-friendly animations for motion.
- For small menus a `ScrollView` is fine; if a list grows beyond a small bounded menu, move to `FlatList` or `FlashList`.
## Lint / Quality Bar
- Keep hooks lint warnings clean before finishing.
- Treat `any`, `no-console`, complexity, and max-lines warnings as refactor prompts, not noise to suppress.
- Do not disable React Hooks lint rules inline unless there is a documented native-interop reason.
- When introducing new persistence or network payloads, add or reuse a parser instead of scattering casts.
## Native-Module Safety
- If adding a native module, ensure it is in `package.json` with an SDK-compatible version.
- Rebuild the dev client after native module additions or changes.
- For optional native capability usage, prefer runtime fallback paths instead of hard crashes.
## Expo Native Config (EAS)
- Treat `packages/mobile-voice/app.json` as the source of truth for iOS native metadata in EAS cloud builds.
- Do not rely on manual edits in `ios/mobilevoice/Info.plist`, entitlements files, or `PrivacyInfo.xcprivacy`; for this package they are generated outputs.
- Keep generated native folders untracked in git (`/ios`, `/android`) to avoid mixed CNG/bare behavior during EAS builds.
- Put App Store compliance and permission metadata in app config using these fields:
- `expo.ios.infoPlist` for Info.plist keys (usage strings, ATS, Bonjour, and related keys).
- `expo.ios.config.usesNonExemptEncryption` for export-compliance encryption declaration.
- `expo.ios.entitlements` for iOS entitlements.
- `expo.ios.privacyManifests` for Apple privacy manifest declarations.
- Keep `app.json` entries explicit and review-friendly:
- Permission descriptions should be complete, product-specific sentences.
- Compliance keys should be set intentionally rather than relying on implicit defaults.
- Preserve existing JSON style in this package (concise arrays and stable key grouping).
- After native config changes, verify resolved config with `bunx expo config --type prebuild --json` and check the resulting `ios` fields.
Example shape:
```json
{
"expo": {
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "...",
"NSMicrophoneUsageDescription": "..."
},
"config": {
"usesNonExemptEncryption": false
},
"entitlements": {
"com.apple.developer.kernel.extended-virtual-addressing": true
},
"privacyManifests": {
"NSPrivacyAccessedAPITypes": []
}
}
}
}
```
## Common Pitfalls
- Black screen + "No script URL provided" often means a stale dev client binary.
- `expo-doctor` duplicate module warnings may appear in Bun workspaces; prioritize runtime verification.
- `expo lint` may auto-generate `eslint.config.js`; do not commit accidental generated config unless requested.
## Before Finishing
- Run `bun run lint`.
- If behavior could break startup, run `bunx expo export --platform ios --clear`.
- Confirm no accidental config side effects were introduced.
- Summarize what was verified on-device vs only in tooling.
- Dev build (internal/dev client):
- bunx eas build --profile development --platform ios
- Production build + auto-submit:
- bunx eas build --profile production --platform ios --auto-submit

View File

@@ -0,0 +1,39 @@
# Mobile Voice
Expo app for voice dictation and OpenCode session monitoring.
## Current monitoring behavior
- Foreground: app reads OpenCode SSE (`GET /event`) and updates monitor status live.
- Background/terminated: app relies on APNs notifications sent by `apn-relay`.
- The app registers its native APNs device token with relay route `POST /v1/device/register`.
## App requirements
- Use a development build or production build (not Expo Go).
- `expo-notifications` plugin is enabled with `enableBackgroundRemoteNotifications: true`.
- Notification permission must be granted.
## Server entry fields in app
When adding a server, provide:
- OpenCode URL
- APN relay URL
- Relay shared secret
Default APN relay URL: `https://apn.dev.opencode.ai`
The app uses these values to:
- send prompts to OpenCode
- register/unregister APNs token with relay
- receive background push updates for monitored sessions
## Local dev
```bash
npx expo start
```
Use your machine LAN IP / reachable host values for OpenCode and relay when testing on a physical device.

View File

@@ -0,0 +1,101 @@
{
"expo": {
"name": "Control",
"slug": "control",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mobilevoice",
"userInterfaceStyle": "automatic",
"ios": {
"icon": "./assets/images/icon.png",
"bundleIdentifier": "com.anomalyco.mobilevoice",
"config": {
"usesNonExemptEncryption": false
},
"entitlements": {
"com.apple.developer.kernel.extended-virtual-addressing": true
},
"infoPlist": {
"NSMicrophoneUsageDescription": "Control uses the microphone while you hold Record to turn your speech into text for an OpenCode session.",
"NSCameraUsageDescription": "Control uses the camera to scan the OpenCode pairing QR code shown on your computer.",
"NSLocalNetworkUsageDescription": "Control uses your local network to discover and connect to OpenCode servers running on your computer.",
"NSBonjourServices": ["_http._tcp."],
"NSAppTransportSecurity": {
"NSAllowsLocalNetworking": true,
"NSExceptionDomains": {
"100.64.0.0/10": {
"NSExceptionAllowsInsecureHTTPLoads": true
},
"ts.net": {
"NSIncludesSubdomains": true,
"NSExceptionAllowsInsecureHTTPLoads": true
}
}
}
}
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"permissions": [
"RECORD_AUDIO",
"POST_NOTIFICATIONS",
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
"android.permission.ACCESS_NETWORK_STATE",
"android.permission.ACCESS_WIFI_STATE",
"android.permission.CHANGE_WIFI_MULTICAST_STATE"
],
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"backgroundColor": "#121212",
"android": {
"image": "./assets/images/splash-icon.png",
"imageWidth": 76
}
}
],
"react-native-audio-api",
"expo-asset",
"expo-audio",
[
"expo-notifications",
{
"enableBackgroundRemoteNotifications": true,
"sounds": ["./assets/sounds/alert.wav"]
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "50b3dac3-8b5e-4142-b749-65ecf7b2904d"
}
},
"owner": "anomaly-co",
"runtimeVersion": "1.0.0",
"updates": {
"url": "https://u.expo.dev/50b3dac3-8b5e-4142-b749-65ecf7b2904d"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

View File

@@ -0,0 +1,3 @@
<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,40 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "expo-symbol 2.svg",
"name" : "expo-symbol 2",
"position" : {
"scale" : 1,
"translation-in-points" : [
1.1008400065293245e-05,
-16.046875
]
}
},
{
"image-name" : "grid.png",
"name" : "grid"
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,27 @@
{
"cli": {
"version": ">= 18.4.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"bun": "1.3.11",
"developmentClient": true,
"distribution": "internal",
"channel": "development"
},
"preview": {
"bun": "1.3.11",
"distribution": "internal",
"channel": "preview"
},
"production": {
"bun": "1.3.11",
"autoIncrement": true,
"channel": "production"
}
},
"submit": {
"production": {}
}
}

View File

@@ -0,0 +1,52 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require("eslint/config")
const tsGuard = require("@typescript-eslint/eslint-plugin")
const expoConfig = require("eslint-config-expo/flat")
const reactHooksNext = require("eslint-plugin-react-hooks")
module.exports = defineConfig([
expoConfig,
{
ignores: ["dist/*"],
},
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: __dirname,
},
},
plugins: {
"react-hooks-next": reactHooksNext,
"ts-guard": tsGuard,
},
rules: {
"ts-guard/no-explicit-any": "warn",
"ts-guard/no-floating-promises": "warn",
complexity: ["warn", 20],
"max-lines": [
"warn",
{
max: 1200,
skipBlankLines: true,
skipComments: true,
},
],
"max-lines-per-function": [
"warn",
{
max: 250,
skipBlankLines: true,
skipComments: true,
},
],
"no-console": ["warn", { allow: ["warn", "error"] }],
"no-nested-ternary": "warn",
"react-hooks/exhaustive-deps": "error",
"react-hooks-next/refs": "warn",
"react-hooks-next/set-state-in-effect": "warn",
"react-hooks-next/static-components": "warn",
},
},
])

View File

@@ -0,0 +1,8 @@
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
// Required for react-native-executorch model files
config.resolver.assetExts.push('pte', 'bin');
module.exports = config;

View File

@@ -0,0 +1,5 @@
- While the model is loading for the first time, there should be some fun little like onboarding sequence that you can go through that makes sure the model is automated properly.
- When a permission/session complete notification is sent, if you click on it, the session/server should auto be selected.
- We need some sort of permissions UI in the top half of the generation.
- Need to figure out a good way to start new sessions.
- When an agent returns a generation, we should be able to expand it into a reader mode view.

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