Compare commits

...

1347 Commits

Author SHA1 Message Date
opencode-agent[bot] 63f01ad01a chore: update nix node_modules hashes 2026-08-09 06:09:25 +00:00
opencode-agent[bot] eb6b2535d9 Fix beta integration 2026-08-09 05:56:24 +00:00
opencode-agent[bot] 170a25b2e9 Apply PR #40845: feat(app): redesign non-modal settings 2026-08-09 05:56:03 +00:00
opencode-agent[bot] 86c89a0dcb Apply PR #40427: some experimental perf improvements 2026-08-09 05:44:12 +00:00
opencode-agent[bot] 64ec9476c8 Apply PR #38790: feat(app): add workspace flows to new layout 2026-08-09 05:41:31 +00:00
opencode-agent[bot] 92e8d42970 Apply PR #38403: fix(ui): standardize v2 tooltip delay 2026-08-09 05:38:47 +00:00
opencode-agent[bot] 5c9518cfaa Apply PR #37927: fix(app): navigate tabs by selection history 2026-08-09 05:37:32 +00:00
opencode-agent[bot] a9af239848 Apply PR #29948: fix(tui): keep command palette available in questions 2026-08-09 05:34:20 +00:00
opencode-agent[bot] 38e10eb140 fix(opencode): ignore unknown config fields (#41312) 2026-08-08 14:53:43 -04:00
opencode-agent[bot] fe82a1b6ca chore: generate 2026-08-08 03:39:08 +00:00
Brendan Allan 2ea728f731 fix(app): populate project picker from home (#41158) 2026-08-08 11:37:30 +08:00
LukeParkerDev a2c3aefcaf fix(app): align settings with v2 APIs 2026-08-07 20:03:34 +10:00
LukeParkerDev 80d7766348 feat(app): align settings with project scopes 2026-08-07 19:53:01 +10:00
LukeParkerDev d3aee774d9 fix(app): scope redesigned settings by server 2026-08-07 19:44:45 +10:00
LukeParkerDev 3b645690ea fix(app): simplify notifications settings heading 2026-08-07 19:43:16 +10:00
LukeParkerDev 6776d1a81e refactor(app): trim settings placeholders 2026-08-07 19:43:03 +10:00
LukeParkerDev 5f139c9612 feat(app): redesign non-modal settings 2026-08-07 19:42:50 +10:00
Aarav Sareen 284214c78d feat(app): open project menu on right-click (#41013) 2026-08-07 17:25:46 +08:00
日常无语的无语者 b05a0d1fbe fix(i18n): use 词元 instead of 令牌 for token in zh locale (#40977) 2026-08-07 19:22:23 +10:00
Jack be25c905fa docs(zen): replace Ling Flash with Tiny (#41027) 2026-08-07 16:48:42 +08:00
Brendan Allan 5347b5e009 fix(app): register new home projects (#41018) 2026-08-07 07:34:32 +00:00
opencode-agent[bot] b467518dae fix(desktop): preserve macOS app on window close (#40974)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-08-07 07:25:12 +00:00
opencode 1ec6bdc8c6 sync release versions for v1.18.15 2026-08-07 06:49:53 +00:00
Aarav Sareen 265472ef72 fix(ui): use tokens for button loading state (#40884) 2026-08-07 12:11:00 +05:30
Frank 325529761b sync 2026-08-07 02:22:25 -04:00
opencode-agent[bot] 741244b69d feat(app): add global locale coverage (#40992)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-07 05:41:11 +00:00
Jack 4abddbbfdd feat(go): promote DeepSeek V4 Flash usage (#40988) 2026-08-07 13:24:03 +08:00
opencode-agent[bot] 5aa5cb3523 fix(app): use chronological message boundaries (#41006)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 05:23:28 +00:00
Aiden Cline 2092350cfa fix(core): align shell output limits (#41007) 2026-08-07 00:18:16 -05:00
opencode-agent[bot] 9113255114 fix(app): order stored messages by creation time (#41001)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 05:06:28 +00:00
Aiden Cline 5fb0d7c99c feat(core): bound tool output (#40929) 2026-08-07 00:02:14 -05:00
opencode-agent[bot] 23cc677108 fix(tui): order messages by creation time (#40994)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:41:15 +00:00
opencode-agent[bot] 28bcc0e4f4 fix(app): sort sessions by persisted time (#41000)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:23:04 +00:00
Dmitry Nefedov 8bf5062b89 feat(tui): add cursor style configuration (#32295)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-08-06 23:20:51 -05:00
opencode-agent[bot] 20750c332e fix(web): order shared messages by creation time (#40995)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:11:28 +00:00
opencode-agent[bot] db581e47a3 fix(opencode): order legacy message loop by time (#40990)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:08:23 +00:00
opencode-agent[bot] a54a693af2 fix(opencode): use chronological message boundaries (#40991)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 04:08:02 +00:00
opencode-agent[bot] d468201952 fix(opencode): use file times for truncation cleanup (#40987)
Co-authored-by: Dax <mail@thdxr.com>
2026-08-07 03:58:31 +00:00
Brendan Allan a1b4843a33 refactor(app): remove legacy layout (#40947) 2026-08-07 11:35:04 +08:00
opencode-agent[bot] 31c5c4e924 fix(app): keep locale names native (#40985)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-07 03:32:43 +00:00
opencode-agent[bot] 8d65dbdd04 fix(app): complete translation coverage (#40981)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-07 03:06:23 +00:00
opencode-agent[bot] b7f9363393 fix(opencode): serialize orphaned compaction history (#40800)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-08-06 21:11:14 -05:00
Kit Langton cc7827fe08 refactor(core): simplify file tools to lexical paths (#40962) 2026-08-06 21:53:52 -04:00
opencode-agent[bot] 76e4d88d21 fix(core): default custom agents to primary (#40880)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-06 20:47:51 -05:00
opencode-agent[bot] 439ed66c7b fix(core): migrate legacy small model (#40966)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-06 20:46:12 -05:00
Kit Langton 047d434aa2 fix(tui): dismiss stale permission prompts (#40960) 2026-08-06 21:43:44 -04:00
opencode-agent[bot] d7651519f3 fix(tui): use tab layout setting (#40952)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-07 01:19:28 +00:00
Kit Langton 1eb3a43add fix(tui): keep model selection session scoped (#40913) 2026-08-06 20:34:57 -04:00
opencode-agent[bot] 69f2cbaa3a chore: generate 2026-08-06 23:37:25 +00:00
Frank dc898ca2c3 sync 2026-08-06 19:34:26 -04:00
Aiden Cline dcae95e2bb feat(ai): expose model compatibility options (#40942) 2026-08-06 17:46:05 -05:00
James Long 0cc507b8a9 feat(cli): add session import and export (#40914) 2026-08-06 17:51:11 -04:00
opencode-agent[bot] 727beae2d5 fix(core): deduplicate websearch consent prompts (#40869)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-08-06 17:50:51 -04:00
Dax Raad cd64a17e37 test(core): cover config precedence 2026-08-06 17:26:35 -04:00
Dax Raad d35ca49c31 feat(tui): enable cwd-scoped session tabs by default 2026-08-06 17:26:06 -04:00
Dax 5cf97f1b96 feat(core): normalize mixed config formats (#40919) 2026-08-06 17:11:13 -04:00
Kit Langton 65c6a71903 fix(tui): simplify MCP status rows (#40916) 2026-08-06 16:50:58 -04:00
Kit Langton a779027303 fix(tui): compact single-line prompts (#40924) 2026-08-06 16:46:24 -04:00
Dax 20fa444f31 fix(config): omit unset optional values (#40918) 2026-08-06 13:06:03 -07:00
Kit Langton c66d84169a fix(tui): open authorization links (#40912) 2026-08-06 15:26:13 -04:00
Aiden Cline 7dbe8c4c13 refactor(mcp): remove unused registration status (#40904) 2026-08-06 13:59:22 -05:00
Aiden Cline 8864b01d0b feat(core): increase retained compaction context (#40906) 2026-08-06 13:45:06 -05:00
Aiden Cline ec95b27308 fix(core): align ChatGPT context limits (#40902) 2026-08-06 13:35:48 -05:00
Aiden Cline be2f74d44a refactor(plugin): split session HTTP hooks (#40724) 2026-08-06 13:23:22 -05:00
James Long 912a801060 refactor(drizzle): declare query variance (#40891) 2026-08-06 13:58:17 -04:00
Dax 0215498f63 fix(core): load well-known config first (#40897) 2026-08-06 10:44:26 -07:00
Dax 1539bd6794 fix(client): evict unresponsive service (#40896) 2026-08-06 10:37:05 -07:00
Dax 939da6a5e7 feat(cli): add debug config command (#40895) 2026-08-06 10:35:48 -07:00
Dax Raad 0f27ee7b4c fix(cli): reuse server for noninteractive commands 2026-08-06 12:57:37 -04:00
Adam 03bff6500a fix(app): update homepage stats 2026-08-06 11:07:33 -05:00
Adam b379d71d19 fix(stats): update github stars 2026-08-06 10:58:58 -05:00
Kit Langton 51cef27579 refactor(core): deduplicate Copilot endpoint routing (#40765) 2026-08-06 11:10:47 -04:00
LukeParkerDev 9d589b5059 fix(app): scope accent send to workspaces 2026-08-06 21:23:18 +10:00
LukeParkerDev 470d7400f5 fix(app): prevent workspace settings suspense 2026-08-06 20:46:18 +10:00
LukeParkerDev 407853d8f3 fix(session-ui): keep accent controls legible in dark mode 2026-08-06 20:30:48 +10:00
Brendan Allan a0c77b71ae refactor(app): use native session info (#40824) 2026-08-06 17:58:56 +08:00
LukeParkerDev a45c4c149c test(app): target v2 request dock events 2026-08-06 19:48:24 +10:00
ayu def7220bfc fix(tui): support copying over ssh with set-clipboard on tmux config (#30472)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-08-06 10:23:32 +02:00
LukeParkerDev b20b5a7f3b feat(app): align workspace session accents, composer button, and settings typography 2026-08-06 18:04:38 +10:00
LukeParkerDev e1f51ce53f fix(app): space workspace and branch controls 2026-08-06 17:39:31 +10:00
opencode-agent[bot] f6c5afe5a9 fix(desktop): disable packaged console logging (#40794)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-06 17:25:41 +10:00
Frank 847771fe06 fix log processor 2026-08-06 03:21:16 -04:00
LukeParkerDev e11d4a1fc9 fix(app): position workspace lifecycle before assistant rows 2026-08-06 16:11:52 +10:00
Dax Raad e62918224f fix(cli): rename auth connect to login 2026-08-06 01:48:53 -04:00
LukeParkerDev 1b99e64e67 refactor(app): simplify workspace flows and i18n 2026-08-06 15:14:44 +10:00
Dax Raad 30d140008f feat(cli): add models command 2026-08-06 01:12:11 -04:00
Aiden Cline a38d44265b fix(core): continue interrupted responses (#40576) 2026-08-06 00:04:27 -05:00
Aiden Cline 0cce215de4 fix(ai): harden compatible chat parsing (#40798) 2026-08-05 23:54:41 -05:00
opencode-agent[bot] a15ec425de test: repair v2 CI failures (#40803)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-05 23:38:38 -05:00
Aiden Cline ae494ffce5 fix(core): retire legacy provider aliases (#40487) 2026-08-05 23:27:14 -05:00
Brendan Allan 25c2150baf refactor(app): use native v2 types (#40608) 2026-08-06 11:54:11 +08:00
LukeParkerDev 5005898468 fix(app): support fallback local workspace state when draft id is absent 2026-08-06 13:19:06 +10:00
LukeParkerDev 627e640c69 fix(app): resolve parity plurals, stream lifecycle, and menu state bugs 2026-08-06 13:16:34 +10:00
Brendan Allan 9314992f98 refactor(app): remove v1 compatibility (#40382) 2026-08-06 11:11:58 +08:00
LukeParkerDev b6b6f87969 Merge remote-tracking branch 'upstream/dev' into workspaces-readyish 2026-08-06 12:32:51 +10:00
LukeParkerDev 9590b51a3f fix(app): polish workspace localization and move targets 2026-08-06 12:21:22 +10:00
Dax 0fbe489e35 feat(core): migrate v1 data to v2 (#40723) 2026-08-05 21:57:58 -04:00
LukeParkerDev f1476dc7d4 fix(app): show rich onboarding tooltip on location trigger hover 2026-08-06 11:53:38 +10:00
opencode-agent[bot] b8bd88901a chore: generate 2026-08-06 01:52:15 +00:00
Kit Langton 03e9789064 test(core): align event persistence expectations (#40783) 2026-08-06 01:50:45 +00:00
Luke Parker f1adabcddc feat(app): export session as json from ui (#40781) 2026-08-06 11:50:33 +10:00
LukeParkerDev a597364516 fix(app): align workspace interactions and styling with designs 2026-08-06 11:26:02 +10:00
Kit Langton 0c558a6856 refactor(console): remove unused mail assets (#40758) 2026-08-06 00:29:55 +00:00
Kit Langton f297bd3b8b refactor(desktop): remove disconnected CLI installer (#40751) 2026-08-06 00:29:37 +00:00
Kit Langton 1bfc23f503 refactor(console): remove unused landing assets (#40757) 2026-08-06 00:28:37 +00:00
LukeParkerDev c40aecbdbe fix(app): restore timeline content inset 2026-08-06 10:26:57 +10:00
Kit Langton 3839aafa25 refactor: remove orphaned sqlite package (#40766) 2026-08-06 00:00:40 +00:00
Dax Raad bd2f37bc90 fix(core): clarify current session label 2026-08-05 19:33:59 -04:00
Dax Raad 120e4e7388 fix(core): make event persistence opt-in 2026-08-05 19:33:59 -04:00
Kit Langton 6f91bc7415 fix(tui): load sidebar project names sooner (#40763) 2026-08-05 19:31:42 -04:00
Luke Parker 24470e52a5 fix(desktop): embed version in server sidecar (#40764) 2026-08-06 07:22:11 +08:00
Kit Langton c46f6ae112 refactor(app): remove unused help placeholder (#40756) 2026-08-05 18:53:25 -04:00
LukeParkerDev d985fa4307 fix(app): anchor workspace onboarding flow 2026-08-06 08:48:55 +10:00
Kit Langton 285444aab4 refactor(web): remove superseded ornate logos (#40750) 2026-08-05 22:35:41 +00:00
mridul 23bbc5cd14 fix(server): allow blob attachments in web UI (#40692) 2026-08-05 22:23:32 +00:00
LukeParkerDev 6c1aabd5e0 fix(app): align workspace move menu styling 2026-08-06 08:21:11 +10:00
LukeParkerDev a16dd3d4ed fix(app): disable legacy file tree in new layout 2026-08-06 08:21:10 +10:00
Kit Langton 56c33e84a3 fix(tui): keep model search order stable (#40753) 2026-08-05 18:19:26 -04:00
LukeParkerDev bbb1a19ed6 fix(app): preserve timeline content width 2026-08-06 08:02:02 +10:00
Kit Langton 73581b3c3b refactor(web): remove unused icons (#40744) 2026-08-05 21:51:54 +00:00
Kit Langton 6e4d01f846 refactor(session-ui): remove unused panel title (#40741) 2026-08-05 21:50:46 +00:00
Kit Langton 8646587c95 refactor(web): remove unused share anchor (#40742) 2026-08-05 17:46:40 -04:00
Kit Langton fb47f06228 refactor(desktop): remove superseded local sidecar (#40743) 2026-08-05 17:46:19 -04:00
Kit Langton 3acaa5a359 refactor(core): remove unused shell posix metadata (#40734) 2026-08-05 21:31:35 +00:00
Kit Langton 0726a25142 refactor(console): remove abandoned desktop promo (#40737) 2026-08-05 21:30:53 +00:00
Kit Langton 0df96ddeb0 refactor(codemode): remove unused spread helper (#40729) 2026-08-05 21:26:33 +00:00
Kit Langton 887673310b refactor(core): remove unused session exports (#40730) 2026-08-05 21:26:16 +00:00
Kit Langton 693a1bff81 refactor(core): remove unused copy strategy registry (#40735) 2026-08-05 21:25:18 +00:00
Kit Langton b40ed3aa85 refactor(console): remove unused mail helpers (#40740) 2026-08-05 21:24:21 +00:00
Kit Langton 732bb9c3cb refactor(core): remove discarded MCP connection details (#40726) 2026-08-05 17:20:13 -04:00
Kit Langton 2fd1660b4d refactor(core): remove test-only ignore helper (#40733) 2026-08-05 21:15:22 +00:00
Kit Langton 681526d348 refactor(core): remove duplicate directory query (#40731) 2026-08-05 21:10:01 +00:00
opencode ebf6fc07a1 sync release versions for v1.18.14 2026-08-05 20:58:52 +00:00
opencode-agent[bot] 04513d9692 chore: generate 2026-08-05 20:56:57 +00:00
Adam 146720e197 fix(stats): improve page speed 2026-08-05 15:54:35 -05:00
Kit Langton e78869c849 refactor(core): remove unused agent default method (#40725) 2026-08-05 20:46:36 +00:00
opencode-agent[bot] b1f8cc04af chore: update nix node_modules hashes 2026-08-05 20:33:25 +00:00
opencode-agent[bot] 082fe93e16 chore: generate 2026-08-05 20:16:40 +00:00
Aiden Cline 709c195905 fix(opencode): preserve compatible stream errors (#40718) 2026-08-05 15:14:03 -05:00
opencode-agent[bot] 3355b78d91 chore: generate 2026-08-05 20:06:05 +00:00
Adam b84c63d034 fix(stats): reduce html payloads 2026-08-05 15:02:39 -05:00
Kit Langton 934935963d refactor(core): remove unused filesystem search methods (#40703) 2026-08-05 19:42:30 +00:00
opencode-agent[bot] 057b5a9dee chore: generate 2026-08-05 19:41:32 +00:00
Aiden Cline 61aefc0759 fix(opencode): expand retryable error patterns (#40707) 2026-08-05 14:39:49 -05:00
Kit Langton f3912a2a8a refactor(app): remove unused UI helpers (#40686) 2026-08-05 15:15:15 -04:00
Kit Langton 45d58717a4 fix(core): bound protected home searches (#40518) 2026-08-05 15:15:02 -04:00
Kit Langton 74e3155ef0 refactor(session-ui): remove unused exported components (#40685) 2026-08-05 15:13:39 -04:00
Kit Langton 0af6c82563 refactor(core): remove orphaned message projector paths (#40705) 2026-08-05 15:13:15 -04:00
Kit Langton 686127f809 refactor(core): remove unreachable repository validation (#40700) 2026-08-05 14:30:29 -04:00
Kit Langton 5256655c4d refactor(core): remove obsolete project commit bridge (#40701) 2026-08-05 18:16:55 +00:00
Aiden Cline f929f8f100 refactor(opencode): simplify retry error matching (#40694) 2026-08-05 13:05:19 -05:00
Kit Langton d10b652637 refactor(core): remove unused formatter methods (#40684) 2026-08-05 13:45:23 -04:00
Kit Langton b03ca0d4e2 refactor(core): remove unused bus sequences (#40681) 2026-08-05 13:41:38 -04:00
Kit Langton 25aaea3d31 refactor(core): remove unused shell tree killer (#40680) 2026-08-05 13:38:59 -04:00
Kit Langton cae7a139bc refactor(server): remove obsolete auth header helpers (#40682) 2026-08-05 13:37:29 -04:00
Kit Langton 5ea62ab05f refactor(core): remove orphaned git change operations (#40683) 2026-08-05 13:32:03 -04:00
Kit Langton faadc05c88 refactor(core): remove unused snapshot operations (#40687) 2026-08-05 13:01:48 -04:00
Kit Langton 3e253c589e refactor(core): persist instruction updates as messages (#40679) 2026-08-05 12:55:29 -04:00
Kit Langton 0a0fc09533 refactor(core): remove unused file mutation methods (#40667) 2026-08-05 12:33:20 -04:00
Kit Langton 5aa0413fea refactor(core): remove dead fork pending copy (#40675) 2026-08-05 12:30:42 -04:00
Kit Langton 6f4c199629 fix(tui): wait for diff request in tests (#40670) 2026-08-05 16:02:22 +00:00
Kit Langton ed8e1f4654 fix(core): reconcile promoted prompt retries from messages (#40664) 2026-08-05 11:43:03 -04:00
Kit Langton 3b0195e045 fix(core): avoid eager directory snapshots (#40552) 2026-08-05 14:52:07 +00:00
Shoubhit Dash 143a776373 fix(acp): surface subagent activity (#40438) 2026-08-05 18:57:15 +05:30
opencode-agent[bot] 4a57013cf8 fix(app): show pending tool details (#40603)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-05 08:25:06 +00:00
LukeParkerDev 6b17dc6190 Merge remote-tracking branch 'upstream/dev' into workspaces-readyish
# Conflicts:
#	packages/app/src/components/help-button.tsx
#	packages/app/src/i18n/zht.ts
#	packages/app/src/pages/session/timeline/message-timeline.tsx
2026-08-05 18:12:21 +10:00
LukeParkerDev e98b90fc51 feat(app): align workspace flows with Q3 designs 2026-08-05 17:56:51 +10:00
Brendan Allan 76b318e990 refactor(app): gate unsupported v2 capabilities (#40381) 2026-08-05 15:15:26 +08:00
opencode-agent[bot] c0ab35c3c2 fix(app): show active search details (#40597)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-05 17:09:42 +10:00
Brendan Allan b1c1b79d15 refactor(app): migrate supported v2 APIs (#40378) 2026-08-05 14:35:02 +08:00
Brendan Allan 4eb1b37d9b fix(app): reconcile v2 session projections (#40376) 2026-08-05 13:46:55 +08:00
Brendan Allan f2b3a12910 refactor(app): own rendering contracts (#40375) 2026-08-05 13:30:50 +08:00
Jack 2f17fc9613 docs(zen): add LongCat free model (#40585) 2026-08-05 12:49:28 +08:00
Aiden Cline daa998f9c3 fix(core): make xAI OAuth device-only (#40538) 2026-08-04 22:55:31 -05:00
Brendan Allan c74a0d8529 test(app): migrate e2e fixtures to v2 (#40374) 2026-08-05 11:33:18 +08:00
opencode-agent[bot] b8ea3ea091 chore: generate 2026-08-05 02:33:08 +00:00
Luke Parker 82a5796159 test(app): harden flaky e2e synchronization (#40556) 2026-08-05 12:31:45 +10:00
opencode-agent[bot] 9f38562237 fix(opencode): include cache writes in ACP usage (#40450)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 21:27:01 -05:00
opencode-agent[bot] 5b4fb1f770 chore: generate 2026-08-05 02:20:04 +00:00
Aiden Cline cb88db6ce3 tweak(opencode): make xAI OAuth device-only to reduce confusion w/ headless environments (#40537) 2026-08-04 21:18:44 -05:00
Aiden Cline 330ef108ce fix(core): report missing shell workdir (#40542) 2026-08-04 20:56:02 -05:00
opencode-agent[bot] f9ac6f3171 fix(ai): restore Responses tool input semantics (#40553)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 20:46:51 -05:00
opencode-agent[bot] 62e5d73d45 fix(ai): classify malformed Responses tool calls (#40549)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 20:40:36 -05:00
Kit Langton ab20a7b4a3 feat(tui): streamline tab navigation shortcuts (#40551) 2026-08-05 01:36:23 +00:00
opencode-agent[bot] ad9a95f6ee fix(ai): preserve Gemini tool finish semantics (#40546)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 20:29:06 -05:00
Luke Parker 66fdd51f0d docs: add RTL development skill (#40543) 2026-08-05 01:01:10 +00:00
LukeParkerDev 1b0e4e4610 refactor(app): use standard worker URLs 2026-08-05 10:48:57 +10:00
LukeParkerDev 842f1dcfdb fix(app): lazy load decoder workers 2026-08-05 10:42:41 +10:00
Aiden Cline 4ed343706d fix: retry empty incomplete streams (#40535) 2026-08-04 19:38:56 -05:00
LukeParkerDev 067dfa341f refactor(app): make desktop profiler portable 2026-08-05 10:36:11 +10:00
opencode-agent[bot] 6d2eb2240a fix(app): finish tool call ID rename (#40539)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 19:26:45 -05:00
opencode-agent[bot] 98dd65cd60 fix(app): collapse deletion-only edit parts (#40536)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-05 00:23:06 +00:00
LukeParkerDev f826f7fc9b docs(app): remove performance report 2026-08-05 09:32:22 +10:00
Dax Raad 53b8111121 refactor: rename tool call ID fields 2026-08-04 17:08:12 -04:00
Kit Langton 1fd817ffcf fix(client): wait for live service contenders (#40515) 2026-08-04 20:23:57 +00:00
Aiden Cline b9c118e0fe chore(core): drop unreachable AI SDK provider packages (#40504) 2026-08-04 14:37:56 -05:00
opencode-agent[bot] d1c9e39978 fix(tui): update tab titles immediately (#40318)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-08-04 14:23:29 -04:00
Aiden Cline 0b1ec457dc fix(tui): show branch beside directory (#40500) 2026-08-04 12:26:18 -05:00
Aiden Cline 9bd99b4c91 feat(vcs): publish branch updates (#40371) 2026-08-04 11:52:17 -05:00
opencode-agent[bot] 94fcb119a3 fix(core): avoid implicit output limits (#40488)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 11:09:23 -05:00
Aiden Cline aa820db18d feat(plugin): add session HTTP middleware (#40327) 2026-08-04 09:15:52 -05:00
James Murdza f0afb6750e fix(server): log upstream 5xx bodies from proxied workspace requests (#40135)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 09:43:59 -04:00
James Murdza 703d09f306 fix(server): don't forward host directory to remote workspace (#40136)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 09:43:45 -04:00
opencode aefaf140c1 sync release versions for v1.18.13 2026-08-04 12:12:30 +00:00
LukeParkerDev 27ecc46dc7 fix(app): improve renderer startup performance 2026-08-04 22:10:39 +10:00
LukeParkerDev 8c7c69c749 fix(session-ui): parse composer input once 2026-08-04 22:09:36 +10:00
LukeParkerDev 9b16d0b069 fix(session-ui): bound completed markdown rendering 2026-08-04 22:09:35 +10:00
LukeParkerDev ccc11dc92d fix(app): move hot response processing off thread 2026-08-04 22:09:35 +10:00
LukeParkerDev a47dabff22 fix(app): parse session messages off thread 2026-08-04 22:09:34 +10:00
LukeParkerDev 1277ceb426 fix(app): stop automatic session neighbor prefetch 2026-08-04 22:09:33 +10:00
LukeParkerDev 7b7335b7e9 fix(app): avoid renderer Shiki WASM startup 2026-08-04 22:09:33 +10:00
LukeParkerDev 7192fa8b7a fix(app): yield before session history ingestion 2026-08-04 22:09:32 +10:00
LukeParkerDev 4062b30409 fix(app): reduce session history page size 2026-08-04 22:09:32 +10:00
Shoubhit Dash 44614c79c4 fix(acp): drain updates before end turn (#40422) 2026-08-04 17:26:33 +05:30
Amy Duquette dc67b7a3be docs: add Poolside provider setup (#39187) 2026-08-04 06:43:54 -05:00
Luke Parker de1bd36a47 fix(app): correct RTL layout interactions (#40410) 2026-08-04 11:39:32 +00:00
opencode-agent[bot] 6c97a46dae fix(stats): hide zero-usage countries (#40400) 2026-08-04 06:26:48 -05:00
Luke Parker c387fe190b feat(app): add debug layout direction toggle (#40393) 2026-08-04 08:52:37 +00:00
opencode-agent[bot] 8721a2710b fix(app): complete shared RTL primitives (#40388)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-04 07:38:56 +00:00
Brendan Allan c75e3b5bae fix(app): supply fallback session titles (#40385) 2026-08-04 15:09:29 +08:00
opencode-agent[bot] edc8d1d91e chore: generate 2026-08-04 06:57:27 +00:00
opencode-agent[bot] e85735028c feat(i18n): localize hardcoded application copy (#40377)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-04 16:56:03 +10:00
opencode-agent[bot] 6c3299103c feat(app): add minimal RTL support (#40372)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-04 05:08:51 +00:00
opencode-agent[bot] 60e23fee16 chore: generate 2026-08-04 04:38:17 +00:00
opencode-agent[bot] 0c380f31ac feat(app): support locale plural rules (#40370)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-04 04:36:57 +00:00
opencode-agent[bot] 26aefa68f5 chore: update nix node_modules hashes 2026-08-04 04:30:14 +00:00
Aiden Cline 42e8d11552 feat(vcs): expose branch info (#40368) 2026-08-03 23:18:13 -05:00
Luke Parker 638788f8d0 fix(app): move markdown parsing to worker (#40356) 2026-08-04 04:13:34 +00:00
opencode-agent[bot] b005a15a4a chore: generate 2026-08-04 03:58:49 +00:00
opencode-agent[bot] 0efa33cf33 feat(desktop): expand and audit locale translations (#40362)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-04 03:57:33 +00:00
Aiden Cline 28d784b83a fix(core): execute tools renamed by context hooks (#40359) 2026-08-03 22:57:06 -05:00
LukeParkerDev 7d6b8105f1 Merge remote-tracking branch 'upstream/dev' into workspaces-readyish
# Conflicts:
#	packages/app/src/components/prompt-input/submit.ts
#	packages/app/src/components/settings-v2/general.tsx
#	packages/app/src/pages/session/timeline/rows-current.test.ts
2026-08-04 09:34:26 +10:00
LukeParkerDev a5a09c17fa Merge remote-tracking branch 'upstream/dev' into workspaces-readyish
# Conflicts:
#	packages/app/src/components/settings-v2/general.tsx
#	packages/app/src/context/server-sync.tsx
#	packages/app/src/pages/new-session.tsx
2026-08-04 09:09:12 +10:00
LukeParkerDev 10c6040b38 fix(app): refine workspace interactions 2026-08-04 08:59:50 +10:00
Aiden Cline a4ad17347f fix(core): apply safe defaults to all agents (#40316) 2026-08-03 17:56:04 -05:00
Aiden Cline 93e8b75cca refactor(schema): rename agent default constructor (#40324) 2026-08-03 15:58:08 -05:00
opencode-agent[bot] 8df03aa1bc fix(core): ignore empty agent files (#40302)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-03 11:00:10 -05:00
Shoubhit Dash 8ce850e142 fix(ai): expose client service requirements (#40275) 2026-08-03 17:01:32 +05:30
Kit Langton 3f30203b72 test(core): stabilize shell integration timing (#40084) 2026-08-02 14:45:36 -04:00
opencode-agent[bot] dd014120cd fix(core): route Bedrock packages natively (#40165)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-02 11:47:35 -05:00
opencode-agent[bot] bf7f0cb521 feat(ai): add native Bedrock Mantle support (#40119)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-02 11:16:22 -05:00
opencode-agent[bot] adbd22833c fix(tui): reserve home and end for navigation (#40123)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-08-02 04:39:23 +00:00
Aiden Cline 634b9b21dd refactor(ai): align multimodal naming (#40073) 2026-08-01 21:57:05 -05:00
Kit Langton 5d729521d3 refactor(tui): remove redundant code (#40081) 2026-08-01 14:22:48 -04:00
Kit Langton c391983ae0 fix(tui): discover plugins across config roots (#39988) 2026-08-01 14:06:58 -04:00
Kit Langton f158abd694 fix(tui): make vertical tabs responsive (#40080) 2026-08-01 14:06:13 -04:00
Kit Langton 0e3da4b4f1 feat(tui): add vertical session tabs (#40072) 2026-08-01 13:10:08 -04:00
Aiden Cline 003b22edda fix: preserve provider error status (#39976) 2026-08-01 11:06:00 -05:00
Kit Langton d5f6c088f0 fix(tui): prevent sidebar scrollbar flash (#40056) 2026-08-01 15:12:37 +00:00
Kit Langton e872bd3c8f feat(tui): prioritize favorite model search results (#40049) 2026-08-01 14:54:46 +00:00
opencode-agent[bot] 85ea15e56d test(tui): wait for mini prompt readiness (#39980)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-01 01:05:18 +00:00
Kit Langton 6a543791b6 fix(tui): persist tab reorder once per drag (#39942) 2026-07-31 20:36:00 -04:00
Kit Langton 47c6840752 fix(tui): harden session tab state hygiene (#39941) 2026-07-31 20:35:07 -04:00
Kit Langton 14e4c3dfd7 fix(tui): ignore hidden tab close hitbox (#39940) 2026-07-31 20:34:13 -04:00
opencode-agent[bot] ee6577936d refactor(tui): remove unused duration formatter (#39964)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:32:46 -04:00
opencode-agent[bot] 8285a18f18 refactor(tui): remove unused revert diff parser (#39963)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:32:35 -04:00
opencode-agent[bot] cfcd8de62a refactor(cli): remove unused warning helper (#39962)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:32:21 -04:00
opencode-agent[bot] c2db18c59b refactor(tui): remove unwired file selection helper (#39961)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:31:50 -04:00
opencode-agent[bot] 70393a1d18 Remove unused error data serializer (#39960)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:31:22 -04:00
opencode-agent[bot] eab59ea021 Remove unused locale helpers (#39959)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:31:06 -04:00
opencode-agent[bot] 4569659b03 Remove unused Zed helpers (#39958)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:53 -04:00
opencode-agent[bot] b5afaed053 Remove unused config optional hook (#39957)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:29 -04:00
opencode-agent[bot] 97f5fb3399 Remove unused subagent retry formatter (#39952)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:20 -04:00
opencode-agent[bot] 18843682ad refactor(tui): remove ignored attention kv option (#39956)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:09 -04:00
opencode-agent[bot] 9eb9c1db7d refactor(tui): remove placeholder LSP panel (#39955)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:29:54 -04:00
opencode-agent[bot] 8846336e11 refactor(tui): remove unused fade file path (#39954)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:29:34 -04:00
opencode-agent[bot] f6b5f6b6b3 refactor(tui): remove prompt re-export barrels (#39953)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:29:27 -04:00
opencode-agent[bot] 02df1e9737 refactor(tui): reuse canonical logo (#39951)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:28:55 -04:00
opencode-agent[bot] 5b84e39f6d refactor(tui): remove stub child cycling commands (#39949)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:28:23 -04:00
opencode-agent[bot] 6d3f84b4ff refactor(tui): remove dialog show helpers (#39948)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:28:02 -04:00
opencode-agent[bot] 255e9bbf06 refactor(tui): remove onboarding hint residue (#39947)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:27:52 -04:00
opencode-agent[bot] 25b6a179e2 refactor(cli): remove no-op migrate command (#39946)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:27:29 -04:00
opencode-agent[bot] 80874c241f refactor(tui): remove dead assistant renderer (#39945)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:27:15 -04:00
opencode-agent[bot] f99469bdf8 refactor(core): remove unused layer exports (#39975)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:26:38 -04:00
opencode-agent[bot] fe0eb4ea8f refactor(core): remove orphaned move service (#39974)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:26:21 -04:00
opencode-agent[bot] a484f87680 refactor(core): remove unused dependencies (#39973)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:26:06 -04:00
opencode-agent[bot] 3b6ab392ac refactor(core): remove unused console state model (#39972)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:25:45 -04:00
opencode-agent[bot] 6c567dc745 refactor(core): remove unreferenced layer map example (#39971)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:25:27 -04:00
opencode-agent[bot] 92567700f6 refactor(tui): remove unused stop voice operation (#39969)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:25:17 -04:00
James Long 90c84639e3 feat(theme): export expandTheme (#39967) 2026-07-31 18:32:55 -04:00
opencode-agent[bot] 56c6add5c3 refactor(core): remove unused helpers (#39943)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-31 16:17:14 -05:00
Kit Langton 2cfe8883ea feat(plugin): expose session rename and wait to plugins (#39932) 2026-07-31 21:08:33 +00:00
Kit Langton 102086c50f feat(tui): hot-reload local TUI plugins (#39776) 2026-07-31 16:35:05 -04:00
Kit Langton 06e26d89ad feat(protocol): accept title on session create (#39935) 2026-07-31 19:39:54 +00:00
Aiden Cline 84dd56ed34 fix(core): bound outbound image history (#39929) 2026-07-31 13:57:04 -05:00
opencode-agent[bot] f92e490bb6 fix(tui): preserve diff hunk boundaries (#39693)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-31 14:47:36 -04:00
Kit Langton 0481dba88e fix(tui): improve narrow layouts (#39918) 2026-07-31 14:44:13 -04:00
Kit Langton dd0e41c633 fix(tui): render plugin slots and routes with component semantics (#39917) 2026-07-31 14:30:57 -04:00
opencode-agent[bot] b69e51d835 refactor(ai): simplify provider options (#39924)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-31 13:27:32 -05:00
opencode-agent[bot] e60e8d9387 refactor(core): rename attachments config to media (#39927)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-31 13:26:53 -05:00
Kit Langton 3bfce3fd2d feat(tui): expand pasted text (#39920) 2026-07-31 18:17:35 +00:00
Aiden Cline b498a5c6c4 feat(ai): expand OpenRouter native support (#39907) 2026-07-31 12:57:18 -05:00
Kit Langton db9e942398 fix(core): resize large image attachments (#39919) 2026-07-31 13:24:05 -04:00
Kit Langton 0e26116f68 fix(tui): stabilize open picker 2026-07-31 16:38:48 +00:00
Aiden Cline 3468aa0140 feat(ai): configure chat max tokens field (#39909) 2026-07-31 11:14:08 -05:00
Kit Langton 7757f712a2 test(app): restore stable offset observer ordering (#39901) 2026-07-31 12:13:16 -04:00
Aiden Cline 7129ed6e62 fix(core): respect model input limits (#39797) 2026-07-31 10:41:36 -05:00
Kit Langton 6c37842520 fix(cli): deduplicate Node asset destinations (#39900) 2026-07-31 11:40:38 -04:00
Kit Langton dc3c996892 fix(tui): stabilize generated session titles (#39894) 2026-07-31 11:13:25 -04:00
Kit Langton 7fd12c560c fix(tui): fit project paths in open menu (#39887) 2026-07-31 11:07:12 -04:00
Kit Langton 7aaf4e7750 refactor(session): centralize fallback title policy (#39890) 2026-07-31 10:56:38 -04:00
Kit Langton 1311d909a7 fix(tui): preserve session location during handoff (#39886) 2026-07-31 10:54:36 -04:00
Kit Langton d07d9ae0da fix(tui): remove shells from their location (#39885) 2026-07-31 10:46:43 -04:00
Kit Langton 1d18459cdc fix(tui): align session picker scope (#39784) 2026-07-31 10:32:16 -04:00
Kit Langton db45026c6c fix(tui): default tabs to global scope (#39783) 2026-07-31 10:15:53 -04:00
Kit Langton 77df98db51 Revert "feat(tui): delete current session (#39750)"
This reverts commit 7814568ba0.
2026-07-31 10:01:35 -04:00
Aiden Cline ff2b184af1 feat(ai): support Gemini thinking levels (#39796) 2026-07-30 23:38:00 -05:00
Aiden Cline 671e164f8d refactor(core): contain Codex in OpenAI plugin (#39734) 2026-07-30 21:43:38 -05:00
Aiden Cline 5b4ebf3e9d fix(core): map xAI native options (#39787) 2026-07-30 21:42:53 -05:00
Aiden Cline 98229d466d feat(plugin): add session request hook (#39764) 2026-07-30 21:11:22 -05:00
Kit Langton 5c7e2b5042 fix(tui): clarify open menu project labels (#39780) 2026-07-30 21:34:29 -04:00
Kit Langton 0a6a5d3e80 fix(session): retry failed title generation (#39748) 2026-07-30 21:26:44 -04:00
Kit Langton eb95bd27fe feat(session): make generated titles optional (#39747) 2026-07-30 21:24:34 -04:00
Kit Langton 865f512a44 fix(tui): preserve current selection across list updates (#39774) 2026-07-31 00:48:51 +00:00
Kit Langton a460f02f67 feat(tui): inherit session directory when creating a new session (#39753) 2026-07-30 20:31:38 -04:00
Kit Langton 146fdb9de1 feat(tui): add open menu for sessions and projects (#39752) 2026-07-30 20:08:12 -04:00
Kit Langton b866900417 fix(tui): name deleted session in toast (#39768) 2026-07-30 19:59:27 -04:00
Kit Langton 8e3e94aa26 fix(tui): focus palette settings after layout (#39585) 2026-07-30 19:39:16 -04:00
Aiden Cline cc1289048e refactor(core): isolate AI SDK native mappings (#39761) 2026-07-30 18:38:29 -05:00
Kit Langton 7814568ba0 feat(tui): delete current session (#39750) 2026-07-30 17:06:41 -04:00
Kit Langton 16b247f756 fix(tui): smooth new session tab handoff (#39745) 2026-07-30 16:17:01 -04:00
Kit Langton 9abd9594de fix(simulation): mirror kitty keyboard protocol (#39741) 2026-07-30 20:03:21 +00:00
Kit Langton 22d2012f75 fix(tui): register storybook only for story runs (#39733) 2026-07-30 15:55:05 -04:00
Kit Langton cc883058df feat(tui): reopen closed session tabs (#39731) 2026-07-30 15:54:43 -04:00
Kit Langton 8ebc4c6f85 fix(tui): call session tabs just tabs (#39730) 2026-07-30 15:52:58 -04:00
Kit Langton b0ed9990b3 feat(tui): add option tab shortcuts (#39725) 2026-07-30 15:51:10 -04:00
Kit Langton e1c04dcce6 feat(tui): add temporary new session tab (#39735) 2026-07-30 15:05:34 -04:00
Kit Langton 188c642d8c refactor(tui): flatten project picker list (#39726) 2026-07-30 14:18:16 -04:00
Kit Langton 9d55b223bb fix(core): stop repository discovery at nearest marker (#39714) 2026-07-30 13:28:29 -04:00
Kit Langton ce7a7e4e23 fix(tui): truncate project picker paths (#39678) 2026-07-30 13:13:31 -04:00
Kit Langton cba5ba03e3 fix(tui): show shells from session location (#39691) 2026-07-30 13:12:01 -04:00
Kit Langton 49081a4e24 fix(tui): flash tabs on prompt admission (#39702) 2026-07-30 16:57:36 +00:00
opencode-agent[bot] d6371f2fcd fix(session): update activity on prompt (#39539)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-30 16:28:10 +00:00
Aiden Cline 7625cbdf47 feat(core): route providers through native AI (#39615) 2026-07-30 11:26:18 -05:00
opencode-agent[bot] 0ffec67ca3 refactor: remove unused V2 code (#39699)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 11:25:03 -05:00
Kit Langton bd132f2614 refactor(core): skip redundant worktree lookup (#39692) 2026-07-30 16:15:07 +00:00
opencode-agent[bot] dcb0df4ac1 fix(core): stop MCP SSE error reconnect loops (#39671)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 10:53:33 -05:00
Kit Langton 02f3504055 fix(tui): hide redundant session directory labels (#39686) 2026-07-30 11:37:33 -04:00
James Long f23ee5e4a8 refactor(core): simplify formatter selection (#39575) 2026-07-30 10:59:01 -04:00
Kit Langton 77aa85c589 feat(tui): project picker with footer crossfade (#39566) 2026-07-30 10:39:22 -04:00
Dax Raad a618946b7e fix(tui): reload plugins with config 2026-07-30 10:31:09 -04:00
opencode-agent[bot] f9de608dea chore: refresh bun lockfile (#39617)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 04:36:21 +00:00
Aiden Cline 906dc8f5b2 fix(ai): apply catalog settings to provider models (#39613) 2026-07-29 22:42:47 -05:00
Aiden Cline 12971935ab feat(core): parse shell permission commands (#39567) 2026-07-29 22:42:18 -05:00
Kit Langton 78c139b8b5 feat(plugin): add ui.tabs API for session tab control (#39591) 2026-07-29 22:57:39 -04:00
Kit Langton c161978852 feat(tui): prefetch open session tabs after connect (#39589) 2026-07-29 22:23:13 -04:00
Kit Langton cb37a7166a feat(tui): make session tab switching fast for long transcripts (#39568) 2026-07-29 22:23:10 -04:00
Dax Raad 488445a679 fix(tui): correct project-aware session lists 2026-07-29 21:59:33 -04:00
opencode-agent[bot] 97786afdd8 refactor(core): share file diff construction (#39586)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-30 00:28:33 +00:00
Aiden Cline b1a5e8a6ae test(tui): restore compaction event lifecycle (#39581) 2026-07-29 18:11:58 -05:00
Aiden Cline 0ece10af43 fix(core): add mutation permission previews (#39578) 2026-07-29 18:04:13 -05:00
Aiden Cline d1a02b149c fix(core): clarify subagent tool guidance (#39572) 2026-07-29 16:48:49 -05:00
James Long 94ee274aeb feat(core): add V2 formatter runtime (#39564) 2026-07-29 17:23:04 -04:00
Kit Langton 3c259fc552 feat(tui): replace scrap screen with component storybook (#39548) 2026-07-29 15:51:15 -04:00
Aiden Cline 210be4b749 fix(core): preserve shell output on timeout (#39559) 2026-07-29 14:31:26 -05:00
James Long 464649e67e feat(tui): batch event delivery (#39551) 2026-07-29 15:20:39 -04:00
Aiden Cline c8dca936b1 feat(plugin): add shell.create.before hook (#39547) 2026-07-29 14:11:52 -05:00
Kit Langton 4f871906bc fix(tui): support cd before session creation (#39555) 2026-07-29 18:45:42 +00:00
Aiden Cline f7ea2fc346 test(cli): update ACP fork expectation (#39554) 2026-07-29 13:21:34 -05:00
Aiden Cline 5cb633a48e feat(core): support pinned Code Mode tools (#39550) 2026-07-29 13:16:49 -05:00
Kit Langton b985d2eb8e feat(tui): polish session tab animations (#39542) 2026-07-29 13:50:49 -04:00
Dax Raad 014908a8d7 feat(tui): reload config file changes 2026-07-29 13:05:48 -04:00
Dax Raad f599f8f3d3 fix(tui): guard Bun runtime plugin support 2026-07-29 12:57:35 -04:00
Aiden Cline b2010220f9 fix(core): clarify Code Mode tool boundary (#39540) 2026-07-29 11:56:53 -05:00
James Long c2e975c4e6 refactor(plugin): expose resolved TUI theme (#39536) 2026-07-29 12:51:55 -04:00
Dax Raad 6aa250ee5d refactor(tui): flatten state storage path 2026-07-29 12:39:21 -04:00
Dax Raad 8f1e3ff75c docs: record v1 to v2 database migration decisions 2026-07-29 12:37:00 -04:00
Dax Raad 5438dfb751 fix(tui): remove invalid model toasts 2026-07-29 12:36:56 -04:00
Dax Raad 4bd16d6f47 feat(tui): default tabs to cwd scope 2026-07-29 12:35:32 -04:00
Dax Raad 9ee337469d feat(tui): add persistent storage context 2026-07-29 12:33:18 -04:00
Aiden Cline 813c41ff6c fix(core): simplify shell execution boundary (#39530) 2026-07-29 11:16:45 -05:00
Dax Raad 247f14f955 feat(tui): add replaceable prompt footer slot 2026-07-29 10:16:34 -04:00
Dax Raad bd906d468d refactor(core): make watcher subscription effectful 2026-07-29 09:50:24 -04:00
Dax Raad fc11ed3838 feat(session): define explicit fork boundaries 2026-07-29 09:50:24 -04:00
Dax Raad 2a85c861e0 fix(session): hide pending admission sequence 2026-07-29 09:50:24 -04:00
Shoubhit Dash 9554f9a16e feat(ai): type Vertex request options (#39499) 2026-07-29 18:23:00 +05:30
Shoubhit Dash d72b428061 feat(ai): type Vertex Chat request options (#39503) 2026-07-29 18:22:41 +05:30
Shoubhit Dash fea17b4a0e feat(ai): type Cloudflare request options (#39507) 2026-07-29 18:22:16 +05:30
Shoubhit Dash 9038e44a68 feat(ai): type Copilot request options (#39496) 2026-07-29 18:22:01 +05:30
Shoubhit Dash 3b8299e3f2 feat(ai): type Azure request options (#39498) 2026-07-29 18:21:43 +05:30
Shoubhit Dash f5cdf0f056 feat(ai): type Anthropic request options (#39502) 2026-07-29 18:21:19 +05:30
Shoubhit Dash 224feff7c4 feat(ai): type compatible Responses options (#39506) 2026-07-29 18:20:55 +05:30
Shoubhit Dash ce2c9e7e26 feat(ai): type Google request options (#39504) 2026-07-29 18:20:30 +05:30
Shoubhit Dash cb80f47112 feat(ai): type Vertex Messages request options (#39501) 2026-07-29 18:20:04 +05:30
Shoubhit Dash b4ac939537 feat(ai): type xAI request options (#39505) 2026-07-29 18:19:44 +05:30
Shoubhit Dash 8a96b80aec feat(ai): type OpenAI request options (#39495) 2026-07-29 18:19:26 +05:30
Shoubhit Dash 333a090975 feat(ai): type Vertex Responses request options (#39500) 2026-07-29 18:19:02 +05:30
Shoubhit Dash 5a78a17e49 feat(ai): type OpenRouter request options (#39508) 2026-07-29 18:18:43 +05:30
Shoubhit Dash 9d6af6afa4 feat(ai): type compatible request options (#39509) 2026-07-29 18:18:24 +05:30
Shoubhit Dash 8c3e06798c refactor(ai): limit provider option inference (#39510) 2026-07-29 18:18:00 +05:30
Shoubhit Dash 5882b64612 feat(ai): type Anthropic-compatible request options (#39497) 2026-07-29 18:07:24 +05:30
Shoubhit Dash 309c4fe6f0 feat(ai): infer model provider options (#39493) 2026-07-29 18:05:56 +05:30
Shoubhit Dash d9555f138b refactor(ai): internalize request compilation (#39132) 2026-07-29 16:51:11 +05:30
Kit Langton b47cfbee7c fix(tui): reduce tab pulse allocations (#39433) 2026-07-28 22:43:46 -04:00
Kit Langton 5504245f7b feat(tui): add session tab playground (#39432) 2026-07-28 22:36:53 -04:00
Kit Langton f64b50d71b feat(tui): add unread tab glow (#39428) 2026-07-28 22:34:33 -04:00
Kit Langton a7b2ea94e5 fix(tui): always show session tab (#39429) 2026-07-28 22:33:34 -04:00
Kit Langton 7b775c2582 fix(cli): embed native watcher binding 2026-07-28 22:32:08 -04:00
Dax 12a931a220 feat(tui): filter subagents by activity 2026-07-28 22:13:46 -04:00
Dax Raad 90100c1365 docs: clarify side-by-side V1 and V2 installs 2026-07-28 22:02:19 -04:00
Dax Raad 139c9febe4 refactor(tui): group tab settings 2026-07-28 21:23:53 -04:00
Dax Raad 06290907a9 feat(tui): restore plugin manager dialog 2026-07-28 21:19:17 -04:00
Kit Langton 1c8175a61a fix(tui): preserve tab context on home and close (#39421) 2026-07-29 01:03:17 +00:00
Dax Raad fe91698ed6 fix(tui): initialize external plugin runtime 2026-07-28 21:00:09 -04:00
Dax Raad 068c32df39 feat(tui): discover project plugins 2026-07-28 20:21:38 -04:00
Kit Langton a2885d1662 feat(tui): add session tab history (#39411) 2026-07-28 19:38:46 -04:00
Kit Langton 38a3dbb4c4 fix(tui): fade full-width tab titles (#39409) 2026-07-28 19:38:28 -04:00
Kit Langton 43383d4fba fix(tui): hide single session tab (#39408) 2026-07-28 18:25:46 -04:00
opencode-agent[bot] 40c4c3918a feat(core): enable fff in node runtimes (#38776)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-28 17:14:11 -05:00
Aiden Cline 754ea99d86 fix(core): preserve shell output tail (#39403) 2026-07-28 16:32:11 -05:00
Kit Langton 37a1b80d5a feat(tui): add adaptive session tabs (#39396) 2026-07-28 17:12:07 -04:00
Aiden Cline f95d04fea0 feat(core): improve shell tool guidance (#39401) 2026-07-28 15:53:19 -05:00
James Long 08b80da931 refactor(tui): split theme hooks (#39395) 2026-07-28 16:25:32 -04:00
Aiden Cline f6fb1a7cdd fix(ai): retry transient client statuses (#39391) 2026-07-28 14:25:33 -05:00
Dax Raad 5bcc0016a6 feat(tui): add plugin context hook 2026-07-28 14:57:59 -04:00
Aiden Cline 771174b5c3 fix(cli): align auto permission flags (#39384) 2026-07-28 13:31:57 -05:00
Dax Raad 44cd984589 feat(tui): refine plugin context slots 2026-07-28 14:19:31 -04:00
James Long c445d98188 feat(theme): extract TUI theme package (#39378) 2026-07-28 14:14:39 -04:00
Kit Langton 27e7b0558a fix(core): preserve plugin update order (#39372) 2026-07-28 13:00:24 -04:00
Kit Langton fb975eeb7c test(ai): add scoped test LLM (#39223) 2026-07-28 16:41:09 +00:00
Kit Langton e556aca833 refactor(core): simplify plugin reload loop (#39356) 2026-07-28 12:21:29 -04:00
opencode-agent[bot] 73bd8a264b fix(app): keep new tab button visible (#39366)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-28 16:18:26 +00:00
opencode-agent[bot] 077338fcc8 fix(app): hide delete for provided servers (#39363)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-28 16:00:47 +00:00
Dax Raad b671a77145 fix(tui): simplify form field labels 2026-07-28 11:56:11 -04:00
Dax Raad 30d09a7d7e fix(core): improve web search consent flow 2026-07-28 11:54:44 -04:00
Dax Raad ee02fb4fce Websearch tweaks 2026-07-28 11:10:33 -04:00
Dax Raad 010133f6df feat(tui): expand v2 plugin context 2026-07-28 11:04:16 -04:00
opencode-agent[bot] 3b0d8f0e6f fix(tui): clear rehydrated compaction state (#39336)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-07-28 16:04:11 +02:00
Simon Klee 4d59b059ee feat(tui): add verbose turn token usage (#39281) 2026-07-28 13:57:19 +02:00
Luke Parker 1be6d94267 fix(desktop): bootstrap v2 background service (#39309) 2026-07-28 21:03:34 +10:00
opencode-agent[bot] 302e9b45ab chore: merge dev into v2 (#39290)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
Co-authored-by: Daniel Polito <danielbpolito@gmail.com>
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: Devin R Leopold <devin.leopold@gmail.com>
Co-authored-by: Zach Bruggeman <mail@bruggie.com>
Co-authored-by: Zach Bruggeman <zbruggeman@ramp.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: David Siewert <david1gruppenplan@gmail.com>
Co-authored-by: Andrei Dziahel <develop7@develop7.info>
Co-authored-by: adityachaudhary99 <adityaachaudhary2003@gmail.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Matt Carey <mcarey@cloudflare.com>
2026-07-28 18:36:55 +10:00
Aiden Cline 7211c9934a feat(core): make edit matching forgiving (#39258) 2026-07-28 00:06:43 -05:00
opencode-agent[bot] 3bda0ce123 fix(core): bound search tool execution (#39238)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-27 23:21:15 -05:00
Aiden Cline 62320947d9 fix(core): refresh system prompt references (#39245) 2026-07-27 21:52:31 -05:00
Aiden Cline 0cb9bb567e fix(core): align Meta system prompt (#39240) 2026-07-27 21:31:49 -05:00
Kit Langton b14adcaf83 docs: forbid type-position import references (#39234) 2026-07-27 22:30:26 -04:00
Kit Langton 5bd3da40a5 fix(core): keep config root watches alive and ignore vendored trees (#39239) 2026-07-27 22:30:21 -04:00
Aiden Cline abcbdad530 fix(core): refresh Meta system prompt (#39237) 2026-07-27 21:14:16 -05:00
Kit Langton debdea40ea feat(core): reload configured plugins from source edits (#39224) 2026-07-27 22:04:28 -04:00
Kit Langton 775f24f049 test(core): add native watcher command reload test (#39216) 2026-07-27 21:08:10 -04:00
Kit Langton 470e360942 test(core): align tool contract expectations (#39172) 2026-07-28 00:48:29 +00:00
Aiden Cline f15398efc3 feat(core): improve edit tool output (#39211) 2026-07-27 19:11:04 -05:00
Kit Langton 4333a44e65 refactor(core): manage watcher lifecycle with RcMap (#39203) 2026-07-27 20:01:17 -04:00
Kit Langton 8b4b0d67d7 feat(core): reload discovered plugins from source edits (#39174) 2026-07-27 18:20:13 -04:00
Aiden Cline 9200e353bf feat(core): improve edit tool guidance (#39198) 2026-07-27 17:03:00 -05:00
Aiden Cline 31124312f6 fix(core): simplify tool schemas (#39184) 2026-07-27 16:04:48 -05:00
James Long 4f622fa7cd fix(tui): reference inferred hues in migrated themes (#39183) 2026-07-27 17:02:35 -04:00
James Long 4e4cf9e25e refactor(tui): extract event stream connection (#38872) 2026-07-27 16:31:21 -04:00
Aiden Cline 6da2f3c38f feat(core): improve read model output (#39146) 2026-07-27 14:52:50 -05:00
Kit Langton 1b39d364bd fix(core): align command reload pipeline and repair plugin fixture (#39171) 2026-07-27 15:29:27 -04:00
Kit Langton 856c569458 feat(core): reload agents from config change feed (#39167) 2026-07-27 15:14:08 -04:00
Kit Langton 92807d0bb9 feat(core): reload commands from config change feed (#39160) 2026-07-27 15:02:40 -04:00
Kit Langton 713658c07b test(core): add config and watcher test services (#39157) 2026-07-27 14:32:38 -04:00
opencode-agent[bot] f5700808c5 fix(tui): preserve subagent list position (#39156)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-27 14:08:00 -04:00
Kit Langton 7eb51d0507 feat(core): diagnose prompt cache prefix changes (#39139) 2026-07-27 13:44:02 -04:00
Kit Langton 02c37c401a feat(core): expose config changes stream (#39131) 2026-07-27 13:38:08 -04:00
opencode-agent[bot] 33e3d1ebca fix(core): tolerate missing tool input schemas (#39130)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-27 11:21:47 -04:00
Kit Langton 1f2c59a1b6 fix(core): commit state before finalize publishes (#38983) 2026-07-27 11:04:04 -04:00
Aiden Cline 65d2a4e00c feat(core): improve read tool parity (#39126) 2026-07-27 09:58:37 -05:00
Shoubhit Dash 7d4de3d9e4 fix(core): clarify web search provider prompt (#39123) 2026-07-27 19:52:36 +05:30
Aiden Cline 9977ef0160 refactor(core): tag read outputs (#39122) 2026-07-27 09:09:44 -05:00
Simon Klee 9a55d125f6 tui: render mini compaction boundaries (#39103) 2026-07-27 14:19:03 +02:00
Simon Klee 766aaf448d tui: add settings to command palette search (#39058) 2026-07-27 10:45:35 +02:00
Simon Klee f14d78afeb tui: skip abort on mini session close (#39067) 2026-07-27 10:42:37 +02:00
Dax Raad 0261f04b90 fix(core): handle oversized ripgrep matches 2026-07-27 03:17:08 -04:00
Luke Parker f77f5a343e fix(ui): standardize tooltip delay 2026-07-27 13:24:01 +08:00
Aiden Cline 9b49e7bec9 test(core): implement catalog host model list (#39053) 2026-07-26 23:32:33 -05:00
Aiden Cline 93cb113cef fix(util): declare node tracing dependency (#39050) 2026-07-26 23:12:36 -05:00
Aiden Cline 4216d35e4b fix(server): declare schema dependency (#39043) 2026-07-26 22:46:46 -05:00
LukeParkerDev 41bf4b140b feat(app): add workspace settings inventory 2026-07-27 13:13:33 +10:00
LukeParkerDev fd599277d6 Merge remote-tracking branch 'upstream/dev' into workspaces-readyish 2026-07-27 13:12:35 +10:00
Dax Raad 863645c671 test(core): update grep error assertion 2026-07-26 20:17:31 -04:00
Dax Raad 5592f5225b fix(app): update remote sdk contracts 2026-07-26 20:16:58 -04:00
Dax Raad 8db7487c89 refactor(core): consolidate tool architecture 2026-07-26 20:16:58 -04:00
Aiden Cline 0fd73a2976 fix(core): align grep behavior and guidance (#38999) 2026-07-26 17:29:27 -05:00
LukeParkerDev 2d27c49367 Merge upstream/dev into workspaces-readyish 2026-07-27 07:02:16 +10:00
Dax Raad 80865407e0 refactor(sdk): remove local legacy package 2026-07-26 02:47:43 -04:00
Dax Raad 28f4284bd7 fix(www): canonicalize production routes 2026-07-26 02:39:12 -04:00
Aiden Cline 7affee529b fix(core): harden grep search behavior (#38922) 2026-07-25 23:23:53 -05:00
Aiden Cline 79c7e9446e fix(core): clarify custom question answers (#38919) 2026-07-25 23:00:26 -05:00
Shoubhit Dash efb629a33a feat(core): add pluggable web search (#35558)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-26 03:55:05 +00:00
Dax Raad 2ddc91a0e8 fix(tui): show shell working directory in prompt 2026-07-25 23:42:53 -04:00
Dax Raad c7871e14d4 fix(www): remove deployment environment gate 2026-07-25 21:21:57 -04:00
Dax Raad 9840f63b12 chore(www): simplify worker routes 2026-07-25 21:17:25 -04:00
Dax Raad 56a9c0150a fix(www): mark deploy script as module 2026-07-25 21:17:25 -04:00
Dax Raad 203a0613b8 feat(www): migrate docs to Blume 2026-07-25 21:17:25 -04:00
Aiden Cline 7d8f1bdab3 tweak(core): simplify skill tool description (#38900) 2026-07-25 17:09:20 -05:00
Aiden Cline 9eea5bc925 fix(core): tweak glob tool description/parameters (#38899) 2026-07-25 16:52:36 -05:00
Aiden Cline f753103e82 fix(core): reject file glob roots (#38890) 2026-07-25 14:43:18 -05:00
Aiden Cline 1e35d33ecb fix(codemode): search nested namespaces (#38887) 2026-07-25 14:17:49 -05:00
Aiden Cline c5bf4edb10 fix(ai): preserve response message phases (#38777) 2026-07-25 14:06:00 -05:00
Aiden Cline cce8bb0e1c fix(core): clarify empty Code Mode guidance (#38883) 2026-07-25 13:20:16 -05:00
Dax Raad 02c66c5fc1 docs(core): fix OpenCode skill links 2026-07-25 14:08:29 -04:00
Aiden Cline 33390cc457 fix(core): keep execute tool cache stable (#38783) 2026-07-25 13:01:05 -05:00
Kit Langton b2afb35527 refactor(core): settle steps lock-free by joining tool fibers first (#38743) 2026-07-25 13:43:17 -04:00
opencode-agent[bot] 828148909d fix(tui): preserve workspace while reconnecting (#38788) 2026-07-25 03:48:42 +00:00
opencode-agent[bot] 454145fe65 fix(tui): preserve workspace while reconnecting (#38788) 2026-07-25 02:22:47 +00:00
LukeParkerDev 974271d6b1 feat(app): add workspace flows to new layout 2026-07-25 12:20:47 +10:00
Aiden Cline 1291dc1f11 fix(core): clarify code mode tool boundary (#38785) 2026-07-24 20:49:55 -05:00
Aiden Cline c7d7f61146 fix(ai): preserve Anthropic usage metadata (#38751) 2026-07-24 16:23:51 -05:00
Aiden Cline 13b6845e7e fix(core): scope MCP execute guidance to Code Mode (#38753) 2026-07-24 16:02:57 -05:00
Aiden Cline d1d97014b4 refactor(core): move static Code Mode guidance (#38746) 2026-07-24 14:46:59 -05:00
Aiden Cline b31747124b fix(core): stream Code Mode tool progress (#38718) 2026-07-24 14:46:46 -05:00
Aiden Cline 49bec25ae5 fix(ai): align Anthropic stream handling (#38733) 2026-07-24 14:23:35 -05:00
Aiden Cline d66d0cb904 fix(core): clarify Code Mode tool availability (#38745) 2026-07-24 13:44:59 -05:00
Kit Langton 3193f3aa95 fix(tui): flag likely cache busts accurately (#38727) 2026-07-24 18:35:13 +00:00
Aiden Cline ee5460a152 fix(codemode): report interrupted tool calls (#38741) 2026-07-24 13:32:54 -05:00
Kit Langton b09a066fb5 fix(ai): report OpenAI cache writes (#38735) 2026-07-24 18:26:30 +00:00
Aiden Cline 423fad730c fix(core): authorize external glob paths (#38714) 2026-07-24 13:24:23 -05:00
Kit Langton 5ae2d6d3f6 refactor(core): settle declined tool calls durably with typed reasons (#38734) 2026-07-24 18:18:02 +00:00
Kit Langton 993f046dd9 fix(ai): layer prompt cache breakpoints (#38725) 2026-07-24 18:02:35 +00:00
opencode-agent[bot] 9b640cf97d test(core): remove flaky npm install test (#38729)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-24 12:33:28 -05:00
Kit Langton 2200d100d0 refactor(core): name the unsettled-tool sweep scope and untangle hosted settlement (#38724) 2026-07-24 13:32:14 -04:00
Kit Langton 68ef893818 fix(core): start all suspended sessions promptly (#38720) 2026-07-24 16:58:50 +00:00
Kit Langton 7ab3dd04ad refactor(core): unify tool fiber bookkeeping into one owned structure (#38719) 2026-07-24 16:55:43 +00:00
Aiden Cline 4f201f87a9 fix(ai): align Bedrock stream handling (#38712) 2026-07-24 11:15:23 -05:00
Kit Langton 5aa276c117 refactor(core): mint assistant message identity before the step runs (#38717) 2026-07-24 16:11:31 +00:00
Aiden Cline 4605308be2 feat(core): render CodeMode catalog deltas from structured snapshots (#38183) 2026-07-24 10:20:26 -05:00
Aiden Cline c64d813347 fix(core): report truncated glob results (#38631) 2026-07-24 10:17:37 -05:00
Kit Langton 6e4a972bb9 refactor(core): clean up callModel readability (#38706) 2026-07-24 11:17:20 -04:00
Kit Langton 835149e42b refactor(core): select small model without sorting (#38707) 2026-07-24 11:08:57 -04:00
Kit Langton 0f3c30118c refactor(core): simplify session runner loop and pending input scopes (#38602) 2026-07-24 14:27:23 +00:00
Shoubhit Dash 35d31d8ec1 refactor(ai): remove dead LLM exports (#38700) 2026-07-24 14:19:07 +00:00
Shoubhit Dash 0374d29232 refactor(ai): colocate provider options (#38698) 2026-07-24 19:08:36 +05:30
Shoubhit Dash d90da82be2 refactor(ai): normalize provider option parsing (#38695) 2026-07-24 19:02:02 +05:30
Shoubhit Dash c06186a9d9 fix(ai): forward Anthropic provider options (#38694) 2026-07-24 18:50:20 +05:30
Shoubhit Dash c5680a206e refactor(ai): make OpenAI Responses extend Open Responses (#38681) 2026-07-24 18:36:35 +05:30
Simon Klee edaee143d9 mini: reserve headroom before showing usage (#38659) 2026-07-24 11:23:53 +02:00
Simon Klee 4184149b90 mini: monochrome rendered markdown only (#38656) 2026-07-24 11:14:48 +02:00
Simon Klee 00f063b381 mini: pack statusline by content width (#38646) 2026-07-24 10:54:30 +02:00
Aiden Cline ea010ab3a4 feat(ai): round-trip Bedrock redacted reasoning (#38623) 2026-07-24 01:06:58 -05:00
Aiden Cline 65c5c7e3f6 feat(ai): round-trip Anthropic redacted thinking blocks (#38614) 2026-07-24 00:42:44 -05:00
opencode-agent[bot] ee69a91f26 docs: add ideal pseudocode skill (#38611)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-23 23:02:44 -05:00
Kit Langton 7456598cde fix(core): share one tool snapshot per request (#38596) 2026-07-24 02:54:22 +00:00
Kit Langton c228fc4886 fix(core): stabilize tool definition ordering (#38590) 2026-07-23 21:30:06 -04:00
Kit Langton bb3f4cc3c7 fix(codemode): stabilize catalog ordering (#38588) 2026-07-23 20:56:18 -04:00
Aiden Cline 8d9727be9f fix(core): improve patch errors (#38369) 2026-07-23 18:59:29 -05:00
Aiden Cline 6401eeaea0 feat(ai): preserve raw finish reasons (#38423) 2026-07-23 18:16:11 -05:00
Kit Langton e7ecee5df2 fix(core): isolate tool hook outcomes (#38571) 2026-07-23 17:40:42 -04:00
opencode-agent[bot] 02f2725154 chore: merge dev into v2 (#38563)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
Co-authored-by: Daniel Polito <danielbpolito@gmail.com>
Co-authored-by: opencode <noreply@opencode.ai>
2026-07-23 16:20:37 -05:00
Kit Langton 79c1544072 refactor(tools): unify tool APIs and result handling (#38367) 2026-07-23 21:13:31 +00:00
Aiden Cline 8cac010bac fix(core): stop forcing toolChoice none on session.generate (#38557) 2026-07-23 14:12:23 -05:00
Aiden Cline 193f6be99c fix(ai): keep tools when Gemini tool choice is none (#38556) 2026-07-23 14:09:25 -05:00
opencode-agent[bot] 2a9f8e3a2c fix(tui): manage focus in devtools panels (#38555)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-23 18:58:02 +00:00
Aiden Cline 2c814120c7 fix(ai): keep tools when Anthropic tool_choice is none (#38553) 2026-07-23 13:52:20 -05:00
opencode-agent[bot] 18fccac6ff fix(tui): preserve first message in new sessions (#38542)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-23 13:59:51 -04:00
opencode-agent[bot] 360e7b412d feat(tui): expose debug settings (#38546)
Co-authored-by: James Long <longster@gmail.com>
2026-07-23 13:37:16 -04:00
opencode-agent[bot] ad596fb42b chore(core): upgrade fff to 0.10.1 (#38545)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-23 12:34:24 -05:00
opencode-agent[bot] 74e92f73e0 refactor(ai): remove unused response format (#38540)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-23 12:32:33 -05:00
James Long 8f3465c951 refactor(tui): load native V2 themes (#38430) 2026-07-23 12:35:29 -04:00
Shoubhit Dash 466b75b19d feat(cli): expand acp v1 support (#38325) 2026-07-23 21:13:46 +05:30
James Long 833dd2ed7f refactor(tui): simplify turn usage reduction (#38514) 2026-07-23 11:22:00 -04:00
Shoubhit Dash bbe985b4d0 chore(cli): upgrade acp sdk (#38316) 2026-07-23 20:44:14 +05:30
James Long 5b1321a8ca feat(tui): add turn token usage diagnostics (#38398) 2026-07-23 10:00:51 -04:00
Simon Klee 52c98a4eeb mini: add replay settings to cli config (#38487) 2026-07-23 12:12:25 +02:00
opencode-agent[bot] b6f85c2250 fix(core): default custom model capabilities (#38449)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-22 23:20:16 -05:00
opencode-agent[bot] 6e8aefcfa0 fix(ai): normalize Bedrock cache usage (#38427)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-22 21:42:53 -05:00
James Long f1f0f47ee2 fix(core): migrate named agent colors (#38414) 2026-07-22 22:38:13 -04:00
Kit Langton 203b9f59b7 fix(core): load dynamic models for generation (#38401) 2026-07-22 22:26:08 -04:00
Aiden Cline 48bcbd09ef fix(ai): handle incomplete responses without reasons (#38374) 2026-07-22 20:49:08 -05:00
James Long d86f732df3 refactor(tui): generate syntax from V2 theme (#38397) 2026-07-22 21:33:25 -04:00
Dax Raad a817fe5e6c fix(schema): loosen agent color response 2026-07-22 19:22:01 -04:00
James Long 381f6c47b4 docs(tui): add generated V2 theme reference (#38396) 2026-07-22 18:30:31 -04:00
James Long b6e14b5a74 refactor(tui): finish V2 theme migration (#38383) 2026-07-22 17:56:22 -04:00
opencode-agent[bot] 36979c9641 test(core): consolidate provider factory coverage (#38390)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-22 16:50:33 -05:00
opencode-agent[bot] 4e067a2014 test(core): remove duplicate patch integration tests (#38389)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-22 16:19:06 -05:00
opencode-agent[bot] 5913c1db0b chore: merge dev into v2 (#38377)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
Co-authored-by: Daniel Polito <danielbpolito@gmail.com>
Co-authored-by: opencode <noreply@opencode.ai>
2026-07-22 15:33:14 -05:00
James Long 88f572cfce refactor(tui): migrate selection views to V2 theme (#38001) 2026-07-22 16:19:00 -04:00
Aiden Cline 2271f9b222 feat(ai): support PDF inputs (#38253) 2026-07-22 13:42:29 -05:00
James Long 8a36abd328 feat(tui): add developer debug bar (#38359) 2026-07-22 14:35:28 -04:00
Aiden Cline 532292b5f3 fix(core): reject malformed patch hunks (#38188) 2026-07-22 13:11:51 -05:00
Dax Raad 89e3141079 fix(core): preserve cache for session generation 2026-07-22 12:21:40 -04:00
Kit Langton 7a1f9764a2 feat(core): compact durable tool metadata (#38343) 2026-07-22 12:09:48 -04:00
opencode-agent[bot] b91dd78ab3 test(core): remove source text assertions (#38342)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-22 11:50:40 -04:00
James Long dba5da7c10 fix(tui): restore shell mode styling (#38231) 2026-07-22 11:23:50 -04:00
opencode-agent[bot] aea36d7630 fix(tui): prevent duplicate message forks (#38240)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-22 10:11:58 -05:00
opencode-agent[bot] 03474816ea test(core): stabilize wellknown event subscriptions (#38331)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-22 09:40:31 -05:00
Kit Langton 5a9ed4d350 fix: make tool progress live-only (#38217) 2026-07-22 10:40:07 -04:00
Simon Klee 4204b9d087 fix(tui): show providers in Mini model search (#38321) 2026-07-22 16:02:17 +02:00
Simon Klee 8fad13365b feat(tui): add mini splash setting (#38317)
Fix #38010
2026-07-22 15:46:50 +02:00
Simon Klee 5841b04fe7 tui: resolve default mini footer agent (#38315) 2026-07-22 15:42:12 +02:00
Simon Klee e12ec8681b fix(tui): render mini markdown in mono mode (#38313) 2026-07-22 15:32:43 +02:00
Simon Klee a3e2cc0dcd feat(tui): add mini agent switching (#38287)
Fixes #36121
2026-07-22 11:14:10 +00:00
Simon Klee 648183cecb fix(tui): quiet hidden mini footer (#38279) 2026-07-22 12:21:43 +02:00
Simon Klee 58d18be590 oc mini v2 (#38278) 2026-07-22 11:50:21 +02:00
Simon Klee dc9fd126a0 test(cli): cover interactive command flags (#38273) 2026-07-22 11:16:14 +02:00
Simon Klee 794137b33b refactor(tui): narrow mini compatibility surfaces (#38262) 2026-07-22 10:35:35 +02:00
Simon Klee 0e08b7330f fix(tui): set mini terminal title on Linux (#38261) 2026-07-22 10:35:07 +02:00
Simon Klee ced3d5e02a fix(tui): submit prompt when resuming session (#38260) 2026-07-22 10:34:39 +02:00
Simon Klee 6e826f3e22 fix(tui): hide project commands from mini palette (#38259) 2026-07-22 10:34:08 +02:00
Simon Klee 8bb1cfaa3b fix: defer catalog validation to session execution (#38258) 2026-07-22 10:32:52 +02:00
Simon Klee 691a7d93c8 feat(tui): add compact command to mini (#38251) 2026-07-22 07:03:46 +02:00
Simon Klee ca6da05d07 fix(tui): show skill name in mini tool output (#38250) 2026-07-22 07:03:02 +02:00
Aiden Cline 23483ea013 feat(ai): support custom reasoning fields (#38227) 2026-07-21 23:42:59 -05:00
Aiden Cline 69c05ae3fc feat(codemode): assimilate promise thenables (#38237) 2026-07-21 23:32:02 -05:00
opencode-agent[bot] 59ad593d9c fix(core): reduce snapshot repository setup (#38162)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-21 23:08:24 -05:00
James Long 6b9136e797 fix(tui): inherit elevated tool theme (#38224) 2026-07-21 22:45:21 -04:00
opencode-agent[bot] 80dc21d8f7 fix(cli): use hosted updater API (#38223)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-22 02:34:48 +00:00
Aiden Cline c8a40450e5 feat(codemode): support generator functions (#38172) 2026-07-21 20:36:42 -05:00
Dax Raad e84938b309 fix(core): make session warming observable 2026-07-21 21:30:05 -04:00
Dax Raad c821d49386 feat(updates): add artifact build endpoints 2026-07-21 21:29:42 -04:00
Dax Raad f683eef5f9 feat(core): add session warming 2026-07-21 20:43:26 -04:00
Aiden Cline a0a6963beb fix(core): discover Copilot API endpoint (#38184) 2026-07-21 19:28:31 -05:00
Dax Raad da35eac93f fix(sdk): build without legacy sources 2026-07-21 20:13:27 -04:00
Dax Raad c944048bcf feat(updates): add release control service 2026-07-21 19:58:56 -04:00
Dax Raad c2351e308f fix(ci): skip legacy artifacts on v2 2026-07-21 19:11:52 -04:00
Dax 8de40be6ea feat(plugin): expose app metadata (#38179) 2026-07-21 22:16:25 +00:00
Simon Klee 2ed8fe5960 mini: add hideable footer details (#38192) 2026-07-21 23:28:40 +02:00
Dax 1ea9137c01 fix(core): identify spawned subagents (#38189) 2026-07-21 16:54:01 -04:00
opencode-agent[bot] 77d3289d27 chore: merge dev into v2 (#38182)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
Co-authored-by: Daniel Polito <danielbpolito@gmail.com>
2026-07-21 15:24:47 -05:00
opencode-agent[bot] e6607fb58d fix(core): authorize symlinks by lexical path (#38180)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-21 15:13:18 -05:00
opencode-agent[bot] 128e2550ef fix(core): use shared app node import (#38181)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-07-21 15:08:53 -05:00
Simon Klee e8d273ae5b mini: add monochrome ASCII mode (#38173) 2026-07-21 22:01:09 +02:00
Aiden Cline 7ec1b6e580 feat(core): deliver CodeMode catalog through instructions (#38003) 2026-07-21 14:46:18 -05:00
Simon Klee c4fa5e6619 mini: add turn summary visibility setting (#38153) 2026-07-21 21:01:47 +02:00
Aiden Cline 6ec17e5d55 fix(core): align patch Unicode matching (#38160) 2026-07-21 13:47:52 -05:00
Dax 0405670cab refactor(core): move database path policy (#38159) 2026-07-21 18:44:37 +00:00
opencode-agent[bot] caf727ecb7 fix(core): filter unsupported media inputs (#38145)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-21 13:44:10 -05:00
Dax 9c38358197 refactor: centralize client identity (#38148) 2026-07-21 14:01:04 -04:00
Simon Klee dd6c95fdc7 mini: add quiet transcript settings (#38152) 2026-07-21 19:36:34 +02:00
Aiden Cline 6f4b9504e5 feat(codemode): support custom async iterators (#38141) 2026-07-21 12:18:46 -05:00
Aiden Cline dbfbb13ccc fix(core): authorize patch move destinations (#38133) 2026-07-21 12:18:23 -05:00
Simon Klee 99b2e78d75 fix(tui): improve responsive footer details (#38116) 2026-07-21 19:00:21 +02:00
opencode-agent[bot] 023f76f6fc fix(core): refresh model catalog every five minutes (#38142)
Co-authored-by: Victor Navarro <36263538+vimtor@users.noreply.github.com>
2026-07-21 16:34:54 +00:00
Dax 69d7c2add7 feat(util): configure package publishing (#38129) 2026-07-21 12:32:23 -04:00
Shoubhit Dash cf651bc41b feat(cli): port ACP to V2 (#37907) 2026-07-21 21:13:21 +05:30
Aiden Cline 8d80365ef4 refactor(codemode): simplify model-facing wording (#38042) 2026-07-21 10:19:53 -05:00
Aiden Cline 3ea8895299 fix(core): enforce patch EOF anchors (#38039) 2026-07-21 10:15:51 -05:00
Dax 3304e79827 fix(tui): prevent autocomplete search flicker (#38128) 2026-07-21 10:41:36 -04:00
opencode-agent[bot] cf2c1bf425 refactor(core): break plugin host cycle (#38120)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-21 10:35:00 -04:00
Dax e0810753f2 refactor: extract shared util package (#37828) 2026-07-21 10:34:29 -04:00
Dax 7243bd9e12 fix: preserve runtime option defaults (#38118) 2026-07-21 10:30:18 -04:00
Simon Klee dd50d457b0 refactor(tui): simplify mini footer status line (#38112) 2026-07-21 16:08:19 +02:00
Simon Klee 592ef7433a mini: settle on wait and durable pending (#37984) 2026-07-21 15:42:55 +02:00
opencode-agent[bot] fd97d789ef test(app): stabilize offset observer test (#38044)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-21 00:26:46 -05:00
Aiden Cline 7111f93836 feat(codemode): support bounded async iteration (#38040) 2026-07-21 00:02:31 -05:00
Aiden Cline e770415fd5 fix(core): normalize CRLF patch lines (#38038) 2026-07-20 23:59:10 -05:00
Aiden Cline 96dc560833 fix(core): accept padded patch markers (#38036) 2026-07-20 23:44:17 -05:00
Aiden Cline eb4ff91c2d fix(core): compose successive patch updates (#38034) 2026-07-20 23:36:48 -05:00
Aiden Cline a81c04fd31 feat(codemode): support labeled control flow (#38035) 2026-07-20 23:36:24 -05:00
Aiden Cline 63e2054f50 fix(core): improve patch errors (#38016) 2026-07-20 22:49:04 -05:00
Brendan Allan 09a38f1984 fix(server): allow authenticated CORS preflight (#38026) 2026-07-20 23:41:27 -04:00
Aiden Cline 065b108bba feat(codemode): support JSON callbacks (#38006) 2026-07-20 22:25:14 -05:00
Dax Raad 39fdd67123 fix: remove legacy sdk from ci builds 2026-07-20 22:51:12 -04:00
Dax Raad 44b6938b2a removed packages/opencode 2026-07-20 21:54:22 -04:00
Dax Raad fc7e4cf93e fix(server): preserve v1 auth compatibility 2026-07-20 21:46:23 -04:00
Dax Raad 43c08387f1 refactor: isolate legacy flags 2026-07-20 21:42:02 -04:00
Dax Raad 407ac2d8aa refactor(server): migrate host options 2026-07-20 21:21:36 -04:00
Dax Raad 9445b4d940 refactor(server): inject config options 2026-07-20 21:05:13 -04:00
Dax Raad 0409e6884d refactor(server): inject runtime options 2026-07-20 20:49:25 -04:00
Dax Raad ace77a5cf7 refactor(server): centralize server options 2026-07-20 20:33:17 -04:00
Aiden Cline f5a487ffcd feat(codemode): add Set method parity (#38000) 2026-07-20 16:48:35 -05:00
Aiden Cline 455b5d3165 fix(core): match dev patch behavior (#37709) 2026-07-20 16:46:11 -05:00
James Long a2c4c7a64b refactor(tui): migrate diff viewer to V2 theme (#37999) 2026-07-20 17:29:43 -04:00
opencode-agent[bot] e93fa06d05 feat(ai): support image-guided generation (#37998)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-20 16:21:38 -05:00
opencode-agent[bot] fa4ec98e1d feat(ai): add Z.ai image generation (#37780)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-20 15:53:21 -05:00
opencode-agent[bot] 4c9a4309ce chore: merge dev into v2 (#37996)
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
2026-07-20 15:20:59 -05:00
James Long c0ba62b972 refactor(tui): expose theme colors as properties (#37995) 2026-07-20 16:17:09 -04:00
opencode-agent[bot] 8cb9361149 feat(ai): add Google image generation (#37781)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-20 15:12:22 -05:00
Dax Raad 642950a763 fix(cli): isolate bun updater cache 2026-07-20 16:02:42 -04:00
opencode-agent[bot] df1f42d80f feat(ai): add xAI image generation (#37778)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-20 15:00:31 -05:00
Aiden Cline 0c18d06c9f feat(codemode): add groupBy parity (#37992) 2026-07-20 14:58:09 -05:00
opencode-agent[bot] 17c1e9b083 refactor(ai): infer image provider options (#37948)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-20 14:44:30 -05:00
Dax Raad 0b2e2cbab0 fix(core): ignore empty provider api keys 2026-07-20 15:29:13 -04:00
James Long 79d0077f63 feat(tui): add categorical theme hues (#37985) 2026-07-20 15:28:53 -04:00
Dax Raad 0aa4031dd0 refactor(core): inject models options 2026-07-20 15:18:39 -04:00
Dax Raad f971aa0719 refactor(server): inject database options 2026-07-20 15:18:39 -04:00
opencode-agent[bot] 61b7caa0b5 fix(tui): make migrated selected background transparent (#37967)
Co-authored-by: James Long <longster@gmail.com>
2026-07-20 13:41:18 -04:00
Aiden Cline 98c29075b4 feat(codemode): complete date parity (#37960) 2026-07-20 12:13:16 -05:00
Aiden Cline 6d50023457 feat(codemode): complete regexp and math parity (#37955) 2026-07-20 11:29:55 -05:00
James Long e8b19afa8f fix(tui): adapt file badge accent to theme mode (#37953) 2026-07-20 11:45:29 -04:00
Aiden Cline 3da0dea8e7 feat(codemode): expand standard library parity (#37943) 2026-07-20 09:59:37 -05:00
Dax Raad c7aa47c144 feat(tui): implement session timeline 2026-07-20 09:53:07 -04:00
James Long 7be95bcd6a feat(tui): support single-mode themes (#37930) 2026-07-20 09:37:48 -04:00
opencode-agent[bot] 3f4fb3f9db fix(tui): show opencode2 continuation command (#37933)
Co-authored-by: Dax <mail@thdxr.com>
2026-07-20 13:36:02 +00:00
James Long 6702ce0d3f fix(tui): soften theme scale extremes (#37555) 2026-07-20 09:20:20 -04:00
LukeParkerDev 985ee1e2ec fix(app): navigate tabs by selection history 2026-07-20 23:15:53 +10:00
Simon Klee a9895a1e8a fix(tui/mini): idle footer when session idles (#37910) 2026-07-20 13:22:46 +02:00
Simon Klee 75c7ac6a2c tui/mini: consolidate stream and panel internals (#37903) 2026-07-20 12:45:48 +02:00
Simon Klee 0eb71d0fc7 refactor(tui): extract shared frontend helpers (#37868) 2026-07-20 10:43:02 +02:00
Aiden Cline deee40c572 fix(codemode): parse canonical index keys (#37851) 2026-07-19 23:55:53 -05:00
Aiden Cline 8b5655ed53 fix(ai): preserve compatible reasoning details (#37708) 2026-07-19 22:30:55 -05:00
opencode-agent[bot] b9525b5878 fix(ai): expand context overflow patterns (#37848)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 22:22:56 -05:00
opencode-agent[bot] 2b8d1998d6 fix(ai): buffer partial tool call identity (#37847)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 22:12:49 -05:00
Aiden Cline 391cfbcfe7 fix(codemode): align string, array, and Date behavior (#37775) 2026-07-19 21:41:06 -05:00
Dax Raad fe0c74f4df fix(cli): accept updated service on restart 2026-07-19 19:29:40 -04:00
Kit Langton 6d32bc9cb0 feat(simulation): control arbitrary tool lifecycles (#37816) 2026-07-19 17:40:30 -04:00
Simon Klee 925c2423de mini: add reconnect, forms, and shared targets (#37811)
Centralize session target resolution for mini and noninteractive
run paths. Recover from transport drops, replace questions with
forms, and keep tool/catalog state location-scoped with live
progress and theme discovery.
2026-07-19 22:45:10 +02:00
Kit Langton 71cb419570 fix(simulation): validate semantic click identity (#37808) 2026-07-19 16:43:53 -04:00
Kit Langton edc93ceff1 feat(simulation): expose semantic UI snapshots (#37802) 2026-07-19 16:04:27 -04:00
opencode-agent[bot] 86a468c4d8 fix(tui): move agent cycling to shift-tab (#37706)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-07-19 21:45:03 +02:00
opencode-agent[bot] f05d2ab551 fix(tui): load root sessions in switcher (#37784)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-19 16:32:32 +00:00
opencode-agent[bot] bef6cfbffe feat(client): support opaque payload schemas (#37773)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 16:08:26 +00:00
Aiden Cline fe9a936867 feat(server): expose runtime MCP controls over HTTP (#37712) 2026-07-19 10:28:25 -05:00
Aiden Cline d5669ca934 docs(codemode): streamline README (#37769) 2026-07-19 10:18:24 -05:00
opencode-agent[bot] 04f0a771a3 feat(ai): add OpenAI image generation (#37714)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 10:17:49 -05:00
Simon Klee c50554d907 mini: move frontend into tui package (#37754) 2026-07-19 14:12:22 +02:00
Simon Klee 3f5ad8441f cli: extract run from mini package (#37737) 2026-07-19 11:11:03 +02:00
Simon Klee cf6e5b3604 mini: fix shell tool output display (#37711) 2026-07-19 09:31:38 +02:00
Aiden Cline cbcf191fdb fix(core): detach disposed MCP registrations from root scope (#37660) 2026-07-18 23:38:07 -05:00
Kit Langton ba0bbdafaa fix(core): preserve the first terminal failure (#37705) 2026-07-19 00:00:23 -04:00
Kit Langton a288cb5a0c fix(core): continue after malformed tool input (#37701) 2026-07-18 23:09:08 -04:00
Kit Langton 57ff57595a fix(core): safely recover malformed tool input (#37698) 2026-07-18 21:52:45 -04:00
Kit Langton 584fdefe6f fix(simulation): render screenshot symbol glyphs (#37691) 2026-07-18 21:36:21 -04:00
Kit Langton c310ef82f4 fix(core): authorize relative external paths (#37689) 2026-07-18 19:55:18 -04:00
Dax Raad 4e85a37590 fix(tui): auto-approve permissions in auto mode 2026-07-18 17:54:59 -04:00
opencode-agent[bot] fe9b051d1a feat(core): allow MCP Code Mode opt-out (#37681)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-18 17:17:56 -04:00
Aiden Cline 33f1b269e9 fix(codemode): stop leaking undefined into tool arguments (#37652) 2026-07-18 13:42:16 -05:00
opencode-agent[bot] 0d68b0bb20 fix(tui): style interrupted compaction neutrally (#37655)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-18 13:41:01 -05:00
Kit Langton 4e56998d3c fix(cli): harden managed service election (#37645) 2026-07-18 14:16:55 -04:00
Aiden Cline d625bc86fc feat(core): publish pending MCP status during connect (#37605) 2026-07-18 12:02:21 -05:00
Aiden Cline 5f437a09b0 feat(codemode): native coercion parity in interpreter (#37608) 2026-07-18 12:01:10 -05:00
opencode-agent[bot] 7d4496eafc test(core): stabilize shell progress test (#37643)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-18 11:59:24 -05:00
opencode-agent[bot] 1f2de535aa fix(ai): parse compatible reasoning deltas (#37558)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-18 10:57:10 -05:00
opencode-agent[bot] 529d55b1c3 fix(core): remove session import cycle (#37596)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-18 13:24:47 +00:00
Aiden Cline deb5b144c3 test(ai): record MiniMax messages compatibility (#37604) 2026-07-17 23:22:09 -05:00
opencode-agent[bot] cd3cca0006 feat(core): add runtime MCP controls (#37308)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-17 22:56:52 -05:00
Aiden Cline 08a7080e11 fix(tui): exit composer picker with up arrow (#37601) 2026-07-17 22:22:00 -05:00
Dax Raad f2579c41b6 docs: allow trusted cli installation 2026-07-17 20:49:42 -04:00
Dax Raad c4830c147d fix(core): defer cyclic layer dependencies 2026-07-17 20:25:21 -04:00
Dax Raad 22c23cb44c fix(cli): reduce startup overhead 2026-07-17 20:14:04 -04:00
opencode-agent[bot] 60ce33cde8 fix(cli): simplify service registration lease (#37576)
Co-authored-by: Dax Raad <826656+thdxr@users.noreply.github.com>
2026-07-17 19:47:17 -04:00
opencode-agent[bot] 6ea8247e0f fix(cli): elect managed service by port bind (#37572)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
2026-07-17 23:12:36 +00:00
Aiden Cline 987242b3e8 fix(core): honor OAuth attempt expiration (#37557) 2026-07-17 16:10:53 -05:00
James Long fafb04ed59 feat(tui): track hue source identity (#37550) 2026-07-17 15:38:22 -04:00
Aiden Cline 87d5b27668 feat(codemode): project OpenAPI schema directions (#37361) 2026-07-17 14:24:50 -05:00
James Long c7a7900ff2 refactor(tui): migrate workflow views to V2 themes (#37526) 2026-07-17 15:18:58 -04:00
opencode-agent[bot] f2a4011371 fix(cli): restore plugin list diagnostics (#37540)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-17 13:42:44 -05:00
Aiden Cline 4bc8faa01c feat(core): add tool namespaces (#37529) 2026-07-17 13:14:06 -05:00
Simon Klee 5d5b33f195 cli: rename node package to opencode-node (#37528) 2026-07-17 19:04:40 +02:00
Kit Langton 3fc06accbd fix(simulation): normalize named key presses (#37523) 2026-07-17 12:24:54 -04:00
James Long 677526d72e refactor(tui): migrate dialog views to V2 themes (#37516) 2026-07-17 12:17:11 -04:00
Kit Langton bce9c639d1 feat(tui): expose session composer top slot (#37518) 2026-07-17 11:55:18 -04:00
Kit Langton 48adb9521c feat(tui): expose session header slot (#37520) 2026-07-17 11:55:06 -04:00
opencode-agent[bot] 9b3a4655f1 fix(core): clarify user-executed shell history (#37515)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-17 10:49:02 -05:00
James Long bd79e24842 fix(tui): raise neutral permission backgrounds (#37513) 2026-07-17 11:35:02 -04:00
James Long d67e035c18 refactor(tui): migrate leaf views to V2 themes (#37510) 2026-07-17 11:19:03 -04:00
Simon Klee f8470404e3 fix(cli): stabilize Windows ARM64 Node builds (#37503) 2026-07-17 16:45:14 +02:00
opencode-agent[bot] 651751405d test(tui): await diff tree context readiness (#37501)
Co-authored-by: James Long <longster@gmail.com>
2026-07-17 10:41:00 -04:00
Kit Langton 44f7bb71c1 chore: upgrade Effect to beta.98 (#37498) 2026-07-17 10:31:27 -04:00
James Long b26d948b76 feat(tui): add hovered theme state (#37404) 2026-07-17 10:19:42 -04:00
Simon Klee 2180fb8e7b ci: fix node publish multi-arch install (#37497) 2026-07-17 16:16:43 +02:00
Kit Langton 3db4458e6c feat(session): expose transient generation API (#37433) 2026-07-17 09:25:50 -04:00
Simon Klee 42e2dde739 fix(cli): prevent Node stdout EPIPE failures
Keep draining standalone server stdout after reading its readiness message so
later server output does not write into a closed pipe.

Install the Node CLI broken-pipe handler before loading the main entrypoint.
Ignore only EPIPE while preserving all unrelated stdout errors. This prevents
Windows Node builds from failing when the TUI prints its exit epilogue after
renderer teardown.
2026-07-17 14:46:46 +02:00
Simon Klee a1b274e6f8 node v2 cli support (#36309) 2026-07-17 14:45:06 +02:00
Aiden Cline 27e1692848 fix: rm used config 2026-07-17 00:15:16 -05:00
Aiden Cline 73567f3570 feat(core): record title and compaction usage in session totals (#37441) 2026-07-17 00:10:30 -05:00
Aiden Cline 91238441a6 fix(codemode): enforce lexical temporal dead zones (#37358) 2026-07-16 22:26:45 -05:00
Kit Langton faf964691b fix(core): initialize provider state before catalog transforms (#37419) 2026-07-16 22:45:42 -04:00
Kit Langton 4a7f760d25 fix(tui): preserve prompt footer actions (#37180) 2026-07-16 22:08:22 -04:00
James Long f76e18201a fix(tui): derive session surface colors from hues (#37401) 2026-07-16 20:16:13 -04:00
Dustin Deus 331533deff fix(cli): isolate server request traces (#37395) 2026-07-17 00:52:14 +02:00
James Long 309860558d refactor(tui): remove secondary action styles (#37378) 2026-07-16 17:44:11 -04:00
opencode-agent[bot] b4a4ef0b3c refactor(tui): remove legacy keymap layer (#37206)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-16 21:33:15 +00:00
opencode-agent[bot] e916b99742 chore: merge dev into v2 (#37370)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
2026-07-16 15:44:26 -05:00
James Long 88b8883122 feat(tui): resolve action state priority (#37373) 2026-07-16 16:40:34 -04:00
James Long ac1b802820 fix(tui): restore selected action styling, fix subagent list (#37371) 2026-07-16 16:20:16 -04:00
Dustin Deus 5ee4c1082a feat(tui): add message navigation shortcuts (#37362) 2026-07-16 16:18:18 -04:00
starptech 8a70d70006 revert(tui): remove message navigation shortcuts 2026-07-16 21:45:40 +02:00
James Long 91b4634363 fix(tui): remove debug theme toggle (#37359) 2026-07-16 15:31:42 -04:00
starptech 4f60bde502 feat(tui): add message navigation shortcuts 2026-07-16 21:27:13 +02:00
Dax Raad 0d3b6d430e fix(tui): restore expandable tool hover state 2026-07-16 15:00:49 -04:00
Dax Raad a7cf21e157 refactor(core): move wellknown refresh to config 2026-07-16 14:32:46 -04:00
Kit Langton e4a16830f1 feat(core): add transient session generation (#37330) 2026-07-16 14:28:54 -04:00
Dax Raad 103f764624 feat(core): add provider policy enforcement 2026-07-16 14:28:18 -04:00
James Long 7a7075d86f feat(tui): refine V2 theme colors (#37346) 2026-07-16 14:01:25 -04:00
Aiden Cline c764732aea feat(codemode): expand destructuring support (#37342) 2026-07-16 12:57:37 -05:00
Dax Raad 72af084dc1 refactor(core): make state transforms synchronous 2026-07-16 13:55:14 -04:00
Dax Raad 65a42fd549 fix(core): debounce state reloads 2026-07-16 13:34:46 -04:00
Aiden Cline ad8e6b1fb6 feat(codemode): support property deletion (#37335) 2026-07-16 12:21:28 -05:00
Kit Langton 041cda905d fix(tui): label only detached subagents as background (#37306) 2026-07-16 12:21:19 -04:00
Aiden Cline 2ad6c42143 feat(core): normalize tool and attachment images at settlement (#37141) 2026-07-16 11:16:19 -05:00
Aiden Cline 60c7f847c1 feat(codemode): support void and Object.is (#37332) 2026-07-16 11:15:29 -05:00
Dax Raad 7ab8a08efa sync 2026-07-16 12:08:12 -04:00
Aiden Cline 7388e69b5c chore(codemode): update interpreter support 2026-07-16 10:44:44 -05:00
Kit Langton 56c0658e6f refactor(core): extract session model request preparation (#37210) 2026-07-16 10:58:06 -04:00
opencode-agent[bot] 06a2592836 fix(tui): align subagent navigation copy (#37319)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-16 10:55:54 -04:00
Kit Langton 4829308f2d fix(core): preserve logical step across retries (#37316) 2026-07-16 10:54:51 -04:00
Kit Langton eec6cd5234 test(core): cover mcp instruction producer (#37303) 2026-07-16 10:40:38 -04:00
Kit Langton fc37ae4452 refactor(core): split instruction observation and commit (#37208) 2026-07-16 10:37:00 -04:00
James Long 5fcef6773c feat(tui): migrate core surfaces to V2 themes (#37145) 2026-07-16 10:22:29 -04:00
Kit Langton a5b28c2af2 refactor(core): rename guidance modules (#37207) 2026-07-16 10:05:09 -04:00
opencode-agent[bot] d01dfa57b7 fix(core): limit v2 subagent nesting depth (#37291) 2026-07-16 09:28:15 -04:00
Shoubhit Dash 198ca749fd feat(ai): add Vertex Responses entrypoint (#37286) 2026-07-16 18:09:44 +05:30
Shoubhit Dash 75f9fd5208 feat(ai): add Vertex Chat entrypoint (#37281) 2026-07-16 17:51:03 +05:30
Shoubhit Dash 282f3f7eb2 refactor(ai): separate Vertex API routes (#37275) 2026-07-16 17:17:15 +05:30
Kit Langton 4678bd1049 feat(plugin): expose synthetic session input (#37212) 2026-07-15 22:55:40 -04:00
Dax Raad 5fb0470b44 feat(api): add experimental wellknown connections 2026-07-15 22:53:38 -04:00
opencode-agent[bot] 6bf92aa7de feat(tui): suggest settings command (#37205)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-15 22:31:31 -04:00
Kit Langton 0a5530d0be fix(plugin): make tool values structural (#37202) 2026-07-15 22:25:01 -04:00
Kit Langton dc7e0f6d2e refactor(core): extract session context loading (#37203) 2026-07-15 22:22:44 -04:00
Aiden Cline b4be28d4c3 tweak: adjust compaction to clearly indicate the convo history (#37195) 2026-07-15 19:28:26 -05:00
opencode-agent[bot] 7624c4f7b3 chore: merge dev into v2 (#37170)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
2026-07-15 18:23:08 -05:00
Aiden Cline 720f062cd0 refactor(core): select system prompts through plugins (#37181) 2026-07-15 18:21:32 -05:00
Dax Raad b5177adb5b feat(api): add command authentication attempts 2026-07-15 19:08:56 -04:00
Dax Raad a772767e9b refactor(api): scope oauth operations by integration 2026-07-15 19:08:56 -04:00
Aiden Cline fd19fa89c4 fix(codemode): linearize reference walks (#37179) 2026-07-15 17:24:36 -05:00
Aiden Cline 0cfa7b39db ignore: adjust agents md 2026-07-15 17:12:57 -05:00
Kit Langton 5e2e0d6965 feat(simulation): add endpoint handshake protocol (#37157) 2026-07-15 17:46:28 -04:00
opencode-agent[bot] f92d84746b refactor(plugin): scope context hook to session (#37175)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-15 16:32:00 -04:00
Dax Raad f2f5eb6f16 feat(plugin): restore ai request hook 2026-07-15 15:58:14 -04:00
Dax Raad b95a5dc259 fix(tui): remove shell expansion hint 2026-07-15 15:43:33 -04:00
Dax Raad 3fdd12fa81 feat(ai): publish package 2026-07-15 15:17:33 -04:00
Dax Raad bb15b16499 refactor(ai): rename llm package 2026-07-15 15:11:03 -04:00
Kit Langton b6ccb66610 fix(core): bound default search results (#37154) 2026-07-15 13:49:16 -04:00
Dax Raad ec8ee60e0b refactor(client): rename service start to ensure 2026-07-15 13:34:32 -04:00
Dax Raad 75bc611ef1 refactor(client): simplify local service lifecycle 2026-07-15 13:19:50 -04:00
Shoubhit Dash 4f1298063b feat(llm): add compatible and vertex provider entrypoints (#36900) 2026-07-15 22:08:19 +05:30
Aiden Cline 22334b94c8 fix(codemode): canonicalize dotted tool paths (#36994) 2026-07-15 11:09:09 -05:00
Kit Langton f5dd181443 refactor(cli): define server connection boundary (#37133) 2026-07-15 11:48:38 -04:00
Kit Langton e8bd386973 fix(simulation): align vertical box drawing (#37137) 2026-07-15 11:26:16 -04:00
opencode-agent[bot] 36f8a1e6e5 fix(tui): summarize deleted patch files (#37134)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-15 11:25:37 -04:00
Kit Langton 35f14900ae feat(simulation): expose normalized terminal frames (#37135) 2026-07-15 11:13:47 -04:00
Dax Raad 83cfafc884 fix(tui): mount toast at app root 2026-07-15 01:24:18 -04:00
Dax Raad 09cd7445f1 tui: remove "Plugins reloaded" toast to reduce UI noise during plugin updates 2026-07-15 01:14:40 -04:00
Dax Raad 39f1336621 refactor(tui): remove home screen tips 2026-07-15 00:55:11 -04:00
Dax Raad ce636daf04 feat(core): include session ID in instructions 2026-07-15 00:47:10 -04:00
Dax Raad c1d2d7aba3 feat(session): support directory moves from slash commands 2026-07-15 00:33:59 -04:00
Dax Raad e8964ce672 refactor(tui): remove hidden command metadata 2026-07-14 23:40:40 -04:00
Dax Raad 00093c70c1 feat(tui): show session location below prompt 2026-07-14 23:32:23 -04:00
Aiden Cline 7eb7fe0763 fix(core): expand reasoning option variants (#36894) 2026-07-14 22:12:02 -05:00
Dax Raad 2cb2f9d538 refactor(tui): simplify location and command contexts 2026-07-14 23:01:53 -04:00
Dax Raad 94a23b8537 refactor(tui): centralize location state 2026-07-14 23:01:41 -04:00
James Long ff3442ce34 refactor(tui): extract V1 theme definitions (#36969) 2026-07-14 22:14:34 -04:00
Aiden Cline 72cf7f12f3 fix(core): restore default model headers (#36975) 2026-07-14 20:50:32 -05:00
Aiden Cline ba1f3d3d32 perf(codemode): batch OpenAPI query parameters (#36978) 2026-07-14 20:37:36 -05:00
Dax Raad 05fdbcce04 fix(cli): rename bash tool to shell 2026-07-14 21:31:42 -04:00
Dax Raad 387bff8fd9 feat(tui): stream shell tool output 2026-07-14 21:29:59 -04:00
James Long 9a25673c37 feat(tui): add V2 theme system (#36950) 2026-07-14 20:28:45 -04:00
Aiden Cline 563f6a65de refactor(codemode): simplify tools types (#36941) 2026-07-14 18:40:43 -05:00
opencode-agent[bot] fca3bca19d test(tui): wait for connection before tool event (#36958)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-14 18:38:00 -05:00
Aiden Cline d88faeb6da fix(core): restore xAI OAuth in v2 (#36955) 2026-07-14 18:25:14 -05:00
Dax Raad 2508a74956 refactor(tui): make data sync owner-driven 2026-07-14 17:33:15 -04:00
Kit Langton 947566f611 refactor(simulation): scope Drive lifecycle with Effect (#36908) 2026-07-14 17:16:50 -04:00
Kit Langton a149a61a89 fix(tui): harden reasoning groups (#36913) 2026-07-14 16:38:42 -04:00
Kit Langton ece2b16cdf feat: expose background service lifecycle (#36895) 2026-07-14 16:38:22 -04:00
Aiden Cline ea89a2f619 feat(codemode): unify callback acceptance and support built-in references (#36771) 2026-07-14 15:25:39 -05:00
opencode-agent[bot] 0d4f9797f4 chore: merge dev into v2 (#36918)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
2026-07-14 15:23:34 -05:00
Dax Raad a32c480d45 refactor(tui): centralize active location 2026-07-14 16:18:30 -04:00
Aiden Cline f4f761246a fix(core): support OpenAI pro mode (#36896) 2026-07-14 15:12:51 -05:00
Dax Raad 915be9b54d feat(plugin): add tool progress reporting 2026-07-14 15:57:16 -04:00
Dax Raad 6039f54126 fix(tui): refine thinking block styling 2026-07-14 15:26:04 -04:00
opencode-agent[bot] 016545513f feat(tui): group sequential thinking (#36901)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-07-14 15:13:16 -04:00
Dax Raad b67bed061a feat(tui): port diff viewer to v2 plugins 2026-07-14 15:10:40 -04:00
Dax Raad b2741d2f97 fix(tui): preserve legacy keybind lookup 2026-07-14 12:49:56 -04:00
Dax Raad 4a93972a78 feat(tui): add v2 plugin runtime 2026-07-14 12:49:56 -04:00
Kit Langton 5c5579e90c fix(llm): reject unterminated provider streams (#36881) 2026-07-14 12:44:45 -04:00
opencode-agent[bot] cd9be63484 fix(tui): hide initial instruction sync (#36891)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-14 12:42:09 -04:00
Shoubhit Dash 40fedf086e fix(llm): port provider retry classification (#36887) 2026-07-14 22:09:50 +05:30
Aiden Cline a6b5cf94b0 fix(tui): refresh MCP status for active location (#36882) 2026-07-14 10:49:56 -05:00
opencode-agent[bot] 634386fe0f chore: merge dev into v2 (#36770)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
2026-07-14 09:58:18 -05:00
Kit Langton 26e27c7a7f fix(tui): distinguish asynchronous UI states (#36759) 2026-07-14 10:56:12 -04:00
Shoubhit Dash ea386fba1f feat(llm): add compatible responses provider (#35908) 2026-07-14 19:32:29 +05:30
opencode-agent[bot] aa8fc4234d fix(core): add session correlation header (#36842)
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
2026-07-14 14:59:22 +02:00
Dax Raad 9b8282ad3d feat(plugin): add v2 tui entrypoint 2026-07-13 23:08:34 -04:00
Dax Raad 2a08cd3b96 refactor(core): simplify plugin entrypoint resolution 2026-07-13 22:45:15 -04:00
Dax Raad 5cf24bf185 tui: prevent crash when opening session switcher from an active session 2026-07-13 21:05:03 -04:00
Aiden Cline c15e3487b2 test(codemode): make promise tests deterministic (#36780) 2026-07-13 18:53:20 -05:00
Aiden Cline 6963f2f6da fix(cli): update mini tests for v2 types (#36779) 2026-07-13 18:38:25 -05:00
Aiden Cline 547e0148c7 chore(codemode): run tests in CI (#36772) 2026-07-13 18:09:10 -05:00
Dax Raad 358d4746a9 refactor(cli): remove legacy sdk dependency 2026-07-13 18:03:16 -04:00
Kit Langton ce2e301e24 feat(tui): style background subagent indicator (#36751) 2026-07-13 17:57:12 -04:00
opencode-agent[bot] fbe7f26e71 fix(core): clamp compaction output budget (#36745)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
2026-07-13 17:50:20 -04:00
Aiden Cline ecb5754f4c refactor(codemode): rename Sandbox terminology to CodeMode (#36768) 2026-07-13 16:38:11 -05:00
Dax Raad 36f8cb7054 refactor(tui): remove legacy sdk client 2026-07-13 17:35:40 -04:00
James Long 4c45a5cf23 refactor(tui): extract system theme generation (#36753) 2026-07-13 16:39:06 -04:00
James Long 748b0d8836 refactor(tui): remove subtle syntax styles (#36746) 2026-07-13 16:14:56 -04:00
Kit Langton ed1636e3bd fix(tui): clamp dialog selection after options shrink (#36712) 2026-07-13 15:59:04 -04:00
Dax Raad 90a87b2a0c refactor(tui): remove legacy sync context 2026-07-13 15:46:25 -04:00
Kit Langton af8480b2e2 fix(tui): pin queued compaction below output (#36738) 2026-07-13 15:45:27 -04:00
Kit Langton 2096adae12 refactor(tui): remove unused subagent formatters (#36732) 2026-07-13 15:20:03 -04:00
Kit Langton 41349ff20a fix(tui): run dialog actions without selection (#36711) 2026-07-13 15:19:40 -04:00
Kit Langton a2f5a0df5d fix(core): compact after tool settlement (#36718) 2026-07-13 15:17:45 -04:00
Kit Langton 3bc924252b fix(www): generate collections before typecheck (#36730) 2026-07-13 15:07:45 -04:00
Kit Langton 8c1808ab74 fix(tui): standardize OpenCode product casing (#36717) 2026-07-13 15:01:48 -04:00
Dax Raad 685f887771 docs: clarify embedded sdk roadmap 2026-07-13 14:54:40 -04:00
Dax Raad 9db5073eab fix(plugin): retain legacy kv api shape 2026-07-13 12:57:31 -04:00
Dax Raad 775fce0177 refactor(tui): consolidate settings in cli config 2026-07-13 12:55:54 -04:00
Dax Raad 4e74f77e44 delete context.md 2026-07-13 11:49:34 -04:00
Dax Raad b980f74ed9 refactor(tui): reuse dialog select for settings 2026-07-13 11:16:05 -04:00
opencode-agent[bot] 2987c30238 revert(tui): restore list highlights (#36680)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
2026-07-13 10:29:44 -04:00
opencode-agent[bot] 50a1add183 fix(tui): open model selector at top (#36679)
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-13 10:20:15 -04:00
Dax Raad 3533047421 sync 2026-07-13 10:19:04 -04:00
opencode-agent[bot] 823bc20427 fix(tui): use gray list highlights (#36669)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
2026-07-13 10:15:30 -04:00
opencode-agent[bot] 339fd50cb9 fix(tui): preserve modal focus across dialog replacement (#36656)
Co-authored-by: Simon Klee <52795+simonklee@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-13 14:54:59 +02:00
Aiden Cline 828a4b3f15 fix(codemode): align array callback behavior (#36584) 2026-07-13 00:34:18 -05:00
Dax Raad a43de3f86d tui: remove legacy V1 CLI TUI integration to simplify startup 2026-07-13 01:05:14 -04:00
Dax Raad fe3b2e8f90 sync 2026-07-13 00:50:51 -04:00
Dax Raad 20af1f5e79 refactor(tui): compact settings values 2026-07-13 03:57:14 +00:00
Kit Langton 8daf912fbf feat(tui): add settings dialog 2026-07-13 03:57:14 +00:00
opencode-agent[bot] 3d545f960b fix(tui): restore clicked reverted prompt (#36567)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-12 21:51:50 -05:00
Kit Langton 58201a32c1 fix: standardize MCP server copy (#36598) 2026-07-12 22:23:59 -04:00
Aiden Cline 64ca9b8d77 refactor(core): replace deferred tool option with codemode (#36560) 2026-07-12 21:21:07 -05:00
Kit Langton a4b91d33d9 fix(tui): align skills dialog copy (#36592) 2026-07-12 22:02:24 -04:00
Kit Langton 397993e898 fix(client): preserve compatible background service (#36583) 2026-07-12 21:32:34 -04:00
Kit Langton c0ed0106b1 fix(tui): show background shell completion (#36534) 2026-07-12 21:01:36 -04:00
opencode-agent[bot] f112a73c06 fix(core): load config across git boundaries (#36577)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-13 00:05:41 +00:00
Dax Raad 7e9b9cb0fd docs: document TUI config migration 2026-07-12 19:28:26 -04:00
Dax Raad 09a6cecf23 refactor(tui): remove code concealment toggle 2026-07-12 18:12:36 -04:00
opencode-agent[bot] 6e55ddd078 fix(config): load configs across git boundaries (#36568)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-12 22:07:20 +00:00
Dax Raad f8f8cc9546 refactor(tui): simplify config context 2026-07-12 17:12:48 -04:00
Aiden Cline 3c60470269 refactor(codemode): remove tool concurrency cap (#36545) 2026-07-12 16:07:53 -05:00
Dax Raad b2bdab24e6 fix(tui): preserve legacy host compatibility 2026-07-12 17:01:02 -04:00
Dax Raad 3568dd1b99 feat(tui): add managed config interface 2026-07-12 17:01:02 -04:00
opencode-agent[bot] 1e17202413 chore: merge dev into v2 (#36556)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Nabs <nabil@instafork.com>
2026-07-12 15:16:08 -05:00
Aiden Cline 49cb73b348 refactor(codemode): split runtime into focused interpreter modules (#36540) 2026-07-12 12:47:56 -05:00
opencode-agent[bot] 430750e2f8 fix(tui): remove unsupported MCP toggle (#36531)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-12 10:56:24 -05:00
Kit Langton 7913c4a490 refactor(server): share event stream encoding (#36484) 2026-07-12 11:48:52 -04:00
Kit Langton 5414697bd1 fix(tui): show background subagent completion (#36530)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-07-12 11:43:13 -04:00
Kit Langton e22e3b8f2c fix(cli): keep update preflight through TUI loading (#36475) 2026-07-11 22:19:13 -04:00
Kit Langton 8e657c7db5 fix(core): settle malformed tool input on failure (#36477) 2026-07-11 21:41:57 -04:00
Kit Langton 02e2277057 fix(tui): lower durable event log level (#36479) 2026-07-11 21:37:08 -04:00
Dax Raad ac1ddc3f83 refactor(tui): simplify config and tool display 2026-07-11 19:51:19 -04:00
opencode-agent[bot] 8f904c8e9a fix(cli): load v2 tui config (#36466)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-11 19:13:29 -04:00
Dax Raad ec07ee56f4 feat(tui): add plugin route api 2026-07-11 18:52:04 -04:00
Dax Raad de86d73d19 feat(tui): add v2 plugin context 2026-07-11 17:56:25 -04:00
Dax Raad 0fa9e5039e refactor(tui): reduce legacy sync usage 2026-07-11 17:05:23 -04:00
Dax Raad c073387723 refactor(tui): use default client export 2026-07-11 16:44:55 -04:00
Dax Raad 1956497f42 fix(client): loosen opaque promise outputs 2026-07-11 16:38:02 -04:00
Dax Raad 2a7e32c416 refactor(tui): remove legacy sdk surfaces 2026-07-11 16:38:02 -04:00
Kit Langton 56a7c06a80 fix(cli): hand update completion directly to the TUI (#36455) 2026-07-11 15:42:31 -04:00
Aiden Cline 75e8fd4da2 test(codemode): drop leftover $codemode references (#36451) 2026-07-11 14:15:11 -05:00
opencode-agent[bot] b9f39dd751 fix(core): disable unused fff content caches (#36452)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-11 15:03:54 -04:00
Aiden Cline 6eeeb4bfcf feat(codemode): make search a global built-in and rewrite README (#36450) 2026-07-11 13:43:33 -05:00
Kit Langton 1ccaca826e feat(cli): update preflight UI while replacing the background service (#36448) 2026-07-11 14:43:16 -04:00
Aiden Cline 04f9b15178 fix(core): include child session id in background subagent start text (#36447) 2026-07-11 13:34:30 -05:00
Kit Langton 66b9cc7931 fix(client): accept larger SSE events (#36442) 2026-07-11 14:15:53 -04:00
Kit Langton e3f7637eb2 feat(tui): add semantic file path truncation (#36352) 2026-07-11 13:57:29 -04:00
Kit Langton 5945a8d429 fix(tui): restore queued compaction indicator (#36440) 2026-07-11 13:54:23 -04:00
Kit Langton c7ceccf869 fix(core): skip fff for aggregate locations (#36437) 2026-07-11 13:47:41 -04:00
Kit Langton 00ab94c44f fix(tui): stabilize compaction completion (#36435) 2026-07-11 13:06:14 -04:00
Aiden Cline fe09a2e9b7 feat(codemode): support Promise.any and new Promise construction (#36339) 2026-07-10 20:46:13 -05:00
Dax 59202356fe fix(tui): fork messages with agent attachments (#36338) 2026-07-10 21:23:46 -04:00
Dax 637282f3b9 fix(tui): make composer close action discoverable (#36337) 2026-07-10 21:19:57 -04:00
opencode-agent[bot] d1550cb599 feat(core): port GitHub Copilot OAuth (#36336)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-10 21:11:12 -04:00
Dax Raad ed6c117184 fix(tui): retain leader subagent shortcut 2026-07-10 21:01:55 -04:00
Dax Raad 6ce62bd84a feat(tui): open subagents with down 2026-07-10 21:01:04 -04:00
Dax Raad a4a948316b fix(core): skip unchanged plugin activation 2026-07-10 20:49:16 -04:00
Dax Raad aca3b7813d fix(core): publish skill source updates 2026-07-10 20:42:44 -04:00
Dax Raad 0eaa75ec0f fix(tui): total root session family cost 2026-07-10 19:51:10 -04:00
Kit Langton 6b3c4f5839 refactor(core): combine git discovery queries (#36321) 2026-07-10 19:41:48 -04:00
Aiden Cline 8e76adb08f feat(codemode): support promise chaining with .then/.catch/.finally (#36304) 2026-07-10 18:22:04 -05:00
Kit Langton 0bb24a46c1 fix(core): defer snapshot repository discovery (#36290) 2026-07-10 17:34:40 -04:00
James Long d3f4695af3 docs: add opencode-drive skill (#36319) 2026-07-10 17:30:41 -04:00
Dax Raad 5b2715d24f refactor(plugin): simplify promise adapter imports 2026-07-10 17:18:46 -04:00
Dax Raad 20e37e7122 fix(plugin): adapt promise host wire values 2026-07-10 17:18:03 -04:00
Dax Raad b87bb4486f fix(client): generate standalone promise DTOs 2026-07-10 17:06:45 -04:00
Dax Raad feaec7a6be fix overloaded models issue 2026-07-10 16:56:37 -04:00
Dax Raad 1c67004999 fix(cli): show background server startup status 2026-07-10 16:14:19 -04:00
opencode-agent[bot] 43ecf3ff1b chore: merge dev into v2 (#36312)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
2026-07-10 15:11:29 -05:00
Dax Raad 9028c2d8f8 docs: clarify beta binary and MCP route 2026-07-10 15:54:30 -04:00
James Long be18f22842 feat(simulation): record viewport resizes (#36306) 2026-07-10 15:08:43 -04:00
Dax Raad 6216dbae15 docs: simplify intro page 2026-07-10 13:39:46 -04:00
Kit Langton 96a9731947 feat(core): replace instruction checkpoints with value-delta sync (#36254) 2026-07-10 13:26:25 -04:00
Dax Raad 768a69bbbd sync 2026-07-10 12:30:49 -04:00
Kit Langton b7b941c354 fix(tui): unify pending tool styling (#36278) 2026-07-10 12:29:09 -04:00
Kit Langton cf5e0adf02 fix(core): simplify compaction semantics (#36267) 2026-07-10 11:36:29 -04:00
Kit Langton 41d0a9f010 fix(tui): hide editor context from transcript (#36264) 2026-07-10 11:33:04 -04:00
Dax Raad accaa792d8 docs: clarify opencode skill references 2026-07-10 11:24:03 -04:00
Dax Raad 76d7a11585 docs: expand opencode skill guidance 2026-07-10 11:23:20 -04:00
Dax Raad c867e66ab5 feat(plugin): add promise plugin cleanup 2026-07-10 11:23:20 -04:00
Kit Langton 278c510549 refactor(core): simplify session runner bookkeeping (#36200) 2026-07-10 09:46:34 -04:00
Dax Raad 0d6ccd2a50 docs: add build overview 2026-07-10 02:51:30 -04:00
Aiden Cline 39cceeb143 fix(codemode): return promises from combinators (#35782) 2026-07-10 00:33:21 -05:00
Aiden Cline a6449cb45c refactor(form): model links as fields (#36129) 2026-07-10 00:22:01 -05:00
Dax Raad 6a85f0d3db feat(client): add root promise entrypoint 2026-07-10 01:09:06 -04:00
Dax Raad fbb0fdf88a fix(plugin): select plugins by id 2026-07-10 00:44:43 -04:00
Dax Raad 2a4b298108 refactor(plugin): include name in promise tools 2026-07-10 00:22:45 -04:00
Dax Raad efeff63749 refactor(plugin): simplify promise tool declarations 2026-07-10 00:18:13 -04:00
Kit Langton d54038b9d2 docs(v2): consolidate specifications (#36186) 2026-07-09 22:47:37 -04:00
Dax Raad 8f04a09d1a refactor(plugin): remove protocol dependency 2026-07-09 22:25:38 -04:00
Dax Raad 761f37370f fix(plugin): restore package publishing 2026-07-09 22:18:58 -04:00
Kit Langton b452368b3b refactor(core): simplify tool admission flow (#36180) 2026-07-09 22:01:31 -04:00
Kit Langton 3785eddfa0 fix(core): preserve admitted tool generations (#36177) 2026-07-09 21:16:00 -04:00
Dax Raad 4a006b1210 refactor(core): derive config watches from entries 2026-07-09 21:12:15 -04:00
Aiden Cline 2db7ccb453 fix(core): restore resilient compaction (#36163) 2026-07-09 19:13:11 -05:00
Aiden Cline 629b304c48 fix(core): continue after configured permission denial (#36155) 2026-07-09 17:35:25 -05:00
Dax Raad 1004fbb2f5 docs: expand OpenCode 2 beta guides 2026-07-09 18:01:15 -04:00
opencode-agent[bot] a7746379d5 chore: merge dev into v2 (#36144)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-09 16:32:44 -05:00
Kit Langton a72992e00f feat(core): session.pending.list API with pending-only session_pending storage (#36126) 2026-07-09 17:25:40 -04:00
James Long 99156c10e6 feat(cli): export frontend logs over OTLP (#36152) 2026-07-09 17:19:22 -04:00
Dax Raad f6f0a5c10f docs: expand user guides 2026-07-09 16:57:13 -04:00
Dax Raad e2eed76101 refactor(tui): migrate v2 calls to new client 2026-07-09 16:57:13 -04:00
Kit Langton 9ff7ef3fb0 fix(tui): handle session search fetch failures without crashing (#36149) 2026-07-09 16:38:58 -04:00
Dax Raad 8fe78c8f8d fix(core): restore plugins after failed activation 2026-07-09 16:28:22 -04:00
Harsh Mathur bd0dffd781 fix(llm): preserve nested OpenAI stream errors (#36130)
Co-authored-by: Harsh Mathur <jeevesforharsh@gmail.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-09 14:40:28 -05:00
Dax Raad 0ac8f91ed6 fix(core): restore catalog status schema export 2026-07-09 14:20:36 -04:00
Dax Raad 4c80c23e11 fix(tui): reconcile refreshed messages 2026-07-09 14:12:02 -04:00
Dax Raad 33c0cc2bb6 fix: synchronize remote data refreshes 2026-07-09 13:56:28 -04:00
Dax Raad cc686ab8f6 fix(cli): unify server resolution 2026-07-09 13:28:01 -04:00
Kit Langton 6524dfc818 feat(core): resume suspended sessions after service restart (#36105) 2026-07-09 13:19:15 -04:00
Dax Raad c91604d519 chore(opencode): suppress dead v1 model types 2026-07-09 13:15:20 -04:00
Dax Raad a2718177be test(core): align models dev snapshot fixtures 2026-07-09 13:15:20 -04:00
Dax Raad 6fb526741b refactor(tui): rename link concept to pair 2026-07-09 13:15:20 -04:00
Dax Raad 85fdb3bde6 refactor(core): optimize location startup 2026-07-09 13:15:20 -04:00
James Long b642f9c7bc feat(tui): log event stream connections (#35973) 2026-07-09 13:13:34 -04:00
James Long 855a9569b5 feat(simulation): add literal screen text matching (#36085) 2026-07-09 10:50:35 -04:00
Kit Langton aa0e626b12 fix(core): defer permission rejection cleanup (#36067) 2026-07-09 09:49:14 -04:00
Aiden Cline 389c866cac docs(codemode): add interpreter doc (#36026) 2026-07-09 00:42:48 -05:00
Aiden Cline 7feefb697f refactor: remove todo tool (#35989) 2026-07-09 00:13:48 -05:00
Dax Raad d4155f2906 refactor(cli): simplify mini arguments 2026-07-08 23:28:40 -04:00
Dax Raad 67f2393f83 refactor(cli): parse variants from model refs 2026-07-08 23:25:37 -04:00
Dax Raad cccd013801 refactor(cli): centralize server resolution 2026-07-08 22:10:30 -04:00
Dax Raad be7a684791 refactor(cli): unify server endpoint handling 2026-07-08 22:10:30 -04:00
Kit Langton 984cab7938 feat(core): generalize session input inbox (#36005) 2026-07-08 22:07:45 -04:00
Aiden Cline 7eea97184a fix(tui): restore prompt after undo (#35987) 2026-07-08 19:54:29 -05:00
Kit Langton 0590819090 fix(server): absorb healthy event bursts 2026-07-08 20:33:28 -04:00
opencode-agent[bot] 687dbba6a3 chore: merge dev into v2 (#35962)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: runvip <164729189+runvip@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-08 17:58:55 -05:00
Dax Raad 2347f4d7b4 fix(tui): simplify server link title 2026-07-08 18:47:48 -04:00
Dax Raad 7698a5e6ac feat: add server link sharing 2026-07-08 18:42:54 -04:00
Aiden Cline ec8eea7cbe test(codemode): add Test262 array coverage (#35965) 2026-07-08 17:26:11 -05:00
Aiden Cline b44a981eef feat(core): global form support (#35959) 2026-07-08 17:25:34 -05:00
James Long 79415f625a feat(tui): revamp session export (#35971) 2026-07-08 18:14:43 -04:00
Simon Klee 19f42f7102 feat(v2/cli): add console login (#35969) 2026-07-09 00:00:09 +02:00
Aiden Cline 212c9f99ee test(codemode): add Test262 string coverage (#35940) 2026-07-08 15:51:54 -05:00
James Long 99e52303e6 feat(core): discover ecosystem skill directories (#35956) 2026-07-08 16:34:41 -04:00
Dax Raad 88925ec6e6 fix(tui): allow callers without log sink 2026-07-08 16:00:52 -04:00
Dax Raad cedf365674 feat(tui): add structured logging 2026-07-08 16:00:07 -04:00
Kit Langton 5b39972947 fix(core): await initial plugin readiness (#35755) 2026-07-08 15:12:45 -04:00
James Long 09553d46e0 refactor(simulation): remove filesystem layers (#35938) 2026-07-08 12:22:04 -04:00
James Long e1d72fa338 fix(simulation): stabilize screenshot artifacts (#35933) 2026-07-08 11:48:53 -04:00
Dax Raad b3cf749a7f fix(tui): show resolved skill name 2026-07-08 11:46:02 -04:00
Dax Raad dd842f9862 fix(cli): elect one service process 2026-07-08 11:46:02 -04:00
Kit Langton 3dd29094f4 feat(http-recorder): sync recorder v0.3 (#35619) 2026-07-08 11:41:59 -04:00
Aiden Cline 05e8d5be73 fix(core): preserve prompt attachment order (#35921) 2026-07-08 10:06:58 -05:00
Dax Raad 8b634e4a58 refactor(tui): simplify client data state 2026-07-08 10:37:48 -04:00
James Long c6156f171c feat(simulation): stream UI recordings (#35909) 2026-07-08 10:33:11 -04:00
Dax Raad 4d2b06f8cf docs: prohibit bypassing git hooks 2026-07-08 10:11:46 -04:00
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 bf15c97e4b Revert "fix(tui): paginate session history"
This reverts commit cc29f86cdc.
2026-07-08 13:45:20 +00:00
Shoubhit Dash 3cbcd8d727 feat(llm): complete provider package entrypoints (#35907) 2026-07-08 18:41:46 +05:30
Dax Raad cc29f86cdc fix(tui): paginate session history 2026-07-08 01:34:43 -04:00
Dax Raad 86e3828da6 fix(core): watch location only when vcs is present 2026-07-08 00:38:56 -04:00
James Long 8d00a51766 feat(simulation): support multiple tool calls (#35861) 2026-07-08 00:35:18 -04:00
opencode-agent[bot] ed4f833813 fix(core): preserve provider metadata namespaces (#35817)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-07 21:45:58 -05:00
opencode-agent[bot] e96c24ce2e test(core): fix mutable plugin fixture (#35837) 2026-07-07 22:22:12 -04:00
Kit Langton ed6ad272ec refactor(schema): apply session review decisions (#35793) 2026-07-07 22:10:11 -04:00
Kit Langton d27746e5b3 test(core): simplify runner test fixtures (#35832) 2026-07-07 21:58:52 -04:00
Kit Langton eb661e6e6d fix(core): settle unadvertised tool calls and repair session hook test contexts 2026-07-07 21:51:23 -04:00
Dax Raad 3379e44abb refactor(core): rename ApplyPatchTool to PatchTool 2026-07-07 21:16:00 -04:00
Dax e1abf59c59 fix(core): provide PluginHooks in plugin test layer (#35811) 2026-07-07 20:03:41 -04:00
Dax 4f976bcf1a feat(plugin): add session request hook (#35794) 2026-07-07 19:56:47 -04:00
Kit Langton e25a724bc2 test(tui): cover patch diff display aliases (#35810) 2026-07-07 19:44:54 -04:00
Kit Langton 521ea87192 fix(core): retain reasoning delta state (#35758) 2026-07-07 19:44:40 -04:00
Aiden Cline 3100701488 fix(tui): restore prompt context usage (#35795) 2026-07-07 17:49:33 -05:00
Kit Langton 9c54291ae8 fix(tui): restore forms on reconnect
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-07 22:47:13 +00:00
Dax Raad 52ad916ba4 fix(tui): restore permissions on reconnect
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-07 22:44:26 +00:00
Dax Raad 5db320b02d refactor: rename apply_patch tool 2026-07-07 16:27:25 -04:00
Aiden Cline 910e37f6d8 fix: update v2 session usage metrics (#35468) 2026-07-07 14:31:54 -05:00
Kit Langton 81f6e06681 feat: enable background subagents by default 2026-07-07 15:18:46 -04:00
James Long da68e2865e feat(tui): wire move session command (#35203) 2026-07-07 14:49:11 -04:00
Aiden Cline f86e1cc1ff fix(codemode): preserve collection value identity (#35776) 2026-07-07 13:40:55 -05:00
Aiden Cline 947bbf9490 feat(mcp): expose resource catalog API (#35773) 2026-07-07 13:34:29 -05:00
James Long 3e57b43a4a feat(session): support admit-only synthetic messages (#35774) 2026-07-07 14:17:20 -04:00
opencode-agent[bot] cb18a42a47 docs(codemode): clarify implicit return guidance (#35763)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-07 12:48:05 -05:00
Aiden Cline 1fb2837fd3 feat(codemode): expand numeric standard library (#35749) 2026-07-07 12:11:57 -05:00
Aiden Cline 66a8d830aa feat(core): support MCP resources (#35656) 2026-07-07 11:55:06 -05:00
opencode-agent[bot] d0733f83bf fix(codemode): return final top-level expression (#35759)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-07 11:44:50 -05:00
Kit Langton b2ce195bda fix(core): normalize Location.Ref keys in LocationServiceMap (#35757) 2026-07-07 12:25:29 -04:00
James Long 6c065ca6a5 feat(simulation): render driven UI screenshots and videos (#35739) 2026-07-07 12:15:01 -04:00
Dax Raad dd1c007877 docs 2026-07-07 12:08:45 -04:00
Kit Langton 8deb0e5780 fix(core): preserve SDK plugins across location eviction (#35725) 2026-07-07 11:43:40 -04:00
Aiden Cline f488089179 fix: preserve compaction files and classify Z.AI overflow (#35747)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
2026-07-07 09:54:31 -05:00
Kit Langton e3a39b214f fix(core): lower failed reasoning to text (#35735) 2026-07-07 09:49:17 -04:00
James Long b6a2912bb1 refactor(simulation): type RPC request payloads (#35734) 2026-07-07 09:21:19 -04:00
opencode-agent[bot] 93b7ca9b4d fix(tui): align switch reminders (#35637)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-06 23:02:34 -05:00
James Long fcb1d4b418 feat(simulation): add named drive instances (#35648) 2026-07-06 22:52:10 -04:00
Kit Langton 04b673432c feat(core): add durable compaction barrier (#35371) 2026-07-06 21:26:05 -04:00
Dax Raad bd947658bb docs: add v2 configuration reference 2026-07-06 20:09:35 -04:00
Kit Langton bb6ee0a5cc docs(codemode): document loop syntax (#35631) 2026-07-06 19:52:39 -04:00
Dax Raad fd4497e6b9 fix(tui): route shell termination by location 2026-07-06 19:39:22 -04:00
Dax Raad 59790380de fix(core): support unlimited shell timeouts 2026-07-06 19:32:22 -04:00
Dax Raad 7668eb728d fix(tui): normalize attachment labels 2026-07-06 19:22:25 -04:00
Dax Raad 79301fbed5 chore(cli): inline build sourcemaps 2026-07-06 19:22:12 -04:00
Dax Raad c0ea1a43f9 feat(tui): support session deletion 2026-07-06 19:19:22 -04:00
Dax Raad 96717c1a8c feat(tui): improve session fork handling 2026-07-06 18:59:01 -04:00
Dax Raad e312f0d775 fix(tui): provide location for home mentions 2026-07-06 18:50:24 -04:00
Aiden Cline bfdbf43ef8 fix(core): clarify MCP timeout budgets (#35626) 2026-07-06 17:47:20 -05:00
Aiden Cline 984430c97d chore: add typecheck profiling (#35622) 2026-07-06 17:17:30 -05:00
Kit Langton bf01264661 feat(core): finalize session event lifecycle (#35272) 2026-07-06 18:14:06 -04:00
Kit Langton b046bbe4e3 feat(codemode): support loop sequence syntax (#35618) 2026-07-06 17:59:13 -04:00
Aiden Cline 8dc9ac07a2 fix(tui): sync v2 terminal title (#35610) 2026-07-06 16:51:23 -05:00
Aiden Cline ba24037e64 feat(codemode): math parity (#35609) 2026-07-06 16:47:17 -05:00
Kit Langton df471872de fix(codemode): reject Promise.all immediately (#35604) 2026-07-06 17:37:50 -04:00
Dax Raad 8942f97998 sync 2026-07-06 17:36:28 -04:00
Kit Langton 754dd57177 fix(codemode): preserve async callback semantics (#35603) 2026-07-06 17:29:11 -04:00
Dax Raad f044def957 fix(tui): detect backgrounded subagents 2026-07-06 17:15:03 -04:00
Dax Raad 9c2842b9b0 fix(tui): track pending session inputs 2026-07-06 17:12:21 -04:00
Aiden Cline 9e0d3976e1 chore: merge dev into v2 (#35591)
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: runvip <164729189+runvip@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
2026-07-06 16:05:29 -05:00
Dax Raad f87998f37f fix(cli): align prompt attachment mentions 2026-07-06 16:28:37 -04:00
Shoubhit Dash e57d9ca390 feat(core): flatten provider config and load native packages (#35563)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-06 16:18:56 -04:00
Kit Langton d603eed5a1 feat(codemode): support destructuring assignment (#35600) 2026-07-06 15:56:52 -04:00
Kit Langton 0e1c0414c2 fix(codemode): align mutable JavaScript semantics (#35598) 2026-07-06 15:55:32 -04:00
Kit Langton dc99c600b6 feat(v2): expose canonical schema contracts (#35595) 2026-07-06 15:45:46 -04:00
James Long 6ffe61b9fe feat(simulation): isolate filesystem and disconnect llm (#35590) 2026-07-06 15:39:03 -04:00
Dax Raad 36d17c30a7 fix(sdk): expose prompt input contract 2026-07-06 15:05:43 -04:00
Dax Raad 4d100de194 feat(core): resolve prompt attachments 2026-07-06 15:04:23 -04:00
Dax Raad c13f06c30c feat(tui): use canonical prompt attachments 2026-07-06 14:35:31 -04:00
Kit Langton 91f1815732 refactor(core): rename system context to instructions (#35583) 2026-07-06 14:29:29 -04:00
Kit Langton 1a52e1118e refactor(core): remove unused session watermarks (#35579) 2026-07-06 13:55:47 -04:00
Kit Langton f2f62438b8 refactor(core): unify live event subscriptions (#35578) 2026-07-06 13:46:59 -04:00
Kit Langton 2830176972 fix(plugin): reload late SDK plugins (#35576) 2026-07-06 13:36:19 -04:00
Dax Raad 06dcf3f221 fix(tui): remove committed undo messages 2026-07-06 13:08:25 -04:00
Dax Raad f9442e7107 test(opencode): update active session mock 2026-07-06 13:01:36 -04:00
Dax Raad ff499c3603 refactor(api): remove watermark sync surface 2026-07-06 13:01:36 -04:00
Aiden Cline 4cee5bd824 refactor(codemode): split interpreter and stdlib (#35572) 2026-07-06 12:01:14 -05:00
Dax Raad f406fed924 fix(cli): ignore version mismatch on reconnect 2026-07-06 12:35:07 -04:00
Aiden Cline 5b2d90a441 feat(codemode): expand standard library support (#35566) 2026-07-06 10:36:02 -05:00
opencode-agent[bot] 04e8801f5b fix(tui): soften interrupted step styling (#35466)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-06 09:30:55 -05:00
Aiden Cline 7f008df79d fix(codemode): improve prompting (#35509) 2026-07-06 09:19:04 -05:00
Simon Klee 175d200e53 V2 opentui upgrade (#35544)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-06 15:34:57 +02:00
Simon Klee 41d11a8a89 cli: demote version mismatch to warning for now (#35548) 2026-07-06 14:39:58 +02:00
Simon Klee 0bba8c780a cli/mini: fix run failure handling (#35539) 2026-07-06 12:34:44 +02:00
Simon Klee 32cf36de9d mini migrate to v2 (#35526) 2026-07-06 11:03:30 +02:00
Simon Klee fb75ea2cf6 mini: fix default model (#35515) 2026-07-06 09:20:23 +02:00
Dax Raad 147da5d278 test(opencode): update project service stub 2026-07-06 01:32:58 -04:00
Dax Raad eed23d8ee9 feat(api): publish v2 reference 2026-07-06 01:29:08 -04:00
Dax Raad e6e951b252 docs: rebuild v2 documentation 2026-07-06 00:58:00 -04:00
Aiden Cline 4e019ba5d9 fix(core): expose MCP output schemas to code mode (#35502) 2026-07-05 23:12:45 -05:00
Aiden Cline 50a762e7b9 fix(mcp): preserve metadata across paginated tools (#35500) 2026-07-05 22:51:08 -05:00
Aiden Cline 780c99bc2e refactor(core): route questions through forms (#35422) 2026-07-05 22:28:05 -05:00
Aiden Cline 5bcf8d5a0b feat(tui): render session forms (#35421) 2026-07-05 19:35:30 -05:00
Dax Raad 652e8ff3fb fix(client): resolve codegen workspace version 2026-07-05 18:31:15 -04:00
Dax Raad 7b88de47c3 chore(client): publish client package 2026-07-05 18:21:23 -04:00
Dax Raad 33cb536879 feat(plugin): publish generation updates 2026-07-05 17:21:39 -04:00
Dax Raad 6ffecf9345 fix(core): reload local plugins on file changes 2026-07-05 16:54:17 -04:00
Dax Raad 08741f6b93 fix(core): scope observability to server 2026-07-05 16:22:04 -04:00
Dax Raad baacb2e776 fix(core): tolerate invalid OTLP configuration 2026-07-05 16:14:02 -04:00
Dax Raad a75978815a feat(server): add loaded locations debug endpoint 2026-07-05 15:59:44 -04:00
Dax Raad c3d26c4912 feat(core): improve runtime observability 2026-07-05 15:59:44 -04:00
Dax Raad a9b7bd9e2f feat(core): manage configurable plugin generations 2026-07-05 15:59:44 -04:00
Aiden Cline f9d1d3b259 refactor(codemode): namespace public types (#35435) 2026-07-05 11:51:50 -05:00
Aiden Cline f950497173 refactor(codemode): remove generic agent tool (#35420) 2026-07-05 10:41:21 -05:00
Aiden Cline 391aa38281 test(core): serve skill fixtures locally (#35429) 2026-07-05 10:40:55 -05:00
Aiden Cline 29e8502bb0 fix(tui): align integration empty state (#35414) 2026-07-05 09:08:09 -05:00
Kit Langton 44b182fe23 fix(core): validate scalar newtypes (#35381) 2026-07-04 21:43:08 -04:00
Kit Langton 905123b9c0 fix(protocol): keep internal events off SSE (#35378) 2026-07-04 21:12:45 -04:00
Kit Langton bb3b6a2f65 fix(protocol): expose MCP tool change events (#35373) 2026-07-04 20:47:33 -04:00
Aiden Cline b8efb33cde feat(codemode): add OpenAPI tool adapter (#35362) 2026-07-04 16:28:11 -05:00
Aiden Cline d65ecd4a90 feat(core): expose deferred tools through execute (#35361) 2026-07-04 16:20:56 -05:00
Simon Klee 57fb3e5cc5 fix(run): align mini with current session contracts (#35354) 2026-07-04 21:14:01 +02:00
Simon Klee ba07481b59 fix(run): restore subprocess output contracts 2026-07-04 21:06:32 +02:00
Aiden Cline b99759c7de fix(core): enforce mcp tool permissions (#35345) 2026-07-04 13:03:40 -05:00
Aiden Cline c590e27639 feat(core): add grouped and deferred tool registration (#35232) 2026-07-04 11:49:24 -05:00
Kit Langton 8f4b62eb49 fix(core): report missing search paths (#35337) 2026-07-04 12:12:56 -04:00
Kit Langton 945d1c8cb2 fix(core): bound recursive file watching (#35323) 2026-07-04 11:55:05 -04:00
Kit Langton 610e618bc5 fix(tui): clear completed background shell status (#35320) 2026-07-04 11:33:45 -04:00
Kit Langton 9daa4d85a4 fix(tui): reconcile session state after reconnect (#35262) 2026-07-04 11:05:41 -04:00
Kit Langton 62af66a74f fix(tui): distinguish variant switch notices (#35315) 2026-07-04 10:58:56 -04:00
Kit Langton 5b44e5bf41 test(core): release shell test locations (#35271) 2026-07-04 10:54:34 -04:00
Dax Raad a15afbe8f2 bye bye orchestrator 2026-07-04 02:04:58 -04:00
Dax Raad 8e0856c43b fix(core): disable external plugin hot reload 2026-07-04 01:58:17 -04:00
Dax Raad f66c829231 Revert "fix(core): bust external plugin import cache"
This reverts commit afe3ebbc35.
2026-07-04 01:54:30 -04:00
Aiden Cline 3baaabede8 fix(core): resolve mcp header env placeholders (#35236) 2026-07-03 23:00:10 -05:00
Dax Raad afe3ebbc35 fix(core): bust external plugin import cache 2026-07-03 23:12:09 -04:00
Kit Langton e2faeb84e5 fix(core): tolerate minimal FSWatcher typings (#35264) 2026-07-03 22:37:42 -04:00
Kit Langton 35ed09ff37 fix(tui): improve MCP error details (#35263) 2026-07-03 22:26:54 -04:00
Dax Raad 9751615651 fix(core): reload all config plugins 2026-07-03 20:41:33 -04:00
Dax Raad c9b24ef027 feat(core): reload config on filesystem changes 2026-07-03 20:35:04 -04:00
Aiden Cline b04d8d53e6 fix(tui): expand MCP server errors in dialog (#35243) 2026-07-03 18:19:16 -05:00
Dax Raad 7bd3f8ac83 chore(client): remove cyclic test dependencies 2026-07-03 18:12:22 -04:00
Dax Raad 6ae2fa5196 feat(plugin): align hooks with client APIs 2026-07-03 18:12:22 -04:00
Kit Langton 650d774372 refactor(schema): session shell payloads and event prefix restore (#35229) 2026-07-03 17:30:25 -04:00
Simon Klee 64e4f6f91b cli: route run commands through v2 APIs (#35234) 2026-07-03 22:47:04 +02:00
Aiden Cline d097cc8065 feat(tui): render execute child calls on v2 (#35231) 2026-07-03 15:10:25 -05:00
Aiden Cline 438654768c fix(codemode): require exact tool paths in guidance (#35224) 2026-07-03 14:55:03 -05:00
James Long 37b26e495b feat(simulation): share control protocol schemas (#35230) 2026-07-03 15:46:04 -04:00
Kit Langton 5d14c7a185 refactor(core): align runner naming with step vocabulary (#35227) 2026-07-03 15:41:50 -04:00
James Long e66cbf36e9 fix(core): constrain location services (#35228) 2026-07-03 15:34:30 -04:00
Dax Raad 50977dd4fe server: emit logs for every HTTP request to help debug API traffic 2026-07-03 15:13:37 -04:00
Dax Raad a644e0e7a0 sync 2026-07-03 14:58:23 -04:00
Kit Langton ca861fdf43 docs: consolidate session work-unit vocabulary (#35218) 2026-07-03 14:49:05 -04:00
Dax Raad 34a08cbdb8 fix(core): tolerate missing models.dev temperature 2026-07-03 14:46:54 -04:00
Aiden Cline bf3ae45439 fix(core): clean up mcp event surface (#35221) 2026-07-03 13:43:21 -05:00
Kit Langton 394e0b9045 refactor(schema): rename V2 session events and normalize payloads (#35217) 2026-07-03 14:25:59 -04:00
James Long 4790a2772c feat(simulation): add driver controlled backend LLM (#35186) 2026-07-03 14:19:25 -04:00
Dax Raad 2ff19171dc feat(tui): reload command restarts the service 2026-07-03 14:01:01 -04:00
Kit Langton 1401901529 chore: effect pattern lint infrastructure (#35210) 2026-07-03 13:58:26 -04:00
Kit Langton 1b88ff8d53 fix(core): clean up Effect audit patterns (#35174) 2026-07-03 13:54:13 -04:00
Dax Raad 08293e74a0 refactor(cli): single password config with legacy fallback 2026-07-03 13:41:15 -04:00
Dax Raad 24d26365e6 refactor(cli): manage environment variables with effect config 2026-07-03 13:37:12 -04:00
Dax Raad c22793d5f6 feat(cli): validate explicit server before starting the tui 2026-07-03 13:34:12 -04:00
Dax Raad c6a52a39b5 fix(cli): read OPENCODE_PASSWORD for explicit server auth 2026-07-03 13:34:12 -04:00
James Long aad8d90dd1 refactor(core): move path resolve into fs service (#35201) 2026-07-03 13:25:38 -04:00
Dax Raad 6f47459c68 chore: update lockfile 2026-07-03 12:42:09 -04:00
Dax Raad dd768e30e2 refactor(client): namespace service exports and share the registration contract 2026-07-03 12:36:56 -04:00
Kit Langton de476aa51b refactor(schema): declare event durability at definition level (#35172) 2026-07-03 12:35:35 -04:00
James Long bd8d858bf7 feat(core): implement V2 session.shell (#35183) 2026-07-03 12:19:09 -04:00
Aiden Cline 5fc4e18b82 test(core): cover when AND semantics and multiselect neq (#35182) 2026-07-03 11:10:49 -05:00
Aiden Cline 12d9f4b29b feat(codemode): add confined execution package (#35118)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-03 10:57:16 -05:00
James Long a2769b5ade fix(core): rewrite replacements while hoisting layers (#35176) 2026-07-03 11:26:58 -04:00
Simon Klee 1fa605ad5e fix(run): handle unattended form blockers (#35170) 2026-07-03 16:35:19 +02:00
Simon Klee 698ef25f33 fix(run): use parentID filter for subagent hydration (#35168) 2026-07-03 15:38:54 +02:00
Kit Langton 88dc960af8 refactor(core): migrate built-in tools to internal plugins (#34956) 2026-07-03 09:03:53 -04:00
Shoubhit Dash 5657cec4f2 feat(core): add mercurial adapter for vcs status and diff (#35141) 2026-07-03 17:42:25 +05:30
Shoubhit Dash 8d790a9ed5 fix(tui): keep session running until execution settles (#35136) 2026-07-03 17:23:30 +05:30
Simon Klee b62d24e40b fix(run/cli): wait for selected model catalog (#35130) 2026-07-03 13:21:06 +02:00
Shoubhit Dash a1b6c84135 feat(core): expose V2 VCS status and diff APIs (#35129) 2026-07-03 16:49:03 +05:30
Aiden Cline 85fde8c11d feat(core): rework form when conditions (#35115) 2026-07-03 04:23:20 -05:00
Aiden Cline c167bde6a0 feat(core): global forms support (#35106) 2026-07-03 03:55:15 -05:00
Aiden Cline d9bf30fc22 feat(server): add form routes (#35099)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-03 02:40:44 -05:00
Aiden Cline 3ce5e9800d feat(core): add form service (#35094) 2026-07-03 01:41:37 -05:00
Dax Raad af5eabcb26 refactor(client): split package into promise and effect entrypoints 2026-07-03 01:46:21 -04:00
Dax Raad 0087dd56d9 fix(schema): expose client-consumed events in the server manifest 2026-07-03 01:45:59 -04:00
Aiden Cline ef2140d121 fix(core): revert form service and mcp elicitation (#35080) 2026-07-03 00:32:10 -05:00
Dax Raad 1de3c6e4a6 feat(client): self-contained local service discovery and lifecycle 2026-07-03 01:19:43 -04:00
Dax Raad b6c3a8ea7b fix(ci): run typecheck on v2 2026-07-03 00:43:48 -04:00
Aiden Cline efcf2c3f5d feat(core): MCP elicitation support (#35064) 2026-07-02 23:25:54 -05:00
Kit Langton e65477ab1d refactor(core): polish runner drain and coordinator readability (#35051) 2026-07-02 22:01:53 -04:00
Kit Langton cd0b274856 refactor(core): simplify v2 prompt lifecycle and execution coordination (#35047) 2026-07-02 21:38:44 -04:00
Kit Langton 57e9e9771d feat(core): deterministic session log replay with synced watermark (#35040) 2026-07-02 21:38:20 -04:00
Aiden Cline 1aae92c42a fix(core): remove mcp tool call timeout (#35043) 2026-07-02 20:15:36 -05:00
opencode-agent[bot] 6555df912a feat(session): filter v2 session list by parent (#35037)
Co-authored-by: Dax <mail@thdxr.com>
2026-07-02 19:48:49 -04:00
Aiden Cline 7ebd344fa2 feat(core): add session form service (#34855) 2026-07-02 17:15:18 -05:00
Aiden Cline 460cdc5aec fix(llm): remove package retry policy (#35003) 2026-07-02 15:51:12 -05:00
James Long b0ca5520a1 feat(server): add lazy simulation layer replacements (#35024) 2026-07-02 16:45:09 -04:00
Kit Langton bc2e270f82 feat(server): durable log reads, changes feed, and watermarked snapshots (#34962) 2026-07-02 16:42:30 -04:00
Kit Langton 33705e632a fix(tui): use catalog display name in model switch notices (#34913) 2026-07-02 16:09:45 -04:00
Kit Langton cff2345c12 fix(core): record selected catalog model identity on assistant steps (#34911) 2026-07-02 16:09:33 -04:00
James Long 0405518180 feat: simulation control surface and architecture spec (#34801) 2026-07-02 15:19:19 -04:00
Aiden Cline c9604c86ec test(core): avoid shell PATH in command test (#34980) 2026-07-02 12:28:58 -05:00
Aiden Cline 967c44552e test(core): make command shell test portable (#34972) 2026-07-02 12:02:34 -05:00
Aiden Cline ec95694e2f docs(llm): track native provider parity (#34862) 2026-07-02 11:51:56 -05:00
Aiden Cline c176baee82 feat(core): port v2 command invocation (#34849) 2026-07-02 11:22:04 -05:00
Kit Langton 016d296f7c refactor(core): migrate glob tool to internal plugin (#34946) 2026-07-02 10:39:04 -04:00
Kit Langton dbaa53329c refactor(core): mechanism-neutral context entry rendering (#34945) 2026-07-02 10:16:24 -04:00
Kit Langton 7843f8fb38 feat(core): API-managed per-session context entries (#34941) 2026-07-02 10:05:47 -04:00
Kit Langton 4045041554 refactor(core): simplify system context into a belief model (#34917) 2026-07-02 09:32:35 -04:00
Shoubhit Dash 7ec7413fdb fix(tui): suppress transient reconnect overlay flashes (#34924) 2026-07-02 18:54:51 +05:30
Simon Klee f016392368 feat(mini): migrate mini to v2 (#34895)
feat(run): migrate non-interactive prompts to V2
feat(run): route mini prompts through V2
fix(run): use current session contracts
fix(run): fix V2 prompt turns
feat(cli): add mini subcommand
feat(run): use settled execution events
fix(run): handle remote prompt file attachments
feat(run): send prompt files as attachments
feat(run): use current APIs for run state
fix(run): adopt app-node runtime deps
feat(run): track subagent sessions
feat(run): move catalogs and default model onto current APIs
2026-07-02 12:06:49 +02:00
Aiden Cline 140224b0fc fix(core): derive models.dev reasoning variants (#34726) 2026-07-02 00:23:46 -05:00
opencode-agent[bot] cfd35c9354 fix(tui): include variant in model switch notice (#34856)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-02 00:17:18 -05:00
Kit Langton 674d08f9be fix(core): route ChatGPT OAuth to the codex backend (#34843) 2026-07-02 00:15:34 -04:00
opencode-agent[bot] 02f012f5d1 fix(tui): clear onboarding after provider connect (#34819)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-01 21:10:24 -05:00
Dax Raad bea6e1499d feat(core): discover instructions on directory reads 2026-07-01 21:11:22 -04:00
Dax Raad 50a1fe49bc fix(core): relativize instruction description to project root 2026-07-01 21:03:46 -04:00
Dax Raad 6380919fda fix(core): use relative paths in instruction load description 2026-07-01 21:02:47 -04:00
Dax Raad d972aa9d83 feat(core): tool execute hooks, session instructions, synthetic endpoint
- V2 tool execute hooks: add `ctx.tool.execute.before/after` plugin API,
  core ToolHooks service, and registry wiring so hosted/local tool calls
  run registered before/after hooks; provider-executed calls are excluded.
- Session synthetic endpoint: add `POST /api/session/:sessionID/synthetic`
  with text/description/metadata plus regenerated Promise/Effect/JS client
  surfaces.
- SessionInstructions service: read tool discovers nearby AGENTS.md walking
  up to the Location root (exclusive) and injects them as durable synthetic
  instructions, with lazy history dedup of prior claims.
- to-llm-message: stop forwarding synthetic message metadata to the model so
  bookkeeping annotations stay model-hidden.
- schema changelog entry for synthetic metadata and the metadata leak fix.
2026-07-01 20:50:10 -04:00
Dax Raad d544ab4d91 feat(tui): show background label on subagent line 2026-07-01 20:25:21 -04:00
Dax Raad ce228bfd7c feat(tui): maintain session family index in data context 2026-07-01 20:22:33 -04:00
Dax Raad eabf85aea2 fix(tui): wire subagent interrupt and drop granular background commands 2026-07-01 20:00:28 -04:00
Dax Raad 2f4a688790 chore: set up orchestrator plugin and mute prompt status labels 2026-07-01 18:51:18 -04:00
Dax Raad 4a90ffedfb fix(server): restore provider node deps after dev merge 2026-07-01 18:39:43 -04:00
Dax Raad 95cf5039be fix(tui): color prompt work counts 2026-07-01 18:23:07 -04:00
Dax Raad 041215996c chore: trigger deploy 2026-07-01 18:16:27 -04:00
Dax Raad d0b920e187 chore: trigger publish ci 2026-07-01 18:11:49 -04:00
James Long 8c94e9005f chore: merge dev into v2 (#34788)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com>
Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Ben Guthrie <benjee.012@gmail.com>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com>
Co-authored-by: Max Anderson <max.a.anderson95@gmail.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: runvip <164729189+runvip@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
2026-07-01 17:12:00 -04:00
Shoubhit Dash 932a40cfd9 fix(tui): pin queued prompts below output (#34791) 2026-07-02 01:53:51 +05:30
Dax Raad 716f6658db feat(core): add skill autoinvoke metadata 2026-07-01 15:13:40 -04:00
Dax Raad 9f1a8f2149 docs: add sample skill 2026-07-01 14:40:56 -04:00
Dax Raad b3650e2316 fix(core): provide job service in session default layer 2026-07-01 14:38:35 -04:00
Dax Raad fb884bb91e feat: update session notices and skill reloads 2026-07-01 14:37:27 -04:00
Dax Raad 98c09884bf feat(protocol): publish package 2026-07-01 13:53:09 -04:00
Dax Raad 5e47501b8b feat(schema): publish package 2026-07-01 13:33:59 -04:00
Dax Raad d51ba6ed94 fix(cli): run update check in background 2026-07-01 13:18:00 -04:00
Dax Raad a6983b65fc fix: skip plugin publish 2026-07-01 12:57:48 -04:00
Dax Raad 1a06198954 fix(tui): indent synthetic session notices 2026-07-01 12:39:06 -04:00
Dax Raad 72ec09cf74 fix(tui): keep backgrounded subagents spinning 2026-07-01 12:33:01 -04:00
Dax Raad e2bca216a2 feat: background blocking tools 2026-07-01 12:18:04 -04:00
Dax Raad a10733dbf4 docs: add debug opencode skill 2026-07-01 12:18:04 -04:00
Shoubhit Dash b5823d1077 fix(tui): show queued prompt admissions (#34771) 2026-07-01 21:27:56 +05:30
Dax e1fafa2e54 fix(cli): start service in home directory 2026-07-01 11:36:57 -04:00
Dax Raad ae49b21376 fix(core): save external permissions at project root 2026-07-01 11:27:24 -04:00
Dax Raad 5710064ae1 feat(core): generate session titles from first prompt
Add SessionTitle service that generates a session title from the
session's sole user message via the customizable "title" agent, then
renames the session. Runs once per session, gated on durable history
having exactly one user message (no default-title string comparison).

Wire it into SessionRunner as a background fork after the first turn's
prompt promotion makes the user message visible; forking before
promotion caused the first-message lookup to see zero rows and silently
no-op.

Closes #34364
2026-07-01 11:07:44 -04:00
Shoubhit Dash 4617b03ca9 fix: surface shell stderr output (#34761) 2026-07-01 20:33:48 +05:30
Dax Raad 6a91a682e4 feat(core): add plugin list endpoint
Adds a plugin.list HTTP endpoint that reports currently loaded plugins, wires it through protocol, server, and generated clients, and merges it into sdk-next's plugin API. Also updates the sample plugin to register a primary agent instead of a subagent for local testing.
2026-07-01 00:53:53 -04:00
Aiden Cline f8626865b9 fix(core): expose models.dev modes as models (#34714) 2026-06-30 22:48:25 -05:00
opencode-agent[bot] 2324a63fc6 feat(cli): add session resume flags (#34713)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-06-30 23:37:25 -04:00
Kit Langton 1ce607c230 refactor(plugin): move tool implementation to plugin (#34665) 2026-06-30 23:30:56 -04:00
Dax Raad c26f6f95f7 fix(core): deny subagent tool for default subagents 2026-06-30 23:11:06 -04:00
Dax Raad d77b87ea65 feat(script): use GitHub run number for preview build identifier 2026-06-30 23:05:46 -04:00
Dax Raad 6ca6566bd3 fix(core): derive reasoning variants from models.dev 2026-06-30 22:50:20 -04:00
Dax Raad 8dd993d25a fix(tui): stabilize reload connection state 2026-06-30 22:31:51 -04:00
Dax Raad 24ab17e718 fix(tui): keep background shell spinner active 2026-06-30 21:56:45 -04:00
Aiden Cline 0a60662d71 feat(cli): add mcp list, add, auth, and logout commands (#34643) 2026-06-30 13:49:07 -05:00
Kit Langton 194b0615e0 feat(plugin): support plugin-provided tools (#34619) 2026-06-30 13:18:56 -04:00
opencode-agent[bot] a5c51e11d0 fix(core): drop legacy config filename (#34650)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 11:12:15 -05:00
opencode-agent[bot] 8f1db7d06d fix(tui): align cli model picker behavior (#34571)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 09:36:38 -05:00
Aiden Cline b1ca070b3b feat(core): add mcp support (#34513) 2026-06-30 08:37:44 -05:00
Aiden Cline 12887e572e fix(core): align agent tests with universal default permissions (#34561) 2026-06-30 08:24:54 -05:00
Aiden Cline 75715e2115 fix(core): parse models.dev reasoning options (#34618) 2026-06-30 07:54:18 -05:00
Dax Raad a1250cd690 test(sdk-next): update embedded client namespaces 2026-06-30 01:16:33 -04:00
Dax Raad 23adaaaeab feat(core): add native skill activation 2026-06-30 01:16:33 -04:00
opencode-agent[bot] 524ee8fc03 fix(core): gate v2 edit tools by model (#34558)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-29 23:53:41 -05:00
Dax Raad 0f9719a7b5 fix(cli): support service autostart setting 2026-06-30 00:28:11 -04:00
Dax Raad 1b83c08b8a Update service configuration CLI 2026-06-30 00:18:26 -04:00
Dax Raad 02cb350880 feat(tui): refresh agents after update events 2026-06-30 00:18:26 -04:00
opencode-agent[bot] 4ce830a919 fix(core): align v2 prompt tool names (#34557)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-29 22:53:56 -05:00
Kit Langton 461a1c3ab4 refactor(core): replace background job service (#34559) 2026-06-29 23:53:35 -04:00
Kit Langton 6846542115 fix(client): singularize generated api groups (#34534) 2026-06-29 21:07:01 -04:00
Aiden Cline f928b5be07 fix(core): sanitize registered tool names (#34512) 2026-06-29 18:30:40 -05:00
Kit Langton ecfa918760 feat(client): expose fs read in promise client (#34504) 2026-06-29 19:21:18 -04:00
Aiden Cline f80624cf17 fix(tui): surface provider error in assistant footer (#34511) 2026-06-29 16:46:50 -05:00
Dax Raad fe59174c23 fix(core): resume after synthetic session message 2026-06-29 17:32:54 -04:00
Dax Raad 2fe057324f fix(core): provide filesystem to shell tool 2026-06-29 17:27:24 -04:00
Dax Raad 19a5b5a05d feat(core): support background shell tool 2026-06-29 17:01:04 -04:00
Dax Raad ff4cab03c1 refactor(core): simplify session fork event 2026-06-29 16:16:13 -04:00
Dax Raad b2d46ecd7e feat(core): add durable session fork event 2026-06-29 16:13:51 -04:00
Dax Raad 360d85a521 fix(tui): indent subagent empty state 2026-06-29 15:46:51 -04:00
Dax Raad c65a7d50c1 feat(tui): integrate composer picker 2026-06-29 15:37:09 -04:00
Dax Raad fa73546a86 feat(tui): add composer tabs 2026-06-29 14:20:43 -04:00
Kit Langton 935ac2db91 feat(client): expose v2 project APIs (#34456) 2026-06-29 14:10:11 -04:00
Dax Raad 01edae4a7f fix(core): spawn shell non-interactively without sourcing rc files
ShellSelect.args() ran zsh/bash with -l and explicitly sourced .zshrc/.bashrc,
loading user functions and aliases that can shadow builtins and return non-zero
exit codes, breaking && chains. Match the old tool behavior: plain non-login
non-interactive shell -c command with cwd passed via spawn options.
2026-06-29 12:05:56 -04:00
Kit Langton 381d67572e refactor(tui): wire generated client reads (#34381) 2026-06-29 11:36:10 -04:00
Dax Raad e8ac44430b feat(cli): poll for updates every 10 minutes in serve 2026-06-29 00:59:01 -04:00
Dax Raad 5ae93092aa refactor(core): replace bash tool with shell tool 2026-06-29 00:44:17 -04:00
Kit Langton 595c6bd4a7 feat: add v2 generate text endpoint (#34371) 2026-06-28 22:43:58 -04:00
Kit Langton 7073e8797f fix(core): remove per-prompt system option (#34361) 2026-06-28 21:56:59 -04:00
Kit Langton f7034a35a8 feat(core): add v2 manual compaction (#34336) 2026-06-28 21:18:19 -04:00
Kit Langton 53b93b6991 feat(sdk-next): let embedders contribute plugins via opencode.plugin (#34356) 2026-06-28 20:30:21 -04:00
opencode-agent[bot] 6067019434 fix(core): restore v2 model prompts (#34275)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-28 14:47:20 -05:00
Dax 42a3cf9645 fix(core): commit staged revert before admitting new prompt (#34338) 2026-06-28 14:50:06 -04:00
Kit Langton 04c6bed240 feat(core): restore per-request system prompt (#34335) 2026-06-28 14:02:46 -04:00
Kit Langton 94e3a29d2f feat(core): wire v2 subagent tool (#34320) 2026-06-28 12:52:39 -04:00
Kit Langton 41283933ff chore: merge dev into v2 (#34317)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com>
Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Ben Guthrie <benjee.012@gmail.com>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com>
2026-06-28 11:30:38 -04:00
Dax Raad 11bf8d8a42 refactor(cli): simplify updater, remove cache and confirmation
- Remove the 24h disk cache (State, readState/writeState, checkInterval)
  that served stale latest versions within the TTL window
- Remove file lock (Flock) and confirmation prompt (Prompt/Terminal)
- Every check now hits the npm registry directly
- Updates always auto-install on any version mismatch within the same
  major, including rollbacks where the registry version is lower
- Drop the interactive option from check() and the confirm/declined/
  dismissed action concepts
2026-06-27 20:52:03 -04:00
Dax Raad c22973ab9f fix(server): log undo revert operations 2026-06-27 20:32:58 -04:00
Dax Raad 612009e0f1 fix(tui): use span for inline styled hotkey in revert footer 2026-06-27 20:32:14 -04:00
Dax Raad a0a6c9545e fix(tui): style redo hotkey as normal text in revert footer 2026-06-27 20:30:52 -04:00
Dax Raad bceb6d0a9e feat(tui): add redo hotkey footer and pad file list in revert banner 2026-06-27 20:30:26 -04:00
Dax Raad 49e7dc191f fix(tui): make undo inclusive of last user message and simplify revert banner 2026-06-27 20:25:08 -04:00
Dax f4720be08e feat(tui): add session rename (#34264) 2026-06-27 20:07:23 -04:00
Dax d1d7ebc2c6 feat(tui): wire up undo/redo and revert for V2 sessions (#34263) 2026-06-28 00:04:53 +00:00
Dax Raad cd942d0669 fix(tui): normalize abbreviated home paths to forward slashes 2026-06-27 18:54:06 -04:00
Dax Raad 4741e3ee6b fix(tui): remove redundant top spacer in session history 2026-06-27 13:54:16 -04:00
Dax Raad 4673bfbaa0 feat(cli): log startup info
Emit a log entry on CLI startup with version, channel, and local flag
so every CLI process is visible in opencode.log from the start.
2026-06-27 13:45:42 -04:00
Dax Raad b458dd8c63 feat(cli): log updater check decisions
Add Effect.logInfo at every previously-silent path in the updater
check so each run is observable in opencode.log: skip reasons
(local install, disabled, policy), the resolved version with source
(cached vs registry), and the final action (up-to-date, dismissed,
declined).
2026-06-27 13:29:46 -04:00
Dax Raad 7edd6c3a1d feat(cli): use installation version and document API debugging
- Replace hardcoded "local" version string with InstallationVersion
- Document DESCRIPTION
  Make a request to the running server

USAGE
  opencode api [flags] <operation | method path...>

ARGUMENTS
  operation | method path... stringOpenAPI operation ID, or an HTTP method followed by a path

FLAGS
  --data, -d string      Request body
  --header, -H string    Request header in name:value form
  --param key=value      OpenAPI path or query parameter

GLOBAL FLAGS
  --help, -h                                                          Show help information
  --version, -v                                                       Show version information
  --completions <bash|zsh|fish|sh>                                    Print shell completion script (choices: bash, zsh, fish, sh)
  --log-level <all|trace|debug|info|warn|warning|error|fatal|none>    Sets the minimum log level (choices: all, trace, debug, info, warn, warning, error, fatal, none) server/API debugging command in AGENTS.md
2026-06-27 13:18:22 -04:00
Dax Raad f43f066741 fix(tui): stop bash spinner when tool errors before command received 2026-06-27 13:17:21 -04:00
Dax Raad b6553d14e1 fix(core): fail interrupted session steps 2026-06-27 01:16:30 -04:00
Dax Raad ab0042a666 docs(opencode): mark package as v1 2026-06-27 01:16:30 -04:00
Dax Raad 5df049d081 feat(cli): add self-update service
Check the npm registry (at most once per 24h, file-locked) for newer
@opencode-ai/cli releases and act on a host-level autoupdate policy read
from config. Apply patch updates automatically, confirm minor updates
interactively, and never auto-update across majors. Skip local installs
and honor the disable flag. Wired into the default TUI command
(interactive) and serve (background).
2026-06-27 00:51:45 -04:00
Dax Raad 573ab9c24b fix(tui): interrupt v2 sessions with escape 2026-06-26 22:32:21 -04:00
Dax Raad 6a16c41e8f fix(core): continue after tool defects 2026-06-26 22:02:30 -04:00
Dax Raad a491cbee64 fix(tui): hide prompt for running shell commands 2026-06-26 21:35:20 -04:00
Dax Raad 658cbe9caf fix(core): load OpenCode provider config asynchronously 2026-06-26 21:16:15 -04:00
Dax Raad 62b2bc39df test(ci): skip v2 end-to-end checks 2026-06-26 21:04:10 -04:00
Dax Raad beb2c52c3f fix(cli): publish scoped package 2026-06-26 21:01:10 -04:00
Dax Raad 655adbf46e fix: stabilize v2 runtime behavior 2026-06-26 20:56:36 -04:00
Dax Raad ac2a78391f fix(ci): publish v2 as next 2026-06-26 15:55:04 -04:00
Dax Raad 9a9bdaba95 test(ci): drop legacy opencode tests 2026-06-26 14:58:40 -04:00
Dax Raad 8b682c42b6 refactor(tui): use v2 client transport 2026-06-26 14:57:01 -04:00
Dax Raad ad4f1c1018 refactor(ci): use branch npm tag 2026-06-26 14:54:35 -04:00
Dax Raad a0afb63ed0 fix(ci): use v2 npm tag 2026-06-26 14:53:35 -04:00
Dax Raad df9ecb8f6a fix(cli): publish v2 package 2026-06-26 14:52:30 -04:00
Dax Raad e6f660fecf feat(tui): add v2 terminal interface 2026-06-26 14:38:25 -04:00
Test 83bee1e776 Merge remote-tracking branch 'origin/dev' into beta-sync-29948
# Conflicts:
#	packages/tui/test/keymap.test.tsx
2026-06-25 07:02:20 +00:00
opencode-agent[bot] 124714ca3a fix(tui): keep command palette available in questions 2026-05-29 23:19:01 +00:00
564 changed files with 77862 additions and 6273 deletions
+1
View File
@@ -322,6 +322,7 @@ jobs:
working-directory: packages/desktop working-directory: packages/desktop
env: env:
NODE_OPTIONS: --max-old-space-size=4096 NODE_OPTIONS: --max-old-space-size=4096
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }} SENTRY_ORG: ${{ vars.SENTRY_ORG }}
@@ -0,0 +1,63 @@
---
name: rtl-aware-development
description: OpenCode Desktop should be RTL-aware. Use when implementing or reviewing RTL/LTR behavior in the web app, desktop app, CSS, menus, scrolling, resizing, icons, mixed-direction text, or Electron title bars.
---
# RTL-Aware Development
Treat direction as independent from language. Test English in both directions as well as real RTL and mixed-script content.
## Guidelines
- Set `lang` and `dir` on the document, and propagate direction through component providers used by portaled menus and popovers. Do not change the selected locale merely to force RTL.
- Keep DOM and focus order semantic. Flexbox and Grid already follow `dir`; do not add `row-reverse`, CSS `order`, or reversed markup just to mirror a layout.
- Prefer logical CSS for semantic layout. Reserve physical coordinates for pointer positions, canvas geometry, native window controls, and other genuinely physical placement.
```css
/* Avoid */
padding-left: 12px;
right: 0;
border-right: 1px solid;
text-align: left;
/* Prefer */
padding-inline-start: 12px;
inset-inline-end: 0;
border-inline-end: 1px solid;
text-align: start;
```
- Isolate mixed-direction text. Use `dir="auto"` or `<bdi>` for unknown text; keep code, URLs, IDs, and filesystem paths LTR without forcing the surrounding component LTR.
```html
<span class="file-row"><bdi dir="auto">README.md</bdi></span> <bdi dir="ltr"><code>C:\src\app.ts</code></bdi>
```
- Mirror directional meaning, not every image. Back/forward, previous/next, disclosure, indentation, and directional progress may need mirroring. Do not mirror brands, clocks, media controls, charts, or text. Reverse physical gradients, `translateX`, SVG transforms, and animation deltas explicitly.
- Map interactions through direction. `clientX` remains physical; resizing a logical edge needs an RTL-aware delta. Logical previous/next keyboard controls may swap ArrowLeft/ArrowRight. Follow the relevant WAI-ARIA widget pattern.
- Do not assume LTR scrolling. RTL `scrollLeft` can start at `0` and become negative. Prefer `scrollIntoView({ inline: "nearest" })` or a tested direction-normalizing helper.
- For Electron title bars, prefer native caption controls and use `titleBarOverlay` plus `env(titlebar-area-*)` for the safe content rectangle. Keep Windows/macOS native-control avoidance and `trafficLightPosition` physical; keep app navigation inside that rectangle logical. Mark interactive titlebar children `app-region: no-drag`.
- Verify behavior, not screenshots alone. Check computed styles, pseudo-element geometry, hit zones, focus order, keyboard behavior, submenu direction, zoom/scaling, and both LTR and RTL scroll endpoints.
## Test Matrix
- English + LTR
- English + forced RTL
- A real RTL locale + RTL
- Mixed RTL/LTR content, long labels, numbers, code, and paths
- Keyboard, pointer resize, scrolling, menus/submenus, and Electron titlebar controls in both directions
## References
- [RTL Styling 101, Ahmad Shadeed](https://rtlstyling.com/posts/rtl-styling/)
- [CSS-Tricks: RTL Styling 101](https://css-tricks.com/rtl-styling-101/)
- [CSS-Tricks: CSS Logical Properties and Values](https://css-tricks.com/css-logical-properties-and-values/)
- [W3C: Structural markup and right-to-left text](https://www.w3.org/International/questions/qa-html-dir)
- [W3C: Inline bidirectional markup](https://www.w3.org/International/articles/inline-bidi-markup/)
- [MDN: CSS logical properties and values](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Logical_properties_and_values)
- [MDN: `dir`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir)
- [MDN: `scrollLeft`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft)
- [web.dev: Logical properties](https://web.dev/learn/css/logical-properties/)
- [Electron: Custom title bar](https://www.electronjs.org/docs/latest/tutorial/custom-title-bar)
- [WAI-ARIA: Window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
- [Kobalte: I18n Provider](https://kobalte.dev/docs/core/components/i18n-provider/)
+617 -793
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-a7NyYa9vRUEqDfZNDPXXmFO58RDEgioyuGSl5CPBvxo=", "x86_64-linux": "sha256-uqMoKsqjOm6h/Nj/Ffu68zk96WZuW0AbmP71oyyqCFE=",
"aarch64-linux": "sha256-l4OJtSEllHvRhktjcaJYwkXBSaJvsIoyoLusbZfYMcM=", "aarch64-linux": "sha256-E0YzQ7cBgSjFVYtf0tI9vxC2n/Baa8qk556LIS/MsW4=",
"aarch64-darwin": "sha256-IIl0BQGs1/HLFh0auiQjiwfSQ2nfHcK2G2BAphYW59c=", "aarch64-darwin": "sha256-zBYiBdFHsf1Q0ioD7EDUfTSM2ERiatiHwhSx/nkpRmc=",
"x86_64-darwin": "sha256-vVeuPyd4ZIRYrHouphTuEb4rkRZLKKTAHe840jNh9rU=" "x86_64-darwin": "sha256-b2cvQ1ao3tQDhhlJoNLtj4GqylgzSLtq6l43TdYsd14="
} }
} }
+3 -2
View File
@@ -70,7 +70,7 @@
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"luxon": "3.6.1", "luxon": "3.6.1",
"marked": "17.0.6", "marked": "18.0.7",
"marked-shiki": "1.2.1", "marked-shiki": "1.2.1",
"remend": "1.3.0", "remend": "1.3.0",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
@@ -158,6 +158,7 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch"
} }
} }
+17
View File
@@ -19,6 +19,23 @@
- Always prefer `createStore` over multiple `createSignal` calls - Always prefer `createStore` over multiple `createSignal` calls
## Localization
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
- Render count-sensitive copy only through `language.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `language.t(...)`.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
- Prefer complete translated phrases. Do not concatenate grammatical fragments or make call sites assemble sentences. Keep placeholders to irreducible dynamic values such as names, paths, and counts.
- If a translation cannot be expressed by the current API, deepen the shared language/UI i18n module so one typed call owns locale selection, plural resolution, fallback, and interpolation. Do not leak that machinery into product code.
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
- For developer-facing terminology, prefer the words already used by the target language's developer community over literal dictionary translations. Cross-check maintained localized developer products such as Firefox, KDE, and VS Code; use at least two independent corpora when they are available. If established practice keeps an English loanword or acronym, keep it rather than inventing a translation.
- Translate complete UI phrases in context. A glossary hit is evidence, not permission to translate word-by-word. Check terse labels such as session, prompt, agent, model, fork, shell, terminal, workspace, and worktree in the same grammatical role before choosing a term.
- Before a locale is ready, audit recurring concepts for one consistent translation and review every value that still equals English. Classify retained English as a product name, provider/tool name, URL, code token, keyboard legend, acronym, asset name, or established borrowing; translate unexplained leftovers.
- In translation review notes, name the corpora used and call out uncertain or region-specific terminology so native speakers can focus review where it matters.
- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels.
## Tool Calling ## Tool Calling
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
+16
View File
@@ -0,0 +1,16 @@
## Required Reading
- Before writing, changing, or reviewing E2E tests, ALWAYS read and follow Playwright's official [Best Practices](https://playwright.dev/docs/best-practices), [Auto-waiting](https://playwright.dev/docs/actionability), and [Assertions](https://playwright.dev/docs/test-assertions) guides.
- Use the official [Locators](https://playwright.dev/docs/locators), [Network](https://playwright.dev/docs/network), and [Test Isolation](https://playwright.dev/docs/browser-contexts) guides when those concerns apply.
## Test Hygiene
- Test user-visible behavior with isolated, deterministic data and scoped, unique locators.
- Prefer role, label, text, and explicit test-contract locators. Do not use `.first()` or `.last()` merely to silence strictness errors.
- Use locator actions, Playwright auto-waiting, and web-first assertions for observable readiness and outcomes.
- NEVER use `waitForTimeout`, `setTimeout`, sleeps, animation-frame counts, or other wall-clock delays to synchronize a test. Wait for the specific UI state, request, response, event, or application outcome instead.
- Do not treat navigation, a network response, DOM attachment, or visibility alone as proof that asynchronously rendered UI is ready. Assert the state the next action actually requires.
- Register event and network waits before the action that triggers them.
- Do not retry state-changing actions. Retry idempotent readiness checks, then perform the action once and assert its outcome.
- Keep action and assertion timeouts adaptive. Do not use short timeouts as readiness probes or rely on retries to hide flakes.
- Assert exact outcomes and identities so stale state, duplicate rendering, and interactions with the wrong element cannot pass.
+26
View File
@@ -56,6 +56,32 @@ Benchmarks do not assert machine-dependent performance budgets. Streaming proces
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing. Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
## Desktop profiler
The desktop profiler launches the existing production build directly, creates isolated desktop state, chooses an available CDP port, and writes reports under the OS temporary directory by default.
```sh
bun run profile:desktop --help
```
Create a private partial snapshot from the default local database and run Home once:
```sh
bun run profile:desktop --partial-snapshot-out /tmp/opencode-perf.db \
--window-end 2026-08-04T06:14:26.878Z \
--scenarios home,calibration --skip-build
```
Repeat against the immutable partial snapshot:
```sh
bun run profile:desktop --mode partial-snapshot --db /tmp/opencode-perf.db \
--window-end 2026-08-04T06:14:26.878Z \
--scenarios home,calibration --runs 3 --skip-build
```
Partial snapshots contain private application data and must not be committed or shared. The profiler copies each partial snapshot to a per-run working database and remaps selected project paths to temporary workspaces, leaving the source snapshot unchanged. `PROFILE_SUMMARY` is the compact comparison output; `PROFILE_REPORT` points to the complete JSON report with the database hash, invocation parameters, raw runs, and attribution data.
## Chrome traces ## Chrome traces
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically: Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
@@ -0,0 +1,137 @@
import { Database } from "bun:sqlite"
import { mkdir, rm } from "node:fs/promises"
import path from "node:path"
import { progress } from "./progress"
import type { Options, Target } from "./types"
export async function createPartialSnapshot(source: string, destination: string, options: Options, targets: Target[]) {
await mkdir(path.dirname(destination), { recursive: true })
await rm(destination, { force: true })
const input = new Database(source, { readonly: true })
const schema = input
.query(
`SELECT type, name, sql FROM sqlite_schema
WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'
ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END, name`,
)
.all() as { type: string; name: string; sql: string }[]
input.close()
const output = new Database(destination, { create: true })
output.run("PRAGMA foreign_keys = OFF")
schema.filter((item) => item.type === "table").forEach((item) => output.run(item.sql))
output.run("ATTACH DATABASE ? AS source", source)
const selected = [...new Set(targets.map((target) => target.id))]
const placeholders = selected.map(() => "?").join(",")
for (const table of schema.filter((item) => item.type === "table").map((item) => item.name)) {
progress("copying partial snapshot table", { table })
if (table === "event") continue
if (table === "message") {
output.run(
`INSERT INTO main.message SELECT * FROM source.message
WHERE (time_created >= ? AND time_created < ? AND session_id IN (
SELECT id FROM source.session WHERE parent_id IS NULL
)) OR session_id IN (${placeholders})`,
options.windowStart,
options.windowEnd,
...selected,
)
continue
}
if (table === "part") {
output.run("INSERT INTO main.part SELECT * FROM source.part WHERE message_id IN (SELECT id FROM main.message)")
continue
}
if (["session_context_epoch", "session_input", "session_message", "session_share", "todo"].includes(table)) {
output.run(
`INSERT INTO main."${table}" SELECT * FROM source."${table}" WHERE session_id IN (${placeholders})`,
...selected,
)
continue
}
output.run(`INSERT INTO main."${table}" SELECT * FROM source."${table}"`)
}
output.run("DETACH DATABASE source")
schema.filter((item) => item.type !== "table").forEach((item) => output.run(item.sql))
output.close()
}
export async function fingerprint(file: string) {
const input = Bun.file(file)
const hasher = new Bun.CryptoHasher("sha256")
for await (const chunk of input.stream()) hasher.update(chunk)
return { bytes: input.size, sha256: hasher.digest("hex") }
}
export function loadCorpus(options: Options) {
const database = new Database(options.database, { readonly: true })
database.run("PRAGMA query_only = ON")
const sessions = database
.query(
`SELECT id, project_id AS projectID, directory, title
FROM session AS candidate
WHERE parent_id IS NULL
AND EXISTS (
SELECT 1 FROM message
WHERE session_id = candidate.id AND time_created >= ? AND time_created < ?
)`,
)
.all(options.windowStart, options.windowEnd) as { id: string; projectID: string; directory: string; title: string }[]
const messageRows = database.query(
`SELECT id, data FROM message
WHERE session_id = ? AND time_created >= ? AND time_created < ?
ORDER BY time_created, id`,
)
const partRows = database.query(`SELECT data FROM part WHERE message_id = ? ORDER BY id`)
const ranked = sessions
.map((session) => {
const messages = messageRows.all(session.id, options.windowStart, options.windowEnd) as {
id: string
data: string
}[]
const parts = messages.flatMap((message) => partRows.all(message.id) as { data: string }[])
return {
...session,
bytes:
messages.reduce((sum, message) => sum + Buffer.byteLength(message.data), 0) +
parts.reduce((sum, part) => sum + Buffer.byteLength(part.data), 0),
messages: messages.length,
parts: parts.length,
userTurns: messages.filter((message) => JSON.parse(message.data).role === "user").length,
}
})
.filter((session) => session.messages > 0)
.sort((a, b) => a.bytes - b.bytes || a.id.localeCompare(b.id))
if (ranked.length === 0) throw new Error("No sessions found in the profile window")
const select = (label: Target["label"], percentile: number) => ({
label,
...ranked[Math.max(0, Math.ceil(ranked.length * percentile) - 1)]!,
})
const targets = [select("p50", 0.5), select("p95", 0.95), select("max", 1)] satisfies Target[]
const typingText = loadTypingText(database, partRows, messageRows, targets[2]!, options)
const projectIDs = [...new Set(ranked.map((session) => session.projectID))]
database.close()
return { targets, typingText, projectIDs }
}
function loadTypingText(
database: Database,
partRows: ReturnType<Database["query"]>,
messageRows: ReturnType<Database["query"]>,
target: Target,
options: Options,
) {
const messages = messageRows.all(target.id, options.windowStart, options.windowEnd) as { id: string; data: string }[]
const text = messages
.filter((message) => JSON.parse(message.data).role === "user")
.flatMap((message) =>
(partRows.all(message.id) as { data: string }[]).flatMap((part) => {
const data = JSON.parse(part.data)
return data.type === "text" && typeof data.text === "string" ? [data.text] : []
}),
)
.sort((a, b) => b.length - a.length)[0]
if (!text) throw new Error("No real user prompt found for composer profiling")
return text
}
@@ -0,0 +1,116 @@
import { Database } from "bun:sqlite"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import type { Options } from "./types"
export async function prepareDesktopState(
options: Options,
databasePath: string,
userData: string,
run: number,
projectIDs: string[],
) {
const database = new Database(databasePath)
const projects = database.query("SELECT id, worktree, sandboxes FROM project ORDER BY id").all() as {
id: string
worktree: string
sandboxes: string
}[]
const selected = new Set(projectIDs)
const profileProjects = projects.filter((project) => selected.has(project.id))
const worktrees =
options.mode === "partial-snapshot"
? await remapDirectories(database, profileProjects, path.join(options.output, "workspaces", String(run)))
: profileProjects.map((project) => project.worktree)
database.close()
await Bun.write(
path.join(userData, "opencode.settings"),
JSON.stringify({ firstLaunchOnboardingComplete: true, oldLayoutEligible: true, tauriMigrated: true }),
)
await Bun.write(
path.join(userData, "opencode.global.dat"),
JSON.stringify({
server: JSON.stringify({
list: [],
projects: { local: worktrees.map((worktree) => ({ worktree, expanded: true })) },
lastProject: worktrees[0] ? { local: worktrees[0] } : {},
recentlyClosed: {},
}),
}),
)
}
async function remapDirectories(
database: Database,
projects: { id: string; worktree: string; sandboxes: string }[],
root: string,
) {
await mkdir(root, { recursive: true })
const mappings = new Map<string, string>()
const worktrees = await Promise.all(
projects.map(async (project, index) => {
const worktree = path.join(root, `project-${String(index + 1).padStart(3, "0")}`)
await mkdir(worktree, { recursive: true })
mappings.set(project.worktree, worktree)
const sandboxes = JSON.parse(project.sandboxes) as string[]
const nextSandboxes = await Promise.all(
sandboxes.map(async (sandbox, sandboxIndex) => {
const next = path.join(worktree, `sandbox-${sandboxIndex + 1}`)
await mkdir(next, { recursive: true })
mappings.set(sandbox, next)
return next
}),
)
database.run("UPDATE project SET worktree = ?, sandboxes = ? WHERE id = ?", worktree, JSON.stringify(nextSandboxes), project.id)
return worktree
}),
)
const byProject = new Map(projects.map((project, index) => [project.id, worktrees[index]!]))
const sessions = database.query("SELECT id, project_id, directory FROM session").all() as {
id: string
project_id: string
directory: string
}[]
const directories = database.query("SELECT * FROM project_directory").all() as {
project_id: string
directory: string
type: string | null
strategy: string | null
time_created: number
}[]
const selected = new Set(projects.map((project) => project.id))
const nextDirectories = await Promise.all(
directories.filter((item) => selected.has(item.project_id)).map(async (item, index) => {
const directory =
mappings.get(item.directory) ?? path.join(byProject.get(item.project_id) ?? root, `directory-${index + 1}`)
await mkdir(directory, { recursive: true })
return { ...item, directory }
}),
)
database.transaction(() => {
sessions.filter((session) => selected.has(session.project_id)).forEach((session) =>
database.run(
"UPDATE session SET directory = ? WHERE id = ?",
mappings.get(session.directory) ?? byProject.get(session.project_id) ?? worktrees[0]!,
session.id,
),
)
database.run(
`DELETE FROM project_directory WHERE project_id IN (${projects.map(() => "?").join(",")})`,
...projects.map((project) => project.id),
)
nextDirectories.forEach((item) =>
database.run(
`INSERT INTO project_directory (project_id, directory, type, strategy, time_created)
VALUES (?, ?, ?, ?, ?)`,
item.project_id,
item.directory,
item.type,
item.strategy,
item.time_created,
),
)
})()
return worktrees
}
@@ -0,0 +1,54 @@
import { Database } from "bun:sqlite"
import { afterAll, expect, test } from "bun:test"
import { mkdir, rm } from "node:fs/promises"
import path from "node:path"
import { createPartialSnapshot, fingerprint } from "./corpus"
import { parseOptions } from "./options"
const directory = path.join(import.meta.dir, `.tmp-${process.pid}`)
const source = path.join(directory, "source.db")
const partialSnapshot = path.join(directory, "partial-snapshot.db")
await mkdir(directory, { recursive: true })
const database = new Database(source, { create: true })
database.run("CREATE TABLE sample (value TEXT NOT NULL)")
database.run("INSERT INTO sample VALUES ('repeatable')")
database.close()
afterAll(() => rm(directory, { recursive: true, force: true }))
test("parses a portable fixed-window partial snapshot invocation", () => {
const options = parseOptions([
"--mode",
"partial-snapshot",
"--db",
source,
"--window-end",
"2026-08-04T06:14:26.878Z",
"--window-hours",
"24",
"--scenarios",
"home,calibration",
"--runs",
"3",
"--skip-build",
])!
expect(options.database).toBe(source)
expect(options.windowEnd).toBe(1_785_824_066_878)
expect(options.windowStart).toBe(1_785_737_666_878)
expect(options.scenarios).toEqual(["home", "calibration"])
expect(options.runs).toBe(3)
expect(options.build).toBe(false)
})
test("creates a consistent private partial database snapshot", async () => {
const options = parseOptions(["--db", source, "--window-end", "2026-08-04T06:14:26.878Z"])!
await createPartialSnapshot(source, partialSnapshot, options, [])
const copy = new Database(partialSnapshot, { readonly: true })
expect(copy.query("SELECT value FROM sample").get()).toEqual({ value: "repeatable" })
copy.close()
expect(await fingerprint(partialSnapshot)).toEqual({
bytes: expect.any(Number),
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
})
})
@@ -0,0 +1,88 @@
import { Global } from "@opencode-ai/core/global"
import { existsSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
import { scenarios, type Options, type Scenario } from "./types"
const help = `Desktop renderer profiler
Usage:
bun run profile:desktop [options]
Options:
--mode local|partial-snapshot
Local corpus or fixed partial snapshot (default: local)
--db <path> SQLite database (default: opencode data directory)
--partial-snapshot-out <path>
Copy the benchmark corpus to a private partial snapshot
--output <directory> Report directory (default: OS temp directory)
--window-end <ISO|epoch> End of corpus window (default: now; required for partial snapshot)
--window-hours <hours> Corpus window size (default: 24)
--scenarios <names> Comma list: ${scenarios.join(",")} (default: all)
--runs <count> Restart Electron and repeat (default: 1)
--skip-build Use the existing desktop production build
--diagnostics Capture Chrome traces
--cpu Capture sampled CPU summaries
--response-urls Attribute Response.text durations by URL
--help Show this message
Partial snapshots contain private application data. Do not commit or share them.
`
export function parseOptions(args: string[], now = Date.now()): Options | undefined {
if (args.includes("--help")) {
console.log(help)
return
}
const value = (name: string) => {
const index = args.indexOf(name)
if (index === -1) return
const result = args[index + 1]
if (!result || result.startsWith("--")) throw new Error(`${name} requires a value`)
return result
}
const mode = value("--mode") ?? "local"
if (mode !== "local" && mode !== "partial-snapshot") throw new Error(`Unsupported mode: ${mode}`)
const endValue = value("--window-end")
if (mode === "partial-snapshot" && !endValue)
throw new Error("--window-end is required in partial-snapshot mode")
const windowEnd = endValue ? parseTime(endValue) : now
const windowHours = number(value("--window-hours") ?? "24", "--window-hours")
const selected = (value("--scenarios")?.split(",") ?? [...scenarios]).map((item) => item.trim())
if (selected.some((item) => !scenarios.includes(item as Scenario)))
throw new Error(`--scenarios must contain only: ${scenarios.join(", ")}`)
const database = path.resolve(value("--db") ?? path.join(Global.Path.data, "opencode.db"))
if (!existsSync(database)) throw new Error(`Database does not exist: ${database}`)
return {
mode,
database,
output: path.resolve(
value("--output") ?? path.join(tmpdir(), "opencode-performance", new Date(windowEnd).toISOString().replace(/[:.]/g, "-")),
),
windowStart: windowEnd - windowHours * 60 * 60 * 1_000,
windowEnd,
scenarios: selected as Scenario[],
runs: number(value("--runs") ?? "1", "--runs"),
build: !args.includes("--skip-build"),
diagnostics: args.includes("--diagnostics"),
cpu: args.includes("--cpu"),
responseURLs: args.includes("--response-urls"),
partialSnapshotOut: value("--partial-snapshot-out")
? path.resolve(value("--partial-snapshot-out")!)
: undefined,
}
}
function parseTime(value: string) {
const result = /^\d+$/.test(value) ? Number(value) : Date.parse(value)
if (!Number.isFinite(result)) throw new Error(`Invalid --window-end: ${value}`)
return result
}
function number(value: string, option: string) {
const result = Number(value)
if (!Number.isFinite(result) || result <= 0) throw new Error(`${option} must be greater than zero`)
return result
}
@@ -0,0 +1,192 @@
import type { Page } from "@playwright/test"
import type { Options, ProbeResult } from "./types"
export async function installProbe(page: Page, options: Options) {
await page.addInitScript((attributeResponses) => {
const state = {
longTasks: [] as number[],
animationFrames: [] as ProbeResult["animationFrames"],
frameGaps: [] as number[],
responseText: [] as ProbeResult["responseText"],
}
;(window as Window & { __opencodeRendererProfile?: typeof state }).__opencodeRendererProfile = state
if (PerformanceObserver.supportedEntryTypes.includes("longtask")) {
new PerformanceObserver((list) =>
state.longTasks.push(...list.getEntries().map((entry) => entry.duration)),
).observe({ type: "longtask" })
}
if (PerformanceObserver.supportedEntryTypes.includes("long-animation-frame")) {
new PerformanceObserver((list) =>
state.animationFrames.push(
...list.getEntries().map((entry) => {
const frame = entry as PerformanceEntry & {
blockingDuration: number
scripts?: {
duration: number
forcedStyleAndLayoutDuration?: number
sourceFunctionName?: string
sourceURL?: string
sourceCharPosition?: number
invoker?: string
invokerType?: string
}[]
}
return {
duration: frame.duration,
blockingDuration: frame.blockingDuration,
forcedStyleAndLayoutDuration:
frame.scripts?.reduce((sum, script) => sum + (script.forcedStyleAndLayoutDuration ?? 0), 0) ?? 0,
scripts:
frame.scripts?.map((script) => ({
function: script.sourceFunctionName || "(anonymous)",
source: script.sourceURL?.split("/").at(-1) || "(document)",
position: script.sourceCharPosition ?? -1,
invoker: script.invoker ?? "(unknown)",
invokerType: script.invokerType ?? "(unknown)",
duration: script.duration,
forcedStyleAndLayoutDuration: script.forcedStyleAndLayoutDuration ?? 0,
})) ?? [],
}
}),
),
).observe({ type: "long-animation-frame" })
}
let previous = performance.now()
const frame = (now: number) => {
const gap = now - previous
if (gap > 20) state.frameGaps.push(gap)
previous = now
requestAnimationFrame(frame)
}
requestAnimationFrame(frame)
if (!attributeResponses) return
const responseText = Response.prototype.text
Response.prototype.text = function () {
const started = performance.now()
const url = this.url
return responseText.call(this).then((text) => {
state.responseText.push({ url, duration: performance.now() - started })
return text
})
}
}, options.responseURLs)
}
export async function resetProbe(page: Page) {
await page.evaluate(() => {
const state = (window as Window & { __opencodeRendererProfile?: ProbeResult }).__opencodeRendererProfile
if (!state) return
state.longTasks.length = 0
state.animationFrames.length = 0
state.frameGaps.length = 0
state.responseText.length = 0
})
}
export async function collectProbe(page: Page) {
return page.evaluate(
() => (window as Window & { __opencodeRendererProfile?: ProbeResult }).__opencodeRendererProfile!,
)
}
export function summarizeProbe(probe: ProbeResult) {
const scripts = new Map<
string,
{
function: string
source: string
position: number
invoker: string
invokerType: string
durationMs: number
forcedStyleAndLayoutMs: number
}
>()
probe.animationFrames
.flatMap((frame) => frame.scripts)
.forEach((script) => {
const key = `${script.source}:${script.position}:${script.invoker}`
const current = scripts.get(key) ?? {
function: script.function,
source: script.source,
position: script.position,
invoker: script.invoker,
invokerType: script.invokerType,
durationMs: 0,
forcedStyleAndLayoutMs: 0,
}
current.durationMs += script.duration
current.forcedStyleAndLayoutMs += script.forcedStyleAndLayoutDuration
scripts.set(key, current)
})
return {
longTasks: {
count: probe.longTasks.length,
totalMs: sum(probe.longTasks),
maxMs: Math.max(0, ...probe.longTasks),
},
longAnimationFrames: {
count: probe.animationFrames.length,
totalBlockingMs: sum(probe.animationFrames.map((frame) => frame.blockingDuration)),
maxDurationMs: Math.max(0, ...probe.animationFrames.map((frame) => frame.duration)),
forcedStyleAndLayoutMs: sum(probe.animationFrames.map((frame) => frame.forcedStyleAndLayoutDuration)),
scripts: [...scripts.values()].sort((a, b) => b.durationMs - a.durationMs).slice(0, 15),
},
frameGaps: {
count: probe.frameGaps.length,
maxMs: Math.max(0, ...probe.frameGaps),
},
responseText: probe.responseText
.map((item) => ({ path: responsePath(item.url), durationMs: item.duration }))
.sort((a, b) => b.durationMs - a.durationMs),
}
}
export async function startCPUProfile(page: Page, enabled: boolean) {
if (!enabled) return { stop: async () => [] }
const session = await page.context().newCDPSession(page)
await session.send("Profiler.enable")
await session.send("Profiler.setSamplingInterval", { interval: 1_000 })
await session.send("Profiler.start")
return {
async stop() {
const result = await session.send("Profiler.stop")
await session.detach()
const self = new Map<number, number>()
result.profile.samples?.forEach((id, index) => {
self.set(id, (self.get(id) ?? 0) + (result.profile.timeDeltas?.[index] ?? 0) / 1_000)
})
return result.profile.nodes
.map((node) => ({
function: node.callFrame.functionName || "(anonymous)",
source: sourceName(node.callFrame.url),
line: node.callFrame.lineNumber + 1,
selfMs: self.get(node.id) ?? 0,
}))
.filter((node) => node.selfMs >= 1)
.sort((a, b) => b.selfMs - a.selfMs)
.slice(0, 40)
},
}
}
function responsePath(value: string) {
try {
return new URL(value).pathname
} catch {
return value
}
}
function sourceName(value: string) {
if (!value) return "(native)"
try {
return new URL(value).pathname.split("/").at(-1) || "(document)"
} catch {
return value.split(/[\\/]/).at(-1) || value
}
}
function sum(values: number[]) {
return values.reduce((total, value) => total + value, 0)
}
@@ -0,0 +1,7 @@
const started = performance.now()
export function progress(message: string, details?: Record<string, unknown>) {
const elapsed = ((performance.now() - started) / 1_000).toFixed(1)
const suffix = details ? ` ${JSON.stringify(details)}` : ""
console.error(`[desktop-profile +${elapsed}s] ${message}${suffix}`)
}
@@ -0,0 +1,156 @@
import { chromium, type Page } from "@playwright/test"
import { copyFile, mkdir, rm } from "node:fs/promises"
import path from "node:path"
import { prepareDesktopState } from "./desktop-state"
import { progress } from "./progress"
import type { Options } from "./types"
export async function withDesktop<T>(
options: Options,
desktop: string,
run: number,
projectIDs: string[],
use: (page: Page) => Promise<T>,
) {
const port = availablePort()
const endpoint = `http://127.0.0.1:${port}`
const userData = path.join(options.output, `user-data-${run}`)
const database =
options.mode === "partial-snapshot" ? path.join(options.output, `working-database-${run}.db`) : options.database
await rm(userData, { recursive: true, force: true })
await mkdir(userData, { recursive: true })
if (database !== options.database) await copyFile(options.database, database)
await prepareDesktopState(options, database, userData, run, projectIDs)
const electron = path.join(
desktop,
"node_modules",
"electron",
"dist",
(await Bun.file(path.join(desktop, "node_modules", "electron", "path.txt")).text()).trim(),
)
progress("launching Electron", { run, port })
const child = Bun.spawn([electron, "."], {
cwd: desktop,
env: {
...process.env,
OPENCODE_DB: database,
OPENCODE_CHANNEL: "dev",
OPENCODE_PROFILE_LOAF: "1",
OPENCODE_PROFILE_CDP_PORT: String(port),
OPENCODE_PROFILE_USER_DATA: userData,
OPENCODE_PERFORMANCE_TRACE_DIR: options.diagnostics ? path.join(options.output, "traces", String(run)) : "",
OPENCODE_PERFORMANCE_RUN_ID: `desktop-${run}`,
},
stdout: "pipe",
stderr: "pipe",
})
const stdout = drain(child.stdout, "stdout")
const stderr = drain(child.stderr, "stderr")
let browser: Awaited<ReturnType<typeof chromium.connectOverCDP>> | undefined
try {
progress("waiting for CDP", { run })
await waitForCDP(endpoint, child, stdout, stderr)
progress("connecting Playwright", { run })
browser = await chromium.connectOverCDP(endpoint)
progress("waiting for renderer", { run })
const page = await waitForRenderer(browser)
progress("waiting for desktop API", { run })
await page.waitForFunction(() => typeof window.api === "object", undefined, { timeout: 60_000 })
progress("desktop ready", { run })
return await use(page)
} finally {
progress("stopping Electron", { run })
await browser?.close().catch(() => {})
await killTree(child.pid)
await Promise.allSettled([stdout, stderr])
if (database !== options.database) {
await Bun.sleep(500)
await rm(database, { force: true }).catch(() => undefined)
}
}
}
export async function run(command: string[], cwd: string, database: string) {
const child = Bun.spawn(command, {
cwd,
env: { ...process.env, OPENCODE_DB: database, OPENCODE_CHANNEL: "dev" },
stdout: "inherit",
stderr: "inherit",
})
const code = await child.exited
if (code !== 0) throw new Error(`${command.join(" ")} exited with ${code}`)
}
function availablePort() {
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
const port = server.port
server.stop(true)
return port
}
async function waitForCDP(
endpoint: string,
child: ReturnType<typeof Bun.spawn>,
stdout: Promise<string>,
stderr: Promise<string>,
) {
const timeout = Date.now() + 5 * 60_000
let heartbeat = Date.now() + 10_000
while (Date.now() < timeout) {
const ready = await fetch(`${endpoint}/json/version`)
.then((response) => response.ok)
.catch(() => false)
if (ready) return
if (child.exitCode !== null)
throw new Error(`Desktop exited before CDP was ready (${child.exitCode})\n${await stdout}\n${await stderr}`)
if (Date.now() >= heartbeat) {
progress("still waiting for CDP")
heartbeat = Date.now() + 10_000
}
await Bun.sleep(250)
}
throw new Error("Timed out waiting for desktop CDP")
}
async function waitForRenderer(browser: Awaited<ReturnType<typeof chromium.connectOverCDP>>) {
const timeout = Date.now() + 60_000
let heartbeat = Date.now() + 10_000
while (Date.now() < timeout) {
const page = browser
.contexts()
.flatMap((context) => context.pages())
.find((candidate) => candidate.url().startsWith("oc://renderer"))
if (page) return page
if (Date.now() >= heartbeat) {
progress("still waiting for renderer")
heartbeat = Date.now() + 10_000
}
await Bun.sleep(100)
}
throw new Error("Desktop renderer target was not found")
}
async function drain(stream: ReadableStream<Uint8Array>, label: string) {
const decoder = new TextDecoder()
let output = ""
let pending = ""
for await (const chunk of stream) {
const text = decoder.decode(chunk, { stream: true })
output = (output + text).slice(-50_000)
const lines = (pending + text).split(/\r?\n/)
pending = lines.pop() ?? ""
lines.filter(Boolean).forEach((line) => progress(`Electron ${label}`, { line: line.slice(0, 500) }))
}
if (pending) progress(`Electron ${label}`, { line: pending.slice(0, 500) })
return output + decoder.decode()
}
async function killTree(pid: number) {
if (process.platform !== "win32") {
process.kill(pid, "SIGTERM")
return
}
const child = Bun.spawn(["taskkill", "/pid", String(pid), "/T", "/F"], { stdout: "ignore", stderr: "ignore" })
await child.exited
}
@@ -0,0 +1,75 @@
import type { Page } from "@playwright/test"
import { progress } from "./progress"
export async function setDesktopRoute(page: Page, route: string) {
await page.evaluate(async (value) => {
const api = window.api as typeof window.api & { getWindowID?: () => Promise<string> }
const id = (await api.getWindowID?.()) ?? "browser"
localStorage.setItem(`opencode.desktop.window.${id}.last-active-url`, value)
}, route)
}
export async function waitForQuietDOM(page: Page) {
progress("waiting for DOM to settle")
await page.evaluate(
() =>
new Promise<void>((resolve) => {
let settled = false
let timer = setTimeout(done, 750)
const deadline = setTimeout(done, 30_000)
const observer = new MutationObserver(() => {
clearTimeout(timer)
timer = setTimeout(done, 750)
})
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
function done() {
if (settled) return
settled = true
clearTimeout(deadline)
observer.disconnect()
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
}
}),
)
progress("DOM settled")
}
export async function waitForSelector(page: Page, selector: string, label: string) {
progress("waiting for UI", { label })
try {
await page.waitForSelector(selector, { timeout: 30_000 })
} catch (error) {
progress("UI wait failed", {
label,
url: page.url(),
body: (await page.locator("body").innerText().catch(() => "")).replace(/\s+/g, " ").slice(0, 500),
})
throw error
}
progress("UI ready", { label })
}
export async function domCounts(page: Page, review = false) {
return page.evaluate((review) => ({
elements: document.getElementsByTagName("*").length,
...(review
? {
diffViewers: document.querySelectorAll('[data-component="file"][data-mode="diff"]').length,
diffLines: document.querySelectorAll("[data-line]").length,
}
: {
timelineRows: document.querySelectorAll("[data-timeline-row]").length,
messageRows: document.querySelectorAll("[data-message-id]").length,
markdownRoots: document.querySelectorAll('[data-component="markdown"]').length,
diffViewers: document.querySelectorAll('[data-component="file"][data-mode="diff"]').length,
}),
}), review)
}
export function sum(values: number[]) {
return values.reduce((total, value) => total + value, 0)
}
export function percentile(values: number[], quantile: number) {
return values.toSorted((a, b) => a - b)[Math.max(0, Math.ceil(values.length * quantile) - 1)] ?? 0
}
@@ -0,0 +1,178 @@
import type { Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { startChromeTrace } from "../chrome-trace"
import { collectProbe, resetProbe, startCPUProfile, summarizeProbe } from "./probe"
import { progress } from "./progress"
import { domCounts, percentile, setDesktopRoute, sum, waitForQuietDOM, waitForSelector } from "./scenario-utils"
import type { Options, Target } from "./types"
export async function runScenarios(page: Page, options: Options, targets: Target[], typingText: string) {
const results: unknown[] = []
if (options.scenarios.includes("home")) results.push(await profileHome(page, options))
if (options.scenarios.includes("calibration")) results.push(await profileCalibration(page))
if (options.scenarios.includes("session")) {
for (const target of targets) results.push(await profileSession(page, options, target))
}
if (options.scenarios.some((scenario) => ["composer", "history", "review"].includes(scenario))) {
await openSession(page, targets[2]!)
}
if (options.scenarios.includes("composer")) results.push(await profileComposer(page, options, typingText))
if (options.scenarios.includes("history")) results.push(await profileHistory(page, options, targets[2]!))
if (options.scenarios.includes("review")) {
const review = await profileReview(page, options)
if (review) results.push(review)
}
return results
}
async function profileHome(page: Page, options: Options) {
const measured = await measure(page, options, "home", async () => {
await setDesktopRoute(page, "/")
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 })
await waitForSelector(page, '[data-component="home-session-row"]', "Home session rows")
await waitForQuietDOM(page)
})
return { ...measured, dom: await domCounts(page) }
}
async function profileCalibration(page: Page) {
await resetProbe(page)
await page.evaluate(
() =>
new Promise<void>((resolve) => {
setTimeout(function opencodeProfileCalibration() {
const end = performance.now() + 80
while (performance.now() < end) {
// Deliberate benchmark-only main-thread block.
}
requestAnimationFrame(() => setTimeout(resolve, 100))
})
}),
)
return { name: "attribution-calibration", ...summarizeProbe(await collectProbe(page)) }
}
async function profileSession(page: Page, options: Options, target: Target) {
await prepareHome(page)
const measured = await measure(page, options, `session-${target.label}`, async () => {
await navigateSession(page, target)
await waitForSelector(page, '[data-component="prompt-input"]', "session composer")
await waitForQuietDOM(page)
})
return { ...measured, context: targetContext(target), dom: await domCounts(page) }
}
async function profileComposer(page: Page, options: Options, typingText: string) {
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]').first()
await editor.click()
await page.keyboard.press("Control+A")
await page.keyboard.press("Backspace")
const printable = [...typingText].filter((character) => !["\r", "\n", "\t"].includes(character))
const measuredText = printable.slice(-120).join("")
const prefix = printable.slice(0, -measuredText.length).join("")
if (prefix) await page.keyboard.insertText(prefix)
await waitForQuietDOM(page)
const durations: number[] = []
const measured = await measure(page, options, "composer-typing", async () => {
for (const character of measuredText) {
const started = performance.now()
await page.keyboard.type(character)
durations.push(performance.now() - started)
}
await waitForQuietDOM(page)
})
await page.keyboard.press("Control+A")
await page.keyboard.press("Backspace")
return {
...measured,
context: { promptCharacters: printable.length, measuredCharacters: measuredText.length },
typing: {
totalMs: sum(durations),
meanMs: sum(durations) / durations.length,
p50Ms: percentile(durations, 0.5),
p95Ms: percentile(durations, 0.95),
maxMs: Math.max(...durations),
},
}
}
async function profileHistory(page: Page, options: Options, target: Target) {
await waitForSelector(page, '[data-component="prompt-input"]', "history session composer")
await waitForQuietDOM(page)
let requests = 0
const onResponse = (response: { url(): string }) => {
if (/\/session\/[^/]+\/message(?:\?|$)/.test(response.url())) requests++
}
page.on("response", onResponse)
const measured = await measure(page, options, "session-max-history-boundary", async () => {
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }).first()
await scroller.evaluate((element) => {
element.scrollTop = 0
element.dispatchEvent(new WheelEvent("wheel", { deltaY: -10_000, bubbles: true }))
element.dispatchEvent(new Event("scroll", { bubbles: true }))
})
const timeout = Date.now() + 60_000
while (requests === 0 && Date.now() < timeout) await page.waitForTimeout(50)
if (requests === 0) throw new Error("History boundary did not request a page")
await waitForQuietDOM(page)
})
page.off("response", onResponse)
return { ...measured, context: targetContext(target), messageRequests: requests }
}
async function profileReview(page: Page, options: Options) {
const button = page.getByRole("button", { name: "Toggle review" })
if (!(await button.isVisible().catch(() => false))) return
const panel = page.locator("#review-panel")
if (await panel.isVisible().catch(() => false)) {
await button.click()
await panel.waitFor({ state: "hidden", timeout: 60_000 })
await waitForQuietDOM(page)
}
const measured = await measure(page, options, "review-open", async () => {
await button.click()
await panel.waitFor({ state: "visible", timeout: 60_000 })
await waitForQuietDOM(page)
})
return { ...measured, dom: await domCounts(page, true) }
}
async function measure(page: Page, options: Options, name: string, action: () => Promise<void>) {
progress("scenario started", { name })
await resetProbe(page)
const stopTrace = options.diagnostics ? await startChromeTrace(page, name) : undefined
const cpu = await startCPUProfile(page, options.cpu)
const started = performance.now()
await action()
const result = {
name,
elapsedMs: performance.now() - started,
...summarizeProbe(await collectProbe(page)),
cpu: await cpu.stop(),
trace: await stopTrace?.(),
}
progress("scenario completed", { name, elapsedMs: Math.round(result.elapsedMs), longTasks: result.longTasks.count })
return result
}
async function openSession(page: Page, target: Target) {
await navigateSession(page, target)
await waitForSelector(page, '[data-component="prompt-input"]', "session composer")
await waitForQuietDOM(page)
}
async function prepareHome(page: Page) {
await setDesktopRoute(page, "/")
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 })
await waitForSelector(page, '[data-component="home-session-row"]', "Home session rows")
await waitForQuietDOM(page)
}
async function navigateSession(page: Page, target: Target) {
await setDesktopRoute(page, `/server/${base64Encode("sidecar")}/session/${target.id}`)
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 })
}
function targetContext(target: Target) {
return { serializedBytes: target.bytes, messages: target.messages, parts: target.parts, userTurns: target.userTurns }
}
@@ -0,0 +1,50 @@
export const scenarios = ["home", "calibration", "session", "composer", "history", "review"] as const
export type Scenario = (typeof scenarios)[number]
export type Options = {
mode: "local" | "partial-snapshot"
database: string
output: string
windowStart: number
windowEnd: number
scenarios: Scenario[]
runs: number
build: boolean
diagnostics: boolean
cpu: boolean
responseURLs: boolean
partialSnapshotOut?: string
}
export type Target = {
label: "p50" | "p95" | "max"
id: string
projectID: string
directory: string
title: string
bytes: number
messages: number
parts: number
userTurns: number
}
export type ProbeResult = {
longTasks: number[]
animationFrames: {
duration: number
blockingDuration: number
forcedStyleAndLayoutDuration: number
scripts: {
function: string
source: string
position: number
invoker: string
invokerType: string
duration: number
forcedStyleAndLayoutDuration: number
}[]
}[]
frameGaps: number[]
responseText: { url: string; duration: number }[]
}
@@ -0,0 +1,107 @@
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { createPartialSnapshot, fingerprint, loadCorpus } from "./desktop-profile/corpus"
import { parseOptions } from "./desktop-profile/options"
import { installProbe } from "./desktop-profile/probe"
import { progress } from "./desktop-profile/progress"
import { withDesktop, run } from "./desktop-profile/runtime"
import { runScenarios } from "./desktop-profile/scenarios"
const root = path.resolve(import.meta.dir, "../../../..")
const desktop = path.join(root, "packages/desktop")
const options = parseOptions(process.argv.slice(2))
if (!options) process.exit(0)
await mkdir(options.output, { recursive: true })
progress("loading corpus", { mode: options.mode })
let corpus = loadCorpus(options)
if (options.partialSnapshotOut) {
progress("creating partial snapshot")
await createPartialSnapshot(options.database, options.partialSnapshotOut, options, corpus.targets)
options.database = options.partialSnapshotOut
options.mode = "partial-snapshot"
corpus = loadCorpus(options)
}
if (options.build) {
progress("building desktop production bundle")
await run(["bun", "run", "build"], desktop, options.database)
}
progress("corpus ready", { targets: corpus.targets.map((target) => target.label), runs: options.runs })
const runs = []
for (let index = 1; index <= options.runs; index++) {
runs.push(
await withDesktop(options, desktop, index, corpus.projectIDs, async (page) => {
await installProbe(page, options)
await page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({ ...settings, general: { ...settings.general, newLayoutDesigns: true } }),
)
})
return runScenarios(page, options, corpus.targets, corpus.typingText)
}),
)
}
const report = {
schemaVersion: 2,
source: options.mode === "partial-snapshot" ? "partial-database-snapshot" : "local-opencode-db",
command: process.argv.slice(2),
diagnostics: options.diagnostics,
profileCPU: options.cpu,
database: await fingerprint(options.database),
window: {
start: new Date(options.windowStart).toISOString(),
end: new Date(options.windowEnd).toISOString(),
},
revision: (await Bun.$`git rev-parse HEAD`.cwd(root).text()).trim(),
targets: corpus.targets.map(({ id: _, projectID: __, directory: ___, title: ____, ...target }) => target),
summary: summarize(runs),
runs: runs.map((results, index) => ({ index: index + 1, results })),
}
const file = path.join(options.output, "renderer-profile.json")
await Bun.write(file, JSON.stringify(report, null, 2))
console.log(`PROFILE_REPORT ${file}`)
console.log(`PROFILE_SUMMARY ${JSON.stringify(report.summary)}`)
console.log(JSON.stringify(report, null, 2))
function summarize(runs: unknown[][]) {
type Result = {
name: string
elapsedMs?: number
longTasks: { count: number; totalMs: number; maxMs: number }
longAnimationFrames: { totalBlockingMs: number }
typing?: { p50Ms: number; p95Ms: number; maxMs: number }
}
return Object.fromEntries(
[...Map.groupBy(runs.flat() as Result[], (result) => result.name)].map(([name, samples]) => [
name,
{
samples: samples.length,
elapsedMedianMs: median(samples.flatMap((sample) => sample.elapsedMs ?? [])),
longTasks: {
maxCount: Math.max(...samples.map((sample) => sample.longTasks.count)),
maxTotalMs: Math.max(...samples.map((sample) => sample.longTasks.totalMs)),
maxTaskMs: Math.max(...samples.map((sample) => sample.longTasks.maxMs)),
},
maxBlockingMs: Math.max(...samples.map((sample) => sample.longAnimationFrames.totalBlockingMs)),
...(samples[0]?.typing
? {
typingMedianMs: {
p50: median(samples.flatMap((sample) => sample.typing?.p50Ms ?? [])),
p95: median(samples.flatMap((sample) => sample.typing?.p95Ms ?? [])),
max: median(samples.flatMap((sample) => sample.typing?.maxMs ?? [])),
},
}
: {}),
},
]),
)
}
function median(values: number[]) {
if (values.length === 0) return
return values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)]
}
@@ -0,0 +1 @@
import "./profile-desktop"
@@ -197,7 +197,9 @@ export async function setupTimeline(
) )
}, },
async waitForPart(partID: string) { async waitForPart(partID: string) {
await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible() const part = page.locator(`[data-timeline-part-id="${partID}"]`)
await expect(part).toHaveCount(1)
await expect(part).toBeVisible()
}, },
} }
} }
@@ -18,6 +18,7 @@ test("opens the comment editor when code is clicked", async ({ page }) => {
await line.click() await line.click()
await expect(review.getByRole("textbox")).toBeVisible() await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
}) })
test("opens the comment editor when a line number is clicked", async ({ page }) => { test("opens the comment editor when a line number is clicked", async ({ page }) => {
@@ -27,6 +28,7 @@ test("opens the comment editor when a line number is clicked", async ({ page })
await lineNumber.click() await lineNumber.click()
await expect(review.getByRole("textbox")).toBeVisible() await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
}) })
test("opens the comment editor for a line number range", async ({ page }) => { test("opens the comment editor for a line number range", async ({ page }) => {
@@ -36,15 +38,10 @@ test("opens the comment editor for a line number range", async ({ page }) => {
await expectAppVisible(start) await expectAppVisible(start)
await expectAppVisible(end) await expectAppVisible(end)
const from = await start.boundingBox() await start.dragTo(end)
const to = await end.boundingBox()
if (!from || !to) throw new Error("Missing line number bounds")
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
await page.mouse.down()
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2)
await page.mouse.up()
await expect(review.getByRole("textbox")).toBeVisible() await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
}) })
test("shows a comment button when a line number is hovered", async ({ page }) => { test("shows a comment button when a line number is hovered", async ({ page }) => {
@@ -54,31 +51,38 @@ test("shows a comment button when a line number is hovered", async ({ page }) =>
const comment = review.getByRole("button", { name: "Comment", exact: true }) const comment = review.getByRole("button", { name: "Comment", exact: true })
await expect(async () => { await expect(async () => {
await page.mouse.move(0, 0)
await lineNumber.hover() await lineNumber.hover()
await expect(comment).toBeVisible({ timeout: 500 }) await expect(lineNumber).toHaveAttribute("data-hovered", "")
await comment.click({ timeout: 500 }) await expect(comment).toHaveCount(1)
}).toPass() await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.focus()
await expect(comment).toBeFocused()
}).toPass({ timeout: 10_000 })
await comment.press("Enter")
await expect(review.getByRole("textbox")).toBeVisible() await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
}) })
test("stages a submitted line comment in the prompt context", async ({ page }) => { test("stages a submitted line comment in the prompt context", async ({ page }) => {
const requests: string[] = []
page.on("request", (request) => { page.on("request", (request) => {
if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`) expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET")
}) })
const review = page.locator('[data-component="session-review"]') const review = page.locator('[data-component="session-review"]')
await review.getByText("export const value = 'after'", { exact: true }).click() await review.getByText("export const value = 'after'", { exact: true }).click()
await review.getByRole("textbox").fill("Use the existing value instead") const textbox = review.getByRole("textbox")
await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click() await expect(textbox).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
await textbox.fill("Use the existing value instead")
const submit = review.locator('[data-slot="line-comment-action"][data-variant="primary"]')
await expect(submit).toBeEnabled()
await submit.click()
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible() await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "Session" }).click() await page.getByRole("tab", { name: "Session" }).click()
const context = page.getByText("Use the existing value instead", { exact: true }).last() const context = page.getByText("Use the existing value instead", { exact: true }).last()
await expect(context).toBeVisible() await expect(context).toBeVisible()
await expect(context.locator("..")).toContainText("review.ts:2") await expect(context.locator("..")).toContainText("review.ts:2")
expect(requests).toEqual([])
}) })
async function openReview(page: Page) { async function openReview(page: Page) {
@@ -144,15 +148,22 @@ async function openReview(page: Page) {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title) await expectSessionTitle(page, title)
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff") const changes = page.getByRole("tab", { name: "Changes" })
await page.getByRole("tab", { name: "Changes" }).click() const diffResponse = page.waitForResponse(
(response) =>
response.request().method() === "GET" && response.ok() && new URL(response.url()).pathname === "/api/vcs/diff",
)
await changes.click()
expect((await (await diffResponse).json()).data).toHaveLength(1) expect((await (await diffResponse).json()).data).toHaveLength(1)
await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
const review = page.locator('[data-component="session-review"]') const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review) await expectAppVisible(review)
await review const file = review.locator('[data-file="src/review.ts"]')
.getByRole("heading", { name: /review\.ts/ }) await expectAppVisible(file)
.getByRole("button") const trigger = file.getByRole("button", { expanded: false })
.first() await expect(trigger).toHaveCount(1)
.click() await trigger.click()
await expect(file.getByRole("button", { expanded: true })).toBeVisible()
await expect(file.getByText("export const value = 'after'", { exact: true })).toBeVisible()
} }
@@ -111,7 +111,7 @@ test("restores the draft caret before typing after a request dock closes", async
}) })
await mockServer(page, { questions: [] }) await mockServer(page, { questions: [] })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection() await transport.waitForConnection({ path: "/api/event" })
await expectSessionTitle(page, title) await expectSessionTitle(page, title)
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]') const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
@@ -132,7 +132,8 @@ test("restores the draft caret before typing after a request dock closes", async
}), }),
) )
.toBe(cursor) .toBe(cursor)
await transport.send({ await transport.send(
{
directory, directory,
payload: { payload: {
type: "question.asked", type: "question.asked",
@@ -149,15 +150,22 @@ test("restores the draft caret before typing after a request dock closes", async
tool: { messageID: "message-caret", callID: "call-caret" }, tool: { messageID: "message-caret", callID: "call-caret" },
}, },
}, },
}) },
undefined,
"/api/event",
)
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question).toBeVisible() await expect(question).toBeVisible()
await expect(editor).toHaveCount(0) await expect(editor).toHaveCount(0)
await transport.send({ await transport.send(
{
directory, directory,
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } }, payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
}) },
undefined,
"/api/event",
)
await expect(question).toHaveCount(0) await expect(question).toHaveCount(0)
await expect(editor).toBeVisible() await expect(editor).toBeVisible()
await page.keyboard.press("x") await page.keyboard.press("x")
@@ -18,7 +18,7 @@ import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
const initialPageSize = 20 const initialPageSize = 20
const historyPageSize = 200 const historyPageSize = 50
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
for (const profile of [ for (const profile of [
{ locale: "de", label: "Erkundet" }, { locale: "de", label: "Erkundung abgeschlossen" },
{ locale: "ar", label: "تم الاستكشاف" }, { locale: "ar", label: "تم الاستكشاف" },
] as const) { ] as const) {
test(`projects translated context status in ${profile.locale}`, async ({ page }) => { test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
@@ -1,9 +1,8 @@
import { expect, test } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { import {
assistantMessage, assistantMessage,
partUpdated, partUpdated,
setupTimeline, setupTimeline,
status,
textPart, textPart,
userMessage, userMessage,
} from "../performance/timeline-stability/fixture" } from "../performance/timeline-stability/fixture"
@@ -17,7 +16,7 @@ test("keeps one connection open while delivering multiple events", async ({ page
await timeline.waitForPart("prt_transport_first") await timeline.waitForPart("prt_transport_first")
await timeline.waitForPart("prt_transport_second") await timeline.waitForPart("prt_transport_second")
expect(first.connectionID).toBe(second.connectionID) expect(first.connectionID).toBe(second.connectionID)
expect(await timeline.transport.connections()).toHaveLength(1) await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
expect(await timeline.transport.acknowledgements()).toHaveLength(2) expect(await timeline.transport.acknowledgements()).toHaveLength(2)
}) })
@@ -51,20 +50,28 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => {
}) })
test("delivers server heartbeat without mutating the timeline", async ({ page }) => { test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
const sentinelID = "prt_transport_heartbeat_sentinel"
const timeline = await setupTimeline(page, { const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])], messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
}) })
const before = await page.locator("[data-timeline-row]").allTextContents() await timeline.waitForPart("prt_transport_steady")
const before = await stableTimelineRows(page)
await timeline.transport.heartbeat() await timeline.transport.writeRaw(": heartbeat\n\n")
await timeline.settle() await timeline.transport.send(partUpdated(textPart(sentinelID, "heartbeat processed")))
await timeline.waitForPart(sentinelID)
expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before) await expect
expect(await timeline.transport.connections()).toHaveLength(1) .poll(async () => {
const rows = await timelineRows(page)
return rows.filter((row) => before.some((item) => item.key === row.key))
})
.toEqual(before)
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
}) })
test("reconnects after a clean close", async ({ page }) => { test("reconnects after a clean close", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 }) const timeline = await setupTimeline(page)
const first = await timeline.transport.waitForConnection() const first = await timeline.transport.waitForConnection()
await timeline.transport.close() await timeline.transport.close()
@@ -77,27 +84,30 @@ test("reconnects after a clean close", async ({ page }) => {
}) })
test("reconnects after a stream error", async ({ page }) => { test("reconnects after a stream error", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 }) const timeline = await setupTimeline(page)
const first = await timeline.transport.waitForConnection() const first = await timeline.transport.waitForConnection()
await timeline.transport.error("contract failure") await timeline.transport.error("contract failure")
const second = await timeline.transport.waitForConnection({ after: first.id }) const second = await timeline.transport.waitForConnection({ after: first.id })
await timeline.transport.send(status("busy")) await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error")))
await timeline.waitForPart("prt_transport_error")
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2) await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
expect(second.id).toBeGreaterThan(first.id) expect(second.id).toBeGreaterThan(first.id)
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
}) })
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" }) const timeline = await setupTimeline(page, { protocol: "v2" })
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { const first = await timeline.transport.send(
id: "timeline-event-7", partUpdated(textPart("prt_transport_id", "event with id")),
}) { id: "timeline-event-7" },
"/api/event",
)
await timeline.waitForPart("prt_transport_id") await timeline.waitForPart("prt_transport_id")
await timeline.transport.error("retry with event id") await timeline.transport.error("retry with event id", "/api/event")
const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) const connection = await timeline.transport.waitForConnection({ after: first.connectionID, path: "/api/event" })
expect(first.eventID).toBe("timeline-event-7") expect(first.eventID).toBe("timeline-event-7")
expect(connection.headers["last-event-id"]).toBeUndefined() expect(connection.headers["last-event-id"]).toBeUndefined()
@@ -112,5 +122,35 @@ test("passes through non-event fetches", async ({ page }) => {
}) })
expect(health).toEqual({ healthy: true }) expect(health).toEqual({ healthy: true })
expect(await timeline.transport.connections()).toHaveLength(1) await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
}) })
async function stableTimelineRows(page: Page) {
let previous: Awaited<ReturnType<typeof timelineRows>> | undefined
let stable = 0
await expect
.poll(
async () => {
const next = await timelineRows(page)
stable = JSON.stringify(next) === JSON.stringify(previous) ? stable + 1 : 0
previous = next
return stable
},
{ intervals: [50, 50, 100] },
)
.toBeGreaterThanOrEqual(2)
return previous!
}
function timelineRows(page: Page) {
return page.locator("[data-timeline-key]").evaluateAll((elements) =>
elements.map((element) => ({
key: element.getAttribute("data-timeline-key"),
row: element.querySelector("[data-timeline-row]")?.getAttribute("data-timeline-row"),
parts: Array.from(element.querySelectorAll("[data-timeline-part-id]"), (part) =>
part.getAttribute("data-timeline-part-id"),
),
text: element.textContent,
})),
)
}
@@ -0,0 +1,365 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
import { installSseTransport } from "../utils/sse-transport"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const root = "C:/OpenCode/WorkspaceProject"
const workspace = "C:/OpenCode/worktree/project/feature"
const createdWorkspace = "C:/OpenCode/worktree/project/quick-contrast-fix"
const project = {
id: "proj_workspaces",
worktree: root,
vcs: "git" as const,
name: "workspace-project",
time: { created: 1, updated: 1 },
sandboxes: [workspace],
}
const provider = {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test model", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
}
const diff = {
file: "src/workspace.ts",
additions: 3,
deletions: 1,
patch: "@@ -1 +1 @@\n-export const workspace = false\n+export const workspace = true",
}
function userMessage(sessionID: string, id: string, text: string, withDiff = false) {
return {
info: {
id,
sessionID,
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "opencode", modelID: "test" },
...(withDiff ? { summary: { diffs: [diff] } } : {}),
},
parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text }],
}
}
async function init(page: Page, tab: Record<string, unknown>) {
await page.addInitScript(
({ root, server, tab }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: root, expanded: true }] }, lastProject: { local: root } }),
)
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ server, ...tab }]))
},
{ root, server, tab },
)
}
test("selects an existing workspace from the start menu", async ({ page }) => {
const draftID = "draft_workspaces"
await mockOpenCodeServer(page, {
directory: root,
project,
provider,
sessions: [],
pageMessages: () => ({ items: [] }),
})
await init(page, { type: "draft", draftID, directory: root })
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
await page.getByRole("button", { name: /^local$/i }).click()
await page.getByRole("menuitem", { name: /Workspace/ }).hover()
await page.getByRole("menuitem", { name: "feature" }).click()
await expect(page.getByRole("button", { name: /feature/ })).toBeVisible()
})
test("lists and manually deletes workspaces from settings", async ({ page }) => {
const draftID = "draft_workspace_settings"
const cleanWorkspace = `${workspace}-clean`
const inventory = { ...project, sandboxes: [cleanWorkspace] }
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
await mockOpenCodeServer(page, {
directory: root,
project: inventory,
provider,
sessions: [],
pageMessages: () => ({ items: [] }),
})
let releaseSessions = () => {}
const sessionsReady = new Promise<void>((resolve) => {
releaseSessions = resolve
})
await page.route("**/session?**", async (route) => {
const url = new URL(route.request().url())
if (route.request().method() !== "GET" || url.pathname !== "/session" || url.searchParams.get("limit") !== "1000")
return route.fallback()
await sessionsReady
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: "[]",
})
})
await page.route("**/experimental/worktree**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "DELETE" },
})
return
}
if (route.request().method() !== "DELETE") return route.fallback()
await transport.send({
directory: "global",
payload: {
id: "evt_workspace_deleted_settings",
type: "project.updated",
properties: { ...inventory, sandboxes: [] },
},
})
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: "true",
})
})
await init(page, { type: "draft", draftID, directory: root })
await page.goto(`/new-session?draftId=${draftID}`)
await transport.waitForConnection()
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
const app = page.locator('[data-component="session-new-design"]')
const appNode = await app.elementHandle()
await page.getByRole("button", { name: /local|new workspace/i }).click()
const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ })
if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover()
await page.getByRole("menuitem", { name: "View all" }).click()
const settings = page.locator(".settings-v2-dialog")
await expect(settings.getByRole("tab", { name: "Workspaces" })).toHaveAttribute("data-selected")
expect(await appNode?.evaluate((node) => node.isConnected)).toBe(true)
releaseSessions()
await expect(settings.getByText(cleanWorkspace, { exact: true })).toBeVisible()
await settings.getByRole("button", { name: 'Delete workspace "feature-clean"?' }).click()
const confirmation = page
.locator('[data-component="dialog-v2"]')
.filter({ hasText: 'Delete workspace "feature-clean"?' })
const removed = page.waitForRequest(
(request) => request.method() === "DELETE" && new URL(request.url()).pathname === "/experimental/worktree",
)
await confirmation.getByRole("button", { name: "Delete workspace" }).click()
const request = await removed
expect(new URL(request.url()).searchParams.get("directory")).toBe(root)
expect(request.postDataJSON()).toEqual({ directory: cleanWorkspace })
await expect(settings.getByText(cleanWorkspace, { exact: true })).toHaveCount(0)
})
test("submits the owning prompt after a new workspace becomes ready", async ({ page }) => {
const draftID = "draft_workspace_submit"
const sessionID = "ses_workspace_submit"
const session = {
id: sessionID,
slug: "workspace-submit",
projectID: project.id,
directory: createdWorkspace,
title: "New session",
version: "dev",
time: { created: 1, updated: 2 },
}
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
await mockOpenCodeServer(page, {
directory: root,
project,
provider,
sessions: [session],
pageMessages: () => ({ items: [] }),
})
await page.route("**/experimental/worktree**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
})
return
}
if (route.request().method() !== "POST") return route.fallback()
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }),
})
})
await page.route("**/session**", async (route) => {
const url = new URL(route.request().url())
if (url.pathname !== "/session") return route.fallback()
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
})
return
}
if (route.request().method() !== "POST") return route.fallback()
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify(session),
})
})
await page.route(`**/session/${sessionID}/prompt_async**`, async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
})
return
}
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*" },
})
})
await init(page, { type: "draft", draftID, directory: root })
await page.goto(`/new-session?draftId=${draftID}`)
await transport.waitForConnection()
await page.getByRole("button", { name: /^local$/i }).click()
await page.getByRole("menuitem", { name: "New workspace" }).click()
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
await editor.fill("Build workspace support")
await page.locator('[data-action="prompt-submit"]').click()
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
await expect(lifecycle).toContainText("Creating workspace")
const sent = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/session/${sessionID}/prompt_async`,
)
await transport.send({
directory: createdWorkspace,
payload: {
id: "evt_submit_ready",
type: "worktree.ready",
properties: { name: "quick-contrast-fix" },
},
})
await sent
await expect(lifecycle).toContainText("Workspace created")
})
test("moves a changed local session through workspace creation without changing lifecycle semantics", async ({
page,
}) => {
const sessionID = "ses_workspace_move_new"
const messageID = "msg_workspace_move_new"
const session = {
id: sessionID,
slug: "workspace-move-new",
projectID: project.id,
directory: root,
title: "Create a workspace",
version: "dev",
time: { created: 1, updated: 2 },
}
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
await mockOpenCodeServer(page, {
directory: root,
project,
provider,
sessions: [session],
pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Create isolated workspace", true)] }),
vcsDiff: [diff],
})
await page.route("**/experimental/worktree**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
})
return
}
if (route.request().method() !== "POST") return route.fallback()
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }),
})
})
await page.route("**/experimental/control-plane/move-session", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
})
return
}
session.directory = createdWorkspace
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: "null",
})
})
await init(page, { type: "session", sessionId: sessionID })
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await transport.waitForConnection()
await page.locator("[data-session-title]").getByRole("button", { name: "Session details" }).click()
const panel = page.locator('[data-component="session-summary-panel"]')
await panel.getByRole("button", { name: "Local repository" }).click()
await expect(page.getByRole("menuitem", { name: "New workspace" })).toBeVisible()
await page.getByRole("menuitem", { name: "New workspace" }).click()
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
await expect(lifecycle).toContainText("Creating workspace")
const moved = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === "/experimental/control-plane/move-session",
)
await transport.send({
directory: createdWorkspace,
payload: {
id: "evt_worktree_ready",
type: "worktree.ready",
properties: { name: "quick-contrast-fix" },
},
})
expect((await moved).postDataJSON()).toEqual({
sessionID,
destination: { directory: createdWorkspace },
moveChanges: true,
})
await transport.send({
directory: createdWorkspace,
payload: {
id: "evt_workspace_created",
type: "session.next.moved",
properties: {
timestamp: Date.now(),
sessionID,
location: { directory: createdWorkspace },
subdirectory: "",
},
},
})
await expect(lifecycle).toContainText("Workspace created")
})
+45 -21
View File
@@ -29,23 +29,38 @@ export type SseEventOptions = {
export type SseTransport<T> = { export type SseTransport<T> = {
server: string server: string
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord> waitForConnection(options?: {
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement> after?: number
timeout?: number
path?: SseConnectionRecord["path"]
}): Promise<SseConnectionRecord>
send(payload: T, options?: SseEventOptions, path?: SseConnectionRecord["path"]): Promise<SseDeliveryAcknowledgement>
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]> burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement> split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement> heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement> writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
close(): Promise<void> close(): Promise<void>
disconnect(message?: string): Promise<void> disconnect(message?: string): Promise<void>
error(message?: string): Promise<void> error(message?: string, path?: SseConnectionRecord["path"]): Promise<void>
connections(): Promise<SseConnectionRecord[]> connections(): Promise<SseConnectionRecord[]>
acknowledgements(): Promise<SseDeliveryAcknowledgement[]> acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
} }
type BrowserCommand<T> = type BrowserCommand<T> =
| { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] } | {
type: "send"
deliveries: { payload: T; options?: SseEventOptions }[]
burst: boolean
cuts?: number[]
path?: SseConnectionRecord["path"]
}
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string } | { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
| { type: "end"; mode: "close" | "disconnect" | "error"; message?: string } | {
type: "end"
mode: "close" | "disconnect" | "error"
message?: string
path?: SseConnectionRecord["path"]
}
| { type: "connections" } | { type: "connections" }
| { type: "acknowledgements" } | { type: "acknowledgements" }
@@ -73,7 +88,8 @@ export async function installSseTransport<T>(
let nextConnectionID = 0 let nextConnectionID = 0
let nextDeliveryID = 0 let nextDeliveryID = 0
const current = () => connections.findLast((connection) => connection.endedAt === undefined) const current = (path?: SseConnectionRecord["path"]) =>
connections.findLast((connection) => connection.endedAt === undefined && (!path || connection.path === path))
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => { const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
const boundaries = [...new Set(cuts ?? [])] const boundaries = [...new Set(cuts ?? [])]
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength) .filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
@@ -125,8 +141,8 @@ export async function installSseTransport<T>(
acknowledgements.push(acknowledgement) acknowledgements.push(acknowledgement)
return acknowledgement return acknowledgement
} }
const end = (mode: "close" | "disconnect" | "error", message?: string) => { const end = (mode: "close" | "disconnect" | "error", message?: string, path?: SseConnectionRecord["path"]) => {
const connection = current() const connection = current(path)
if (!connection) throw new Error("SSE transport has no active connection") if (!connection) throw new Error("SSE transport has no active connection")
connection.endedAt = performance.now() connection.endedAt = performance.now()
connection.endedBy = mode connection.endedBy = mode
@@ -146,8 +162,8 @@ export async function installSseTransport<T>(
if (input.type === "connections") if (input.type === "connections")
return connections.map(({ controller: _controller, ...connection }) => connection) return connections.map(({ controller: _controller, ...connection }) => connection)
if (input.type === "acknowledgements") return acknowledgements if (input.type === "acknowledgements") return acknowledgements
if (input.type === "end") return end(input.mode, input.message) if (input.type === "end") return end(input.mode, input.message, input.path)
const connection = current() const connection = current(input.type === "send" ? input.path : undefined)
if (!connection) throw new Error("SSE transport has no active connection") if (!connection) throw new Error("SSE transport has no active connection")
if (input.type === "raw") { if (input.type === "raw") {
marker(input.marker) marker(input.marker)
@@ -247,21 +263,29 @@ export async function installSseTransport<T>(
return { return {
server, server,
async waitForConnection(input = {}) { async waitForConnection(input = {}) {
await page.waitForFunction( const connection = await page.waitForFunction(
(after) => { ({ after, path }) => {
const transport = (window as BrowserTransport).__testSseTransport const transport = (window as BrowserTransport).__testSseTransport
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
return connections?.some((connection) => connection.id > after) return connections?.findLast(
(connection) =>
connection.id > after && connection.endedAt === undefined && (!path || connection.path === path),
)
}, },
input.after ?? 0, { after: input.after ?? 0, path: input.path },
{ timeout: input.timeout }, { timeout: input.timeout },
) )
return (await command<SseConnectionRecord[]>({ type: "connections" })).findLast( let result: SseConnectionRecord | undefined
(connection) => connection.id > (input.after ?? 0), try {
)! result = await connection.jsonValue()
} finally {
await connection.dispose()
}
if (!result) throw new Error("SSE transport connection disappeared while waiting")
return result
}, },
send(payload, eventOptions) { send(payload, eventOptions, path) {
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false }) return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, path })
}, },
burst(payloads, eventOptions = []) { burst(payloads, eventOptions = []) {
return command({ return command({
@@ -299,8 +323,8 @@ export async function installSseTransport<T>(
disconnect(message) { disconnect(message) {
return command({ type: "end", mode: "disconnect", message }) return command({ type: "end", mode: "disconnect", message })
}, },
error(message) { error(message, path) {
return command({ type: "end", mode: "error", message }) return command({ type: "end", mode: "error", message, path })
}, },
connections() { connections() {
return command({ type: "connections" }) return command({ type: "connections" })
+4 -2
View File
@@ -1,11 +1,12 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.18.12", "version": "1.18.15",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./desktop-menu": "./src/desktop-menu.ts", "./desktop-menu": "./src/desktop-menu.ts",
"./i18n/desktop-native": "./src/i18n/desktop-native.ts",
"./updater": "./src/updater.ts", "./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts", "./wsl/types": "./src/wsl/types.ts",
"./vite": "./vite.js", "./vite": "./vite.js",
@@ -27,7 +28,8 @@
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report", "test:e2e:report": "playwright show-report e2e/playwright-report",
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts", "test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts" "test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
"profile:desktop": "bun run e2e/performance/profile-desktop.ts"
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
+34 -14
View File
@@ -1,10 +1,9 @@
import "@/index.css" import "@/index.css"
import * as Sentry from "@sentry/solid" import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context" import { I18nProvider } from "@opencode-ai/ui/context"
import type { UiI18n } from "@opencode-ai/ui/context/i18n"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/session-ui/file"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo" import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
@@ -59,18 +58,24 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk" import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context" import { WslServersProvider } from "@/wsl/context"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { createSessionLineage } from "@/pages/session/session-lineage" import { createSessionLineage } from "@/pages/session/session-lineage"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { NewHome } from "@/pages/home"
import { LegacyHome } from "@/pages/home/legacy-home"
const NewSession = lazy(() => import("@/pages/new-session")) const NewSession = lazy(() => import("@/pages/new-session"))
const NewLayout = lazy(() => import("@/pages/layout-new"))
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome })))
const LegacyLayout = lazy(() => import("@/pages/layout"))
const LegacyHome = lazy(() => import("@/pages/home/legacy-home").then((module) => ({ default: module.LegacyHome })))
const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
const SessionPage = lazy(() => import("@/pages/session").then((module) => ({ default: module.SessionPage })))
const SessionRouteErrorBoundary = lazy(() =>
import("@/pages/session").then((module) => ({ default: module.SessionRouteErrorBoundary })),
)
const TargetSessionRouteContent = lazy(() =>
import("@/pages/session").then((module) => ({ default: module.TargetSessionRouteContent })),
)
const SessionRoute = () => { const SessionRoute = () => {
const settings = useSettings() const settings = useSettings()
@@ -234,7 +239,19 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
function UiI18nBridge(props: ParentProps) { function UiI18nBridge(props: ParentProps) {
const language = useLanguage() const language = useLanguage()
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider> return (
<I18nProvider
value={{
locale: language.intl,
layoutLocale: language.layoutLocale,
t: language.t as UiI18n["t"],
plural: language.plural,
pluralForm: language.pluralForm,
}}
>
{props.children}
</I18nProvider>
)
} }
function LayoutCompatibility(props: ParentProps) { function LayoutCompatibility(props: ParentProps) {
@@ -327,7 +344,7 @@ function DesktopCommands() {
if (platform.platform === "desktop" && platform.exportDebugLogs) { if (platform.platform === "desktop" && platform.exportDebugLogs) {
commands.push({ commands.push({
id: "logs.export", id: "logs.export",
title: "Export logs", title: language.t("command.logs.export"),
category: language.t("command.category.settings"), category: language.t("command.category.settings"),
onSelect: () => { onSelect: () => {
void platform.exportDebugLogs?.() void platform.exportDebugLogs?.()
@@ -385,7 +402,12 @@ function DraftProviders(props: ParentProps) {
) )
} }
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { export function AppBaseProviders(
props: ParentProps<{
locale?: Locale
onNativeTranslations?: Parameters<typeof LanguageProvider>[0]["onNativeTranslations"]
}>,
) {
return ( return (
<MetaProvider> <MetaProvider>
<Font /> <Font />
@@ -394,7 +416,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
void window.api?.setTitlebar?.({ mode, scheme }) void window.api?.setTitlebar?.({ mode, scheme })
}} }}
> >
<LanguageProvider locale={props.locale}> <LanguageProvider locale={props.locale} onNativeTranslations={props.onNativeTranslations}>
<UiI18nBridge> <UiI18nBridge>
<ErrorBoundary <ErrorBoundary
fallback={(error) => { fallback={(error) => {
@@ -405,9 +427,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
<QueryProvider> <QueryProvider>
<WslServersProvider> <WslServersProvider>
<DialogProvider> <DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider> <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider> </DialogProvider>
</WslServersProvider> </WslServersProvider>
</QueryProvider> </QueryProvider>
+34 -15
View File
@@ -60,7 +60,7 @@ function Cell(props: {
label: string label: string
tip: string tip: string
value: string value: string
wide?: boolean span?: 2 | 3
}) { }) {
const content = () => ( const content = () => (
<div <div
@@ -69,7 +69,8 @@ function Cell(props: {
"min-h-[20px] w-fit justify-start px-1.5 py-0.5 text-left": !!props.inline, "min-h-[20px] w-fit justify-start px-1.5 py-0.5 text-left": !!props.inline,
"justify-center text-center": !props.inline, "justify-center text-center": !props.inline,
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline, "min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
"col-span-2": !!props.wide && !props.inline, "col-span-2": props.span === 2 && !props.inline,
"col-span-3": props.span === 3 && !props.inline,
}} }}
> >
<div <div
@@ -116,11 +117,18 @@ function Cell(props: {
) )
} }
function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) { function ToggleCell(props: {
active: boolean
inline?: boolean
label: string
onClick: () => void
tip: string
value: string
}) {
const content = () => ( const content = () => (
<button <button
type="button" type="button"
aria-label="Force focus styles on all interactive elements" aria-label={`${props.label}: ${props.value}`}
aria-pressed={props.active} aria-pressed={props.active}
classList={{ classList={{
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true, "flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
@@ -137,26 +145,22 @@ function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => vo
"flex-col items-center": !props.inline, "flex-col items-center": !props.inline,
}} }}
> >
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span> <span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">{props.label}</span>
<span <span class="text-[11px] leading-none font-bold">{props.value}</span>
classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}
>
{props.active ? "ON" : "OFF"}
</span>
</span> </span>
</button> </button>
) )
if (props.inline) { if (props.inline) {
return ( return (
<TooltipV2 value="Force focus styles on all interactive elements" placement="top"> <TooltipV2 value={props.tip} placement="top">
{content()} {content()}
</TooltipV2> </TooltipV2>
) )
} }
return ( return (
<Tooltip value="Force focus styles on all interactive elements" placement="top"> <Tooltip value={props.tip} placement="top">
{content()} {content()}
</Tooltip> </Tooltip>
) )
@@ -475,7 +479,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
"gap-[9px]": !!props.inline, "gap-[9px]": !!props.inline,
"gap-px": !props.inline, "gap-px": !props.inline,
"flex w-full flex-nowrap items-center justify-start": !!props.inline, "flex w-full flex-nowrap items-center justify-start": !!props.inline,
"grid-cols-5": !props.inline, "grid-cols-4": !props.inline,
grid: !props.inline, grid: !props.inline,
}} }}
> >
@@ -557,10 +561,25 @@ export function DebugBar(props: { inline?: boolean } = {}) {
bad={bad(heap(), 0.8)} bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined} dim={state.heap.used === undefined}
inline={props.inline} inline={props.inline}
wide={!platform.setForceFocus} span={platform.setForceFocus ? 2 : 3}
/>
<ToggleCell
active={language.direction() === "rtl"}
inline={props.inline}
label={language.t("debugBar.direction.label")}
tip={language.t("debugBar.direction.tip")}
value={language.t(`debugBar.direction.${language.direction()}`)}
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
/> />
{platform.setForceFocus && ( {platform.setForceFocus && (
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} /> <ToggleCell
active={state.focus}
inline={props.inline}
label={language.t("debugBar.focus.label")}
tip={language.t("debugBar.focus.tip")}
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
onClick={() => void toggleFocus()}
/>
)} )}
</div> </div>
</aside> </aside>
@@ -509,7 +509,12 @@ function ProviderConnection(props: {
const hint = suffix?.[1] const hint = suffix?.[1]
return { return {
label: suffix ? label.slice(0, -suffix[0].length) : label, label: suffix ? label.slice(0, -suffix[0].length) : label,
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined, hint:
hint?.toLowerCase() === "headless"
? language.t("provider.connect.method.headless")
: hint?.toLowerCase() === "browser" || (!hint && value?.type === "key")
? language.t("provider.connect.method.browser")
: undefined,
} }
} }
@@ -131,7 +131,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const saveMutation = useMutation(() => ({ const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => { mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server") if ((await serverSDK().protocol) !== "v1") throw new Error(language.t("provider.custom.unavailable"))
const disabledProviders = serverSync().data.config.disabled_providers ?? [] const disabledProviders = serverSync().data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
@@ -88,7 +88,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
class="w-full" class="w-full"
placement="right-start" placement="right-start"
gutter={6} gutter={6}
openDelay={0} delay="intent"
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }} contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
value={ value={
<ModelTooltip <ModelTooltip
@@ -460,7 +460,7 @@ function ModelSelectorPopoverV2View(props: {
class="w-full" class="w-full"
placement="right-start" placement="right-start"
gutter={6} gutter={6}
openDelay={0} delay="intent"
value={ value={
<ModelTooltip <ModelTooltip
model={item} model={item}
@@ -1,4 +1,5 @@
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@@ -14,6 +15,7 @@ export type DialogGoUpsellProps = {
export function DialogUsageExceeded(props: DialogGoUpsellProps) { export function DialogUsageExceeded(props: DialogGoUpsellProps) {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const runAction = () => { const runAction = () => {
@@ -32,7 +34,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3"> <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={dismiss}> <Button variant="ghost" size="large" onClick={dismiss}>
Don't show again {language.t("dialog.usageExceeded.dontShowAgain")}
</Button> </Button>
<Button variant="primary" size="large" onClick={runAction}> <Button variant="primary" size="large" onClick={runAction}>
{props.actionLabel} {props.actionLabel}
@@ -139,6 +139,7 @@ test("resolves directory autocomplete from the current browser root", async () =
directories.push(input.location?.directory ?? "") directories.push(input.location?.directory ?? "")
return Promise.resolve({ data: [] }) return Promise.resolve({ data: [] })
}, },
list: () => Promise.resolve({ data: [] }),
}, },
}, },
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"] } as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
@@ -152,6 +153,67 @@ test("resolves directory autocomplete from the current browser root", async () =
expect(directories).toEqual(["/repo", "/repo/src"]) expect(directories).toEqual(["/repo", "/repo/src"])
}) })
test("keeps indexed directory results for servers that support empty search", async () => {
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
list: () => Promise.reject(new Error("listing should not run when search returns results")),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
expect(await search("")).toEqual(["/home/luke/projects"])
})
test("lists the default directory when empty search is unsupported", async () => {
const calls: string[] = []
const directories = Array.from({ length: 60 }, (_, index) => ({
path: `project-${index}/`,
type: "directory" as const,
}))
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
list: (input: { location?: { directory?: string } }) => {
calls.push(input.location?.directory ?? "")
return Promise.resolve({
data: [...directories, { path: "README.md", type: "file" }],
})
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
const results = await search("")
expect(results).toHaveLength(60)
expect(results.at(-1)).toBe("/home/luke/project-59")
expect(calls).toEqual(["/home/luke"])
})
test("matches the default directory listing when typed search is unsupported", async () => {
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
list: () =>
Promise.resolve({
data: [
{ path: "Documents/", type: "directory" },
{ path: "Downloads/", type: "directory" },
],
}),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
expect(await search("documents")).toEqual(["/home/luke/Documents"])
})
test("searches from an absolute root without a default base", async () => { test("searches from an absolute root without a default base", async () => {
const directories: string[] = [] const directories: string[] = []
const sdk = { const sdk = {
@@ -379,8 +379,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
.then((result) => result.data.map((entry) => entry.path)) .then((result) => result.data.map((entry) => entry.path))
.catch(() => []) .catch(() => [])
if (!active()) return [] if (!active()) return []
if (results.length) {
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
} }
const fallback = query
? await match(input.directory, query, 50)
: (await directories(input.directory)).map((item) => item.absolute)
if (!active()) return []
return fallback
}
const segments = query.replace(/^\/+/, "").split("/") const segments = query.replace(/^\/+/, "").split("/")
const head = segments.slice(0, -1).filter((part) => part && part !== ".") const head = segments.slice(0, -1).filter((part) => part && part !== ".")
const tail = segments.at(-1) ?? "" const tail = segments.at(-1) ?? ""
+9 -7
View File
@@ -29,14 +29,14 @@ export type { Kind } from "@/components/file-tree"
const INDENT_STEP = 16 const INDENT_STEP = 16
function rowPaddingLeft(level: number, type: FileNode["type"]) { function rowPaddingStart(level: number, type: FileNode["type"]) {
if (type === "directory") return 8 + level * INDENT_STEP if (type === "directory") return 8 + level * INDENT_STEP
if (level === 0) return 8 if (level === 0) return 8
return 8 + level * INDENT_STEP - INDENT_STEP return 8 + level * INDENT_STEP - INDENT_STEP
} }
function guideLineLeft(level: number) { function guideLineStart(level: number) {
return rowPaddingLeft(level, "directory") + 8 return rowPaddingStart(level, "directory") + 8
} }
export const kindLabel = (kind: Kind) => { export const kindLabel = (kind: Kind) => {
@@ -87,7 +87,7 @@ const FileTreeNodeV2 = (
...local.classList, ...local.classList,
[local.class ?? ""]: !!local.class, [local.class ?? ""]: !!local.class,
}} }}
style={`padding-left: ${rowPaddingLeft(local.level, local.node.type)}px`} style={`padding-inline-start: ${rowPaddingStart(local.level, local.node.type)}px`}
draggable={local.draggable} draggable={local.draggable}
onDragStart={(event: DragEvent) => { onDragStart={(event: DragEvent) => {
if (!local.draggable) return if (!local.draggable) return
@@ -99,7 +99,9 @@ const FileTreeNodeV2 = (
{...rest} {...rest}
> >
{local.children} {local.children}
<span class="flex-1 min-w-0 text-12-medium whitespace-nowrap truncate">{local.node.name}</span> <span class="flex-1 min-w-0 text-start text-12-medium whitespace-nowrap truncate">
<bdi dir="auto">{local.node.name}</bdi>
</span>
{(() => { {(() => {
const value = kind() const value = kind()
if (!value || local.node.type !== "file") return null if (!value || local.node.type !== "file") return null
@@ -116,7 +118,7 @@ const FileTreeNodeV2 = (
function GuideLines(props: { level: number }) { function GuideLines(props: { level: number }) {
return ( return (
<For each={Array.from({ length: props.level })}> <For each={Array.from({ length: props.level })}>
{(_, index) => <div data-slot="file-tree-v2-guide" style={`left: ${guideLineLeft(index())}px`} />} {(_, index) => <div data-slot="file-tree-v2-guide" style={`inset-inline-start: ${guideLineStart(index())}px`} />}
</For> </For>
) )
} }
@@ -227,7 +229,7 @@ export default function FileTreeV2(props: {
style={{ style={{
position: "absolute", position: "absolute",
top: "0", top: "0",
left: "0", "inset-inline-start": "0",
width: "100%", width: "100%",
height: `${item().size}px`, height: `${item().size}px`,
transform: `translateY(${item().start}px)`, transform: `translateY(${item().start}px)`,
+2 -2
View File
@@ -146,13 +146,13 @@ const FileTreeNode = (
<Dynamic <Dynamic
component={local.as ?? "div"} component={local.as ?? "div"}
classList={{ classList={{
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true, "w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-start hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
"bg-surface-base-active": local.node.path === local.active, "bg-surface-base-active": local.node.path === local.active,
...local.classList, ...local.classList,
[local.class ?? ""]: !!local.class, [local.class ?? ""]: !!local.class,
[local.nodeClass ?? ""]: !!local.nodeClass, [local.nodeClass ?? ""]: !!local.nodeClass,
}} }}
style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`} style={`padding-inline-start: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
draggable={local.draggable} draggable={local.draggable}
onDragStart={(event: DragEvent) => { onDragStart={(event: DragEvent) => {
if (!local.draggable) return if (!local.draggable) return
+26 -35
View File
@@ -1,34 +1,35 @@
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { isRTL } from "@kobalte/core/i18n"
import { createSignal, Show } from "solid-js" import { createSignal, Show } from "solid-js"
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer" import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useLanguage } from "@/context/language"
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4" import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import homeImage from "@/assets/help/home.png" import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.png" import tabsImage from "@/assets/help/tabs.png"
// TODO: wire to changelog / seen-state when available
const showPopover = () => true
// can remove this after the tabs rollout has been out for a while // can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() { export function TabsInfoPopup() {
const settings = useSettings() const settings = useSettings()
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage()
const [drawerOpen, setDrawerOpen] = createSignal(false) const [drawerOpen, setDrawerOpen] = createSignal(false)
const windows = () => platform.platform === "desktop" && platform.os === "windows" const windows = () => platform.platform === "desktop" && platform.os === "windows"
const rtl = () => isRTL(language.intl())
return ( return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right"> <Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side={rtl() ? "left" : "right"}>
<Show when={settings.general.shouldDisplayTabsToast()}> <Show when={settings.general.shouldDisplayTabsToast()}>
<div <div
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]" class="fixed bottom-5 end-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label="Introducing Tabs. Organize your work and active sessions with tabs" aria-label={language.t("help.tabs.toast.ariaLabel")}
> >
<button <button
type="button" type="button"
aria-label="Dismiss Tabs information" aria-label={language.t("help.tabs.toast.dismiss")}
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]" class="absolute top-3 end-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
onClick={settings.general.dismissTabsToast} onClick={settings.general.dismissTabsToast}
> >
<svg <svg
@@ -44,7 +45,7 @@ export function TabsInfoPopup() {
</button> </button>
<button <button
type="button" type="button"
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left" class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-start"
onClick={() => { onClick={() => {
settings.general.dismissTabsToast() settings.general.dismissTabsToast()
setDrawerOpen(true) setDrawerOpen(true)
@@ -62,10 +63,10 @@ export function TabsInfoPopup() {
/> />
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5"> <div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]"> <p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
Introducing Tabs {language.t("help.tabs.title")}
</p> </p>
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]"> <p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
Organize your work and active sessions with tabs {language.t("help.tabs.description")}
</p> </p>
</div> </div>
</button> </button>
@@ -75,7 +76,10 @@ export function TabsInfoPopup() {
style={ style={
windows() windows()
? { ? {
inset: "0 0 0 auto", top: "0",
bottom: "0",
"inset-inline-end": "0",
"inset-inline-start": "auto",
"max-height": "100vh", "max-height": "100vh",
"max-width": "100vw", "max-width": "100vw",
"border-radius": "0", "border-radius": "0",
@@ -89,9 +93,9 @@ export function TabsInfoPopup() {
type="button" type="button"
size="small" size="small"
variant="neutral" variant="neutral"
aria-label="Close" aria-label={language.t("common.close")}
icon={<IconV2 name="xmark-small" />} icon={<IconV2 name="xmark-small" />}
class="absolute top-[10px] left-[-36px]" class="absolute top-[10px] start-[-36px]"
/> />
</Show> </Show>
<div <div
@@ -102,7 +106,7 @@ export function TabsInfoPopup() {
}} }}
> >
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted"> <p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
July 14 {language.t("help.tabs.date")}
</p> </p>
<Show when={!windows()}> <Show when={!windows()}>
<DrawerClose <DrawerClose
@@ -110,36 +114,23 @@ export function TabsInfoPopup() {
type="button" type="button"
size="small" size="small"
variant="ghost-muted" variant="ghost-muted"
aria-label="Close" aria-label={language.t("common.close")}
icon={<IconV2 name="xmark-small" />} icon={<IconV2 name="xmark-small" />}
/> />
</Show> </Show>
</div> </div>
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8"> <div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base"> <p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
Introducing Tabs {language.t("help.tabs.title")}
</p> </p>
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"> <div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
<p>OpenCode Desktop is now built around tabs.</p> <p>{language.t("help.tabs.introduction")}</p>
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" /> <img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p> <p>{language.t("help.tabs.sessions")}</p>
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when <p>{language.t("help.tabs.organize")}</p>
you're starting something new, and close it when you're done. <p>{language.t("help.tabs.home")}</p>
</p>
<p>
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
memorable if you plan to keep them around.
</p>
<p>
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
</p>
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" /> <img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>When you reopen the app, your tabs are still open.</p> <p>{language.t("help.tabs.persistence")}</p>
<p>
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
will become permanent in a few weeks.
</p>
</div> </div>
</div> </div>
</DrawerContent> </DrawerContent>
@@ -37,6 +37,7 @@ export type PromptInputV2ComposerProps = {
class?: string class?: string
controller: PromptInputV2ComposerController controller: PromptInputV2ComposerController
borderUnderlay?: boolean borderUnderlay?: boolean
accentSubmit?: boolean
} }
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission"> export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
@@ -53,6 +54,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<PromptInputV2 <PromptInputV2
controller={props.controller} controller={props.controller}
accentSubmit={props.accentSubmit}
borderUnderlay={props.borderUnderlay} borderUnderlay={props.borderUnderlay}
class={props.class} class={props.class}
variantControlVisible={!props.controller.model.loading} variantControlVisible={!props.controller.model.loading}
@@ -137,7 +139,10 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never), t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}), }),
) )
const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder()) const designPlaceholder = () =>
promptDesignPlaceholder(mode(), placeholder(), (key, params) =>
language.t(key as Parameters<typeof language.t>[0], params as never),
)
const historyComments = () => { const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
+2 -1
View File
@@ -77,7 +77,7 @@ import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/
import { PromptContextItems } from "./prompt-input/context-items" import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder" import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state" import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
@@ -1493,6 +1493,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
onRemove={removeAttachment} onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")} removeLabel={language.t("prompt.attachment.remove")}
fileLabel={language.t("ui.common.file")}
newLayoutDesigns={false} newLayoutDesigns={false}
/> />
<div <div
@@ -47,7 +47,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
</span> </span>
} }
placement="top" placement="top"
openDelay={800} {...(!props.newLayoutDesigns ? { openDelay: 800 } : {})}
> >
<div <div
classList={{ classList={{
@@ -16,6 +16,7 @@ type PromptImageAttachmentsProps = {
onOpen: (attachment: ImageAttachmentPart) => void onOpen: (attachment: ImageAttachmentPart) => void
onRemove: (id: string) => void onRemove: (id: string) => void
removeLabel: string removeLabel: string
fileLabel: string
newLayoutDesigns: boolean newLayoutDesigns: boolean
comments?: PromptCommentItem[] comments?: PromptCommentItem[]
commentActive?: (item: PromptCommentItem) => boolean commentActive?: (item: PromptCommentItem) => boolean
@@ -52,12 +53,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<For each={props.comments ?? []}> <For each={props.comments ?? []}>
{(item) => ( {(item) => (
<div class="relative group shrink-0"> <div class="relative group shrink-0">
<TooltipV2 <TooltipV2 value={item.comment} placement="top" contentClass="max-w-[300px] break-words">
value={item.comment}
placement="top"
openDelay={800}
contentClass="max-w-[300px] break-words"
>
<CommentCardV2 <CommentCardV2
comment={item.comment ?? ""} comment={item.comment ?? ""}
path={item.path} path={item.path}
@@ -94,7 +90,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
} }
> >
<AttachmentCardV2 title={attachment.filename}> <AttachmentCardV2 title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime)} {typeLabel(attachment.filename, attachment.mime, props.fileLabel)}
</AttachmentCardV2> </AttachmentCardV2>
</Show> </Show>
} }
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { promptPlaceholder } from "./placeholder" import { promptDesignPlaceholder, promptPlaceholder } from "./placeholder"
describe("promptPlaceholder", () => { describe("promptPlaceholder", () => {
const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}` const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}`
@@ -46,3 +46,18 @@ describe("promptPlaceholder", () => {
expect(value).toBe("prompt.placeholder.simple") expect(value).toBe("prompt.placeholder.simple")
}) })
}) })
describe("promptDesignPlaceholder", () => {
const t = (key: string, params?: Record<string, string>) => {
if (key !== "ui.promptInput.placeholder.normal") return key
return `Ask anything, ${params?.slash} for commands, ${params?.at} for context...`
}
test("composes the design placeholder from localized fragments", () => {
expect(promptDesignPlaceholder("normal", "fallback", t)).toBe("Ask anything, / for commands, @ for context...")
})
test("preserves the shell placeholder", () => {
expect(promptDesignPlaceholder("shell", "Enter shell command...", t)).toBe("Enter shell command...")
})
})
@@ -14,7 +14,11 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
return input.t("prompt.placeholder.normal", { example: input.example }) return input.t("prompt.placeholder.normal", { example: input.example })
} }
export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) { export function promptDesignPlaceholder(
mode: PromptPlaceholderInput["mode"],
placeholder: string,
t: PromptPlaceholderInput["t"],
) {
if (mode === "shell") return placeholder if (mode === "shell") return placeholder
return "Ask anything, / for commands, @ for context..." return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
} }
@@ -1,18 +1,18 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { Prompt, PromptStore } from "@/context/prompt" import type { Prompt, PromptStore } from "@/context/prompt"
import type { ModelSelection } from "@/context/local" import { Worktree } from "@/utils/worktree"
import { WorkspaceOperation } from "@/utils/workspace-operation"
import { ServerScope } from "@/utils/server-scope"
let createPromptSubmit: typeof import("./submit").createPromptSubmit let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = [] const createdSessions: string[] = []
const sessionCreateInputs: Array<{ type SessionCreateInput = {
agent?: string agent?: string
model?: { id: string; providerID: string; variant?: string } model?: { id: string; providerID: string; variant?: string }
location?: { directory: string } location?: { directory: string }
}> = [] }
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
const optimistic: Array<{ const optimistic: Array<{
directory?: string directory?: string
sessionID?: string sessionID?: string
@@ -22,24 +22,35 @@ const optimistic: Array<{
variant?: string variant?: string
} }
}> = [] }> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {} const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = [] const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const syncedDirectories: string[] = [] const sentShellDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = [] const sentPrompts: string[] = []
const promptInputs: unknown[] = [] const promptInputs: unknown[] = []
const sentCommands: unknown[] = [] const sentCommands: unknown[] = []
const commands: Array<{ name: string }> = [] const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
const syncedServers: string[] = []
const optimisticServers: string[] = []
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
let serverSessionSyncs = 0 let serverSessionSyncs = 0
let params: { id?: string } = {} let params: { id?: string } = {}
let search: { draftId?: string } = {} let search: { draftId?: string } = {}
let selected = "/repo/worktree-a" let selected = "/repo/worktree-a"
let variant: string | undefined let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined let createSessionGate: Promise<void> | undefined
let createWorktreeGate: Promise<void> | undefined
let worktreeFailure: Error | undefined
let worktreeHung = false
let worktreeCreates = 0
let activeSDK = "server-a"
let activeServerSync = "server-a"
let activeDirectorySync = "server-a"
let commands: Array<{ name: string }> = []
let worktreeDirectory = "/repo/new-0"
let worktreeID = 0
const draftServers: Record<string, string> = {}
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({ const [promptStore, setPromptStore] = createStore<PromptStore>({
@@ -67,19 +78,21 @@ const prompt = {
replaceComments: () => undefined, replaceComments: () => undefined,
items: () => [], items: () => [],
}, },
capture: () => prompt, capture: (scope?: unknown, target?: unknown) => {
promptCaptures.push({ scope, target })
return prompt
},
} }
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
const clientFor = (directory: string) => { const clientFor = (directory: string) => {
createdClients.push(directory)
return { return {
api: { api: {
session: { session: {
create: async (input: (typeof sessionCreateInputs)[number]) => { create: async (input: SessionCreateInput) => {
await createSessionGate await createSessionGate
const location = input.location?.directory ?? directory const location = input.location?.directory ?? directory
createdSessions.push(location) createdSessions.push(location)
sessionCreateInputs.push(input)
return { return {
id: `session-${createdSessions.length}`, id: `session-${createdSessions.length}`,
projectID: "project", projectID: "project",
@@ -102,6 +115,7 @@ const clientFor = (directory: string) => {
}, },
shell: async (input: { sessionID: string; id?: string; command: string }) => { shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input) sentShell.push(input)
sentShellDirectories.push(directory)
}, },
}, },
}, },
@@ -110,7 +124,16 @@ const clientFor = (directory: string) => {
abort: async () => ({ data: undefined }), abort: async () => ({ data: undefined }),
}, },
worktree: { worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }), create: async (_input: unknown, options?: { signal?: AbortSignal }) => {
worktreeCreates++
if (worktreeHung)
return new Promise<never>((_, reject) => {
options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true })
})
await createWorktreeGate
if (worktreeFailure) throw worktreeFailure
return { data: { directory: worktreeDirectory } }
},
}, },
} }
} }
@@ -127,13 +150,13 @@ beforeAll(async () => {
mock.module("@opencode-ai/sdk/v2/client", () => ({ mock.module("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: (input: { directory: string }) => { createOpencodeClient: (input: { directory: string }) => {
createdClients.push(input.directory)
return clientFor(input.directory) return clientFor(input.directory)
}, },
})) }))
mock.module("@opencode-ai/ui/toast", () => ({ mock.module("@opencode-ai/ui/toast", () => ({
Toast: { Region: () => null }, Toast: { Region: () => null },
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
showToast: () => 0, showToast: () => 0,
})) }))
@@ -151,20 +174,13 @@ beforeAll(async () => {
current: () => ({ name: "agent" }), current: () => ({ name: "agent" }),
}, },
session: { session: {
promote(directory: string, sessionID: string) { promote: () => undefined,
promoted.push({ directory, sessionID })
},
}, },
}), }),
})) }))
mock.module("@/context/permission", () => { mock.module("@/context/permission", () => {
const state = (server: string) => ({ return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
enableAutoAccept(sessionID: string, directory: string) {
enabledAutoAccept.push({ server, sessionID, directory })
},
})
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
}) })
mock.module("@/context/server", () => ({ mock.module("@/context/server", () => ({
@@ -173,7 +189,10 @@ beforeAll(async () => {
mock.module("@/context/tabs", () => ({ mock.module("@/context/tabs", () => ({
useTabs: () => ({ useTabs: () => ({
draft: () => ({ server: "project-server" }), draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }),
updateDraft: (draftID: string, draft: { worktree?: string }) => {
updatedDrafts.push({ draftID, ...draft })
},
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => { promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
promotedDrafts.push({ draftID, ...session }) promotedDrafts.push({ draftID, ...session })
}, },
@@ -194,22 +213,24 @@ beforeAll(async () => {
mock.module("@/context/sdk", () => ({ mock.module("@/context/sdk", () => ({
useSDK: () => { useSDK: () => {
const sdk = { return () => ({
scope: "local", scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
directory: "/repo/main", directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
client: rootClient, client: rootClient,
api: rootClient.api, api: rootClient.api,
url: "http://localhost:4096", url: "http://localhost:4096",
createApi: (directory: string) => clientFor(directory).api,
createClient(opts: any) { createClient(opts: any) {
return clientFor(opts.directory) return clientFor(opts.directory)
}, },
} })
return () => sdk
}, },
})) }))
mock.module("@/context/sync", () => ({ mock.module("@/context/sync", () => ({
useSync: () => () => ({ useSync: () => () => {
const server = activeDirectorySync
return {
data: { command: commands }, data: { command: commands },
session: { session: {
optimistic: { optimistic: {
@@ -218,22 +239,22 @@ beforeAll(async () => {
sessionID?: string sessionID?: string
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } } message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
}) => { }) => {
optimisticServers.push(server)
optimistic.push(value) optimistic.push(value)
optimisticSeeded.push(
!!value.directory &&
!!value.sessionID &&
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
)
}, },
remove: () => undefined, remove: () => undefined,
}, },
}, },
set: () => undefined, set: () => undefined,
}), project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
}
},
})) }))
mock.module("@/context/server-sync", () => ({ mock.module("@/context/server-sync", () => ({
useServerSync: () => () => ({ useServerSync: () => () => {
const server = activeServerSync
return {
session: { session: {
remember: () => undefined, remember: () => undefined,
set: () => undefined, set: () => undefined,
@@ -242,7 +263,7 @@ beforeAll(async () => {
}, },
}, },
child: (directory: string) => { child: (directory: string) => {
syncedDirectories.push(directory) syncedServers.push(server)
storedSessions[directory] ??= [] storedSessions[directory] ??= []
return [ return [
{ session: storedSessions[directory] }, { session: storedSessions[directory] },
@@ -259,7 +280,8 @@ beforeAll(async () => {
}, },
] ]
}, },
}), }
},
})) }))
mock.module("@/context/platform", () => ({ mock.module("@/context/platform", () => ({
@@ -279,40 +301,47 @@ beforeAll(async () => {
}) })
beforeEach(() => { beforeEach(() => {
createdClients.length = 0
createdSessions.length = 0 createdSessions.length = 0
sessionCreateInputs.length = 0
enabledAutoAccept.length = 0
optimistic.length = 0 optimistic.length = 0
optimisticSeeded.length = 0
promoted.length = 0
promotedDrafts.length = 0 promotedDrafts.length = 0
updatedDrafts.length = 0
sentCommands.length = 0
sentPrompts.length = 0 sentPrompts.length = 0
promptInputs.length = 0 promptInputs.length = 0
sentCommands.length = 0 syncedServers.length = 0
commands.length = 0 optimisticServers.length = 0
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] promptCaptures.length = 0
params = {} params = {}
search = {} search = {}
sentShell.length = 0 sentShell.length = 0
syncedDirectories.length = 0 sentShellDirectories.length = 0
selected = "/repo/worktree-a" selected = "/repo/worktree-a"
variant = undefined variant = undefined
permissionServer = "server-a" activeSDK = "server-a"
activeServerSync = "server-a"
activeDirectorySync = "server-a"
commands = []
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
worktreeDirectory = `/repo/new-${++worktreeID}`
createSessionGate = undefined createSessionGate = undefined
serverSessionSyncs = 0 serverSessionSyncs = 0
createWorktreeGate = undefined
worktreeFailure = undefined
worktreeHung = false
worktreeCreates = 0
for (const key of Object.keys(draftServers)) delete draftServers[key]
for (const key of Object.keys(storedSessions)) delete storedSessions[key] for (const key of Object.keys(storedSessions)) delete storedSessions[key]
}) })
describe("prompt submit worktree selection", () => { const event = { preventDefault: () => undefined } as unknown as Event
test("reads the latest worktree accessor value per submit", async () => { const makeSubmit = (overrides: Partial<Parameters<typeof createPromptSubmit>[0]> = {}) =>
const submit = createPromptSubmit({ createPromptSubmit({
prompt, prompt,
info: () => undefined, info: () => undefined,
imageAttachments: () => [], imageAttachments: () => [],
commentCount: () => 0, commentCount: () => 0,
autoAccept: () => false, autoAccept: () => false,
mode: () => "shell", mode: () => "normal",
working: () => false, working: () => false,
editor: () => undefined, editor: () => undefined,
queueScroll: () => undefined, queueScroll: () => undefined,
@@ -324,153 +353,101 @@ describe("prompt submit worktree selection", () => {
newSessionWorktree: () => selected, newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined, onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined, onSubmit: () => undefined,
...overrides,
}) })
const event = { preventDefault: () => undefined } as unknown as Event describe("prompt submit worktree selection", () => {
test("admits only one concurrent new-workspace submission", async () => {
selected = "create"
let release = () => {}
createWorktreeGate = new Promise<void>((resolve) => {
release = resolve
})
const submit = makeSubmit()
await submit.handleSubmit(event) const first = submit.handleSubmit(event)
selected = "/repo/worktree-b" const duplicate = submit.handleSubmit(event)
await submit.handleSubmit(event) expect(worktreeCreates).toBe(1)
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) release()
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) await Promise.all([first, duplicate])
expect(sessionCreateInputs).toEqual([ expect(createdSessions).toEqual([worktreeDirectory])
{ Worktree.ready(ServerScope.local, worktreeDirectory)
agent: "agent", await settle()
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-a" }, expect(worktreeCreates).toBe(1)
}, expect(createdSessions).toHaveLength(1)
{ expect(sentPrompts).toEqual([worktreeDirectory])
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-b" },
},
])
expect(sentShell).toEqual([
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
expect(serverSessionSyncs).toBe(0)
expect(promoted).toEqual([
{ directory: "/repo/worktree-a", sessionID: "session-1" },
{ directory: "/repo/worktree-b", sessionID: "session-2" },
])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
}) })
test("applies auto-accept to newly created sessions", async () => { test("aborts a hung new-workspace request and allows retry", async () => {
const submit = createPromptSubmit({ selected = "create"
prompt, worktreeHung = true
info: () => undefined, let resets = 0
imageAttachments: () => [], const submit = makeSubmit({
commentCount: () => 0, onNewSessionWorktreeReset: () => resets++,
autoAccept: () => true, worktreeRequestTimeoutMs: 1,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
}) })
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event) await submit.handleSubmit(event)
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }]) expect(worktreeCreates).toBe(1)
expect(createdSessions).toEqual([])
expect(selected).toBe("create")
expect(promptValue).toEqual([{ type: "text", content: "ls", start: 0, end: 2 }])
expect(resets).toBe(0)
worktreeHung = false
await submit.handleSubmit(event)
Worktree.ready(ServerScope.local, worktreeDirectory)
await settle()
expect(worktreeCreates).toBe(2)
expect(createdSessions).toEqual([worktreeDirectory])
expect(sentPrompts).toEqual([worktreeDirectory])
expect(resets).toBe(1)
}) })
test("keeps auto-accept bound to the submission server", async () => { test("keeps async submission effects bound to the initiating context", async () => {
search = { draftId: "draft-1" }
draftServers["draft-1"] = "project-server-a"
draftServers["draft-2"] = "project-server-b"
let release = () => {} let release = () => {}
createSessionGate = new Promise<void>((resolve) => { createSessionGate = new Promise<void>((resolve) => {
release = resolve release = resolve
}) })
const submit = createPromptSubmit({ let submitted = 0
prompt, const submit = makeSubmit({
info: () => undefined, onSubmit: () => submitted++,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
}) })
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) const result = submit.handleSubmit(event)
permissionServer = "server-b" activeSDK = "server-b"
activeServerSync = "server-b"
activeDirectorySync = "server-b"
search.draftId = "draft-2"
release() release()
await result await result
await settle()
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }]) expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
}) expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
test("promotes drafts using the selected project's server", async () => { expect(optimisticServers).toEqual(["server-a"])
search = { draftId: "draft-1" } expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
const submit = createPromptSubmit({ expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
prompt, expect(WorkspaceOperation.get("server-b" as ServerScope, "session-1")).toBeUndefined()
info: () => undefined, expect(submitted).toBe(0)
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
}) })
test("includes the selected variant on optimistic prompts", async () => { test("includes the selected variant on optimistic prompts", async () => {
params = { id: "session-1" } params.id = "session-1"
variant = "high" variant = "high"
const submit = createPromptSubmit({ const submit = makeSubmit({
prompt,
info: () => ({ id: "session-1" }), info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
onSubmit: () => undefined,
}) })
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event) await submit.handleSubmit(event)
await Bun.sleep(0) await Bun.sleep(0)
@@ -500,24 +477,12 @@ describe("prompt submit worktree selection", () => {
commands.push({ name: "review" }) commands.push({ name: "review" })
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }] promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
const submit = createPromptSubmit({ const submit = makeSubmit({
prompt,
info: () => ({ id: "session-1" }), info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
}) })
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) await submit.handleSubmit(event)
await settle()
expect(sentCommands).toEqual([ expect(sentCommands).toEqual([
{ {
@@ -533,66 +498,43 @@ describe("prompt submit worktree selection", () => {
expect(serverSessionSyncs).toBe(0) expect(serverSessionSyncs).toBe(0)
}) })
test("uses an injected model selection", async () => { test("waits for a new workspace before sending an initial shell", async () => {
params = { id: "session-1" } selected = "create"
const model = { const submit = makeSubmit({
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }), mode: () => "shell",
variant: { current: () => "draft-variant" },
} as unknown as ModelSelection
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
model,
}) })
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(optimistic[0]).toMatchObject({
message: {
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
},
})
})
test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event) await submit.handleSubmit(event)
expect(sentShell).toEqual([])
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending")
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1) Worktree.ready(ServerScope.local, worktreeDirectory)
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" }) await settle()
expect(optimisticSeeded).toEqual([true])
expect(sentShellDirectories).toEqual([worktreeDirectory])
expect(sentShell[0]).toMatchObject({
sessionID: "session-1",
command: "ls",
agent: "agent",
model: { providerID: "provider", modelID: "model" },
})
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
})
test("settles a pending workspace operation when the initial prompt is aborted", async () => {
selected = "create"
const submit = makeSubmit()
await submit.handleSubmit(event)
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending")
params = { id: "session-1" }
await submit.abort()
await settle()
expect(sentPrompts).toEqual([])
expect(WorkspaceOperation.get(ServerScope.local, "session-1")).toMatchObject({
status: "failed",
})
}) })
}) })
+223 -124
View File
@@ -15,10 +15,12 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync" import { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
import { WorkspaceOperation } from "@/utils/workspace-operation"
import { WORKSPACE_PREPARATION_TIMEOUT_MS, workspaceRequestWithTimeout } from "@/utils/workspace-request"
import { buildRequestParts } from "./build-request-parts" import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope" import { ScopedKey, type ServerScope } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state" import { createPromptSubmissionState } from "./submission-state"
import { normalizeSessionInfo } from "@/utils/session" import { normalizeSessionInfo } from "@/utils/session"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
@@ -27,9 +29,13 @@ import { blobDataUrl } from "@/utils/draft-store"
type PendingPrompt = { type PendingPrompt = {
abort: AbortController abort: AbortController
cleanup: VoidFunction cleanup: VoidFunction
scope: ServerScope
sessionID: string
serverSync: ServerSync
} }
const pending = new Map<string, PendingPrompt>() const pending = new Map<string, PendingPrompt>()
const submitting = new Set<string>()
export type FollowupDraft = { export type FollowupDraft = {
sessionID: string sessionID: string
@@ -43,6 +49,7 @@ export type FollowupDraft = {
type FollowupSendInput = { type FollowupSendInput = {
api: DirectorySDK["api"]["session"] api: DirectorySDK["api"]["session"]
scope: ServerScope
serverSync: ServerSync serverSync: ServerSync
sync: DirectorySync sync: DirectorySync
draft: FollowupDraft draft: FollowupDraft
@@ -56,6 +63,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ?
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image") const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
export async function sendFollowupDraft(input: FollowupSendInput) { export async function sendFollowupDraft(input: FollowupSendInput) {
const operation = WorkspaceOperation.get(input.scope, input.draft.sessionID)
if (operation?.status === "pending" && operation.messageID !== input.messageID) return false
const text = draftText(input.draft.prompt) const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt) const images = draftImages(input.draft.prompt)
const setBusy = () => { const setBusy = () => {
@@ -229,6 +238,7 @@ type PromptSubmitInput = {
onAbort?: () => void onAbort?: () => void
onSubmit?: () => void onSubmit?: () => void
model?: ModelSelection model?: ModelSelection
worktreeRequestTimeoutMs?: number
} }
export function createPromptSubmit(input: PromptSubmitInput) { export function createPromptSubmit(input: PromptSubmitInput) {
@@ -244,7 +254,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const params = useParams() const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs() const tabs = useTabs()
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID) const pendingKey = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID)
let pendingSubmission: { key: string; scope: ServerScope; sessionID: string } | undefined
const errorMessage = (err: unknown) => { const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
@@ -257,18 +268,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
const abort = async () => { const abort = async () => {
const sessionID = params.id const routeSessionID = params.id
const owned =
pendingSubmission && (!routeSessionID || routeSessionID === pendingSubmission.sessionID)
? pending.get(pendingSubmission.key)
: undefined
const sessionID = routeSessionID ?? owned?.sessionID
if (!sessionID) return Promise.resolve() if (!sessionID) return Promise.resolve()
;(owned?.serverSync ?? serverSync()).session.set("todo", sessionID, [])
serverSync().session.set("todo", sessionID, [])
input.onAbort?.() input.onAbort?.()
const key = pendingKey(sessionID) const key = owned ? pendingSubmission!.key : pendingKey(sdk().scope, sessionID)
const queued = pending.get(key) const queued = owned ?? pending.get(key)
if (queued) { if (queued) {
queued.abort.abort() queued.abort.abort()
queued.cleanup() queued.cleanup()
WorkspaceOperation.fail(queued.scope, queued.sessionID)
pending.delete(key) pending.delete(key)
return Promise.resolve() return Promise.resolve()
} }
@@ -300,9 +316,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
} }
const seed = (dir: string, info: Session) => { const seed = (target: ServerSync, dir: string, info: Session) => {
serverSync().session.remember(info) target.session.remember(info)
const [, setStore] = serverSync().child(dir) const [, setStore] = target.child(dir)
setStore("session", (list: Session[]) => { setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id) const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list] const next = [...list]
@@ -334,6 +350,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (input.working()) void abort() if (input.working()) void abort()
return return
} }
if (params.id && WorkspaceOperation.get(sdk().scope, params.id)?.status === "pending") return
const modelSelection = input.model ?? local.model const modelSelection = input.model ?? local.model
const currentModel = modelSelection.current() const currentModel = modelSelection.current()
@@ -347,22 +364,47 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return return
} }
const submissionSDK = sdk()
const submissionSync = sync()
const submissionServerSync = serverSync()
const submissionScope = submissionSDK.scope
const projectDirectory = submissionSDK.directory
const projectRoot = submissionSync.project?.worktree ?? projectDirectory
const sessionID = params.id
const isNewSession = !sessionID
const currentSession = input.info()
const draftID = search.draftId
const draftServer = draftID ? tabs.draft(draftID).server : undefined
const capturePrompt = prompt.capture
const localSession = local.session
const handoff = layout.handoff
const resetWorktree = input.onNewSessionWorktreeReset
const onSubmit = input.onSubmit
const permissionState = permission.currentServerState()
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main"
const submissionKey = ScopedKey.from(
submissionScope,
draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`,
)
if (submitting.has(submissionKey)) return
submitting.add(submissionKey)
try {
input.addToHistory(currentPrompt, mode) input.addToHistory(currentPrompt, mode)
input.resetHistoryNavigation() input.resetHistoryNavigation()
const projectDirectory = sdk().directory
const permissionState = permission.currentServerState()
const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory let sessionDirectory = projectDirectory
let client = sdk().client let client = submissionSDK.client
let api = submissionSDK.api.session
if (isNewSession) { if (isNewSession) {
if (worktreeSelection === "create") { if (worktreeSelection === "create") {
const createdWorktree = await client.worktree const createdWorktree = await workspaceRequestWithTimeout(
.create({ directory: projectDirectory }) (signal) => client.worktree.create({ directory: projectDirectory }, { signal }),
language.t("prompt.toast.worktreeCreateFailed.title"),
input.worktreeRequestTimeoutMs ?? WORKSPACE_PREPARATION_TIMEOUT_MS,
)
.then((x) => x.data) .then((x) => x.data)
.catch((err) => { .catch((err) => {
showToast({ showToast({
@@ -379,7 +421,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}) })
return return
} }
WorktreeState.pending(sdk().scope, createdWorktree.directory) WorktreeState.pending(submissionScope, createdWorktree.directory)
sessionDirectory = createdWorktree.directory sessionDirectory = createdWorktree.directory
} }
@@ -388,20 +430,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
if (sessionDirectory !== projectDirectory) { if (sessionDirectory !== projectDirectory) {
client = sdk().createClient({ client = submissionSDK.createClient({
directory: sessionDirectory, directory: sessionDirectory,
throwOnError: true, throwOnError: true,
}) })
serverSync().child(sessionDirectory) api = submissionSDK.createApi(sessionDirectory).session
submissionServerSync.child(sessionDirectory)
}
} }
input.onNewSessionWorktreeReset?.() let session = currentSession
}
let session = input.info()
if (!session && isNewSession) { if (!session && isNewSession) {
const created = await sdk() const created = await submissionSDK.api.session
.api.session.create({ .create({
agent: currentAgent.name, agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
location: { directory: sessionDirectory }, location: { directory: sessionDirectory },
@@ -415,21 +456,27 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return undefined return undefined
}) })
if (created) { if (created) {
seed(sessionDirectory, created) seed(submissionServerSync, sessionDirectory, created)
session = created session = created
await startTransition(() => { await startTransition(() => {
if (!session) return if (!session) return
if (draftID) tabs.updateDraft(draftID, { worktree: undefined })
if (!draftID) resetWorktree?.()
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory) if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id, { localSession.promote(sessionDirectory, session.id, {
agent: currentAgent.name, agent: currentAgent.name,
model: { providerID: currentModel.provider.id, modelID: currentModel.id }, model: { providerID: currentModel.provider.id, modelID: currentModel.id },
variant: variant ?? null, variant: variant ?? null,
}) })
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) submission.retarget(
capturePrompt(
{ dir: base64Encode(sessionDirectory), id: session.id },
{ server: draftServer, scope: submissionScope },
),
)
}) })
} }
} }
@@ -486,96 +533,45 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return return
} }
input.onSubmit?.() const startWorkspaceOperation = (messageID: string) => {
if (!isNewSession) return
if (mode === "shell") { if (worktreeSelection !== "main" && worktreeSelection !== "create" && sessionDirectory !== projectRoot) {
clearInput() WorkspaceOperation.start(submissionScope, session.id, "move", sessionDirectory, messageID)
const eventID = Event.ID.create() WorkspaceOperation.complete(submissionScope, session.id)
sdk() }
.api.session.shell({ if (worktreeSelection !== "create") return
sessionID: session.id, const worktree = WorktreeState.get(submissionScope, sessionDirectory)
id: eventID, WorkspaceOperation.start(submissionScope, session.id, "create", sessionDirectory, messageID)
command: text, if (worktree?.status === "ready") WorkspaceOperation.complete(submissionScope, session.id)
agent, if (worktree?.status === "failed") WorkspaceOperation.fail(submissionScope, session.id)
model,
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.shellSendFailed.title"),
description: errorMessage(err),
})
restoreInput()
})
return
} }
if (text.startsWith("/")) { const waitForWorktree = async (cleanup: VoidFunction) => {
const [cmdName, ...args] = text.split(" ") const worktree = WorktreeState.get(submissionScope, sessionDirectory)
const commandName = cmdName.slice(1) if (!worktree) return true
const customCommand = sync().data.command.find((c) => c.name === commandName) if (worktree.status === "ready") {
if (customCommand) { WorkspaceOperation.complete(submissionScope, session.id)
clearInput() return true
const messageID = Identifier.ascending("message")
serverSync().session.set("session_status", session.id, { type: "busy" })
sdk()
.api.session.command({
sessionID: session.id,
id: messageID,
command: commandName,
arguments: args.join(" "),
agent,
model: { id: model.modelID, providerID: model.providerID, variant },
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
name: attachment.filename,
})),
),
})
.catch((err) => {
serverSync().session.set("session_status", session.id, { type: "idle" })
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")),
})
restoreInput()
})
return
} }
if (worktree.status === "failed") {
WorkspaceOperation.fail(submissionScope, session.id)
throw new Error(worktree.message)
} }
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")
const removeOptimisticMessage = () => {
sync().session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
})
}
for (const item of commentItems) submission.target().context.remove(item.key)
clearInput()
const waitForWorktree = async () => {
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
if (!worktree || worktree.status !== "pending") return true
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
sync().set("session_status", session.id, { type: "busy" }) submissionSync.set("session_status", session.id, { type: "busy" })
} }
const controller = new AbortController() const controller = new AbortController()
const cleanup = () => { const key = pendingKey(submissionScope, session.id)
if (sessionDirectory === projectDirectory) { pendingSubmission = { key, scope: submissionScope, sessionID: session.id }
sync().set("session_status", session.id, { type: "idle" }) pending.set(key, {
} abort: controller,
removeOptimisticMessage() cleanup,
if (restoreInput()) restoreCommentItems(submission.target(), commentItems) scope: submissionScope,
} sessionID: session.id,
serverSync: submissionServerSync,
pending.set(pendingKey(session.id), { abort: controller, cleanup }) })
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => { const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) { if (controller.signal.aborted) {
@@ -603,31 +599,131 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}) })
const result = await Promise.race([ const result = await Promise.race([
WorktreeState.wait(sdk().scope, sessionDirectory), WorktreeState.wait(submissionScope, sessionDirectory),
abortWait, abortWait,
timeout, timeout,
]).finally(() => { ]).finally(() => {
pending.delete(key)
if (pendingSubmission?.key === key) pendingSubmission = undefined
if (timer.id === undefined) return if (timer.id === undefined) return
clearTimeout(timer.id) clearTimeout(timer.id)
}) })
pending.delete(pendingKey(session.id))
if (controller.signal.aborted) return false if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message) if (result.status === "failed") {
WorkspaceOperation.fail(submissionScope, session.id)
throw new Error(result.message)
}
WorkspaceOperation.complete(submissionScope, session.id)
return true return true
} }
if (!draftID || search.draftId === draftID) onSubmit?.()
if (mode === "shell") {
clearInput()
const eventID = Event.ID.create()
startWorkspaceOperation(eventID)
void waitForWorktree(() => {
restoreInput()
})
.then((ready) => {
if (!ready) return
return api.shell({
sessionID: session.id,
id: eventID,
command: text,
agent,
model,
})
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.shellSendFailed.title"),
description: errorMessage(err),
})
restoreInput()
})
return
}
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
if (customCommand) {
clearInput()
const messageID = Identifier.ascending("message")
startWorkspaceOperation(messageID)
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
void waitForWorktree(() => {
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
restoreInput()
})
.then(async (ready) => {
if (!ready) return
return api.command({
sessionID: session.id,
id: messageID,
command: commandName,
arguments: args.join(" "),
agent,
model: { id: model.modelID, providerID: model.providerID, variant },
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
name: attachment.filename,
})),
),
})
})
.catch((err) => {
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")),
})
restoreInput()
})
return
}
}
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")
startWorkspaceOperation(messageID)
const removeOptimisticMessage = () => {
submissionSync.session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
})
}
for (const item of commentItems) submission.target().context.remove(item.key)
clearInput()
const cleanup = () => {
if (sessionDirectory === projectDirectory) {
submissionSync.set("session_status", session.id, { type: "idle" })
}
removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
}
void sendFollowupDraft({ void sendFollowupDraft({
api: sdk().api.session, api,
sync: sync(), scope: submissionScope,
serverSync: serverSync(), sync: submissionSync,
serverSync: submissionServerSync,
draft, draft,
messageID, messageID,
optimisticBusy: sessionDirectory === projectDirectory, optimisticBusy: sessionDirectory === projectDirectory,
before: waitForWorktree, before: () => waitForWorktree(cleanup),
}).catch((err) => { }).catch((err) => {
pending.delete(pendingKey(session.id)) pending.delete(pendingKey(submissionScope, session.id))
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
sync().set("session_status", session.id, { type: "idle" }) submissionSync.set("session_status", session.id, { type: "idle" })
} }
showToast({ showToast({
title: language.t("prompt.toast.promptSendFailed.title"), title: language.t("prompt.toast.promptSendFailed.title"),
@@ -636,6 +732,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
removeOptimisticMessage() removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems) if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
}) })
} finally {
submitting.delete(submissionKey)
}
} }
return { return {
@@ -1,8 +1,7 @@
import { For, Show } from "solid-js" import { createMemo, createSignal, For, Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -11,25 +10,42 @@ export function PromptWorkspaceSelector(props: {
projectRoot: string projectRoot: string
workspaces: string[] workspaces: string[]
branch?: string branch?: string
onboarding?: boolean
onChange: (value: string) => void onChange: (value: string) => void
onDone: () => void onDone: () => void
onViewAll: () => void
}) { }) {
const language = useLanguage() const language = useLanguage()
let pending: string | undefined const [search, setSearch] = createSignal("")
let searchInput: HTMLInputElement | undefined
let focusSearch = false
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
const selected = () => (props.value === props.projectRoot ? "main" : props.value) const selected = () => (props.value === props.projectRoot ? "main" : props.value)
const workspaces = createMemo(() => {
const query = search().trim().toLowerCase()
if (!query) return props.workspaces
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
})
const icon = () => { const icon = () => {
if (selected() === "main") return "monitor" if (selected() === "main") return "monitor"
if (selected() === "create") return "workspace-new" if (selected() === "create") return "workspace-new"
return "workspace" return "workspace-isolated"
} }
const select = (value: string) => { const select = (value: string) => {
pending = value pending = { type: "select", value }
} }
const onOpenChange = (open: boolean) => { const onOpenChange = (open: boolean) => {
if (open) return if (open) {
const value = pending setSearch("")
return
}
const action = pending
pending = undefined pending = undefined
if (value) props.onChange(value) if (action?.type === "select") props.onChange(action.value)
if (action?.type === "viewAll") {
props.onViewAll()
return
}
props.onDone() props.onDone()
} }
const label = () => { const label = () => {
@@ -41,44 +57,160 @@ export function PromptWorkspaceSelector(props: {
return ( return (
<> <>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span> <span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2
placement="top"
value={
props.onboarding ? (
<div class="flex flex-col gap-1 text-left">
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
<span>{language.t("workspace.onboarding.title")}</span>
</div>
<span class="font-[440] text-v2-text-text-muted">
{language.t("workspace.onboarding.description")}
</span>
</div>
) : (
language.t("session.new.workspace.trigger.tooltip")
)
}
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
class="min-w-0"
>
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}> <MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"> <MenuV2.Trigger
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" /> aria-description={language.t("session.new.workspace.trigger.tooltip")}
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
>
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{label()}</span> <span class="min-w-0 truncate">{label()}</span>
<Show when={props.onboarding}>
<span
data-slot="workspace-onboarding-dot"
aria-hidden="true"
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
/>
</Show>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" /> <Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger> </MenuV2.Trigger>
<MenuV2.Portal> <MenuV2.Portal>
<MenuV2.Content class="w-[180px]"> <MenuV2.Content class="w-[200px]">
<MenuV2.Group> <MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel> <MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<MenuV2.Item onSelect={() => select("main")}> <MenuV2.Item onSelect={() => select("main")}>
<IconV2 name="monitor" /> <Icon name="monitor" />
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span> <TooltipV2
placement="right"
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("session.new.workspace.local")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.local.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
</TooltipV2>
<Show when={selected() === "main"}> <Show when={selected() === "main"}>
<Icon name="check" size="small" class="shrink-0" /> <Icon name="check" size="small" class="shrink-0" />
</Show> </Show>
</MenuV2.Item> </MenuV2.Item>
<MenuV2.Item onSelect={() => select("create")}> <MenuV2.Item onSelect={() => select("create")}>
<IconV2 name="workspace-new" /> <Icon name="workspace-new" />
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span> <TooltipV2
placement="right"
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("workspace.new")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.new.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</TooltipV2>
<Show when={selected() === "create"}> <Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" /> <Icon name="check" size="small" class="shrink-0" />
</Show> </Show>
</MenuV2.Item> </MenuV2.Item>
</MenuV2.Group> </MenuV2.Group>
<Show when={props.workspaces.length > 0}> <Show
<MenuV2.Separator /> when={props.workspaces.length > 0}
<MenuV2.Sub gutter={0} overlap overflowPadding={8}> fallback={
<MenuV2.SubTrigger> <>
<IconV2 name="workspace" /> <MenuV2.Separator class="h-[0.5px]" />
{language.t("session.new.workspace.existing")} <MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</>
}
>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Sub
gutter={0}
overlap
overflowPadding={8}
onOpenChange={(open) => {
if (!open) {
focusSearch = false
return
}
if (!focusSearch || props.workspaces.length < 10) return
focusSearch = false
requestAnimationFrame(() => searchInput?.focus())
}}
>
<MenuV2.SubTrigger
onKeyDown={(event) => {
if (
event.key === "ArrowRight" ||
event.key === "ArrowLeft" ||
event.key === "Enter" ||
event.key === " "
)
focusSearch = true
}}
>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</span>
</MenuV2.SubTrigger> </MenuV2.SubTrigger>
<MenuV2.Portal> <MenuV2.Portal>
<MenuV2.SubContent class="max-w-[200px]"> <MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
<For each={props.workspaces}> <Show when={props.workspaces.length >= 10}>
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(element) => {
searchInput = element
}}
value={search()}
placeholder={language.t("session.new.workspace.search.placeholder")}
aria-label={language.t("session.new.workspace.search.placeholder")}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key === "Escape" ||
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter"
)
return
event.stopPropagation()
}}
/>
</div>
</Show>
<For each={workspaces()}>
{(workspace) => ( {(workspace) => (
<MenuV2.Item onSelect={() => select(workspace)}> <MenuV2.Item onSelect={() => select(workspace)}>
<IconV2 name="workspace-isolated" /> <Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span> <span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}> <Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" /> <Icon name="check" size="small" class="shrink-0" />
@@ -86,6 +218,10 @@ export function PromptWorkspaceSelector(props: {
</MenuV2.Item> </MenuV2.Item>
)} )}
</For> </For>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</MenuV2.SubContent> </MenuV2.SubContent>
</MenuV2.Portal> </MenuV2.Portal>
</MenuV2.Sub> </MenuV2.Sub>
@@ -93,35 +229,45 @@ export function PromptWorkspaceSelector(props: {
</MenuV2.Content> </MenuV2.Content>
</MenuV2.Portal> </MenuV2.Portal>
</MenuV2> </MenuV2>
<PromptGitStatus branch={props.branch} /> </TooltipV2>
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ml-1" />
</> </>
) )
} }
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) { export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
const language = useLanguage() const language = useLanguage()
const label = () => { const label = () => {
if (props.noGit) return language.t("session.new.git.none") if (props.noGit) return language.t("session.new.git.none")
if (!props.branch) return undefined
if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch })
return props.branch return props.branch
} }
const icon = () => {
if (props.noGit) return "monitor"
if (props.from) return "branch-out"
return "branch"
}
return ( return (
<Show when={label()}> <Show when={label()}>
{(value) => ( {(value) => (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2 <TooltipV2
placement="top" placement="top"
value={value()} value={value()}
class="min-w-0 max-w-[220px]" class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
contentClass="max-w-[calc(100vw-32px)] break-all" contentClass="max-w-[calc(100vw-32px)] break-all"
> >
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]"> <div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" /> <Icon
name={icon()}
size="small"
class="shrink-0 text-v2-icon-icon-muted"
/>
<span class="min-w-0 truncate">{value()}</span> <span class="min-w-0 truncate">{value()}</span>
</div> </div>
</TooltipV2> </TooltipV2>
</>
)} )}
</Show> </Show>
) )
@@ -0,0 +1,196 @@
import type { Project } from "@opencode-ai/sdk/v2/client"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path"
import { createStore } from "solid-js/store"
import { For, Show, type ComponentProps, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useSettingsDialog } from "@/components/settings-dialog"
import { pathKey } from "@/utils/path-key"
import { Worktree } from "@/utils/worktree"
import { WorkspaceOperation } from "@/utils/workspace-operation"
import { showToast } from "@/utils/toast"
import type { ServerScope } from "@/utils/server-scope"
import { workspaceDirectories } from "@/utils/workspace"
import {
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
WORKSPACE_PREPARATION_TIMEOUT_MS,
workspaceRequestWithTimeout,
} from "@/utils/workspace-request"
export function SessionWorkspaceMenu(props: {
eligible?: boolean
sessionID: string
project: Project
directory: string
messageID?: string
placement?: ComponentProps<typeof MenuV2>["placement"]
gutter?: number
class?: string
contentClass?: string
children: JSX.Element
onOpenChange?: (open: boolean) => void
}) {
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const openWorkspaces = useSettingsDialog("workspaces")
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const operationPending = () => WorkspaceOperation.get(serverSDK().scope, props.sessionID)?.status === "pending"
const blocked = () =>
props.eligible === false || operationPending() || serverSync().session.data.session_working(props.sessionID)
const workspaces = () =>
workspaceDirectories(props.project).filter((workspace) => pathKey(workspace) !== pathKey(props.directory))
const fail = (scope: ServerScope, sessionID: string, message: string) => {
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
WorkspaceOperation.fail(scope, sessionID)
showToast({ variant: "error", title: language.t("workspace.move.failed"), description: message })
}
const move = async (selection: "create" | string) => {
if (store.selected || blocked()) return
const sdk = serverSDK()
const sync = serverSync()
const scope = sdk.scope
const sessionID = props.sessionID
const messageID = props.messageID
const root = props.project.worktree
const source = props.directory
setStore("selected", selection)
try {
const destination =
selection === "create"
? await createWorkspace(root, sessionID, messageID, sdk, (message) => fail(scope, sessionID, message), {
createFailed: language.t("prompt.toast.worktreeCreateFailed.title"),
stillPreparing: language.t("workspace.error.stillPreparing"),
})
: selection
if (!destination) return
WorkspaceOperation.start(scope, sessionID, selection === "create" ? "create" : "move", destination, messageID)
if (sync.session.data.session_working(sessionID)) throw new Error(language.t("workspace.move.failed"))
await workspaceRequestWithTimeout(
(signal) =>
sdk.client.experimental.controlPlane.moveSession(
{
sessionID,
destination: { directory: destination },
moveChanges: true,
},
{ signal },
),
language.t("workspace.move.failed"),
WORKSPACE_PREPARATION_TIMEOUT_MS,
)
const session = await workspaceRequestWithTimeout(
(signal) => sync.session.resolve(sessionID, { force: true, signal }),
language.t("workspace.move.failed"),
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
)
if (!session || pathKey(session.directory) !== pathKey(destination))
throw new Error(language.t("workspace.move.failed"))
WorkspaceOperation.complete(scope, sessionID, destination)
sync.reindexSession(sessionID, source)
} catch (error) {
fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed"))
} finally {
setStore("selected", undefined)
}
}
return (
<MenuV2
placement={props.placement ?? "bottom-end"}
gutter={props.gutter ?? 4}
modal={false}
onOpenChange={props.onOpenChange}
>
<MenuV2.Trigger class={props.class} disabled={blocked()}>
{props.children}
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class={`w-[200px] ${props.contentClass ?? ""}`}>
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("workspace.move.menu.title")}</MenuV2.GroupLabel>
<Show when={pathKey(props.directory) !== pathKey(props.project.worktree)}>
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
<Icon name="monitor" />
{language.t("session.new.workspace.local")}
</MenuV2.Item>
</Show>
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Icon name="workspace-new" />
{language.t("workspace.new")}
</MenuV2.Item>
<Show when={workspaces().length > 0}>
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<Icon name="workspace-isolated" />
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
<For each={workspaces()}>
{(workspace) => (
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
</MenuV2.Item>
)}
</For>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
</MenuV2.Group>
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
<MenuV2.Item onSelect={() => openWorkspaces()}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
)
}
async function createWorkspace(
root: string,
sessionID: string,
messageID: string | undefined,
serverSDK: ReturnType<ReturnType<typeof useServerSDK>>,
fail: (message: string) => void,
messages: { createFailed: string; stillPreparing: string },
) {
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", root, messageID)
const created = await workspaceRequestWithTimeout(
(signal) => serverSDK.client.worktree.create({ directory: root }, { signal }),
messages.createFailed,
WORKSPACE_PREPARATION_TIMEOUT_MS,
)
.then((result) => result.data)
.catch((error) => {
fail(error instanceof Error ? error.message : messages.createFailed)
return undefined
})
if (!created?.directory) return
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", created.directory, messageID)
Worktree.pending(serverSDK.scope, created.directory)
const timer = { id: undefined as ReturnType<typeof setTimeout> | undefined }
const timeout = new Promise<ReturnType<typeof Worktree.get>>((resolve) => {
timer.id = setTimeout(
() => resolve({ status: "failed", message: messages.stillPreparing }),
WORKSPACE_PREPARATION_TIMEOUT_MS,
)
})
const ready = await Promise.race([Worktree.wait(serverSDK.scope, created.directory), timeout]).finally(() => {
if (timer.id) clearTimeout(timer.id)
})
if (!ready || ready.status === "failed") {
fail(ready?.message ?? messages.createFailed)
return
}
return created.directory
}
@@ -5,12 +5,15 @@ import { checksum } from "@opencode-ai/core/util/encode"
import { findLast } from "@opencode-ai/core/util/array" import { findLast } from "@opencode-ai/core/util/array"
import { same } from "@/utils/same" import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Accordion } from "@opencode-ai/ui/accordion" import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/session-ui/file" import { File } from "@opencode-ai/session-ui/file"
import { Markdown } from "@opencode-ai/session-ui/markdown" import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
@@ -120,7 +123,8 @@ export function SessionContextTab() {
() => { () => {
const revert = info()?.revert?.messageID const revert = info()?.revert?.messageID
if (!revert) return userMessages() if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert) const boundary = userMessages().findIndex((message) => message.id === revert)
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
}, },
emptyUserMessages, emptyUserMessages,
{ equals: same }, { equals: same },
@@ -220,6 +224,31 @@ export function SessionContextTab() {
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
] satisfies { label: string; value: () => JSX.Element }[] ] satisfies { label: string; value: () => JSX.Element }[]
const exportSession = async () => {
const sessionID = params.id
if (!sessionID) return
try {
const data = await fetchSessionExport({
sessionID,
client: sdk().client,
})
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("toast.session.export.success.title"),
description: language.t("toast.session.export.success.description", { filename }),
})
} catch (err) {
showToast({
variant: "error",
title: language.t("toast.session.export.failed.title"),
description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
})
}
}
let scroll: HTMLDivElement | undefined let scroll: HTMLDivElement | undefined
let frame: number | undefined let frame: number | undefined
let pending: { x: number; y: number } | undefined let pending: { x: number; y: number } | undefined
@@ -328,7 +357,18 @@ export function SessionContextTab() {
</Show> </Show>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div> <div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Button
size="small"
variant="ghost"
class="gap-1.5 px-2 text-text-weak hover:text-text-base"
onClick={exportSession}
>
<Icon name="download" size="small" />
<span>{language.t("context.export.session")}</span>
</Button>
</div>
<Accordion multiple> <Accordion multiple>
<For each={messages()}> <For each={messages()}>
{(message) => ( {(message) => (
@@ -7,6 +7,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { type Component, For, Show } from "solid-js" import { type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { useServerSync } from "@/context/server-sync"
import { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker" import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
@@ -43,6 +44,7 @@ export const SettingsModels: Component = () => {
const SettingsModelsContent: Component = () => { const SettingsModelsContent: Component = () => {
const language = useLanguage() const language = useLanguage()
const models = useModels() const models = useModels()
useServerSync()().loadProviders()
const list = useFilteredList<ModelItem>({ const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(), items: (_filter) => models.list(),
@@ -10,8 +10,8 @@ import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models" import { SettingsModelsV2 } from "./models"
import "./settings-v2.css" import "./settings-v2.css"
import { SettingsServersV2 } from "./servers" import { SettingsServersV2 } from "./servers"
import { SettingsWorkspacesV2 } from "./workspaces"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLayout } from "@/context/layout"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
@@ -22,18 +22,16 @@ export const DialogSettings: Component<{
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
const layout = useLayout()
const tabs = useTabs() const tabs = useTabs()
const serverSync = useServerSync() const serverSync = useServerSync()
const [tab, setTab] = createSignal(props.defaultValue ?? "general") const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const directory = createMemo(() => { const directory = createMemo(() => {
const route = layout.route() const active = tabs.active()
if (route.type === "dir-new-sesssion") return route.dir if (active?.type === "draft") return active.directory
if (route.type === "draft") { if (active?.type === "session") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) const session = serverSync().session.get(active.sessionId)
return draft?.type === "draft" ? draft.directory : undefined if (session) return session.directory
} }
if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
return undefined return undefined
}) })
@@ -58,11 +56,11 @@ export const DialogSettings: Component<{
<TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle> <TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full"> <div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="general"> <TabsV2.Trigger value="general">
<Icon name="sliders" /> <Icon name="sliders" size="small" />
{language.t("settings.tab.general")} {language.t("settings.tab.general")}
</TabsV2.Trigger> </TabsV2.Trigger>
<TabsV2.Trigger value="shortcuts"> <TabsV2.Trigger value="shortcuts">
<Icon name="keyboard" /> <Icon name="keyboard" size="small" />
{language.t("settings.tab.shortcuts")} {language.t("settings.tab.shortcuts")}
</TabsV2.Trigger> </TabsV2.Trigger>
</div> </div>
@@ -71,16 +69,20 @@ export const DialogSettings: Component<{
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle> <TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full"> <div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="workspaces">
<Icon name="workspace-isolated" size="small" />
{language.t("settings.tab.workspaces")}
</TabsV2.Trigger>
<TabsV2.Trigger value="servers"> <TabsV2.Trigger value="servers">
<Icon name="server" /> <Icon name="server" size="small" />
{language.t("status.popover.tab.servers")} {language.t("status.popover.tab.servers")}
</TabsV2.Trigger> </TabsV2.Trigger>
<TabsV2.Trigger value="providers"> <TabsV2.Trigger value="providers">
<Icon name="providers" /> <Icon name="providers" size="small" />
{language.t("settings.providers.title")} {language.t("settings.providers.title")}
</TabsV2.Trigger> </TabsV2.Trigger>
<TabsV2.Trigger value="models"> <TabsV2.Trigger value="models">
<Icon name="models" /> <Icon name="models" size="small" />
{language.t("settings.models.title")} {language.t("settings.models.title")}
</TabsV2.Trigger> </TabsV2.Trigger>
</div> </div>
@@ -99,6 +101,9 @@ export const DialogSettings: Component<{
<TabsV2.Content value="shortcuts" class="settings-v2-panel"> <TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds v2 /> <SettingsKeybinds v2 />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="workspaces" class="settings-v2-panel">
<SettingsWorkspacesV2 activeDirectory={directory()} />
</TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel"> <TabsV2.Content value="servers" class="settings-v2-panel">
<SettingsServersV2 /> <SettingsServersV2 />
</TabsV2.Content> </TabsV2.Content>
@@ -8,7 +8,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useUpdaterAction } from "../updater-action" import { useUpdaterAction } from "../updater-action"
import { useSettings } from "@/context/settings" import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings"
import { ExternalLink } from "../external-link" import { ExternalLink } from "../external-link"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row" import { SettingsRowV2 } from "./parts/row"
@@ -87,6 +87,34 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
) )
} }
const WorkspaceDestinationSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
const options = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [
{ value: "last-used", label: language.t("settings.workspaces.default.lastUsed") },
{ value: "local", label: language.t("settings.workspaces.default.local") },
{ value: "new", label: language.t("settings.workspaces.default.new") },
])
return (
<SettingsRowV2
title={language.t("settings.workspaces.default.title")}
description={language.t("settings.workspaces.default.description")}
>
<SelectV2
appearance="inline"
options={options()}
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
value={(option) => option.value}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)}
/>
</SettingsRowV2>
)
}
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
const language = useLanguage() const language = useLanguage()
const options = createMemo(() => const options = createMemo(() =>
@@ -329,6 +357,7 @@ export const SettingsGeneralV2: Component<{
<SettingsListV2> <SettingsListV2>
<LanguageSetting /> <LanguageSetting />
<WorkspaceDestinationSetting />
<PermissionScopeSetting controller={permissionScope} /> <PermissionScopeSetting controller={permissionScope} />
<ShellSetting controller={shell} /> <ShellSetting controller={shell} />
@@ -391,18 +420,6 @@ export const SettingsGeneralV2: Component<{
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3> <h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
<SettingsListV2> <SettingsListV2>
<SettingsRowV2
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>
</SettingsRowV2>
<SettingsRowV2 <SettingsRowV2
title={language.t("settings.general.row.showSearch.title")} title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")} description={language.t("settings.general.row.showSearch.description")}
@@ -9,6 +9,7 @@ import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
@@ -23,6 +24,7 @@ export const SettingsModelsV2: Component = () => {
const language = useLanguage() const language = useLanguage()
const models = useModels() const models = useModels()
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
useServerSync()().loadProviders()
const [store, setStore] = persisted( const [store, setStore] = persisted(
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"), Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
createStore({ collapsed: {} as Record<string, boolean> }), createStore({ collapsed: {} as Record<string, boolean> }),
@@ -287,7 +287,7 @@
} }
.settings-v2-provider-env-hint { .settings-v2-provider-env-hint {
padding-right: 12px; padding-inline-end: 12px;
font-size: 13px; font-size: 13px;
font-weight: 440; font-weight: 440;
line-height: 1; line-height: 1;
@@ -311,7 +311,7 @@
line-height: 1; line-height: 1;
color: var(--v2-text-text-accent); color: var(--v2-text-text-accent);
cursor: pointer; cursor: pointer;
text-align: left; text-align: start;
} }
.settings-v2-providers-view-all:hover { .settings-v2-providers-view-all:hover {
@@ -361,13 +361,13 @@
} }
.settings-v2-tab-search [data-slot="text-input-v2-input"] { .settings-v2-tab-search [data-slot="text-input-v2-input"] {
padding-right: 28px; padding-inline-end: 28px;
} }
.settings-v2-tab-search-clear { .settings-v2-tab-search-clear {
position: absolute; position: absolute;
top: 50%; top: 50%;
right: 6px; inset-inline-end: 6px;
z-index: 1; z-index: 1;
transform: translateY(-50%); transform: translateY(-50%);
} }
@@ -684,6 +684,223 @@
color: var(--v2-text-text-base); color: var(--v2-text-text-base);
} }
.settings-v2-tab-header.settings-v2-workspaces-header {
padding-bottom: 24px;
}
.settings-v2-workspaces-header .settings-v2-tab-title {
font-weight: 610;
}
.settings-v2-tab-body.settings-v2-workspaces {
gap: 16px;
}
.settings-v2-workspaces-toolbar {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-v2-workspaces-count {
font-size: 15px;
font-weight: 530;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-v2-workspaces-toolbar-actions {
display: flex;
align-items: center;
gap: 4px;
}
.settings-v2-workspaces-delete-all {
color: var(--v2-state-fg-danger);
}
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
display: flex;
flex-direction: column;
gap: 0;
padding: 20px;
border-radius: 6px;
background-color: var(--v2-background-bg-base);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
}
.settings-v2-workspaces-row {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
}
.settings-v2-workspaces-row:not(:last-child) {
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-workspaces-row-header {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.settings-v2-workspaces-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 8px;
}
.settings-v2-workspaces-main {
display: flex;
min-width: 0;
}
.settings-v2-workspaces-row-actions {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.settings-v2-workspaces-main [data-component="tooltip-v2-trigger"] {
min-width: 0;
}
.settings-v2-workspaces-path {
display: block;
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-family: inherit;
font-size: 13px;
font-weight: 530;
line-height: 1;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
text-align: left;
cursor: default;
}
.settings-v2-workspaces-meta {
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-faint);
}
.settings-v2-workspaces-active,
.settings-v2-workspaces-more {
flex-shrink: 0;
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-faint);
}
.settings-v2-workspaces-sessions {
display: flex;
flex-direction: column;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 4px;
background-color: var(--v2-background-bg-base);
overflow: hidden;
}
.settings-v2-workspaces-session {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
font-size: 13px;
font-weight: 440;
line-height: 16px;
color: var(--v2-text-text-base);
}
.settings-v2-workspaces-session:not(:last-child) {
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-workspaces-session > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-v2-workspaces-session-time {
flex-shrink: 0;
font-size: 11px;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-v2-workspaces-empty {
display: flex;
align-items: center;
justify-content: center;
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
}
@media (max-width: 639px) {
.settings-v2-workspaces-header {
padding: 24px 20px 20px;
}
.settings-v2-tab-body.settings-v2-workspaces {
padding: 0 20px 24px;
}
.settings-v2-workspaces-toolbar,
.settings-v2-workspaces-main {
align-items: flex-start;
}
.settings-v2-workspaces-toolbar {
flex-wrap: wrap;
}
.settings-v2-workspaces-toolbar-actions {
width: 100%;
flex-wrap: wrap;
justify-content: space-between;
}
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
padding: 14px;
}
.settings-v2-workspaces-path {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
.settings-v2-workspaces-active {
display: none;
}
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] { [data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
width: 480px; width: 480px;
max-width: calc(100vw - 32px); max-width: calc(100vw - 32px);
@@ -0,0 +1,475 @@
import type { Component } from "solid-js"
import { For, Show, createMemo } from "solid-js"
import { createStore, produce } from "solid-js/store"
import type { Project, Session } from "@opencode-ai/sdk/v2/client"
import { useQuery } from "@tanstack/solid-query"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { showToast } from "@/utils/toast"
import { getRelativeTime } from "@/utils/time"
import { pathKey } from "@/utils/path-key"
import { SettingsListV2 } from "./parts/list"
import { useTabs } from "@/context/tabs"
import { usePlatform } from "@/context/platform"
import { clearWorkspaceTerminals } from "@/context/terminal"
import { ServerConnection } from "@/context/server"
import {
containsDirectory,
filterWorkspaceInventory,
inspectWorkspaceDeletion,
mergeWorkspaceSessionInventory,
removeWorkspacesSequentially,
sessionsForWorkspace,
type WorkspaceDeleteInspection,
workspaceInventory,
} from "@/utils/workspace"
import { listAllSessions, normalizeSessionInfo } from "@/utils/session"
import type { ServerScope } from "@/utils/server-scope"
import "./settings-v2.css"
type Workspace = {
directory: string
project: Project
}
export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const tabs = useTabs()
const platform = usePlatform()
const [store, setStore] = createStore({
project: "all",
transaction: undefined as "confirm" | "running" | undefined,
})
const workspaces = createMemo(() => workspaceInventory(serverSync().data.project))
const projects = createMemo(() => serverSync().data.project.filter((project) => project.sandboxes?.length))
const projectName = (project: Project) => project.name || getFilename(project.worktree)
const projectOptions = createMemo(() => [
{ id: "all", label: language.t("settings.workspaces.filter.all") },
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
])
const selectedProject = createMemo(() =>
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
)
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
const captureDeleteContext = () => {
const sdk = serverSDK()
return { sdk, sync: serverSync(), server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
}
const loadSessions = async (context = captureDeleteContext()) => {
const protocol = await context.sdk.protocol
const fetched =
protocol === "v1"
? await context.sdk.api.session.list({ limit: 1000, order: "desc" }).then((response) => {
if (response.data.length >= 1000) throw new Error("Incomplete legacy session inventory")
return response.data.map(normalizeSessionInfo)
})
: await listAllSessions(context.sdk.api.session, { order: "desc" })
return mergeWorkspaceSessionInventory(
fetched,
Object.values(context.sync.session.data.info).filter((session): session is Session => !!session),
)
}
const sessionQuery = useQuery(() => ({
queryKey: [serverSDK().scope, null, "settings-workspace-sessions"] as const,
queryFn: () => loadSessions(),
refetchOnMount: "always",
}))
const workspaceSessions = (workspace: Workspace) => {
if (!sessionQuery.isSuccess) return []
return sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory)
}
const sessionCount = (workspace: Workspace) => {
if (sessionQuery.isPending) return language.t("session.messages.loading")
if (sessionQuery.isError) return language.t("common.requestFailed")
const count = workspaceSessions(workspace).length
return language.plural("settings.workspaces.sessions", count, {
count,
project: projectName(workspace.project),
})
}
const lastActive = (workspace: Workspace) => {
const updated = workspaceSessions(workspace)[0]?.time.updated
if (!updated) return undefined
return getRelativeTime(new Date(updated).toISOString(), language.t)
}
const sessionTime = (session: Session) => {
if (!session.time.updated) return undefined
return getRelativeTime(new Date(session.time.updated).toISOString(), language.t)
}
const inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
const [working, branch, sessions] = await Promise.all([
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
loadSessions(context),
])
const result = inspectWorkspaceDeletion({
workspace: workspace.directory,
activeDirectory: context.activeDirectory,
sessions,
status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean",
})
return { result, sessions }
}
const inspectionMessage = (result: WorkspaceDeleteInspection) => {
if (result === "active") return language.t("settings.workspaces.delete.blocked.active")
if (result === "linked") return language.t("settings.workspaces.delete.blocked.linked")
if (result === "dirty") return language.t("workspace.status.dirty")
return language.t("workspace.status.clean")
}
const blocked = (result: WorkspaceDeleteInspection) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: inspectionMessage(result),
})
}
const remove = async (workspace: Workspace, allowDirty = false, context = captureDeleteContext()) => {
const preflight = await inspect(workspace, context)
if (preflight.result !== "safe" && (!allowDirty || preflight.result !== "dirty")) {
blocked(preflight.result)
return
}
const removed = await context.sdk.client.worktree
.remove({
directory: workspace.project.worktree,
worktreeRemoveInput: { directory: workspace.directory },
})
.then((result) => result.data)
.catch((error) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
return false
})
if (!removed) return
tabs.store.forEach((tab) => {
if (tab.type !== "draft" || tab.server !== context.server) return
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
if (!directoryMatches && !worktreeMatches) return
tabs.updateDraft(tab.draftID, {
directory: directoryMatches ? workspace.project.worktree : tab.directory,
worktree: undefined,
})
})
clearWorkspaceTerminals(
workspace.directory,
preflight.sessions.map((session) => session.id),
platform,
context.sdk.scope,
)
context.sync.set(
"project",
produce((draft) => {
const project = draft.find((item) => item.id === workspace.project.id)
if (!project) return
project.sandboxes = (project.sandboxes ?? []).filter(
(directory) => pathKey(directory) !== pathKey(workspace.directory),
)
}),
)
}
let inspectionID = 0
const releaseConfirmation = () => {
if (store.transaction === "confirm") setStore("transaction", undefined)
}
const transact = async (task: () => Promise<void>) => {
if (store.transaction !== "confirm") return
setStore("transaction", "running")
try {
await task()
} catch (error) {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
} finally {
setStore("transaction", undefined)
}
}
const confirmDelete = (workspace: Workspace) => {
if (store.transaction) return
const context = captureDeleteContext()
const current = ++inspectionID
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteWorkspace
workspace={workspace}
scope={context.sdk.scope}
inspectionID={current}
inspect={() => inspect(workspace, context)}
inspectionMessage={inspectionMessage}
onDelete={() => transact(() => remove(workspace, true, context))}
/>
),
releaseConfirmation,
)
}
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context))
}
const confirmDeleteAll = () => {
if (store.transaction) return
const context = captureDeleteContext()
const inventory = [...filtered()]
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteAllWorkspaces
count={inventory.length}
project={project}
onDelete={() => transact(() => removeAll(inventory, context))}
/>
),
releaseConfirmation,
)
}
return (
<>
<div class="settings-v2-tab-header settings-v2-workspaces-header">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
</div>
<div class="settings-v2-tab-body settings-v2-workspaces">
<div class="settings-v2-workspaces-toolbar">
<span class="settings-v2-workspaces-count">
{language.plural("settings.workspaces.count", filtered().length)}
</span>
<div class="settings-v2-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<SelectV2
appearance="inline"
options={projectOptions()}
current={projectOptions().find((option) => option.id === selectedProject())}
value={(option) => option.id}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && setStore("project", option.id)}
/>
</Show>
<Show when={filtered().length > 0}>
<MenuV2 placement="bottom-end" gutter={4}>
<MenuV2.Trigger
as={IconButtonV2}
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("common.moreOptions")}
disabled={!!store.transaction}
icon={<Icon name="outline-dots" size="small" />}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={confirmDeleteAll}>
<span class="settings-v2-workspaces-delete-all">
{language.t("settings.workspaces.deleteAll")}
</span>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Show>
</div>
</div>
<div class="settings-v2-workspaces-inventory">
<Show
when={filtered().length > 0}
fallback={<div class="settings-v2-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
>
<SettingsListV2>
<For each={filtered()}>
{(workspace) => {
const linked = () => workspaceSessions(workspace)
return (
<div class="settings-v2-workspaces-row">
<div class="settings-v2-workspaces-row-header">
<div class="settings-v2-workspaces-copy">
<div class="settings-v2-workspaces-main">
<TooltipV2
value={workspace.directory}
placement="top-start"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span tabIndex={0} aria-label={workspace.directory} class="settings-v2-workspaces-path">
{workspace.directory}
</span>
</TooltipV2>
</div>
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
</div>
<div class="settings-v2-workspaces-row-actions">
<Show when={lastActive(workspace)}>
{(value) => (
<TooltipV2
value={language.t("settings.workspaces.lastActiveSession")}
placement="top-end"
>
<span tabIndex={0} class="settings-v2-workspaces-active">
{value()}
</span>
</TooltipV2>
)}
</Show>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("workspace.delete.confirm", {
name: getFilename(workspace.directory),
})}
disabled={!!store.transaction}
icon={<Icon name="trash" size="small" />}
onClick={() => confirmDelete(workspace)}
/>
</div>
</div>
<Show when={linked().length > 0}>
<div class="settings-v2-workspaces-sessions">
<For each={linked()}>
{(session) => (
<div class="settings-v2-workspaces-session">
<span>{session.title}</span>
<Show when={sessionTime(session)}>
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
</Show>
</div>
)}
</For>
</div>
</Show>
</div>
)
}}
</For>
</SettingsListV2>
</Show>
</div>
</div>
</>
)
}
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
const dialog = useDialog()
const language = useLanguage()
const remove = () => {
const deleting = props.onDelete()
dialog.close()
void deleting
}
return (
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("settings.workspaces.deleteAll")}
description={
<>
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
<br />
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
</>
}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="button" variant="danger" onClick={remove}>
{language.t("settings.workspaces.deleteAll")}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
function DialogDeleteWorkspace(props: {
workspace: Workspace
scope: ServerScope
inspectionID: number
inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: Session[] }>
inspectionMessage: (result: WorkspaceDeleteInspection) => string
onDelete: () => Promise<void>
}) {
const dialog = useDialog()
const language = useLanguage()
const status = useQuery(() => ({
queryKey: [props.scope, pathKey(props.workspace.directory), "workspace-delete-status", props.inspectionID] as const,
queryFn: props.inspect,
staleTime: 0,
}))
const description = () => {
if (status.isPending) return language.t("workspace.status.checking")
if (status.isError) return language.t("workspace.status.error")
return props.inspectionMessage(status.data?.result ?? "unknown")
}
const remove = () => {
const deleting = props.onDelete()
dialog.close()
void deleting
}
return (
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("workspace.delete.title")}
description={
<>
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
<br />
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
{props.workspace.directory}
</code>
<br />
{language.t("settings.workspaces.delete.warning")}
<br />
{description()}
</>
}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2
type="button"
variant="danger"
disabled={
status.isPending || status.isError || (status.data?.result !== "safe" && status.data?.result !== "dirty")
}
onClick={remove}
>
{language.t("workspace.delete.button")}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
+3 -3
View File
@@ -573,9 +573,8 @@ export const Terminal = (props: TerminalProps) => {
if (!result) return if (!result) return
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
if (result.response.status === 404 || result.response.status === 405) return if (result.response.status === 404 || result.response.status === 405) return
if (result.response.status === 403) if (result.response.status === 403) throw new Error(language.t("terminal.connectTicket.csrfError"))
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") throw new Error(language.t("terminal.connectTicket.statusError", { status: result.response.status }))
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
} }
// return sdk() // return sdk()
// .api.pty.connectToken({ // .api.pty.connectToken({
@@ -742,6 +741,7 @@ export const Terminal = (props: TerminalProps) => {
<div <div
ref={container} ref={container}
data-component="terminal" data-component="terminal"
dir="ltr"
data-prevent-autofocus data-prevent-autofocus
tabIndex={-1} tabIndex={-1}
style={{ "background-color": terminalColors().background }} style={{ "background-color": terminalColors().background }}
@@ -1,7 +1,7 @@
[data-titlebar-tab] [data-slot="tab-close"] { [data-titlebar-tab] [data-slot="tab-close"] {
position: absolute; position: absolute;
top: 4px; top: 4px;
right: 4px; inset-inline-end: 4px;
display: flex; display: flex;
height: 20px; height: 20px;
width: 20px; width: 20px;
@@ -52,7 +52,7 @@
content: ""; content: "";
position: absolute; position: absolute;
top: 8px; top: 8px;
left: -3.75px; inset-inline-start: -3.75px;
width: 1.5px; width: 1.5px;
height: 12px; height: 12px;
border-radius: 9999px; border-radius: 9999px;
@@ -86,6 +86,23 @@
); );
} }
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
-webkit-mask-image: linear-gradient(
to left,
black 0,
black calc(100% - var(--tab-title-fade-offset) - 16px),
transparent calc(100% - var(--tab-title-fade-offset)),
transparent 100%
);
mask-image: linear-gradient(
to left,
black 0,
black calc(100% - var(--tab-title-fade-offset) - 16px),
transparent calc(100% - var(--tab-title-fade-offset)),
transparent 100%
);
}
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] { [data-slot="tab-link"] {
--tab-title-fade-offset: 24px; --tab-title-fade-offset: 24px;
@@ -93,7 +110,7 @@
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"]) [data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] { [data-slot="tab-link"] {
padding-right: 0; padding-inline-end: 0;
} }
[data-titlebar-tab] [data-slot="tab-title"] { [data-titlebar-tab] [data-slot="tab-title"] {
@@ -5,6 +5,7 @@ import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers" import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
@@ -323,6 +324,7 @@ export function DraftTabItem(props: {
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
}) { }) {
const language = useLanguage()
const closeTab = (event: MouseEvent) => { const closeTab = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -397,7 +399,7 @@ export function DraftTabItem(props: {
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100" class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
onClick={closeTab} onClick={closeTab}
icon={<IconV2 name="xmark-small" />} icon={<IconV2 name="xmark-small" />}
aria-label="Close tab" aria-label={language.t("common.closeTab")}
/> />
</div> </div>
</div> </div>
+38 -7
View File
@@ -1,4 +1,15 @@
import { createEffect, createMemo, createResource, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js" import {
createEffect,
createMemo,
createResource,
createSignal,
Match,
on,
onMount,
Show,
Switch,
untrack,
} from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -42,8 +53,11 @@ export type TitlebarUpdate = {
} }
export function useTitlebarRightMount() { export function useTitlebarRightMount() {
const language = useLanguage()
const [mount, setMount] = createSignal<HTMLElement | null>(null) const [mount, setMount] = createSignal<HTMLElement | null>(null)
onMount(() => setMount(document.getElementById("opencode-titlebar-right"))) const sync = () => setMount(document.getElementById("opencode-titlebar-right"))
onMount(sync)
createEffect(on(language.direction, sync, { defer: true }))
return mount return mount
} }
@@ -113,9 +127,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
return { return {
visible: version !== undefined || installing, visible: version !== undefined || installing,
installing, installing,
label: "Update", label: language.t("titlebar.update"),
ariaLabel: language.t("toast.update.action.installRestart"), ariaLabel: language.t("toast.update.action.installRestart"),
title: version ? `Update ${version}` : undefined, title: version ? language.t("titlebar.updateVersion", { version }) : undefined,
onInstall: () => props.update?.install(), onInstall: () => props.update?.install(),
} }
}) })
@@ -169,7 +183,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
"padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0, "padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined, width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined, "max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"align-self": windows() ? "flex-start" : undefined, // Native Windows caption controls remain on the physical right in both writing directions.
"margin-right": windows() ? "auto" : undefined,
}} }}
data-tauri-drag-region data-tauri-drag-region
> >
@@ -338,6 +353,22 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
keybind: "mod+shift+t", keybind: "mod+shift+t",
onSelect: () => tabsStoreActions.reopenClosedTab(), onSelect: () => tabsStoreActions.reopenClosedTab(),
}, },
{
id: "tab.prev",
category: "tab",
title: "",
keybind: "mod+option+ArrowLeft,ctrl+shift+tab",
hidden: true,
onSelect: tabs.previous,
},
{
id: "tab.next",
category: "tab",
title: "",
keybind: "mod+option+ArrowRight,ctrl+tab",
hidden: true,
onSelect: tabs.next,
},
].filter((v) => v !== undefined) ].filter((v) => v !== undefined)
}) })
@@ -515,7 +546,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
<div <div
class="flex items-center shrink-0" class="flex items-center shrink-0"
classList={{ classList={{
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir, "ltr:-translate-x-[36px] rtl:translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(), "duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(), "duration-180 ease-in": layout.sidebar.opened(),
}} }}
@@ -613,7 +644,7 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
aria-label={props.state.ariaLabel} aria-label={props.state.ariaLabel}
> >
<span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0"> <span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0">
Update {props.state.label}
</span> </span>
<span class="flex size-5 shrink-0 items-center justify-center"> <span class="flex size-5 shrink-0 items-center justify-center">
<Show <Show
+1 -1
View File
@@ -55,7 +55,7 @@ const DrawerContent = <T extends ValidComponent = "div">(props: DynamicProps<T,
<DrawerPrimitive.Content <DrawerPrimitive.Content
class={props.class} class={props.class}
classList={{ classList={{
"group/drawer-content fixed inset-y-[6px] right-[6px] left-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true, "group/drawer-content fixed inset-y-[6px] end-[6px] start-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
}} }}
{...rest} {...rest}
> >
@@ -8,6 +8,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu" import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
export function WindowsAppMenu(props: { export function WindowsAppMenu(props: {
command: ReturnType<typeof useCommand> command: ReturnType<typeof useCommand>
@@ -15,6 +16,7 @@ export function WindowsAppMenu(props: {
variant?: "legacy" | "v2" variant?: "legacy" | "v2"
}) { }) {
let lastFocused: HTMLElement | undefined let lastFocused: HTMLElement | undefined
const language = useLanguage()
const rememberFocus = () => { const rememberFocus = () => {
const active = document.activeElement const active = document.activeElement
@@ -58,7 +60,7 @@ export function WindowsAppMenu(props: {
variant="ghost-muted" variant="ghost-muted"
size="large" size="large"
icon={<IconV2 name="menu" />} icon={<IconV2 name="menu" />}
aria-label="OpenCode menu" aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus} onPointerDown={rememberFocus}
onKeyDown={rememberFocus} onKeyDown={rememberFocus}
/> />
@@ -69,7 +71,7 @@ export function WindowsAppMenu(props: {
icon="menu" icon="menu"
variant="ghost" variant="ghost"
class="titlebar-icon rounded-md shrink-0" class="titlebar-icon rounded-md shrink-0"
aria-label="OpenCode menu" aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus} onPointerDown={rememberFocus}
onKeyDown={rememberFocus} onKeyDown={rememberFocus}
/> />
@@ -79,7 +81,7 @@ export function WindowsAppMenu(props: {
<DropdownMenu.Group> <DropdownMenu.Group>
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel> <DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => ( {DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
<DesktopMenuSubmenu label={menu.label}> <DesktopMenuSubmenu label={language.t(menu.labelKey)}>
{menu.items {menu.items
?.filter((entry) => desktopMenuVisible(entry, "windows")) ?.filter((entry) => desktopMenuVisible(entry, "windows"))
.map((entry) => .map((entry) =>
@@ -87,7 +89,7 @@ export function WindowsAppMenu(props: {
<DropdownMenu.Separator /> <DropdownMenu.Separator />
) : ( ) : (
<DesktopMenuItem <DesktopMenuItem
label={entry.label ?? ""} label={entry.labelKey ? language.t(entry.labelKey) : ""}
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows} keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
disabled={entry.command ? commandDisabled(entry.command) : false} disabled={entry.command ? commandDisabled(entry.command) : false}
onSelect={() => runEntry(entry)} onSelect={() => runEntry(entry)}
+2 -2
View File
@@ -83,7 +83,7 @@ export const ACCEPTED_FILE_EXTENSIONS = Array.from(
), ),
).sort() ).sort()
export function filePickerFilters(ext?: string[]) { export function filePickerFilters(name: string, ext?: string[]) {
if (!ext || ext.length === 0) return undefined if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }] return [{ name, extensions: ext }]
} }
@@ -152,12 +152,6 @@ export async function bootstrapGlobal(input: {
queryClient: QueryClient queryClient: QueryClient
}) { }) {
const slow = [ const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK, input.protocol)),
() =>
input.queryClient.fetchQuery(
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)),
() => () =>
input.queryClient input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)) .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
@@ -524,17 +518,6 @@ export async function bootstrapDirectory(input: {
input.queryClient.fetchQuery( input.queryClient.fetchQuery(
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol), loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
)), )),
() =>
input.queryClient
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
.catch((err) => {
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
})
}),
].filter(Boolean) as (() => Promise<any>)[] ].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint() await waitForPaint()
@@ -263,10 +263,13 @@ describe("createChildStoreManager", () => {
manager.child("/project") manager.child("/project")
expect(queries[0]?.().enabled).toBe(true) expect(queries[0]?.().enabled).toBe(true)
expect(queries[3]?.().enabled).toBe(true) expect(queries[3]?.().enabled).toBe(true)
expect(queries[4]?.().enabled).toBe(true) expect(queries[4]?.().enabled).toBe(false)
expect(queries[5]?.().enabled).toBe(true) expect(queries[5]?.().enabled).toBe(true)
expect(bootstraps).toEqual(["/project"]) expect(bootstraps).toEqual(["/project"])
manager.enableProviders("/project")
expect(queries[4]?.().enabled).toBe(true)
manager.child("/project", { bootstrap: false }) manager.child("/project", { bootstrap: false })
expect(queries[0]?.().enabled).toBe(true) expect(queries[0]?.().enabled).toBe(true)
} finally { } finally {
@@ -47,6 +47,7 @@ export function createChildStoreManager(input: {
const mcpToggles = new Map<string, (enabled: boolean) => void>() const mcpToggles = new Map<string, (enabled: boolean) => void>()
const activeDirectories = new Set<string>() const activeDirectories = new Set<string>()
const activationToggles = new Map<string, (enabled: boolean) => void>() const activationToggles = new Map<string, (enabled: boolean) => void>()
const providerToggles = new Map<string, (enabled: boolean) => void>()
const markKey = (key: DirectoryKey) => { const markKey = (key: DirectoryKey) => {
if (!key) return if (!key) return
@@ -122,6 +123,7 @@ export function createChildStoreManager(input: {
mcpToggles.delete(key) mcpToggles.delete(key)
activeDirectories.delete(key) activeDirectories.delete(key)
activationToggles.delete(key) activationToggles.delete(key)
providerToggles.delete(key)
const dispose = disposers.get(key) const dispose = disposers.get(key)
if (dispose) { if (dispose) {
dispose() dispose()
@@ -187,6 +189,7 @@ export function createChildStoreManager(input: {
const initialIcon = icon[0].value const initialIcon = icon[0].value
const [mcpEnabled, setMcpEnabled] = createSignal(false) const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false) const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
const [providerEnabled, setProviderEnabled] = createSignal(false)
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() })) const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() })) const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
@@ -194,7 +197,7 @@ export function createChildStoreManager(input: {
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() })) const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
const providerQuery = useQuery(() => ({ const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key), ...input.queryOptions.providers(key),
enabled: instanceQueriesEnabled(), enabled: providerEnabled(),
})) }))
const referenceQuery = useQuery(() => ({ const referenceQuery = useQuery(() => ({
...input.queryOptions.references(key), ...input.queryOptions.references(key),
@@ -206,7 +209,7 @@ export function createChildStoreManager(input: {
projectMeta: initialMeta, projectMeta: initialMeta,
icon: initialIcon, icon: initialIcon,
get provider_ready() { get provider_ready() {
return instanceQueriesEnabled() && !providerQuery.isLoading return providerEnabled() && !providerQuery.isLoading
}, },
get provider() { get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} } const EMPTY = { all: new Map(), connected: [], default: {} }
@@ -263,6 +266,7 @@ export function createChildStoreManager(input: {
disposers.set(key, dispose) disposers.set(key, dispose)
mcpToggles.set(key, setMcpEnabled) mcpToggles.set(key, setMcpEnabled)
activationToggles.set(key, setInstanceQueriesEnabled) activationToggles.set(key, setInstanceQueriesEnabled)
providerToggles.set(key, setProviderEnabled)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => { const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return if (!(init instanceof Promise)) return
@@ -329,6 +333,12 @@ export function createChildStoreManager(input: {
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1]) if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
} }
function enableProviders(directory: string) {
const key = directoryKey(directory)
ensureChild(directory)
providerToggles.get(key)?.(true)
}
// Passive Home/project metadata reads must not initialize the directory. // Passive Home/project metadata reads must not initialize the directory.
// A real directory access enables these queries once for the store lifetime. // A real directory access enables these queries once for the store lifetime.
// TODO(v2): After Home switches to v2.project.list and root-filtered, // TODO(v2): After Home switches to v2.project.list and root-filtered,
@@ -387,6 +397,7 @@ export function createChildStoreManager(input: {
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)), mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
active: (directory: string) => activeDirectories.has(directoryKey(directory)), active: (directory: string) => activeDirectories.has(directoryKey(directory)),
disableMcp, disableMcp,
enableProviders,
disposeDirectory, disposeDirectory,
runEviction, runEviction,
vcsCache, vcsCache,
@@ -15,12 +15,12 @@ const rootSession = (input: { id: string; parentID?: string; archived?: number }
}, },
}) as Session }) as Session
const userMessage = (id: string, sessionID: string) => const userMessage = (id: string, sessionID: string, created = 1) =>
({ ({
id, id,
sessionID, sessionID,
role: "user", role: "user",
time: { created: 1 }, time: { created },
agent: "assistant", agent: "assistant",
model: { providerID: "openai", modelID: "gpt" }, model: { providerID: "openai", modelID: "gpt" },
}) as Message }) as Message
@@ -370,13 +370,13 @@ describe("applyDirectoryEvent", () => {
const sessionID = "ses_1" const sessionID = "ses_1"
const [store, setStore] = createStore( const [store, setStore] = createStore(
baseState({ baseState({
message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_3", sessionID)] }, message: { [sessionID]: [userMessage("msg_z", sessionID, 1), userMessage("msg_b", sessionID, 3)] },
part: { msg_2: [textPart("prt_1", sessionID, "msg_2")] }, part: { msg_a: [textPart("prt_1", sessionID, "msg_a")] },
}), }),
) )
applyDirectoryEvent({ applyDirectoryEvent({
event: { type: "message.updated", properties: { info: userMessage("msg_2", sessionID) } }, event: { type: "message.updated", properties: { info: userMessage("msg_a", sessionID, 2) } },
store, store,
setStore, setStore,
push() {}, push() {},
@@ -384,14 +384,14 @@ describe("applyDirectoryEvent", () => {
loadLsp() {}, loadLsp() {},
}) })
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2", "msg_3"]) expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a", "msg_b"])
applyDirectoryEvent({ applyDirectoryEvent({
event: { event: {
type: "message.updated", type: "message.updated",
properties: { properties: {
info: { info: {
...userMessage("msg_2", sessionID), ...userMessage("msg_a", sessionID, 2),
role: "assistant", role: "assistant",
} as Message, } as Message,
}, },
@@ -403,10 +403,10 @@ describe("applyDirectoryEvent", () => {
loadLsp() {}, loadLsp() {},
}) })
expect(store.message[sessionID]?.find((x) => x.id === "msg_2")?.role).toBe("assistant") expect(store.message[sessionID]?.find((x) => x.id === "msg_a")?.role).toBe("assistant")
applyDirectoryEvent({ applyDirectoryEvent({
event: { type: "message.removed", properties: { sessionID, messageID: "msg_2" } }, event: { type: "message.removed", properties: { sessionID, messageID: "msg_a" } },
store, store,
setStore, setStore,
push() {}, push() {},
@@ -414,8 +414,8 @@ describe("applyDirectoryEvent", () => {
loadLsp() {}, loadLsp() {},
}) })
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_3"]) expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_b"])
expect(store.part.msg_2).toBeUndefined() expect(store.part.msg_a).toBeUndefined()
}) })
test("upserts and prunes message parts", () => { test("upserts and prunes message parts", () => {
@@ -15,6 +15,7 @@ import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache" import { dropSessionCaches } from "./session-cache"
import { diffs as list, message as clean } from "@/utils/diffs" import { diffs as list, message as clean } from "@/utils/diffs"
import { messageKey } from "@/utils/session-message"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const SESSION_CONTENT_EVENTS = new Set([ const SESSION_CONTENT_EVENTS = new Set([
@@ -275,7 +276,7 @@ export function applyDirectoryEvent(input: {
input.setStore("message", info.sessionID, [info]) input.setStore("message", info.sessionID, [info])
break break
} }
const result = Binary.search(messages, info.id, (m) => m.id) const result = Binary.search(messages, messageKey(info), messageKey)
if (result.found) { if (result.found) {
input.setStore("message", info.sessionID, result.index, reconcile(info)) input.setStore("message", info.sessionID, result.index, reconcile(info))
break break
@@ -295,8 +296,8 @@ export function applyDirectoryEvent(input: {
produce((draft) => { produce((draft) => {
const messages = draft.message[props.sessionID] const messages = draft.message[props.sessionID]
if (messages) { if (messages) {
const result = Binary.search(messages, props.messageID, (m) => m.id) const index = messages.findIndex((message) => message.id === props.messageID)
if (result.found) messages.splice(result.index, 1) if (index >= 0) messages.splice(index, 1)
} }
const parts = draft.part[props.messageID] const parts = draft.part[props.messageID]
if (parts) { if (parts) {
@@ -322,7 +323,7 @@ export function applyDirectoryEvent(input: {
input.setStore("part", part.messageID, [part]) input.setStore("part", part.messageID, [part])
break break
} }
const result = Binary.search(parts, part.id, (p) => p.id) const result = Binary.search(parts, part.id, (item) => item.id)
if (result.found) { if (result.found) {
input.setStore("part", part.messageID, result.index, reconcile(part)) input.setStore("part", part.messageID, result.index, reconcile(part))
break break
@@ -345,13 +346,13 @@ export function applyDirectoryEvent(input: {
) )
const parts = input.store.part[props.messageID] const parts = input.store.part[props.messageID]
if (!parts) break if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id) const result = Binary.search(parts, props.partID, (part) => part.id)
if (result.found) { if (result.found) {
input.setStore( input.setStore(
produce((draft) => { produce((draft) => {
const list = draft.part[props.messageID] const list = draft.part[props.messageID]
if (!list) return if (!list) return
const next = Binary.search(list, props.partID, (p) => p.id) const next = Binary.search(list, props.partID, (part) => part.id)
if (!next.found) return if (!next.found) return
list.splice(next.index, 1) list.splice(next.index, 1)
if (list.length === 0) delete draft.part[props.messageID] if (list.length === 0) delete draft.part[props.messageID]
@@ -364,7 +365,7 @@ export function applyDirectoryEvent(input: {
const props = event.properties as { messageID: string; partID: string; field: string; delta: string } const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
const parts = input.store.part[props.messageID] const parts = input.store.part[props.messageID]
if (!parts) break if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id) const result = Binary.search(parts, props.partID, (part) => part.id)
if (!result.found) break if (!result.found) break
const field = props.field as keyof (typeof parts)[number] const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field] const current = parts[result.index]?.[field]
@@ -22,6 +22,7 @@ export const homeSessionIndexKey = (server: string) => ["home", "session-index",
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
type HomeSessionPage = { data?: V2SessionListResponse } type HomeSessionPage = { data?: V2SessionListResponse }
type ProjectedHomeSessionPage = { data?: { data: Session[]; cursor: { next?: string } } }
export async function loadHomeSessionIndex( export async function loadHomeSessionIndex(
list: ( list: (
@@ -31,7 +32,30 @@ export async function loadHomeSessionIndex(
eventSequence = 0, eventSequence = 0,
signal?: AbortSignal, signal?: AbortSignal,
) { ) {
const data: SessionV2Info[] = [] return loadHomeSessionPages(list, parseHomeSessionIndex, eventSequence, signal)
}
export async function loadProjectedHomeSessionIndex(
list: (
input: { limit: number; order: "desc"; cursor?: string },
options: { signal?: AbortSignal },
) => Promise<ProjectedHomeSessionPage>,
eventSequence = 0,
signal?: AbortSignal,
) {
return loadHomeSessionPages(list, (sessions) => sessions, eventSequence, signal)
}
async function loadHomeSessionPages<T>(
list: (
input: { limit: number; order: "desc"; cursor?: string },
options: { signal?: AbortSignal },
) => Promise<{ data?: { data: T[]; cursor: { next?: string } } }>,
project: (sessions: T[]) => Session[],
eventSequence: number,
signal?: AbortSignal,
) {
const data: T[] = []
let cursor: string | undefined let cursor: string | undefined
for (;;) { for (;;) {
@@ -46,7 +70,7 @@ export async function loadHomeSessionIndex(
const page = response.data! const page = response.data!
data.push(...page.data) data.push(...page.data)
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next) if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
return { sessions: parseHomeSessionIndex(data), eventSequence } return { sessions: project(data), eventSequence }
cursor = page.cursor.next cursor = page.cursor.next
} }
} }
+133 -121
View File
@@ -2,100 +2,53 @@ import * as i18n from "@solid-primitives/i18n"
import { createEffect, createMemo, createResource } from "solid-js" import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import {
pluralCategory,
type UiI18nPluralLookupKey,
type UiI18nPluralKey,
type UiPluralCategory,
} from "@opencode-ai/ui/context/i18n"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en" import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
import {
createDesktopNativeBundle,
detectDesktopNativeLocale,
DESKTOP_NATIVE_ENGLISH,
DESKTOP_NATIVE_LABELS,
DESKTOP_NATIVE_LOCALES,
DESKTOP_NATIVE_LOCALE_TAGS,
type DesktopNativeBundle,
type DesktopNativeLocale,
} from "@/i18n/desktop-native"
export type Locale = export type Locale = DesktopNativeLocale
| "en" export type Direction = "ltr" | "rtl"
| "zh"
| "zht" const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "ur", "pa", "fa", "dv"])
| "ko"
| "de" function localeDirection(locale: Locale): Direction {
| "es" return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
| "fr" }
| "da"
| "ja"
| "pl"
| "ru"
| "uk"
| "ar"
| "no"
| "br"
| "th"
| "bs"
| "tr"
type RawDictionary = typeof en & typeof uiEn type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary> type Dictionary = i18n.Flatten<RawDictionary>
type AppI18nKey = Extract<keyof typeof en, string>
type AppI18nPluralKey = {
[Key in AppI18nKey]: Key extends `${infer Base}.other` ? (`${Base}.one` extends AppI18nKey ? Base : never) : never
}[AppI18nKey]
type PluralKey = AppI18nPluralKey | UiI18nPluralKey
type AppI18nPluralLookupKey = `${AppI18nPluralKey}.${UiPluralCategory}`
type TranslationKey<Key extends string> = Key extends AppI18nPluralLookupKey | UiI18nPluralLookupKey ? never : Key
type Source = { dict: Record<string, string> } type Source = { dict: Record<string, string> }
function cookie(locale: Locale) { function cookie(locale: Locale) {
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
} }
const LOCALES: readonly Locale[] = [ const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES
"en",
"zh",
"zht",
"ko",
"de",
"es",
"fr",
"da",
"ja",
"pl",
"ru",
"uk",
"bs",
"ar",
"no",
"br",
"th",
"tr",
]
const INTL: Record<Locale, string> = { const INTL = DESKTOP_NATIVE_LOCALE_TAGS
en: "en",
zh: "zh-Hans",
zht: "zh-Hant",
ko: "ko",
de: "de",
es: "es",
fr: "fr",
da: "da",
ja: "ja",
pl: "pl",
ru: "ru",
uk: "uk",
ar: "ar",
no: "nb-NO",
br: "pt-BR",
th: "th",
bs: "bs",
tr: "tr",
}
const LABEL_KEY: Record<Locale, keyof Dictionary> = {
en: "language.en",
zh: "language.zh",
zht: "language.zht",
ko: "language.ko",
de: "language.de",
es: "language.es",
fr: "language.fr",
da: "language.da",
ja: "language.ja",
pl: "language.pl",
ru: "language.ru",
uk: "language.uk",
ar: "language.ar",
no: "language.no",
br: "language.br",
th: "language.th",
bs: "language.bs",
tr: "language.tr",
}
const base = i18n.flatten({ ...en, ...uiEn }) const base = i18n.flatten({ ...en, ...uiEn })
const dicts = new Map<Locale, Dictionary>([["en", base]]) const dicts = new Map<Locale, Dictionary>([["en", base]])
@@ -121,6 +74,50 @@ const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")), th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")), bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")), tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
hi: () => merge(import("@/i18n/hi"), import("@opencode-ai/ui/i18n/hi")),
nl: () => merge(import("@/i18n/nl"), import("@opencode-ai/ui/i18n/nl")),
id: () => merge(import("@/i18n/id"), import("@opencode-ai/ui/i18n/id")),
vi: () => merge(import("@/i18n/vi"), import("@opencode-ai/ui/i18n/vi")),
it: () => merge(import("@/i18n/it"), import("@opencode-ai/ui/i18n/it")),
ur: () => merge(import("@/i18n/ur"), import("@opencode-ai/ui/i18n/ur")),
pa: () => merge(import("@/i18n/pa"), import("@opencode-ai/ui/i18n/pa")),
az: () => merge(import("@/i18n/az"), import("@opencode-ai/ui/i18n/az")),
fi: () => merge(import("@/i18n/fi"), import("@opencode-ai/ui/i18n/fi")),
sv: () => merge(import("@/i18n/sv"), import("@opencode-ai/ui/i18n/sv")),
am: () => merge(import("@/i18n/am"), import("@opencode-ai/ui/i18n/am")),
bg: () => merge(import("@/i18n/bg"), import("@opencode-ai/ui/i18n/bg")),
bn: () => merge(import("@/i18n/bn"), import("@opencode-ai/ui/i18n/bn")),
ca: () => merge(import("@/i18n/ca"), import("@opencode-ai/ui/i18n/ca")),
cs: () => merge(import("@/i18n/cs"), import("@opencode-ai/ui/i18n/cs")),
dv: () => merge(import("@/i18n/dv"), import("@opencode-ai/ui/i18n/dv")),
dz: () => merge(import("@/i18n/dz"), import("@opencode-ai/ui/i18n/dz")),
el: () => merge(import("@/i18n/el"), import("@opencode-ai/ui/i18n/el")),
et: () => merge(import("@/i18n/et"), import("@opencode-ai/ui/i18n/et")),
fa: () => merge(import("@/i18n/fa"), import("@opencode-ai/ui/i18n/fa")),
fo: () => merge(import("@/i18n/fo"), import("@opencode-ai/ui/i18n/fo")),
hr: () => merge(import("@/i18n/hr"), import("@opencode-ai/ui/i18n/hr")),
hu: () => merge(import("@/i18n/hu"), import("@opencode-ai/ui/i18n/hu")),
hy: () => merge(import("@/i18n/hy"), import("@opencode-ai/ui/i18n/hy")),
is: () => merge(import("@/i18n/is"), import("@opencode-ai/ui/i18n/is")),
ka: () => merge(import("@/i18n/ka"), import("@opencode-ai/ui/i18n/ka")),
km: () => merge(import("@/i18n/km"), import("@opencode-ai/ui/i18n/km")),
lo: () => merge(import("@/i18n/lo"), import("@opencode-ai/ui/i18n/lo")),
lt: () => merge(import("@/i18n/lt"), import("@opencode-ai/ui/i18n/lt")),
lv: () => merge(import("@/i18n/lv"), import("@opencode-ai/ui/i18n/lv")),
mk: () => merge(import("@/i18n/mk"), import("@opencode-ai/ui/i18n/mk")),
mn: () => merge(import("@/i18n/mn"), import("@opencode-ai/ui/i18n/mn")),
ms: () => merge(import("@/i18n/ms"), import("@opencode-ai/ui/i18n/ms")),
my: () => merge(import("@/i18n/my"), import("@opencode-ai/ui/i18n/my")),
ne: () => merge(import("@/i18n/ne"), import("@opencode-ai/ui/i18n/ne")),
ro: () => merge(import("@/i18n/ro"), import("@opencode-ai/ui/i18n/ro")),
si: () => merge(import("@/i18n/si"), import("@opencode-ai/ui/i18n/si")),
sk: () => merge(import("@/i18n/sk"), import("@opencode-ai/ui/i18n/sk")),
sl: () => merge(import("@/i18n/sl"), import("@opencode-ai/ui/i18n/sl")),
sq: () => merge(import("@/i18n/sq"), import("@opencode-ai/ui/i18n/sq")),
sr: () => merge(import("@/i18n/sr"), import("@opencode-ai/ui/i18n/sr")),
tg: () => merge(import("@/i18n/tg"), import("@opencode-ai/ui/i18n/tg")),
tk: () => merge(import("@/i18n/tk"), import("@opencode-ai/ui/i18n/tk")),
uz: () => merge(import("@/i18n/uz"), import("@opencode-ai/ui/i18n/uz")),
} }
function loadDict(locale: Locale) { function loadDict(locale: Locale) {
@@ -138,42 +135,9 @@ export function loadLocaleDict(locale: Locale) {
return loadDict(locale).then(() => undefined) return loadDict(locale).then(() => undefined)
} }
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
{ locale: "en", match: (language) => language.startsWith("en") },
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
{ locale: "zh", match: (language) => language.startsWith("zh") },
{ locale: "ko", match: (language) => language.startsWith("ko") },
{ locale: "de", match: (language) => language.startsWith("de") },
{ locale: "es", match: (language) => language.startsWith("es") },
{ locale: "fr", match: (language) => language.startsWith("fr") },
{ locale: "da", match: (language) => language.startsWith("da") },
{ locale: "ja", match: (language) => language.startsWith("ja") },
{ locale: "pl", match: (language) => language.startsWith("pl") },
{ locale: "ru", match: (language) => language.startsWith("ru") },
{ locale: "uk", match: (language) => language.startsWith("uk") },
{ locale: "ar", match: (language) => language.startsWith("ar") },
{
locale: "no",
match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"),
},
{ locale: "br", match: (language) => language.startsWith("pt") },
{ locale: "th", match: (language) => language.startsWith("th") },
{ locale: "bs", match: (language) => language.startsWith("bs") },
{ locale: "tr", match: (language) => language.startsWith("tr") },
]
function detectLocale(): Locale { function detectLocale(): Locale {
if (typeof navigator !== "object") return "en" if (typeof navigator !== "object") return "en"
return detectDesktopNativeLocale(navigator.languages?.length ? navigator.languages : [navigator.language])
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
if (!language) continue
const normalized = language.toLowerCase()
const match = localeMatchers.find((entry) => entry.match(normalized))
if (match) return match.locale
}
return "en"
} }
export function normalizeLocale(value: string): Locale { export function normalizeLocale(value: string): Locale {
@@ -194,12 +158,22 @@ function readStoredLocale() {
} }
const warm = readStoredLocale() ?? detectLocale() const warm = readStoredLocale() ?? detectLocale()
if (warm !== "en") void loadDict(warm) const initialLocale =
warm === "en"
? Promise.resolve(warm)
: loadDict(warm).then(
() => warm,
() => "en" as const,
)
export function loadInitialLocale() {
return initialLocale
}
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language", name: "Language",
gate: false, gate: false,
init: (props: { locale?: Locale }) => { init: (props: { locale?: Locale; onNativeTranslations?: (bundle: DesktopNativeBundle) => void }) => {
const initial = props.locale ?? readStoredLocale() ?? detectLocale() const initial = props.locale ?? readStoredLocale() ?? detectLocale()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("language", ["language.v1"]), Persist.global("language", ["language.v1"]),
@@ -210,34 +184,72 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
const locale = createMemo<Locale>(() => normalizeLocale(store.locale)) const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
const intl = createMemo(() => INTL[locale()]) const intl = createMemo(() => INTL[locale()])
const [layout, setLayout] = createStore({ direction: undefined as Direction | undefined })
const direction = createMemo(() => layout.direction ?? localeDirection(locale()))
const layoutLocale = createMemo(() => {
if (!layout.direction) return intl()
// Kobalte derives menu direction from locale rather than accepting a direction override.
return layout.direction === "rtl" ? "ar" : "en"
})
const [dict] = createResource(locale, loadDict, { const [dict] = createResource(locale, loadDict, {
initialValue: dicts.get(initial) ?? base, initialValue: dicts.get(initial) ?? base,
}) })
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as ( const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as <Key extends string>(
key: keyof Dictionary, key: TranslationKey<Key>,
params?: Record<string, string | number | boolean>, params?: Record<string, string | number | boolean>,
) => string ) => string
const label = (value: Locale) => t(LABEL_KEY[value]) const pluralForm = (
key: PluralKey,
category: UiPluralCategory,
params?: Record<string, string | number | boolean>,
) => {
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
const candidate = `${key}.${category}`
const fallback = `${key}.other`
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, params)
}
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) =>
pluralForm(key, pluralCategory(intl(), count), { ...params, count })
const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value]
createEffect(() => { createEffect(() => {
if (typeof document !== "object") return if (typeof document !== "object") return
document.documentElement.lang = locale() const value = locale()
document.cookie = cookie(locale()) document.documentElement.lang = intl()
document.documentElement.dir = direction()
document.cookie = cookie(value)
})
createEffect(() => {
if (!props.onNativeTranslations || dict.loading) return
const current = dict()
if (!current) return
props.onNativeTranslations(
createDesktopNativeBundle(locale(), (key) => current[key] ?? DESKTOP_NATIVE_ENGLISH[key]),
)
}) })
return { return {
ready, ready,
locale, locale,
intl, intl,
direction,
layoutLocale,
locales: LOCALES, locales: LOCALES,
label, label,
t, t,
plural,
pluralForm,
setLocale(next: Locale) { setLocale(next: Locale) {
setStore("locale", normalizeLocale(next)) setStore("locale", normalizeLocale(next))
}, },
setDirection(next: Direction) {
setLayout("direction", next === localeDirection(locale()) ? undefined : next)
},
} }
}, },
}) })
+10 -4
View File
@@ -6,6 +6,7 @@ import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import type { BlobReference } from "@/utils/draft-store" import type { BlobReference } from "@/utils/draft-store"
import type { Platform } from "@/context/platform"
interface PartBase { interface PartBase {
content: string content: string
@@ -236,13 +237,18 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<P
return value return value
} }
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) { function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt, platform?: Platform) {
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial))) const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)), platform)
return { ready, ...createPromptStateValue(store, setStore) } return { ready, ...createPromptStateValue(store, setStore) }
} }
export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) { export function createPromptSession(
return createPersistedPrompt(promptTarget(serverScope, scope), initial) serverScope: ServerScope,
scope: PromptScope,
initial?: InitialPrompt,
platform?: Platform,
) {
return createPersistedPrompt(promptTarget(serverScope, scope), initial, platform)
} }
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) { export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
+12 -7
View File
@@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk"
import { useSettings } from "./settings" import { useSettings } from "./settings"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs" import { useTabs, type Tab } from "./tabs"
import type { ServerScope } from "@/utils/server-scope"
import { import {
createPromptReady, createPromptReady,
createPromptSession, createPromptSession,
@@ -104,11 +105,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server) params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
const scope = (): PromptScope => const scope = (): PromptScope =>
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id } search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: PromptScope) => { const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => {
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined const current = settings.general.newLayoutDesigns()
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope) ? selectPromptTab(tabs.store, scope, target?.server ?? serverKey())
: undefined
if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK().scope, scope)
const key = scopeKey(scope) const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope)
const existing = cache.get(key) const existing = cache.get(key)
if (existing) { if (existing) {
cache.delete(key) cache.delete(key)
@@ -118,7 +121,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const entry = createRoot( const entry = createRoot(
(dispose) => ({ (dispose) => ({
value: createPromptSession(serverSDK().scope, scope), value: createPromptSession(target?.scope ?? serverSDK().scope, scope),
dispose, dispose,
}), }),
owner, owner,
@@ -130,7 +133,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
const session = createMemo(() => load(scope())) const session = createMemo(() => load(scope()))
const pick = (scope?: PromptScope) => (scope ? load(scope) : session()) const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
scope ? load(scope, target) : session()
const ready = createPromptReady(session) const ready = createPromptReady(session)
const withSuspense = <T,>(cb: () => T): (() => T) => const withSuspense = <T,>(cb: () => T): (() => T) =>
@@ -146,7 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return { return {
ready, ready,
capture: (scope?: PromptScope) => pick(scope).capture(), capture: (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
pick(scope, target).capture(),
current: withSuspense(() => session().current()), current: withSuspense(() => session().current()),
cursor: withSuspense(() => session().cursor()), cursor: withSuspense(() => session().cursor()),
dirty: withSuspense(() => session().dirty()), dirty: withSuspense(() => session().dirty()),
+83 -1
View File
@@ -1,7 +1,89 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" import {
adaptServerEvent,
adaptWorktreeCompatibilityEvent,
applyWorkspaceOperationEvent,
applyWorktreeEvent,
coalesceServerEvents,
enqueueServerEvent,
resumeStreamAfterPageShow,
} from "./server-sdk"
import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@opencode-ai/sdk/v2/client" import type { Event } from "@opencode-ai/sdk/v2/client"
import { ServerScope } from "@/utils/server-scope"
import { Worktree } from "@/utils/worktree"
import { WorkspaceOperation } from "@/utils/workspace-operation"
describe("applyWorktreeEvent", () => {
test("adapts global readiness with the created worktree directory", () => {
const directory = "/repo/worktree-compatible"
const event = adaptWorktreeCompatibilityEvent({
directory,
payload: { id: "ready", type: "worktree.ready", properties: { name: "compatible" } } as Event,
})
if (!event) throw new Error("expected worktree event")
Worktree.pending(ServerScope.local, directory)
applyWorktreeEvent(ServerScope.local, event, "failed")
expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "ready" })
expect(
adaptWorktreeCompatibilityEvent({
directory: "/repo",
payload: {
id: "status",
type: "session.status",
properties: { sessionID: "session", status: { type: "idle" } },
} as Event,
}),
).toBeUndefined()
})
test("preserves the server failure message", () => {
const directory = "/repo/worktree-failed"
Worktree.pending(ServerScope.local, directory)
applyWorktreeEvent(
ServerScope.local,
{
directory,
payload: {
id: "failed",
type: "worktree.failed",
properties: { name: "failed", message: "bootstrap failed" },
} as Event,
},
"fallback",
)
expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "failed", message: "bootstrap failed" })
})
})
test("legacy and current moved events complete matching operations", () => {
const events = [
{
id: "legacy",
payload: {
id: "moved",
type: "session.next.moved",
properties: { timestamp: Date.now(), sessionID: "legacy", location: { directory: "/workspace" } },
} as Event,
},
{
id: "current",
payload: adaptServerEvent({
id: "moved-current",
created: Date.now(),
type: "session.moved",
data: { sessionID: "current", location: { directory: "/workspace" } },
} as OpenCodeEvent),
},
]
events.forEach((event) => {
WorkspaceOperation.start(ServerScope.local, event.id, "move", "/workspace")
applyWorkspaceOperationEvent(ServerScope.local, { directory: "/workspace", payload: event.payload })
expect(WorkspaceOperation.get(ServerScope.local, event.id)?.status).toBe("complete")
})
})
describe("resumeStreamAfterPageShow", () => { describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => { test("restarts a stream only after a back-forward cache restore", () => {
+101 -3
View File
@@ -13,6 +13,10 @@ import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol" import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat" import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
import { Worktree } from "@/utils/worktree"
import { WorkspaceOperation } from "@/utils/workspace-operation"
import { decodeVcsDiff } from "@/utils/vcs-diff-decoder"
import { decodeSessionList } from "./session-message-decoder"
const isAbortError = (error: unknown) => const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
@@ -56,6 +60,11 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
} }
export function adaptWorktreeCompatibilityEvent(event: { directory?: string; payload: Event }) {
if (event.payload.type !== "worktree.ready" && event.payload.type !== "worktree.failed") return
return { directory: event.directory ?? "global", payload: event.payload }
}
const coalescedKey = (event: QueuedServerEvent) => { const coalescedKey = (event: QueuedServerEvent) => {
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}` if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
if (event.payload.type === "message.part.updated") { if (event.payload.type === "message.part.updated") {
@@ -138,6 +147,31 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
return output return output
} }
export function applyWorktreeEvent(scope: ServerScope, event: QueuedServerEvent, fallback: string) {
if (event.payload.type === "worktree.ready") {
Worktree.ready(scope, event.directory)
return true
}
if (event.payload.type !== "worktree.failed") return false
const message = event.payload.properties.message ?? fallback
Worktree.failed(scope, event.directory, message)
return true
}
export function applyWorkspaceOperationEvent(scope: ServerScope, event: QueuedServerEvent) {
if (event.payload.current?.type === "session.moved") {
WorkspaceOperation.complete(
scope,
event.payload.current.data.sessionID,
event.payload.current.data.location.directory,
)
return true
}
if (event.payload.type !== "session.next.moved") return false
WorkspaceOperation.complete(scope, event.payload.properties.sessionID, event.payload.properties.location.directory)
return true
}
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined { function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
if ( if (
event?.type === "session.text.delta" || event?.type === "session.text.delta" ||
@@ -186,6 +220,7 @@ type ServerSDKBase = {
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase { function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage()
const abort = new AbortController() const abort = new AbortController()
const eventFetch = (() => { const eventFetch = (() => {
@@ -238,7 +273,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
last = Date.now() last = Date.now()
const output = coalesceServerEvents(events) const output = coalesceServerEvents(events)
batch(() => { batch(() => {
output.forEach((event) => emitter.emit(event.directory, event.payload)) output.forEach((event) => {
applyWorktreeEvent(scope, event, language.t("common.requestFailed"))
applyWorkspaceOperationEvent(scope, event)
emitter.emit(event.directory, event.payload)
})
}) })
buffer.length = 0 buffer.length = 0
@@ -251,12 +290,57 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
} }
let streamErrorLogged = false let streamErrorLogged = false
let worktreeStreamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)) const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
let attempt: AbortController | undefined let attempt: AbortController | undefined
let worktreeAttempt: AbortController | undefined
let run: Promise<void> | undefined let run: Promise<void> | undefined
let started = false let started = false
let generation = 0 let generation = 0
const consumeWorktreeEvents = async (active: number) => {
// Current worktree lifecycle events are still emitted only on the global compatibility stream.
while (!abort.signal.aborted && started && generation === active) {
const controller = new AbortController()
worktreeAttempt = controller
const onAbort = () => controller.abort()
abort.signal.addEventListener("abort", onAbort)
try {
const events = (await eventSdk.global.event({ signal: controller.signal })).stream
let yielded = Date.now()
for await (const event of events) {
const queued = adaptWorktreeCompatibilityEvent({
directory: event.directory,
payload: event.payload as Event,
})
if (queued) {
worktreeStreamErrorLogged = false
if (enqueueServerEvent(queue, queued)) schedule()
}
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!isStreamClosed(error, controller.signal) && !worktreeStreamErrorLogged) {
worktreeStreamErrorLogged = true
console.error("[global-sdk] worktree event stream failed", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
if (worktreeAttempt === controller) worktreeAttempt = undefined
}
if (abort.signal.aborted || !started || generation !== active) return
await wait(RECONNECT_DELAY_MS)
}
}
const start = () => { const start = () => {
if (started) return run if (started) return run
started = true started = true
@@ -264,6 +348,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
const previous = run const previous = run
const current = (async () => { const current = (async () => {
if (previous) await previous if (previous) await previous
const kind = await protocol
if (kind === "v2") void consumeWorktreeEvents(active)
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started && generation === active) { while (!abort.signal.aborted && started && generation === active) {
attempt = new AbortController() attempt = new AbortController()
@@ -272,7 +358,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
} }
abort.signal.addEventListener("abort", onAbort) abort.signal.addEventListener("abort", onAbort)
try { try {
const kind = await protocol
const events = const events =
kind === "v1" kind === "v1"
? (await eventSdk.global.event({ signal: attempt.signal })).stream ? (await eventSdk.global.event({ signal: attempt.signal })).stream
@@ -320,6 +405,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
started = false started = false
generation++ generation++
attempt?.abort() attempt?.abort()
worktreeAttempt?.abort()
} }
onMount(() => { onMount(() => {
@@ -346,7 +432,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
throwOnError: true, throwOnError: true,
directory, directory,
}) })
const api = createCompatibleApi({ protocol, current: currentApi, legacy }) const api = createCompatibleApi({ protocol, current: currentApi, legacy, decodeVcsDiff, decodeSessionList })
return { return {
server, server,
@@ -432,7 +518,19 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
current: serverSDK.currentApi, current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }), legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory, directory,
decodeVcsDiff,
decodeSessionList,
}), }),
createApi(next: string) {
return createCompatibleApi({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
legacy: (target) => serverSDK.createClient({ directory: target ?? next, throwOnError: true }),
directory: next,
decodeVcsDiff,
decodeSessionList,
})
},
event: emitter, event: emitter,
get url() { get url() {
return serverSDK.url return serverSDK.url
@@ -223,6 +223,23 @@ describe("server session", () => {
expect(ctx.store.lineage.peek("child")).toEqual(result) expect(ctx.store.lineage.peek("child")).toEqual(result)
}) })
test("applies moved session locations without evicting cached state", () => {
const current = { ...session("child"), directory: "/repo/worktree", path: undefined }
const ctx = setup({ child: current })
ctx.store.remember(current)
ctx.store.apply({
type: "session.next.moved",
properties: {
sessionID: "child",
location: { directory: "/repo" },
subdirectory: "packages/app",
},
})
expect(ctx.store.get("child")).toMatchObject({ directory: "/repo", path: "packages/app" })
})
test("loads session content through the server client", async () => { test("loads session content through the server client", async () => {
const ctx = setup({ root: session("root") }) const ctx = setup({ root: session("root") })
@@ -264,6 +281,7 @@ describe("server session", () => {
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }]) expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
}) })
test("extends a current page to include the user for split assistant turns", async () => { test("extends a current page to include the user for split assistant turns", async () => {
@@ -1497,7 +1515,7 @@ describe("server session", () => {
await store.sync("child", { force: true }) await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([boundary, older]) expect(store.data.message.child).toEqual([older, boundary])
}) })
test("preserves a part update for a message being loaded from history", async () => { test("preserves a part update for a message being loaded from history", async () => {
+86 -37
View File
@@ -18,21 +18,27 @@ import { message as cleanMessage } from "@/utils/diffs"
import { sessionNotFoundError } from "@/utils/server-errors" import { sessionNotFoundError } from "@/utils/server-errors"
import { rootSession } from "@/utils/session-route" import { rootSession } from "@/utils/session-route"
import { normalizeSessionInfo } from "@/utils/session" import { normalizeSessionInfo } from "@/utils/session"
import { normalizeSessionMessages } from "@/utils/session-message" import { compareMessages, messageKey, normalizeSessionMessages } from "@/utils/session-message"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer" import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
import type { DecodedLegacyMessagePage } from "./session-message-decode"
type MessageApi = ServerApi["message"] type MessageApi = ServerApi["message"]
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 20 const initialMessagePageSize = 20
const historyMessagePageSize = 200 const historyMessagePageSize = 50
const sessionInfoLimit = 2_048 const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set() const emptyIDs: ReadonlySet<string> = new Set()
function yieldToMain() {
const scheduler = (globalThis as { scheduler?: { yield: () => Promise<void> } }).scheduler
if (scheduler) return scheduler.yield()
return new Promise<void>((resolve) => setTimeout(resolve, 0))
}
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) { function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
const boundary = source.find( const boundary = source.find(
(message) => (message) =>
@@ -64,7 +70,7 @@ type MessagePage = {
function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] { function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] {
return items return items
.slice() .slice()
.sort((a, b) => cmp(a.info.id, b.info.id)) .sort((a, b) => compareMessages(a.info, b.info))
.map((item) => { .map((item) => {
if (item.info.role === "user") { if (item.info.role === "user") {
return { return {
@@ -111,17 +117,16 @@ function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
const part = new Map(page.part.map((item) => [item.id, item.part])) const part = new Map(page.part.map((item) => [item.id, item.part]))
const observed: { messageID: string; parts: Part[] }[] = [] const observed: { messageID: string; parts: Part[] }[] = []
for (const item of items) { for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id) const result = Binary.search(session, messageKey(item.message), messageKey)
if (!result.found) session.splice(result.index, 0, item.message) const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id) const current = part.get(item.message.id)
const confirmed = result.found const confirmed = found ? item.parts.filter((part) => current?.some((value) => value.id === part.id)) : []
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found) if (found) observed.push({ messageID: item.message.id, parts: confirmed })
: []
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
part.set( part.set(
item.message.id, item.message.id,
merge( merge(
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []), found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
item.parts.filter((part) => !confirmed.includes(part)), item.parts.filter((part) => !confirmed.includes(part)),
), ),
) )
@@ -158,6 +163,7 @@ function reconcileFetched<T extends { id: string }>(
retained?: ReadonlySet<string> retained?: ReadonlySet<string>
removed?: ReadonlySet<string> removed?: ReadonlySet<string>
preserveUnfetched?: boolean | ((item: T) => boolean) preserveUnfetched?: boolean | ((item: T) => boolean)
compare?: (a: T, b: T) => number
} = {}, } = {},
) { ) {
const result = new Map(fetched.map((item) => [item.id, item])) const result = new Map(fetched.map((item) => [item.id, item]))
@@ -180,10 +186,15 @@ function reconcileFetched<T extends { id: string }>(
if (!item) result.delete(id) if (!item) result.delete(id)
} }
for (const id of options.removed ?? emptyIDs) result.delete(id) for (const id of options.removed ?? emptyIDs) result.delete(id)
return [...result.values()].sort((a, b) => cmp(a.id, b.id)) const items = [...result.values()]
return options.compare ? items.sort(options.compare) : items
} }
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> } type ServerSessionOptions = {
retry?: typeof retry
protocol?: Promise<"v1" | "v2">
decodeMessages?: (buffer: ArrayBuffer) => Promise<DecodedLegacyMessagePage>
}
export function createServerSession( export function createServerSession(
client: OpencodeClient, client: OpencodeClient,
@@ -301,22 +312,24 @@ export function createServerSession(
return session return session
} }
const resolve = (sessionID: string, options?: { force?: boolean }) => { const resolve = (sessionID: string, options?: { force?: boolean; signal?: AbortSignal }) => {
const cached = data.info[sessionID] const cached = data.info[sessionID]
if (cached && !options?.force) return Promise.resolve(cached) if (cached && !options?.force) return Promise.resolve(cached)
const pending = requests.get(sessionID) const pending = options?.signal ? undefined : requests.get(sessionID)
if (pending) return pending if (pending) return pending
const active = generation(sessionID) const active = generation(sessionID)
const request = sessionApi const request = sessionApi
? sessionApi.get({ sessionID }).then(normalizeSessionInfo) ? sessionApi.get({ sessionID }, { signal: options?.signal }).then(normalizeSessionInfo)
: client.session.get({ sessionID }).then((result) => { : client.session.get({ sessionID }, { signal: options?.signal }).then((result) => {
if (!result.data) throw sessionNotFoundError(sessionID) if (!result.data) throw sessionNotFoundError(sessionID)
return result.data return result.data
}) })
const resolved = request.then((result) => { const resolved = request.then((result) => {
if (options?.signal?.aborted) return result
if (generations.get(sessionID) !== active) return result if (generations.get(sessionID) !== active) return result
return remember(result) return remember(result)
}) })
if (options?.signal) return resolved
requests.set(sessionID, resolved) requests.set(sessionID, resolved)
const cleanup = () => { const cleanup = () => {
if (requests.get(sessionID) === resolved) requests.delete(sessionID) if (requests.get(sessionID) === resolved) requests.delete(sessionID)
@@ -413,8 +426,7 @@ export function createServerSession(
if (!load) return if (!load) return
// A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata. // A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata.
const messages = data.message[sessionID] const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found) if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
load.retainedMessages.add(messageID)
const parts = load.touchedParts.get(messageID) const parts = load.touchedParts.get(messageID)
if (parts) { if (parts) {
parts.add(partID) parts.add(partID)
@@ -437,16 +449,14 @@ export function createServerSession(
load.touchedParts.set(messageID, new Set(parts)) load.touchedParts.set(messageID, new Set(parts))
load.carriedDeltaParts.set(messageID, new Set(parts)) load.carriedDeltaParts.set(messageID, new Set(parts))
const messages = data.message[sessionID] const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found) if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
load.retainedMessages.add(messageID)
} }
for (const [messageID, parts] of load.removedParts) { for (const [messageID, parts] of load.removedParts) {
const touched = load.touchedParts.get(messageID) ?? new Set<string>() const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID)) parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched) load.touchedParts.set(messageID, touched)
const messages = data.message[sessionID] const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found) if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
load.retainedMessages.add(messageID)
} }
for (const [messageID, parts] of load.optimisticParts) { for (const [messageID, parts] of load.optimisticParts) {
load.removedMessages.delete(messageID) load.removedMessages.delete(messageID)
@@ -552,10 +562,11 @@ export function createServerSession(
if (!response.data.length) break if (!response.data.length) break
} }
const response = pages.at(-1)! const response = pages.at(-1)!
await yieldToMain()
const source = pages.flatMap((page) => page.data).toReversed() const source = pages.flatMap((page) => page.data).toReversed()
const normalized = normalizeSessionMessages(sessionID, source) const normalized = normalizeSessionMessages(sessionID, source)
return { return {
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)), session: normalized.messages.sort(compareMessages),
part: [...normalized.parts.entries()] part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)), .sort((a, b) => cmp(a.id, b.id)),
@@ -566,13 +577,24 @@ export function createServerSession(
complete: response.data.length === 0, complete: response.data.length === 0,
} }
} }
const response = await (options?.retry ?? retry)(() => { const response = await (options?.retry ?? retry)(async () => {
onAttempt?.() onAttempt?.()
return client.session.messages({ sessionID, limit, before }) if (!options?.decodeMessages) return client.session.messages({ sessionID, limit, before })
const response = await client.session.messages({ sessionID, limit, before }, { parseAs: "arrayBuffer" })
if (!(response.data instanceof ArrayBuffer)) throw new Error("Session messages response is not an ArrayBuffer")
return { response, decoded: await options.decodeMessages(response.data) }
}) })
await yieldToMain()
if ("decoded" in response)
return {
...response.decoded,
sourceMode: before ? ("older" as const) : ("latest" as const),
cursor: response.response.response.headers.get("x-next-cursor") ?? undefined,
complete: !response.response.response.headers.get("x-next-cursor"),
}
const items = (response.data ?? []).filter((item) => !!item?.info?.id) const items = (response.data ?? []).filter((item) => !!item?.info?.id)
return { return {
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)), session: items.map((item) => cleanMessage(item.info)).sort(compareMessages),
part: items.map((item) => ({ part: items.map((item) => ({
id: item.info.id, id: item.info.id,
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
@@ -696,7 +718,7 @@ export function createServerSession(
const normalized = normalizeSessionMessages(sessionID, source) const normalized = normalizeSessionMessages(sessionID, source)
return { return {
...page, ...page,
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)), session: normalized.messages.sort(compareMessages),
part: [...normalized.parts.entries()] part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)), .sort((a, b) => cmp(a.id, b.id)),
@@ -713,6 +735,7 @@ export function createServerSession(
retained: load?.retainedMessages, retained: load?.retainedMessages,
removed: load?.removedMessages, removed: load?.removedMessages,
preserveUnfetched, preserveUnfetched,
compare: compareMessages,
}) })
batch(() => { batch(() => {
if (source) setData("session_message", sessionID, reconcile(source)) if (source) setData("session_message", sessionID, reconcile(source))
@@ -754,7 +777,7 @@ export function createServerSession(
try { try {
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load)) const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
const first = page.session.reduce<Message | undefined>( const first = page.session.reduce<Message | undefined>(
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest), (oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest),
undefined, undefined,
) )
if (generations.get(sessionID) !== active) return if (generations.get(sessionID) !== active) return
@@ -804,14 +827,15 @@ export function createServerSession(
session: merge( session: merge(
page.session, page.session,
parents.map((parent) => parent.message), parents.map((parent) => parent.message),
), ).sort(compareMessages),
part: merge( part: merge(
page.part, page.part,
parents.map((parent) => ({ id: parent.message.id, part: parent.parts })), parents.map((parent) => ({ id: parent.message.id, part: parent.parts })),
), ),
} }
const preserveUnfetched = const preserveUnfetched =
mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0))) mode === "prepend" ||
(!result.complete && (!first || ((message: Message) => compareMessages(message, first) < 0)))
applyMessagePage( applyMessagePage(
sessionID, sessionID,
result, result,
@@ -928,7 +952,7 @@ export function createServerSession(
.message({ sessionID, messageID }) .message({ sessionID, messageID })
.then((message) => { .then((message) => {
const current = data.session_message[sessionID] ?? [] const current = data.session_message[sessionID] ?? []
const messages = [...current.filter((item) => item.id !== message.id), message].sort((a, b) => cmp(a.id, b.id)) const messages = [...current.filter((item) => item.id !== message.id), message].sort(compareMessages)
projectV2({ sessionID, messages, touched: [message.id] }) projectV2({ sessionID, messages, touched: [message.id] })
}) })
.catch(() => {}) .catch(() => {})
@@ -1018,6 +1042,27 @@ export function createServerSession(
evict([sessionID]) evict([sessionID])
return return
} }
case "session.next.moved": {
const props = event.properties as {
timestamp: number
sessionID: string
location: { directory: string; workspaceID?: string }
subdirectory?: string
}
const current = data.info[props.sessionID]
if (!current) {
void resolve(props.sessionID, { force: true }).catch(() => {})
return
}
remember({
...current,
directory: props.location.directory,
path: props.subdirectory,
workspaceID: props.location.workspaceID,
time: { ...current.time, updated: props.timestamp },
})
return
}
case "todo.updated": { case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] } const props = event.properties as { sessionID: string; todos: Todo[] }
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" })) setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
@@ -1051,7 +1096,7 @@ export function createServerSession(
setData("message", info.sessionID, [info]) setData("message", info.sessionID, [info])
return return
} }
const result = Binary.search(messages, info.id, (message) => message.id) const result = Binary.search(messages, messageKey(info), messageKey)
if (result.found) setData("message", info.sessionID, result.index, reconcile(info)) if (result.found) setData("message", info.sessionID, result.index, reconcile(info))
if (!result.found) if (!result.found)
setData("message", info.sessionID, (value = []) => { setData("message", info.sessionID, (value = []) => {
@@ -1084,8 +1129,8 @@ export function createServerSession(
produce((draft) => { produce((draft) => {
const messages = draft.message[props.sessionID] const messages = draft.message[props.sessionID]
if (messages) { if (messages) {
const result = Binary.search(messages, props.messageID, (message) => message.id) const index = messages.findIndex((message) => message.id === props.messageID)
if (result.found) messages.splice(result.index, 1) if (index >= 0) messages.splice(index, 1)
} }
deleteMessageParts(draft, props.messageID) deleteMessageParts(draft, props.messageID)
}), }),
@@ -1097,7 +1142,7 @@ export function createServerSession(
if (SKIP_PARTS.has(part.type)) return if (SKIP_PARTS.has(part.type)) return
const messages = data.message[part.sessionID] const messages = data.message[part.sessionID]
const load = messageLoads.get(part.sessionID) const load = messageLoads.get(part.sessionID)
const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found const missing = !messages?.some((message) => message.id === part.messageID)
// Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan. // Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan.
if ( if (
missing && missing &&
@@ -1341,7 +1386,8 @@ export function createServerSession(
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] }) if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
if (!items) if (!items)
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]])) optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message])) indexLegacyMessage(input.message)
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
setData( setData(
"part_text_accum_delta", "part_text_accum_delta",
produce((draft) => { produce((draft) => {
@@ -1374,6 +1420,9 @@ export function createServerSession(
) )
return return
} }
setData("session_message", input.sessionID, (messages) =>
messages?.filter((message) => message.id !== input.messageID),
)
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID)) setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
setData(produce((draft) => deleteMessageParts(draft, input.messageID))) setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
}, },
+68 -2
View File
@@ -1,18 +1,26 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import type { Event, OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
import type { import type {
McpListInput, McpListInput,
McpResourceCatalogInput, McpResourceCatalogInput,
SessionApi, SessionApi,
SessionInfo, SessionInfo,
SessionListInput, SessionListInput,
OpenCodeEvent,
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import { QueryClient } from "@tanstack/solid-query" import { QueryClient } from "@tanstack/solid-query"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load" import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync" import {
captureSessionMove,
loadActiveSessionsQuery,
loadMcpQuery,
loadMcpResourcesQuery,
seedActiveSessionStatuses,
} from "./server-sync"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session" import { createServerSession } from "./server-session"
import { adaptServerEvent } from "./server-sdk"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
type McpApi = ServerApi["mcp"] type McpApi = ServerApi["mcp"]
@@ -102,6 +110,52 @@ describe("active session query", () => {
}) })
}) })
describe("session move normalization", () => {
test("captures and applies current moves from the source placement", () => {
const session = createServerSession({} as OpencodeClient)
session.remember(sessionAt("/source"))
const current = {
id: "event-current-move",
created: 10,
type: "session.moved",
data: { sessionID: "session", location: { directory: "/destination" } },
} as OpenCodeEvent
const event = adaptServerEvent(current)
expect(captureSessionMove(event, session.get)).toEqual({
sessionID: "session",
from: "/source",
refresh: "session.next.moved",
})
session.applyV2(current)
session.apply(event)
expect(session.get("session")?.directory).toBe("/destination")
})
test("captures and applies V1 moves from the source placement", () => {
const session = createServerSession({} as OpencodeClient)
session.remember(sessionAt("/source"))
const event = {
type: "session.next.moved",
properties: {
timestamp: 10,
sessionID: "session",
location: { directory: "/destination" },
subdirectory: "packages/app",
},
} as Event
expect(captureSessionMove(event, session.get)).toEqual({
sessionID: "session",
from: "/source",
refresh: "session.next.moved",
})
session.apply(event)
expect(session.get("session")).toMatchObject({ directory: "/destination", path: "packages/app" })
})
})
describe("pickDirectoriesToEvict", () => { describe("pickDirectoriesToEvict", () => {
test("keeps pinned stores and evicts idle stores", () => { test("keeps pinned stores and evicts idle stores", () => {
const now = 5_000 const now = 5_000
@@ -174,6 +228,18 @@ function sessionInfo(id: string) {
} as SessionInfo } as SessionInfo
} }
function sessionAt(directory: string): Session {
return {
id: "session",
slug: "session",
projectID: "project",
directory,
title: "Session",
version: "",
time: { created: 1, updated: 1 },
}
}
describe("estimateRootSessionTotal", () => { describe("estimateRootSessionTotal", () => {
test("keeps exact total for full fetches", () => { test("keeps exact total for full fetches", () => {
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42) expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
+107 -9
View File
@@ -8,11 +8,11 @@ import type {
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" import { type Accessor, batch, createMemo, createSignal, getOwner, onCleanup, onMount, untrack } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error" import type { InitError } from "../pages/error"
import { ServerSDK } from "./server-sdk" import { ServerSDK, type ServerEvent } from "./server-sdk"
import { import {
bootstrapDirectory, bootstrapDirectory,
bootstrapGlobal, bootstrapGlobal,
@@ -46,6 +46,32 @@ import { ServerConnection, useServer } from "./server"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/core/util/retry"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { createHomeSessionIndexCache } from "./global-sync/home-session-index" import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
export function captureSessionMove(event: ServerEvent, get: (sessionID: string) => { directory: string } | undefined) {
const sessionID =
event.current?.type === "session.moved"
? event.current.data.sessionID
: event.type === "session.next.moved"
? event.properties.sessionID
: undefined
if (!sessionID) return
return { sessionID, from: get(sessionID)?.directory, refresh: "session.next.moved" as const }
}
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
const type: string = event.type
const current: string | undefined = event.current?.type
return (
type === "session.created" ||
type === "session.updated" ||
type === "session.deleted" ||
type === "session.next.moved" ||
current === "session.moved" ||
current === "session.renamed" ||
current === "session.archived" ||
current === "session.forked"
)
}
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
import type { import type {
@@ -59,6 +85,7 @@ import type {
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import { toggleMcp } from "./global-sync/mcp" import { toggleMcp } from "./global-sync/mcp"
import { createServerSession, type ServerSession } from "./server-session" import { createServerSession, type ServerSession } from "./server-session"
import { decodeSessionMessages } from "./session-message-decoder"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -226,6 +253,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, { const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
protocol: serverSDK.protocol, protocol: serverSDK.protocol,
decodeMessages: decodeSessionMessages,
}) })
const queryOptionsApi = makeQueryOptionsApi( const queryOptionsApi = makeQueryOptionsApi(
serverSDK.scope, serverSDK.scope,
@@ -235,11 +263,18 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
serverSDK.protocol, serverSDK.protocol,
) )
const [providersEnabled, setProvidersEnabled] = createSignal(false)
const [backgroundEnabled, setBackgroundEnabled] = createSignal(false)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], queries: [
{ ...queryOptionsApi.globalConfig(), enabled: backgroundEnabled() },
{ ...queryOptionsApi.providers(null), enabled: providersEnabled() },
{ ...queryOptionsApi.path(null), enabled: backgroundEnabled() },
],
})) }))
const activeSessionsQuery = useQuery(() => const activeSessionsQuery = useQuery(() =>
loadActiveSessionsQuery(serverSDK.scope, { ({
...loadActiveSessionsQuery(serverSDK.scope, {
active: async () => { active: async () => {
if ((await serverSDK.protocol) === "v1") { if ((await serverSDK.protocol) === "v1") {
const statuses = (await serverSDK.client.session.status()).data ?? {} const statuses = (await serverSDK.client.session.status()).data ?? {}
@@ -261,6 +296,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return active return active
}, },
}), }),
enabled: backgroundEnabled(),
}),
) )
const [globalStore, setGlobalStore] = createStore<GlobalStore>({ const [globalStore, setGlobalStore] = createStore<GlobalStore>({
@@ -299,10 +336,36 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
let bootingRoot = false let bootingRoot = false
let eventFrame: number | undefined let eventFrame: number | undefined
let eventTimer: ReturnType<typeof setTimeout> | undefined let eventTimer: ReturnType<typeof setTimeout> | undefined
let providerFrame: number | undefined
let providerIdle: number | undefined
let providerTimer: ReturnType<typeof setTimeout> | undefined
onMount(() => {
providerFrame = requestAnimationFrame(() => {
providerFrame = requestAnimationFrame(() => {
providerFrame = undefined
providerTimer = setTimeout(() => {
providerTimer = undefined
if ("requestIdleCallback" in window) {
providerIdle = requestIdleCallback(() => {
setProvidersEnabled(true)
setBackgroundEnabled(true)
}, { timeout: 5_000 })
return
}
setProvidersEnabled(true)
setBackgroundEnabled(true)
}, 10_000)
})
})
})
onCleanup(() => { onCleanup(() => {
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame) if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
if (eventTimer !== undefined) clearTimeout(eventTimer) if (eventTimer !== undefined) clearTimeout(eventTimer)
if (providerFrame !== undefined) cancelAnimationFrame(providerFrame)
if (providerIdle !== undefined) cancelIdleCallback(providerIdle)
if (providerTimer !== undefined) clearTimeout(providerTimer)
}) })
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
@@ -528,19 +591,55 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}) })
} }
const reindexSession = (sessionID: string, from?: string) => {
const next = session.get(sessionID)
if (!next) return
indexSession(next)
if (!from) return
const source = children.children[directoryKey(from)]
if (!source) return
applyDirectoryEvent({
event: {
type: "session.moved",
properties: {
sessionID,
projectID: next.projectID,
location: { directory: next.directory, workspaceID: next.workspaceID },
subpath: next.path,
},
},
directory: from,
store: source[0],
setStore: source[1],
push: queue.push,
retainedLimit: sessionMeta.get(directoryKey(from))?.limit,
sessionContent: false,
permission: session.data.permission,
loadLsp() {},
})
}
const unsub = serverSDK.event.listen((e) => { const unsub = serverSDK.event.listen((e) => {
const directory = e.name const directory = e.name
const key = directoryKey(directory) const key = directoryKey(directory)
const event = e.details const event = e.details
const eventType: string = event.type const eventType: string = event.type
const recent = bootingRoot || Date.now() - bootedAt < 1500 const recent = bootingRoot || Date.now() - bootedAt < 1500
const moved = captureSessionMove(event, session.get)
if (event.current) session.applyV2(event.current) if (event.current) session.applyV2(event.current)
session.apply(event) session.apply(event)
if (moved) reindexSession(moved.sessionID, moved.from)
if (shouldRefreshWorkspaceSessions(event)) {
void queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "settings-workspace-sessions",
})
}
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") { if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
homeSessions.apply(event) homeSessions.apply(event)
} }
homeSessions.refresh(event.type) homeSessions.refresh(moved?.refresh ?? event.type)
if (eventType === "integration.connection.updated") void refreshProviders() if (eventType === "integration.connection.updated") void refreshProviders()
if (directory === "global") { if (directory === "global") {
@@ -572,10 +671,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return return
} }
if (event.current?.type === "session.moved") {
const info = session.get(event.current.data.sessionID)
if (info) indexSession(info)
}
if (event.current?.type === "session.forked") if (event.current?.type === "session.forked")
void session void session
.resolve(event.current.data.sessionID, { force: true }) .resolve(event.current.data.sessionID, { force: true })
@@ -682,12 +777,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
child: children.child, child: children.child,
peek: children.peek, peek: children.peek,
disableMcp: children.disableMcp, disableMcp: children.disableMcp,
enableProviders: children.enableProviders,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
loadProviders: () => setProvidersEnabled(true),
refreshProviders, refreshProviders,
// bootstrap, // bootstrap,
updateConfig: updateConfigMutation.mutateAsync, updateConfig: updateConfigMutation.mutateAsync,
project: projectApi, project: projectApi,
session, session,
reindexSession,
homeSessions, homeSessions,
mcp: { mcp: {
toggle: async (directory: string, name: string) => { toggle: async (directory: string, name: string) => {
@@ -0,0 +1,64 @@
import { expect, test } from "bun:test"
import type { Message, Part, Session, SessionV2Info } from "@opencode-ai/sdk/v2/client"
import { decodeHomeSessionPage, decodeLegacyMessagePage, decodeLegacySessionList } from "./session-message-decode"
test("decodes and projects a legacy message page", () => {
const info = {
id: "message",
sessionID: "session",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "provider", modelID: "model" },
} as Message
const part = {
id: "part",
sessionID: "session",
messageID: info.id,
type: "text",
text: "hello",
} as Part
const result = decodeLegacyMessagePage(new TextEncoder().encode(JSON.stringify([{ info, parts: [part] }])).buffer)
expect(result.session).toEqual([info])
expect(result.part).toEqual([{ id: info.id, part: [part] }])
expect(result.source).toEqual([{ id: info.id, type: "user", text: "hello", time: info.time }])
})
test("decodes and projects a legacy session list", () => {
const session = {
id: "session",
projectID: "project",
directory: "/repo",
title: "Session",
version: "1",
time: { created: 1, updated: 1 },
} as Session
const result = decodeLegacySessionList(new TextEncoder().encode(JSON.stringify([session])).buffer)
expect(result).toEqual([
expect.objectContaining({ id: session.id, title: session.title, location: { directory: "/repo" } }),
])
})
test("bounds Home sessions by directory before returning from the decoder", () => {
const session = (id: string, directory: string, updated: number) =>
({
id,
projectID: "project",
location: { directory },
subpath: "",
title: id,
time: { created: updated, updated },
}) as SessionV2Info
const page = {
data: [session("old", "/repo", 1), session("new", "/repo", 2), session("other", "/other", 3)],
cursor: {},
}
const result = decodeHomeSessionPage(new TextEncoder().encode(JSON.stringify(page)).buffer, {
directories: ["/repo"],
limit: 1,
})
expect(result.data.map((item) => item.id)).toEqual(["new"])
})
@@ -0,0 +1,94 @@
import type { SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Message, Part, Session, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
import { message as cleanMessage } from "@/utils/diffs"
import { pathKey } from "@/utils/path-key"
import { parseHomeSessionIndex } from "./global-sync/home-session-index"
import { takeRecentSessions } from "./global-sync/session-trim"
export type DecodedLegacyMessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
source: SessionMessageInfo[]
}
export function decodeLegacyMessagePage(buffer: ArrayBuffer): DecodedLegacyMessagePage {
const text = new TextDecoder().decode(buffer)
const items = (text ? (JSON.parse(text) as { info?: Message; parts?: Part[] }[]) : []).filter(
(item): item is { info: Message; parts: Part[] } => !!item.info?.id && Array.isArray(item.parts),
)
return {
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => compare(a.id, b.id)),
part: items.map((item) => ({
id: item.info.id,
part: item.parts.filter((part) => !!part?.id).sort((a, b) => compare(a.id, b.id)),
})),
source: items
.slice()
.sort((a, b) => compare(a.info.id, b.info.id))
.map((item) =>
item.info.role === "user"
? {
id: item.info.id,
type: "user" as const,
text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
time: item.info.time,
}
: {
id: item.info.id,
type: "assistant" as const,
agent: item.info.agent ?? item.info.mode,
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
content: [],
time: item.info.time,
},
),
}
}
export function decodeLegacySessionList(buffer: ArrayBuffer) {
const text = new TextDecoder().decode(buffer)
return (text ? (JSON.parse(text) as Session[]) : []).map(legacySessionInfo)
}
export function decodeHomeSessionPage(buffer: ArrayBuffer, options?: { directories: string[]; limit: number }) {
const text = new TextDecoder().decode(buffer)
const page = (text ? JSON.parse(text) : { data: [], cursor: {} }) as V2SessionListResponse
const sessions = parseHomeSessionIndex(page.data)
if (!options) return { data: sessions, cursor: page.cursor }
const directories = new Set(options.directories.map(pathKey))
return {
data: [...Map.groupBy(sessions, (session) => pathKey(session.directory))]
.filter(([directory]) => directories.has(directory))
.flatMap(([, items]) => takeRecentSessions(items, options.limit, Number.NEGATIVE_INFINITY)),
cursor: page.cursor,
}
}
export function legacySessionInfo(session: Session): SessionInfo {
return {
id: session.id,
parentID: session.parentID,
projectID: session.projectID,
agent: session.agent,
model: session.model && {
id: session.model.id,
providerID: session.model.providerID,
variant: session.model.variant,
},
cost: session.cost ?? 0,
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: session.time,
title: session.title,
location: { directory: session.directory, workspaceID: session.workspaceID },
subpath: session.path,
revert: session.revert && {
messageID: session.revert.messageID,
partID: session.revert.partID,
snapshot: session.revert.snapshot,
},
}
}
function compare(a: string, b: string) {
return a < b ? -1 : a > b ? 1 : 0
}
@@ -0,0 +1,56 @@
import type { DecodedLegacyMessagePage } from "./session-message-decode"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { Session } from "@opencode-ai/sdk/v2/client"
type Response = { id: number; data?: unknown; error?: string }
let worker: Worker | undefined
let nextID = 0
const pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>()
export function decodeSessionMessages(buffer: ArrayBuffer) {
return decode<DecodedLegacyMessagePage>("messages", buffer)
}
export function decodeSessionList(buffer: ArrayBuffer) {
return decode<SessionInfo[]>("sessions", buffer)
}
export function decodeHomeSessionPage(buffer: ArrayBuffer, options: { directories: string[]; limit: number }) {
return decode<{ data: Session[]; cursor: { next?: string } }>("homeSessions", buffer, options)
}
function decode<T>(
type: "messages" | "sessions" | "homeSessions",
buffer: ArrayBuffer,
options?: { directories: string[]; limit: number },
) {
const id = ++nextID
return new Promise<T>((resolve, reject) => {
pending.set(id, { resolve: (value) => resolve(value as T), reject })
getWorker().postMessage({ id, type, buffer, options }, [buffer])
})
}
function getWorker() {
if (worker) return worker
worker = new Worker(new URL("./session-message-decoder.worker.ts", import.meta.url), { type: "module" })
worker.onmessage = (event: MessageEvent<Response>) => {
const request = pending.get(event.data.id)
if (!request) return
pending.delete(event.data.id)
if (event.data.error) {
request.reject(new Error(event.data.error))
return
}
request.resolve(event.data.data)
}
worker.onerror = (event) => {
const error = new Error(event.message)
pending.forEach((request) => request.reject(error))
pending.clear()
worker?.terminate()
worker = undefined
}
return worker
}
@@ -0,0 +1,25 @@
import { decodeHomeSessionPage, decodeLegacyMessagePage, decodeLegacySessionList } from "./session-message-decode"
type DecoderRequest = {
id: number
type: "messages" | "sessions" | "homeSessions"
buffer: ArrayBuffer
options?: { directories: string[]; limit: number }
}
self.onmessage = (event: MessageEvent<DecoderRequest>) => {
try {
self.postMessage({
id: event.data.id,
data: (() => {
if (event.data.type === "messages") return decodeLegacyMessagePage(event.data.buffer)
if (event.data.type === "sessions") return decodeLegacySessionList(event.data.buffer)
return decodeHomeSessionPage(event.data.buffer, event.data.options)
})(),
})
} catch (error) {
self.postMessage({ id: event.data.id, error: error instanceof Error ? error.message : String(error) })
}
}
export {}
+41 -1
View File
@@ -3,6 +3,10 @@ import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
export type WorkspaceLastUsed = "local" | "workspace"
export interface NotificationSettings { export interface NotificationSettings {
agent: boolean agent: boolean
@@ -50,6 +54,10 @@ export interface Settings {
permissions: { permissions: {
autoApprove: boolean autoApprove: boolean
} }
workspaces: {
defaultDestination: WorkspaceDefaultDestination
lastUsed: Record<string, WorkspaceLastUsed>
}
notifications: NotificationSettings notifications: NotificationSettings
sounds: SoundSettings sounds: SoundSettings
} }
@@ -206,6 +214,10 @@ const defaultSettings: Settings = {
permissions: { permissions: {
autoApprove: false, autoApprove: false,
}, },
workspaces: {
defaultDestination: "last-used",
lastUsed: {},
},
notifications: { notifications: {
agent: true, agent: true,
permissions: true, permissions: true,
@@ -355,6 +367,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("general", "followup", "steer") setStore("general", "followup", "steer")
}) })
createEffect(() => {
if (!ready() || !newLayoutDesigns() || store.general?.showFileTree !== true) return
setStore("general", "showFileTree", false)
})
return { return {
ready, ready,
get current() { get current() {
@@ -453,7 +470,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
}, },
}, },
visibility: { visibility: {
fileTree: visible(showFileTree), fileTree: createMemo(() => !newLayoutDesigns()),
search: visible(showSearch), search: visible(showSearch),
status: visible(showStatus), status: visible(showStatus),
customAgents: visible(showCustomAgents), customAgents: visible(showCustomAgents),
@@ -499,6 +516,29 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("permissions", "autoApprove", value) setStore("permissions", "autoApprove", value)
}, },
}, },
workspaces: {
defaultDestination: withFallback(
() => store.workspaces?.defaultDestination,
defaultSettings.workspaces.defaultDestination,
),
setDefaultDestination(value: WorkspaceDefaultDestination) {
setStore("workspaces", (current) => ({
...defaultSettings.workspaces,
...current,
defaultDestination: value,
}))
},
lastUsed(scope: ServerScope, projectID: string) {
return store.workspaces?.lastUsed?.[ScopedKey.from(scope, projectID)]
},
setLastUsed(scope: ServerScope, projectID: string, value: WorkspaceLastUsed) {
setStore("workspaces", (current) => ({
...defaultSettings.workspaces,
...current,
lastUsed: { ...current?.lastUsed, [ScopedKey.from(scope, projectID)]: value },
}))
},
},
notifications: { notifications: {
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent), agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
setAgent(value: boolean) { setAgent(value: boolean) {
@@ -4,11 +4,11 @@ import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "
type Text = Extract<Part, { type: "text" }> type Text = Extract<Part, { type: "text" }>
const userMessage = (id: string, sessionID: string): Message => ({ const userMessage = (id: string, sessionID: string, created = 1): Message => ({
id, id,
sessionID, sessionID,
role: "user", role: "user",
time: { created: 1 }, time: { created },
agent: "assistant", agent: "assistant",
model: { providerID: "openai", modelID: "gpt" }, model: { providerID: "openai", modelID: "gpt" },
}) })
@@ -22,21 +22,21 @@ const textPart = (id: string, sessionID: string, messageID: string): Text => ({
}) })
describe("sync optimistic reducers", () => { describe("sync optimistic reducers", () => {
test("applyOptimisticAdd inserts message in sorted order and stores parts", () => { test("applyOptimisticAdd inserts by creation time", () => {
const sessionID = "ses_1" const sessionID = "ses_1"
const draft = { const draft = {
message: { [sessionID]: [userMessage("msg_2", sessionID)] }, message: { [sessionID]: [userMessage("msg_z", sessionID, 1)] },
part: {} as Record<string, Part[] | undefined>, part: {} as Record<string, Part[] | undefined>,
} }
applyOptimisticAdd(draft, { applyOptimisticAdd(draft, {
sessionID, sessionID,
message: userMessage("msg_1", sessionID), message: userMessage("msg_a", sessionID, 2),
parts: [textPart("prt_2", sessionID, "msg_1"), textPart("prt_1", sessionID, "msg_1")], parts: [textPart("prt_2", sessionID, "msg_a"), textPart("prt_1", sessionID, "msg_a")],
}) })
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2"]) expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
expect(draft.part.msg_1?.map((x) => x.id)).toEqual(["prt_1", "prt_2"]) expect(draft.part.msg_a?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
}) })
test("applyOptimisticRemove removes message and part entries", () => { test("applyOptimisticRemove removes message and part entries", () => {
@@ -60,19 +60,33 @@ describe("sync optimistic reducers", () => {
const sessionID = "ses_1" const sessionID = "ses_1"
const page = mergeOptimisticPage( const page = mergeOptimisticPage(
{ {
session: [userMessage("msg_1", sessionID)], session: [userMessage("msg_z", sessionID, 1)],
part: [{ id: "msg_1", part: [textPart("prt_1", sessionID, "msg_1")] }], part: [{ id: "msg_z", part: [textPart("prt_1", sessionID, "msg_z")] }],
complete: true, complete: true,
}, },
[{ message: userMessage("msg_2", sessionID), parts: [textPart("prt_2", sessionID, "msg_2")] }], [{ message: userMessage("msg_a", sessionID, 2), parts: [textPart("prt_2", sessionID, "msg_a")] }],
) )
expect(page.session.map((x) => x.id)).toEqual(["msg_1", "msg_2"]) expect(page.session.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_2"]) expect(page.part.find((x) => x.id === "msg_a")?.part.map((x) => x.id)).toEqual(["prt_2"])
expect(page.confirmed).toEqual([]) expect(page.confirmed).toEqual([])
expect(page.complete).toBe(true) expect(page.complete).toBe(true)
}) })
test("mergeOptimisticPage uses IDs only to break equal-time ties", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_z", sessionID, 1)],
part: [],
complete: true,
},
[{ message: userMessage("msg_a", sessionID, 1), parts: [] }],
)
expect(page.session.map((message) => message.id)).toEqual(["msg_a", "msg_z"])
})
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => { test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
const sessionID = "ses_1" const sessionID = "ses_1"
const page = mergeOptimisticPage( const page = mergeOptimisticPage(
+5 -4
View File
@@ -3,6 +3,7 @@ import { createMemo } from "solid-js"
import { useServerSync } from "./server-sync" import { useServerSync } from "./server-sync"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { messageKey } from "@/utils/session-message"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -67,7 +68,7 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[])
const confirmed: string[] = [] const confirmed: string[] = []
for (const item of items) { for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id) const result = Binary.search(session, messageKey(item.message), messageKey)
const found = result.found const found = result.found
if (!found) session.splice(result.index, 0, item.message) if (!found) session.splice(result.index, 0, item.message)
@@ -92,7 +93,7 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[])
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) { export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID] const messages = draft.message[input.sessionID]
if (messages) { if (messages) {
const result = Binary.search(messages, input.message.id, (m) => m.id) const result = Binary.search(messages, messageKey(input.message), messageKey)
messages.splice(result.index, 0, input.message) messages.splice(result.index, 0, input.message)
} else { } else {
draft.message[input.sessionID] = [input.message] draft.message[input.sessionID] = [input.message]
@@ -103,8 +104,8 @@ export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddI
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) { export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID] const messages = draft.message[input.sessionID]
if (messages) { if (messages) {
const result = Binary.search(messages, input.messageID, (m) => m.id) const index = messages.findIndex((message) => message.id === input.messageID)
if (result.found) messages.splice(result.index, 1) if (index >= 0) messages.splice(index, 1)
} }
delete draft.part[input.messageID] delete draft.part[input.messageID]
} }
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
function history(): TabHistory {
return { stack: [], index: -1 }
}
describe("tab history", () => {
test("moves backward and forward through selected tabs", () => {
const selected = ["a", "b", "c"].reduce(rememberTab, history())
const available = new Set(selected.stack)
const previous = previousTab(selected, available)
expect(previous?.key).toBe("b")
const first = previousTab(previous!.state, available)
expect(first?.key).toBe("a")
const next = nextTab(first!.state, available)
expect(next?.key).toBe("b")
})
test("replaces forward history after a new selection", () => {
const selected = ["a", "b", "c"].reduce(rememberTab, history())
const previous = previousTab(selected, new Set(selected.stack))
const next = rememberTab(previous!.state, "d")
expect(next).toEqual({ stack: ["a", "b", "d"], index: 2 })
expect(nextTab(next, new Set(next.stack))).toBeUndefined()
})
test("skips tabs that are no longer open", () => {
const selected = ["a", "b", "c"].reduce(rememberTab, history())
expect(previousTab(selected, new Set(["a", "c"]))?.key).toBe("a")
})
test("skips a repeated current tab after closing the previous selection", () => {
const selected = ["a", "b", "c", "b"].reduce(rememberTab, history())
expect(previousTab(selected, new Set(["a", "b"]))?.key).toBe("a")
})
})
+28
View File
@@ -0,0 +1,28 @@
const MAX_TAB_HISTORY = 100
export type TabHistory = {
stack: string[]
index: number
}
export function rememberTab(state: TabHistory, key: string): TabHistory {
if (state.stack[state.index] === key) return state
const stack = state.stack.slice(0, state.index + 1).concat(key).slice(-MAX_TAB_HISTORY)
return { stack, index: stack.length - 1 }
}
export function previousTab(state: TabHistory, available: Set<string>) {
return move(state, -1, available)
}
export function nextTab(state: TabHistory, available: Set<string>) {
return move(state, 1, available)
}
function move(state: TabHistory, offset: -1 | 1, available: Set<string>) {
const current = state.stack[state.index]
for (let index = state.index + offset; index >= 0 && index < state.stack.length; index += offset) {
const key = state.stack[index]
if (key && key !== current && available.has(key)) return { state: { ...state, index }, key }
}
}
+21
View File
@@ -13,6 +13,7 @@ import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state" import { createDraftPromptSession, type PromptModel } from "./prompt-state"
import { migrateTabs } from "./tab-migration" import { migrateTabs } from "./tab-migration"
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
export type SessionTab = { export type SessionTab = {
type: "session" type: "session"
@@ -74,6 +75,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const memory = createTabMemory(getOwner()) const memory = createTabMemory(getOwner())
const closing = new Set<string>() const closing = new Set<string>()
let history: TabHistory = { stack: [], index: -1 }
let recentWrite = 0 let recentWrite = 0
let recentValue: string | undefined let recentValue: string | undefined
@@ -148,10 +150,21 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const navigateTab = (tab: Tab) => { const navigateTab = (tab: Tab) => {
const href = tabHref(tab) const href = tabHref(tab)
history = rememberTab(history, tabKey(tab))
setRecentKey(tabKey(tab)) setRecentKey(tabKey(tab))
navigate(href) navigate(href)
} }
const moveHistory = (direction: "previous" | "next") => {
const available = new Set(store.map(tabKey))
const result = direction === "previous" ? previousTab(history, available) : nextTab(history, available)
if (!result) return
const tab = store.find((item) => tabKey(item) === result.key)
if (!tab) return
history = result.state
navigateTab(tab)
}
const removeTab = (index: number) => { const removeTab = (index: number) => {
const tab = store[index] const tab = store[index]
if (!tab) return if (!tab) return
@@ -177,6 +190,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
} }
const actions = { const actions = {
active() {
if (location.pathname === "/") return
const key = recentKey()
return store.find((tab) => tabKey(tab) === key)
},
addSessionTab: (tab: Omit<SessionTab, "type">) => { addSessionTab: (tab: Omit<SessionTab, "type">) => {
const next = { type: "session" as const, ...tab } const next = { type: "session" as const, ...tab }
const existing = store.find((item) => tabKey(item) === tabKey(next)) const existing = store.find((item) => tabKey(item) === tabKey(next))
@@ -357,8 +375,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
select: navigateTab, select: navigateTab,
remember(tab: Tab) { remember(tab: Tab) {
const key = tabKey(tab) const key = tabKey(tab)
history = rememberTab(history, key)
if (recentKey() !== key) setRecentKey(key) if (recentKey() !== key) setRecentKey(key)
}, },
previous: () => moveHistory("previous"),
next: () => moveHistory("next"),
toggleHome(input: { home: boolean; current?: Tab }) { toggleHome(input: { home: boolean; current?: Tab }) {
if (input.home) { if (input.home) {
const tab = store.find((tab) => tabKey(tab) === recentKey()) const tab = store.find((tab) => tabKey(tab) === recentKey())
+22 -1
View File
@@ -2,12 +2,33 @@ import { describe, expect, test } from "bun:test"
import { DESKTOP_MENU } from "./desktop-menu" import { DESKTOP_MENU } from "./desktop-menu"
describe("desktop menu", () => { describe("desktop menu", () => {
test("navigates between tabs", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && (item.labelKey === "desktop.menu.previousTab" || item.labelKey === "desktop.menu.nextTab"),
)
expect(items).toEqual([
{ type: "item", labelKey: "desktop.menu.previousTab", command: "tab.prev", accelerator: { macos: "Option+Left" } },
{ type: "item", labelKey: "desktop.menu.nextTab", command: "tab.next", accelerator: { macos: "Option+Right" } },
])
})
test("exports logs through the desktop command registry", () => { test("exports logs through the desktop command registry", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter( const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && item.label === "Export Logs...", (item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs",
) )
expect(items).toHaveLength(2) expect(items).toHaveLength(2)
expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true) expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true)
}) })
test("provides translated labels for role-backed entries", () => {
const windowMenu = DESKTOP_MENU.find((menu) => menu.role === "windowMenu")
const roleItems = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && item.role && item.labelKey,
)
expect(windowMenu?.labelKey).toBe("desktop.menu.window")
expect(roleItems.length).toBeGreaterThan(0)
})
}) })
+132 -50
View File
@@ -1,3 +1,5 @@
import type { DesktopNativeKey } from "./i18n/desktop-native"
export type DesktopMenuPlatform = "macos" | "windows" export type DesktopMenuPlatform = "macos" | "windows"
export type DesktopMenuAction = export type DesktopMenuAction =
@@ -44,7 +46,7 @@ export type DesktopMenuRole =
export type DesktopMenuItem = { export type DesktopMenuItem = {
type: "item" type: "item"
label?: string labelKey?: DesktopNativeKey
command?: string command?: string
action?: DesktopMenuAction action?: DesktopMenuAction
role?: DesktopMenuRole role?: DesktopMenuRole
@@ -63,7 +65,7 @@ export type DesktopMenuEntry = DesktopMenuItem | DesktopMenuSeparator
export type DesktopMenu = { export type DesktopMenu = {
id: string id: string
label: string labelKey: DesktopNativeKey
role?: DesktopMenuRole role?: DesktopMenuRole
items?: DesktopMenuEntry[] items?: DesktopMenuEntry[]
platforms?: DesktopMenuPlatform[] platforms?: DesktopMenuPlatform[]
@@ -72,15 +74,20 @@ export type DesktopMenu = {
export const DESKTOP_MENU: DesktopMenu[] = [ export const DESKTOP_MENU: DesktopMenu[] = [
{ {
id: "app", id: "app",
label: "OpenCode", labelKey: "desktop.menu.app",
platforms: ["macos"], platforms: ["macos"],
items: [ items: [
{ type: "item", role: "about" }, { type: "item", role: "about" },
{ type: "item", label: "Check for Updates...", action: "app.checkForUpdates", enabled: "updater" }, {
{ type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } }, type: "item",
{ type: "item", label: "Reload Webview", action: "view.reload" }, labelKey: "desktop.menu.checkForUpdates",
{ type: "item", label: "Restart", action: "app.relaunch" }, action: "app.checkForUpdates",
{ type: "item", label: "Export Logs...", command: "logs.export" }, enabled: "updater",
},
{ type: "item", labelKey: "desktop.menu.settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
{ type: "item", labelKey: "desktop.menu.reloadWebview", action: "view.reload" },
{ type: "item", labelKey: "desktop.menu.restart", action: "app.relaunch" },
{ type: "item", labelKey: "desktop.menu.exportLogs", command: "logs.export" },
{ type: "separator" }, { type: "separator" },
{ type: "item", role: "hide" }, { type: "item", role: "hide" },
{ type: "item", role: "hideOthers" }, { type: "item", role: "hideOthers" },
@@ -91,46 +98,81 @@ export const DESKTOP_MENU: DesktopMenu[] = [
}, },
{ {
id: "file", id: "file",
label: "File", labelKey: "desktop.menu.file",
items: [ items: [
{ {
type: "item", type: "item",
label: "New Session", labelKey: "desktop.menu.newSession",
command: "session.new", command: "session.new",
accelerator: { macos: "Shift+Cmd+S" }, accelerator: { macos: "Shift+Cmd+S" },
}, },
{ type: "item", label: "Open Project...", command: "project.open", accelerator: { macos: "Cmd+O" } },
{ {
type: "item", type: "item",
label: "Settings", labelKey: "desktop.menu.openProject",
command: "project.open",
accelerator: { macos: "Cmd+O" },
},
{
type: "item",
labelKey: "desktop.menu.settings",
command: "settings.open", command: "settings.open",
accelerator: { windows: "Ctrl+," }, accelerator: { windows: "Ctrl+," },
platforms: ["windows"], platforms: ["windows"],
}, },
{ {
type: "item", type: "item",
label: "New Window", labelKey: "desktop.menu.newWindow",
action: "window.new", action: "window.new",
accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" }, accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" },
}, },
{ type: "separator" }, { type: "separator" },
{ type: "item", label: "Close Window", action: "window.close", role: "close" }, { type: "item", labelKey: "desktop.menu.closeWindow", action: "window.close", role: "close" },
], ],
}, },
{ {
id: "edit", id: "edit",
label: "Edit", labelKey: "desktop.menu.edit",
items: [ items: [
{ type: "item", label: "Undo", action: "edit.undo", role: "undo", accelerator: { windows: "Ctrl+Z" } },
{ type: "item", label: "Redo", action: "edit.redo", role: "redo", accelerator: { windows: "Ctrl+Y" } },
{ type: "separator" },
{ type: "item", label: "Cut", action: "edit.cut", role: "cut", accelerator: { windows: "Ctrl+X" } },
{ type: "item", label: "Copy", action: "edit.copy", role: "copy", accelerator: { windows: "Ctrl+C" } },
{ type: "item", label: "Paste", action: "edit.paste", role: "paste", accelerator: { windows: "Ctrl+V" } },
{ type: "item", label: "Delete", action: "edit.delete" },
{ {
type: "item", type: "item",
label: "Select All", labelKey: "desktop.menu.undo",
action: "edit.undo",
role: "undo",
accelerator: { windows: "Ctrl+Z" },
},
{
type: "item",
labelKey: "desktop.menu.redo",
action: "edit.redo",
role: "redo",
accelerator: { windows: "Ctrl+Y" },
},
{ type: "separator" },
{
type: "item",
labelKey: "desktop.menu.cut",
action: "edit.cut",
role: "cut",
accelerator: { windows: "Ctrl+X" },
},
{
type: "item",
labelKey: "desktop.menu.copy",
action: "edit.copy",
role: "copy",
accelerator: { windows: "Ctrl+C" },
},
{
type: "item",
labelKey: "desktop.menu.paste",
action: "edit.paste",
role: "paste",
accelerator: { windows: "Ctrl+V" },
},
{ type: "item", labelKey: "desktop.menu.delete", action: "edit.delete" },
{
type: "item",
labelKey: "desktop.menu.selectAll",
action: "edit.selectAll", action: "edit.selectAll",
role: "selectAll", role: "selectAll",
accelerator: { windows: "Ctrl+A" }, accelerator: { windows: "Ctrl+A" },
@@ -139,47 +181,87 @@ export const DESKTOP_MENU: DesktopMenu[] = [
}, },
{ {
id: "view", id: "view",
label: "View", labelKey: "desktop.menu.view",
items: [ items: [
{ type: "item", label: "Toggle Sidebar", command: "sidebar.toggle" }, { type: "item", labelKey: "desktop.menu.toggleSidebar", command: "sidebar.toggle" },
{ type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } }, {
{ type: "item", label: "Toggle File Tree", command: "fileTree.toggle" }, type: "item",
labelKey: "desktop.menu.toggleTerminal",
command: "terminal.toggle",
accelerator: { macos: "Ctrl+`" },
},
{ type: "item", labelKey: "desktop.menu.toggleFileTree", command: "fileTree.toggle" },
{ type: "separator" }, { type: "separator" },
{ type: "item", label: "Reload", action: "view.reload", role: "reload" }, { type: "item", labelKey: "desktop.menu.reload", action: "view.reload", role: "reload" },
{ type: "item", label: "Toggle Developer Tools", action: "view.toggleDevTools", role: "toggleDevTools" }, {
type: "item",
labelKey: "desktop.menu.toggleDeveloperTools",
action: "view.toggleDevTools",
role: "toggleDevTools",
},
{ type: "separator" }, { type: "separator" },
{ {
type: "item", type: "item",
label: "Actual Size", labelKey: "desktop.menu.actualSize",
action: "view.resetZoom", action: "view.resetZoom",
role: "resetZoom", role: "resetZoom",
accelerator: { windows: "Ctrl+0" }, accelerator: { windows: "Ctrl+0" },
}, },
{ type: "item", label: "Zoom In", action: "view.zoomIn", role: "zoomIn", accelerator: { windows: "Ctrl++" } }, {
{ type: "item", label: "Zoom Out", action: "view.zoomOut", role: "zoomOut", accelerator: { windows: "Ctrl+-" } }, type: "item",
labelKey: "desktop.menu.zoomIn",
action: "view.zoomIn",
role: "zoomIn",
accelerator: { windows: "Ctrl++" },
},
{
type: "item",
labelKey: "desktop.menu.zoomOut",
action: "view.zoomOut",
role: "zoomOut",
accelerator: { windows: "Ctrl+-" },
},
{ type: "separator" }, { type: "separator" },
{ type: "item", label: "Toggle Full Screen", action: "view.toggleFullscreen", role: "togglefullscreen" }, {
type: "item",
labelKey: "desktop.menu.toggleFullScreen",
action: "view.toggleFullscreen",
role: "togglefullscreen",
},
], ],
}, },
{ {
id: "go", id: "go",
label: "Go", labelKey: "desktop.menu.go",
items: [ items: [
{ type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } }, { type: "item", labelKey: "desktop.menu.back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
{ type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } }, { type: "item", labelKey: "desktop.menu.forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
{ type: "separator" }, { type: "separator" },
{ type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } }, { type: "item", labelKey: "desktop.menu.previousTab", command: "tab.prev", accelerator: { macos: "Option+Left" } },
{ type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } }, { type: "item", labelKey: "desktop.menu.nextTab", command: "tab.next", accelerator: { macos: "Option+Right" } },
{ type: "separator" }, { type: "separator" },
{ {
type: "item", type: "item",
label: "Previous Project", labelKey: "desktop.menu.previousSession",
command: "session.previous",
accelerator: { macos: "Option+Up" },
},
{
type: "item",
labelKey: "desktop.menu.nextSession",
command: "session.next",
accelerator: { macos: "Option+Down" },
},
{ type: "separator" },
{
type: "item",
labelKey: "desktop.menu.previousProject",
command: "project.previous", command: "project.previous",
accelerator: { macos: "Cmd+Option+Up" }, accelerator: { macos: "Cmd+Option+Up" },
}, },
{ {
type: "item", type: "item",
label: "Next Project", labelKey: "desktop.menu.nextProject",
command: "project.next", command: "project.next",
accelerator: { macos: "Cmd+Option+Down" }, accelerator: { macos: "Cmd+Option+Down" },
}, },
@@ -187,31 +269,31 @@ export const DESKTOP_MENU: DesktopMenu[] = [
}, },
{ {
id: "window", id: "window",
label: "Window", labelKey: "desktop.menu.window",
role: "windowMenu", role: "windowMenu",
items: [ items: [
{ type: "item", label: "Minimize", action: "window.minimize" }, { type: "item", labelKey: "desktop.menu.minimize", action: "window.minimize" },
{ type: "item", label: "Maximize", action: "window.toggleMaximize" }, { type: "item", labelKey: "desktop.menu.maximize", action: "window.toggleMaximize" },
{ type: "separator" }, { type: "separator" },
{ type: "item", label: "Close Window", action: "window.close" }, { type: "item", labelKey: "desktop.menu.closeWindow", action: "window.close" },
], ],
}, },
{ {
id: "help", id: "help",
label: "Help", labelKey: "desktop.menu.help",
items: [ items: [
{ type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" }, { type: "item", labelKey: "desktop.menu.documentation", href: "https://opencode.ai/docs" },
{ type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" }, { type: "item", labelKey: "desktop.menu.supportForum", href: "https://discord.com/invite/opencode" },
{ type: "item", label: "Export Logs...", command: "logs.export" }, { type: "item", labelKey: "desktop.menu.exportLogs", command: "logs.export" },
{ type: "separator" }, { type: "separator" },
{ {
type: "item", type: "item",
label: "Share Feedback", labelKey: "desktop.menu.shareFeedback",
href: "https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml", href: "https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml",
}, },
{ {
type: "item", type: "item",
label: "Report a Bug", labelKey: "desktop.menu.reportBug",
href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml", href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml",
}, },
], ],
+4 -1
View File
@@ -3,6 +3,7 @@
import * as Sentry from "@sentry/solid" import * as Sentry from "@sentry/solid"
import { render } from "solid-js/web" import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app" import { AppBaseProviders, AppInterface } from "@/app"
import { loadInitialLocale } from "@/context/language"
import { type Platform, PlatformProvider } from "@/context/platform" import { type Platform, PlatformProvider } from "@/context/platform"
import { createBrowserDraftStore } from "@/utils/draft-store" import { createBrowserDraftStore } from "@/utils/draft-store"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
@@ -149,6 +150,7 @@ if (import.meta.env.VITE_SENTRY_DSN) {
} }
if (root instanceof HTMLElement) { if (root instanceof HTMLElement) {
void loadInitialLocale().then((locale) => {
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token")) const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
clearAuthToken() clearAuthToken()
const server: ServerConnection.Http = { const server: ServerConnection.Http = {
@@ -162,7 +164,7 @@ if (root instanceof HTMLElement) {
render( render(
() => ( () => (
<PlatformProvider value={platform}> <PlatformProvider value={platform}>
<AppBaseProviders> <AppBaseProviders locale={locale}>
<AppInterface <AppInterface
defaultServer={ServerConnection.Key.make(getDefaultUrl())} defaultServer={ServerConnection.Key.make(getDefaultUrl())}
canonicalLocalServer={ServerConnection.key(server)} canonicalLocalServer={ServerConnection.key(server)}
@@ -174,4 +176,5 @@ if (root instanceof HTMLElement) {
), ),
root, root,
) )
})
} }
+6
View File
@@ -21,6 +21,12 @@ export function useProviders(directory: Accessor<string | undefined>) {
const serverSync = useServerSync() const serverSync = useServerSync()
const params = useParams() const params = useParams()
const dir = () => (directory ? directory() : decode64(params.dir)) const dir = () => (directory ? directory() : decode64(params.dir))
createEffect(() => {
const value = dir()
if (value) {
serverSync().enableProviders(value)
}
})
const providers = () => { const providers = () => {
const value = dir() const value = dir()
const projectStore = value ? serverSync().child(value)[0] : undefined const projectStore = value ? serverSync().child(value)[0] : undefined
File diff suppressed because it is too large Load Diff
+244 -82
View File
@@ -1,4 +1,102 @@
export const dict = { export const dict = {
"desktop.menu.app": "OpenCode",
"desktop.menu.file": "ملف",
"desktop.menu.edit": "تحرير",
"desktop.menu.view": "عرض",
"desktop.menu.go": "انتقال",
"desktop.menu.window": "نافذة",
"desktop.menu.help": "مساعدة",
"desktop.menu.checkForUpdates": "التحقق من وجود تحديثات...",
"desktop.menu.settings": "الإعدادات",
"desktop.menu.reloadWebview": "إعادة تحميل Webview",
"desktop.menu.restart": "إعادة التشغيل",
"desktop.menu.exportLogs": "تصدير السجلات...",
"desktop.menu.newSession": "جلسة جديدة",
"desktop.menu.openProject": "فتح مشروع...",
"desktop.menu.newWindow": "نافذة جديدة",
"desktop.menu.closeWindow": "إغلاق النافذة",
"desktop.menu.undo": "تراجع",
"desktop.menu.redo": "إعادة",
"desktop.menu.cut": "قص",
"desktop.menu.copy": "نسخ",
"desktop.menu.paste": "لصق",
"desktop.menu.delete": "حذف",
"desktop.menu.selectAll": "تحديد الكل",
"desktop.menu.toggleSidebar": "إظهار الشريط الجانبي أو إخفاؤه",
"desktop.menu.toggleTerminal": "إظهار المحطة الطرفية أو إخفاؤها",
"desktop.menu.toggleFileTree": "إظهار شجرة الملفات أو إخفاؤها",
"desktop.menu.reload": "إعادة التحميل",
"desktop.menu.toggleDeveloperTools": "إظهار أدوات المطور أو إخفاؤها",
"desktop.menu.actualSize": "الحجم الفعلي",
"desktop.menu.zoomIn": "تكبير",
"desktop.menu.zoomOut": "تصغير",
"desktop.menu.toggleFullScreen": "الدخول إلى ملء الشاشة أو الخروج منه",
"desktop.menu.back": "رجوع",
"desktop.menu.forward": "تقدم",
"desktop.menu.previousSession": "الجلسة السابقة",
"desktop.menu.nextSession": "الجلسة التالية",
"desktop.menu.previousProject": "المشروع السابق",
"desktop.menu.nextProject": "المشروع التالي",
"desktop.menu.minimize": "تصغير النافذة",
"desktop.menu.maximize": "تكبير النافذة",
"desktop.menu.documentation": "وثائق OpenCode",
"desktop.menu.supportForum": "منتدى الدعم",
"desktop.menu.shareFeedback": "إرسال الملاحظات",
"desktop.menu.reportBug": "الإبلاغ عن خلل",
"desktop.menu.ariaLabel": "قائمة OpenCode",
"desktop.updater.dialog.checkFailed.message": "فشل التحقق من وجود تحديثات.",
"desktop.updater.dialog.checkFailed.title": "خطأ في التحديث",
"desktop.updater.dialog.upToDate.message": "لديك أحدث إصدار.",
"desktop.updater.dialog.upToDate.title": "لا توجد تحديثات",
"desktop.updater.dialog.ready.message": "تم تنزيل التحديث {{version}}. هل تريد إعادة التشغيل الآن؟",
"desktop.updater.dialog.ready.title": "التحديث جاهز",
"desktop.updater.dialog.restart": "إعادة التشغيل",
"desktop.updater.dialog.later": "لاحقًا",
"desktop.recovery.action.relaunch": "إعادة تشغيل التطبيق",
"desktop.recovery.action.exportLogs": "تصدير السجلات",
"desktop.recovery.action.keepWaiting": "متابعة الانتظار",
"desktop.recovery.action.quit": "إنهاء",
"desktop.recovery.loadFailed": "فشل تحميل OpenCode",
"desktop.recovery.terminated": "توقفت نافذة OpenCode بشكل غير متوقع",
"desktop.recovery.unresponsive": "OpenCode لا يستجيب",
"desktop.recovery.unresponsive.detail": "يمكنك إعادة تشغيل التطبيق أو فتح السجلات أو متابعة الانتظار.",
"desktop.recovery.loadFailed.detail": "النافذة: {{window}}\nعنوان URL: {{url}}\nالخطأ: {{code}} {{description}}",
"desktop.recovery.terminated.detail": "النافذة: {{window}}\nالسبب: {{reason}}\nالرمز: {{code}}",
"desktop.recovery.unknown": "<غير معروف>",
"desktop.dialog.chooseFolder": "اختيار مجلد",
"desktop.dialog.chooseFile": "اختيار ملف",
"desktop.dialog.saveFile": "حفظ الملف",
"desktop.dialog.files": "الملفات",
"desktop.server.local": "الخادم المحلي",
"desktop.wsl.error.windowsOnly": "لا يتوفر WSL إلا على Windows",
"desktop.wsl.error.unavailable": "WSL غير متاح",
"desktop.wsl.error.listInstalled": "فشل سرد توزيعات WSL المثبتة",
"desktop.wsl.error.listOnline": "فشل سرد توزيعات WSL المتاحة عبر الإنترنت",
"desktop.wsl.error.executeDistro": "تعذر تنفيذ الأوامر في التوزيعة",
"desktop.wsl.error.installWsl": "فشل تثبيت WSL",
"desktop.wsl.error.installDistro": "فشل تثبيت التوزيعة: {{distro}}",
"desktop.wsl.error.installOpencode": "فشل تثبيت OpenCode",
"desktop.wsl.error.alreadyAdded": "تمت إضافة {{distro}} بالفعل",
"desktop.wsl.error.opencodeMissing": "opencode غير مثبت في هذه التوزيعة",
"desktop.wsl.error.opencodeCannotRun": "opencode مثبت، لكن تعذر تشغيله",
"desktop.wsl.error.opencodeNotInstalled": "OpenCode غير مثبت في {{distro}}",
"desktop.wsl.error.updateVersion":
"اكتمل تحديث OpenCode، لكن {{distro}} ما زالت تعرض الإصدار {{installed}}؛ الإصدار المتوقع هو {{expected}}",
"desktop.wsl.error.noVersion": "لا يوجد إصدار",
"desktop.wsl.error.serverExited": "خرج خادم WSL بعد بدء التشغيل (code={{code}} signal={{signal}})",
"desktop.wsl.error.serverExitedBeforeHealthy":
"خرج خادم WSL قبل أن يصبح سليمًا (code={{code}} signal={{signal}}){{output}}",
"desktop.wsl.error.healthTimeout": "انتهت مهلة فحص سلامة العملية المصاحبة للتوزيعة {{distro}} بعد {{timeout}}ms",
"desktop.wsl.error.commandTimeout": "انتهت مهلة {{command}} {{args}} بعد {{timeout}}ms",
"desktop.wsl.error.failedPort": "فشل الحصول على المنفذ",
"desktop.picker.error.notSelected": "لم يحدد منتقي الملفات ملفًا",
"desktop.picker.error.sizeLimit": "تتجاوز المرفقات المحددة الحد البالغ {{limit}} MB",
"command.category.suggested": "مقترح", "command.category.suggested": "مقترح",
"command.category.view": "عرض", "command.category.view": "عرض",
"command.category.project": "مشروع", "command.category.project": "مشروع",
@@ -16,6 +114,7 @@ export const dict = {
"command.category.permissions": "أذونات", "command.category.permissions": "أذونات",
"command.category.workspace": "مساحة عمل", "command.category.workspace": "مساحة عمل",
"command.category.settings": "إعدادات", "command.category.settings": "إعدادات",
"command.logs.export": "تصدير السجلات",
"theme.scheme.system": "نظام", "theme.scheme.system": "نظام",
"theme.scheme.light": "فاتح", "theme.scheme.light": "فاتح",
"theme.scheme.dark": "داكن", "theme.scheme.dark": "داكن",
@@ -59,16 +158,16 @@ export const dict = {
"command.message.next.description": "انتقل إلى رسالة المستخدم التالية", "command.message.next.description": "انتقل إلى رسالة المستخدم التالية",
"command.model.choose": "اختيار نموذج", "command.model.choose": "اختيار نموذج",
"command.model.choose.description": "حدد نموذجًا مختلفًا", "command.model.choose.description": "حدد نموذجًا مختلفًا",
"command.mcp.toggle": "تبديل MCPs", "command.mcp.toggle": "تبديل خوادم MCP",
"command.mcp.toggle.description": "تبديل MCPs", "command.mcp.toggle.description": "تبديل خوادم MCP",
"command.agent.cycle": "تغيير الوكيل", "command.agent.cycle": "تغيير الوكيل",
"command.agent.cycle.description": "التبديل إلى الوكيل التالي", "command.agent.cycle.description": "التبديل إلى الوكيل التالي",
"command.agent.cycle.reverse": "تغيير الوكيل للخلف", "command.agent.cycle.reverse": "تغيير الوكيل للخلف",
"command.agent.cycle.reverse.description": "التبديل إلى الوكيل السابق", "command.agent.cycle.reverse.description": "التبديل إلى الوكيل السابق",
"command.model.variant.cycle": "تغيير جهد التفكير", "command.model.variant.cycle": "تغيير جهد الاستدلال",
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي", "command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
"command.prompt.mode.shell": "Shell", "command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt", "command.prompt.mode.normal": "موجّه",
"command.permissions.autoaccept.enable": "قبول الأذونات تلقائيًا", "command.permissions.autoaccept.enable": "قبول الأذونات تلقائيًا",
"command.permissions.autoaccept.disable": "إيقاف قبول الأذونات تلقائيًا", "command.permissions.autoaccept.disable": "إيقاف قبول الأذونات تلقائيًا",
"command.workspace.toggle": "تبديل مساحات العمل", "command.workspace.toggle": "تبديل مساحات العمل",
@@ -85,6 +184,9 @@ export const dict = {
"command.session.share.description": "مشاركة هذه الجلسة ونسخ الرابط إلى الحافظة", "command.session.share.description": "مشاركة هذه الجلسة ونسخ الرابط إلى الحافظة",
"command.session.unshare": "إلغاء مشاركة الجلسة", "command.session.unshare": "إلغاء مشاركة الجلسة",
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة", "command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
"command.session.export": "تصدير الجلسة",
"command.session.export.description": "تصدير النص الكامل للجلسة بصيغة JSON",
"palette.search.placeholder": "البحث في الملفات والأوامر والجلسات", "palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
"palette.search.placeholder.home": "البحث في الأوامر والجلسات", "palette.search.placeholder.home": "البحث في الأوامر والجلسات",
"palette.empty": "لا توجد نتائج", "palette.empty": "لا توجد نتائج",
@@ -94,13 +196,14 @@ export const dict = {
"dialog.provider.empty": "لم يتم العثور على موفرين", "dialog.provider.empty": "لم يتم العثور على موفرين",
"dialog.provider.group.popular": "شائع", "dialog.provider.group.popular": "شائع",
"dialog.provider.group.other": "آخر", "dialog.provider.group.other": "آخر",
"dialog.provider.custom.label": "موفر مخصص متوافق مع OpenAI",
"dialog.provider.tag.recommended": "موصى به", "dialog.provider.tag.recommended": "موصى به",
"dialog.provider.opencode.note": "نماذج مختارة تتضمن Claude و GPT و Gemini والمزيد", "dialog.provider.opencode.note": "نماذج مختارة تتضمن Claude و GPT و Gemini والمزيد",
"dialog.provider.opencode.tagline": "نماذج موثوقة ومحسنة", "dialog.provider.opencode.tagline": "نماذج موثوقة ومحسنة",
"dialog.provider.opencodeGo.tagline": "اشتراك منخفض التكلفة للجميع", "dialog.provider.opencodeGo.tagline": "اشتراك منخفض التكلفة للجميع",
"dialog.provider.anthropic.note": "اتصل باستخدام Claude Pro/Max أو مفتاح API", "dialog.provider.anthropic.note": "وصول مباشر إلى نماذج Claude، بما فيها Pro و Max",
"dialog.provider.copilot.note": "اتصل باستخدام Copilot أو مفتاح API", "dialog.provider.copilot.note": "نماذج ذكاء اصطناعي للمساعدة في البرمجة عبر GitHub Copilot",
"dialog.provider.openai.note": "اتصل باستخدام ChatGPT Pro/Plus أو مفتاح API", "dialog.provider.openai.note": "نماذج GPT لمهام الذكاء الاصطناعي العامة السريعة والمتقدمة",
"dialog.provider.google.note": "نماذج Gemini لاستجابات سريعة ومنظمة", "dialog.provider.google.note": "نماذج Gemini لاستجابات سريعة ومنظمة",
"dialog.provider.openrouter.note": "الوصول إلى جميع النماذج المدعومة من موفر واحد", "dialog.provider.openrouter.note": "الوصول إلى جميع النماذج المدعومة من موفر واحد",
"dialog.provider.vercel.note": "وصول موحد إلى نماذج الذكاء الاصطناعي مع توجيه ذكي", "dialog.provider.vercel.note": "وصول موحد إلى نماذج الذكاء الاصطناعي مع توجيه ذكي",
@@ -112,17 +215,20 @@ export const dict = {
"dialog.model.manage.provider.toggle": "تبديل جميع نماذج {{provider}}", "dialog.model.manage.provider.toggle": "تبديل جميع نماذج {{provider}}",
"dialog.model.unpaid.freeModels.title": "نماذج مجانية مقدمة من OpenCode", "dialog.model.unpaid.freeModels.title": "نماذج مجانية مقدمة من OpenCode",
"dialog.model.unpaid.addMore.title": "إضافة المزيد من النماذج من موفرين مشهورين", "dialog.model.unpaid.addMore.title": "إضافة المزيد من النماذج من موفرين مشهورين",
"dialog.model.unpaid.viewMoreProviders": "عرض أكثر من 70 موفرًا إضافيًا",
"dialog.provider.viewAll": "عرض المزيد من الموفرين", "dialog.provider.viewAll": "عرض المزيد من الموفرين",
"provider.connect.title": "اتصال {{provider}}", "provider.connect.title": "الاتصال بـ {{provider}}",
"provider.connect.title.anthropicProMax": "تسجيل الدخول باستخدام Claude Pro/Max", "provider.connect.title.anthropicProMax": "تسجيل الدخول باستخدام Claude Pro/Max",
"provider.connect.selectMethod": "حدد طريقة تسجيل الدخول لـ {{provider}}.", "provider.connect.selectMethod": "حدد طريقة تسجيل الدخول لـ {{provider}}.",
"provider.connect.method.apiKey": "مفتاح API", "provider.connect.method.apiKey": "مفتاح API",
"provider.connect.method.browser": "المتصفح",
"provider.connect.method.headless": "بدون واجهة رسومية",
"provider.connect.status.inProgress": "جارٍ التفويض...", "provider.connect.status.inProgress": "جارٍ التفويض...",
"provider.connect.status.waiting": "في انتظار التفويض...", "provider.connect.status.waiting": "في انتظار التفويض...",
"provider.connect.status.failed": "فشل التفويض: {{error}}", "provider.connect.status.failed": "فشل التفويض: {{error}}",
"provider.connect.apiKey.description": "provider.connect.apiKey.description":
"أدخل مفتاح واجهة برمجة تطبيقات {{provider}} الخاص بك لتوصيل حسابك واستخدام نماذج {{provider}} في OpenCode.", "أدخل مفتاح API الخاص بـ {{provider}} لتوصيل حسابك واستخدام نماذج {{provider}} في OpenCode.",
"provider.connect.apiKey.label": "مفتاح واجهة برمجة تطبيقات {{provider}}", "provider.connect.apiKey.label": "مفتاح API لـ {{provider}}",
"provider.connect.apiKey.placeholder": "مفتاح API", "provider.connect.apiKey.placeholder": "مفتاح API",
"provider.connect.apiKey.required": "مفتاح API مطلوب", "provider.connect.apiKey.required": "مفتاح API مطلوب",
"provider.connect.opencodeZen.line1": "provider.connect.opencodeZen.line1":
@@ -148,6 +254,7 @@ export const dict = {
"provider.connect.toast.connected.title": "تم توصيل {{provider}}", "provider.connect.toast.connected.title": "تم توصيل {{provider}}",
"provider.connect.toast.connected.description": "نماذج {{provider}} متاحة الآن للاستخدام.", "provider.connect.toast.connected.description": "نماذج {{provider}} متاحة الآن للاستخدام.",
"provider.custom.title": "موفر مخصص", "provider.custom.title": "موفر مخصص",
"provider.custom.unavailable": "الموفرون المخصصون غير متاحين على هذا الخادم",
"provider.custom.description.prefix": "تكوين موفر متوافق مع OpenAI. راجع ", "provider.custom.description.prefix": "تكوين موفر متوافق مع OpenAI. راجع ",
"provider.custom.description.link": "وثائق تكوين الموفر", "provider.custom.description.link": "وثائق تكوين الموفر",
"provider.custom.description.suffix": ".", "provider.custom.description.suffix": ".",
@@ -196,26 +303,26 @@ export const dict = {
"model.input.image": "صورة", "model.input.image": "صورة",
"model.input.audio": "صوت", "model.input.audio": "صوت",
"model.input.video": "فيديو", "model.input.video": "فيديو",
"model.input.pdf": "pdf", "model.input.pdf": "PDF",
"model.tooltip.allows": "يسمح: {{inputs}}", "model.tooltip.allows": "يسمح: {{inputs}}",
"model.tooltip.context.label": "السياق", "model.tooltip.context.label": "السياق",
"model.tooltip.inputs": "المدخلات", "model.tooltip.inputs": "المدخلات",
"model.tooltip.model": "النموذج", "model.tooltip.model": "النموذج",
"model.tooltip.provider": "الموفر", "model.tooltip.provider": "الموفر",
"model.tooltip.reasoning": "الاستنتاج", "model.tooltip.reasoning": "الاستدلال",
"model.tooltip.reasoning.allowed": سمح بالاستنتاج", "model.tooltip.reasoning.allowed": دعم الاستدلال",
"model.tooltip.reasoning.none": "بدون استنتاج", "model.tooltip.reasoning.none": "بدون استدلال",
"model.tooltip.context": "حد السياق {{limit}}", "model.tooltip.context": "حد السياق {{limit}}",
"common.search.placeholder": "بحث", "common.search.placeholder": "بحث",
"common.goBack": "رجوع", "common.goBack": "رجوع",
"common.goForward": "انتقل للأمام", "common.goForward": "التقدم",
"common.loading": "جارٍ التحميل", "common.loading": "جارٍ التحميل",
"common.loading.ellipsis": "...", "common.loading.ellipsis": "...",
"common.cancel": "إلغاء", "common.cancel": "إلغاء",
"common.clear": "مسح", "common.clear": "مسح",
"common.connect": "اتصال", "common.connect": "اتصال",
"common.disconnect": "قطع الاتصال", "common.disconnect": "قطع الاتصال",
"common.continue": "إرسال", "common.continue": "متابعة",
"common.submit": "إرسال", "common.submit": "إرسال",
"common.save": "حفظ", "common.save": "حفظ",
"common.saving": "جارٍ الحفظ...", "common.saving": "جارٍ الحفظ...",
@@ -227,7 +334,7 @@ export const dict = {
"prompt.placeholder.summarizeComments": "لخّص التعليقات…", "prompt.placeholder.summarizeComments": "لخّص التعليقات…",
"prompt.placeholder.summarizeComment": "لخّص التعليق…", "prompt.placeholder.summarizeComment": "لخّص التعليق…",
"prompt.mode.shell": "Shell", "prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt", "prompt.mode.normal": "موجّه",
"prompt.mode.shell.exit": "esc للخروج", "prompt.mode.shell.exit": "esc للخروج",
"session.child.promptDisabled": "لا يمكن إرسال موجّهات إلى جلسات الوكلاء الفرعيين.", "session.child.promptDisabled": "لا يمكن إرسال موجّهات إلى جلسات الوكلاء الفرعيين.",
"session.child.backToParent": "العودة إلى الجلسة الرئيسية.", "session.child.backToParent": "العودة إلى الجلسة الرئيسية.",
@@ -249,17 +356,17 @@ export const dict = {
"prompt.example.16": "إضافة معالجة الأخطاء لهذه الدالة", "prompt.example.16": "إضافة معالجة الأخطاء لهذه الدالة",
"prompt.example.17": "اشرح نمط regex هذا", "prompt.example.17": "اشرح نمط regex هذا",
"prompt.example.18": "تحويل هذا إلى TypeScript", "prompt.example.18": "تحويل هذا إلى TypeScript",
"prompt.example.19": "إضافة تسجيل الدخول (logging) في جميع أنحاء قاعدة التعليمات البرمجية", "prompt.example.19": "إضافة التسجيل في جميع أنحاء قاعدة التعليمات البرمجية",
"prompt.example.20": "ما هي التبعيات القديمة؟", "prompt.example.20": "ما هي التبعيات القديمة؟",
"prompt.example.21": "ساعدني في كتابة برنامج نصي للهجرة", "prompt.example.21": "ساعدني في كتابة برنامج نصي للترحيل",
"prompt.example.22": "تنفيذ التخزين المؤقت لهذه النقطة النهائية", "prompt.example.22": "تنفيذ التخزين المؤقت لنقطة النهاية هذه",
"prompt.example.23": "إضافة ترقيم الصفحات إلى هذه القائمة", "prompt.example.23": "إضافة ترقيم الصفحات إلى هذه القائمة",
"prompt.example.24": "إنشاء أمر CLI لـ...", "prompt.example.24": "إنشاء أمر CLI لـ...",
"prompt.example.25": "كيف تعمل متغيرات البيئة هنا؟", "prompt.example.25": "كيف تعمل متغيرات البيئة هنا؟",
"prompt.popover.emptyResults": "لا توجد نتائج مطابقة", "prompt.popover.emptyResults": "لا توجد نتائج مطابقة",
"prompt.popover.emptyCommands": "لا توجد أوامر مطابقة", "prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF أو الملفات النصية هنا", "prompt.dropzone.label": "أفلت الصور أو ملفات PDF أو الملفات النصية هنا",
"prompt.dropzone.file.label": "أفلت لإشارة @ للملف", "prompt.dropzone.file.label": "أفلت الملف للإشارة إليه باستخدام @",
"prompt.slash.badge.custom": "مخصص", "prompt.slash.badge.custom": "مخصص",
"prompt.slash.badge.skill": "مهارة", "prompt.slash.badge.skill": "مهارة",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@@ -267,7 +374,7 @@ export const dict = {
"prompt.context.includeActiveFile": "تضمين الملف النشط", "prompt.context.includeActiveFile": "تضمين الملف النشط",
"prompt.context.removeActiveFile": "إزالة الملف النشط من السياق", "prompt.context.removeActiveFile": "إزالة الملف النشط من السياق",
"prompt.context.removeFile": "إزالة الملف من السياق", "prompt.context.removeFile": "إزالة الملف من السياق",
"prompt.action.attachFile": رفاق ملف", "prompt.action.attachFile": ضافة ملفات",
"prompt.menu.addImagesAndFiles": "إضافة ملفات والمزيد", "prompt.menu.addImagesAndFiles": "إضافة ملفات والمزيد",
"prompt.menu.imagesAndFiles": "الصور والملفات", "prompt.menu.imagesAndFiles": "الصور والملفات",
"prompt.menu.commands": "الأوامر", "prompt.menu.commands": "الأوامر",
@@ -275,7 +382,7 @@ export const dict = {
"prompt.menu.shellCommand": "أمر shell", "prompt.menu.shellCommand": "أمر shell",
"prompt.attachment.remove": "إزالة المرفق", "prompt.attachment.remove": "إزالة المرفق",
"prompt.action.send": "إرسال", "prompt.action.send": "إرسال",
"prompt.action.stop": "توقف", "prompt.action.stop": "إيقاف",
"prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم", "prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم",
"prompt.toast.attachmentDuplicate.title": "تم تحميل هذا الملف بالفعل", "prompt.toast.attachmentDuplicate.title": "تم تحميل هذا الملف بالفعل",
"prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.", "prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
@@ -287,10 +394,10 @@ export const dict = {
"prompt.toast.commandSendFailed.title": "فشل إرسال الأمر", "prompt.toast.commandSendFailed.title": "فشل إرسال الأمر",
"prompt.toast.promptSendFailed.title": "فشل إرسال الموجه", "prompt.toast.promptSendFailed.title": "فشل إرسال الموجه",
"prompt.toast.promptSendFailed.description": "تعذر استرداد الجلسة", "prompt.toast.promptSendFailed.description": "تعذر استرداد الجلسة",
"dialog.mcp.title": "MCPs", "dialog.mcp.title": "خوادم MCP",
"dialog.mcp.description": "{{enabled}} من {{total}} مفعل", "dialog.mcp.description": "تم تفعيل {{enabled}} من أصل {{total}}",
"dialog.mcp.empty": "لم يتم تكوين MCPs", "dialog.mcp.empty": "لم يتم تكوين أي خوادم MCP",
"dialog.lsp.empty": م الكشف تلقائيًا عن LSPs من أنواع الملفات", "dialog.lsp.empty": ُكتشف خوادم LSP تلقائيًا من أنواع الملفات",
"dialog.plugins.empty": "الإضافات المكونة في opencode.json", "dialog.plugins.empty": "الإضافات المكونة في opencode.json",
"mcp.status.connected": "متصل", "mcp.status.connected": "متصل",
"mcp.status.failed": "فشل", "mcp.status.failed": "فشل",
@@ -401,6 +508,21 @@ export const dict = {
"wsl.onboarding.desktopVersion": "سطح المكتب {{version}}", "wsl.onboarding.desktopVersion": "سطح المكتب {{version}}",
"wsl.onboarding.versionMismatch": "لا يتطابق الإصدار المثبت مع إصدار تطبيق سطح المكتب.", "wsl.onboarding.versionMismatch": "لا يتطابق الإصدار المثبت مع إصدار تطبيق سطح المكتب.",
"wsl.onboarding.adding": "جارٍ الإضافة...", "wsl.onboarding.adding": "جارٍ الإضافة...",
"help.tabs.toast.ariaLabel": "نقدّم علامات التبويب. نظّم عملك وجلساتك النشطة باستخدام علامات التبويب",
"help.tabs.toast.dismiss": "إغلاق معلومات علامات التبويب",
"help.tabs.title": "نقدّم علامات التبويب",
"help.tabs.description": "نظّم عملك وجلساتك النشطة باستخدام علامات التبويب",
"help.tabs.date": "14 يوليو",
"help.tabs.introduction": "أصبح OpenCode Desktop الآن قائمًا على علامات التبويب.",
"help.tabs.sessions":
"ابدأ جلسة جديدة في علامة تبويب، أو افتح جلسة حالية من أي من مشاريعك. افتح علامة تبويب جديدة عندما تبدأ عملاً جديدًا، وأغلقها عند الانتهاء.",
"help.tabs.organize":
"يسهّل إبقاء بضع علامات تبويب مفتوحة تنظيم جلساتك النشطة. أعد تسمية علامات التبويب بأسماء يسهل تذكرها إذا كنت تنوي إبقاءها مفتوحة.",
"help.tabs.home":
"ستجد جميع جلساتك ومشاريعك في شاشة الصفحة الرئيسية الجديدة. يؤدي تحديد جلسة إلى فتحها في علامة تبويب.",
"help.tabs.persistence": "عندما تعيد فتح التطبيق، تظل علامات تبويبك مفتوحة.",
"help.tabs.worktrees":
"لا يدعم التصميم الجديد أشجار عمل Git حتى الآن، وستتوفر قريبًا. لذلك، إذا كنت تفضل مواصلة استخدام التخطيط السابق، فيمكنك التبديل بين التخطيطات في الإعدادات. ضع في اعتبارك أن التخطيط الجديد سيصبح دائمًا خلال بضعة أسابيع.",
"dialog.project.edit.title": "تحرير المشروع", "dialog.project.edit.title": "تحرير المشروع",
"dialog.project.edit.name": "الاسم", "dialog.project.edit.name": "الاسم",
"dialog.project.edit.icon": "أيقونة", "dialog.project.edit.icon": "أيقونة",
@@ -409,35 +531,39 @@ export const dict = {
"dialog.project.edit.icon.recommended": "موصى به: 128x128px", "dialog.project.edit.icon.recommended": "موصى به: 128x128px",
"dialog.project.edit.color": "لون", "dialog.project.edit.color": "لون",
"dialog.project.edit.color.select": "اختر لون {{color}}", "dialog.project.edit.color.select": "اختر لون {{color}}",
"dialog.project.edit.worktree.startup": "سكريبت بدء تشغيل مساحة العمل", "dialog.project.edit.worktree.startup": "برنامج نصي لبدء تشغيل مساحة العمل",
"dialog.project.edit.worktree.startup.description": "يتم تشغيله بعد إنشاء مساحة عمل جديدة (شجرة عمل).", "dialog.project.edit.worktree.startup.description": "يتم تشغيله بعد إنشاء مساحة عمل جديدة (شجرة عمل).",
"dialog.project.edit.worktree.startup.placeholder": "مثال: bun install", "dialog.project.edit.worktree.startup.placeholder": "مثال: bun install",
"dialog.usageExceeded.dontShowAgain": "عدم الإظهار مرة أخرى",
"context.breakdown.title": "تفصيل السياق", "context.breakdown.title": "تفصيل السياق",
"context.breakdown.note": 'تفصيل تقريبي لرموز الإدخال. يشمل "أخرى" تعريفات الأدوات والنفقات العامة.', "context.breakdown.note": 'تفصيل تقريبي لرموز الإدخال المميزة. يشمل "أخرى" تعريفات الأدوات والأعباء الإضافية.',
"context.breakdown.system": "النظام", "context.breakdown.system": "النظام",
"context.breakdown.user": "المستخدم", "context.breakdown.user": "المستخدم",
"context.breakdown.assistant": "المساعد", "context.breakdown.assistant": "المساعد",
"context.breakdown.tool": "استدعاءات الأداة", "context.breakdown.tool": "استدعاءات الأدوات",
"context.breakdown.other": "أخرى", "context.breakdown.other": "أخرى",
"context.systemPrompt.title": "موجه النظام", "context.systemPrompt.title": "موجّه النظام",
"context.rawMessages.title": "الرسائل الخام", "context.rawMessages.title": "الرسائل الخام",
"context.export.session": "تصدير الجلسة",
"context.stats.session": "جلسة", "context.stats.session": "جلسة",
"context.stats.messages": "رسائل", "context.stats.messages": "رسائل",
"context.stats.provider": "موفر", "context.stats.provider": "موفر",
"context.stats.model": "نموذج", "context.stats.model": "نموذج",
"context.stats.limit": "حد السياق", "context.stats.limit": "حد السياق",
"context.stats.totalTokens": "إجمالي الرموز", "context.stats.totalTokens": "إجمالي الرموز المميزة",
"context.stats.usage": "استخدام", "context.stats.usage": "استخدام",
"context.stats.inputTokens": "رموز الإدخال", "context.stats.inputTokens": "رموز الإدخال المميزة",
"context.stats.outputTokens": "رموز الإخراج", "context.stats.outputTokens": "رموز الإخراج المميزة",
"context.stats.reasoningTokens": "رموز الاستنتاج", "context.stats.reasoningTokens": "رموز الاستدلال المميزة",
"context.stats.cacheTokens": "رموز التخزين المؤقت (قراءة/كتابة)", "context.stats.cacheTokens": "رموز التخزين المؤقت المميزة (قراءة/كتابة)",
"context.stats.userMessages": "رسائل المستخدم", "context.stats.userMessages": "رسائل المستخدم",
"context.stats.assistantMessages": "رسائل المساعد", "context.stats.assistantMessages": "رسائل المساعد",
"context.stats.totalCost": "التكلفة الإجمالية", "context.stats.totalCost": "التكلفة الإجمالية",
"context.stats.sessionCreated": "تم إنشاء الجلسة", "context.stats.sessionCreated": "تم إنشاء الجلسة",
"context.stats.lastActivity": "آخر نشاط", "context.stats.lastActivity": "آخر نشاط",
"context.usage.tokens": "رموز", "context.usage.tokens": "الرموز المميزة",
"context.usage.usage": "استخدام", "context.usage.usage": "استخدام",
"context.usage.cost": "تكلفة", "context.usage.cost": "تكلفة",
"context.usage.clickToView": "انقر لعرض السياق", "context.usage.clickToView": "انقر لعرض السياق",
@@ -465,9 +591,9 @@ export const dict = {
"toast.theme.title": "تم تبديل السمة", "toast.theme.title": "تم تبديل السمة",
"toast.scheme.title": "مخطط الألوان", "toast.scheme.title": "مخطط الألوان",
"toast.workspace.enabled.title": "تم تمكين مساحات العمل", "toast.workspace.enabled.title": "تم تمكين مساحات العمل",
"toast.workspace.enabled.description": "الآن يتم عرض عدة worktrees في الشريط الجانبي", "toast.workspace.enabled.description": "تُعرض الآن عدة أشجار عمل في الشريط الجانبي",
"toast.workspace.disabled.title": "تم تعطيل مساحات العمل", "toast.workspace.disabled.title": "تم تعطيل مساحات العمل",
"toast.workspace.disabled.description": "يتم عرض worktree الرئيسي فقط في الشريط الجانبي", "toast.workspace.disabled.description": "تُعرض شجرة العمل الرئيسية فقط في الشريط الجانبي",
"toast.permissions.autoaccept.on.title": "يتم قبول الأذونات تلقائيًا", "toast.permissions.autoaccept.on.title": "يتم قبول الأذونات تلقائيًا",
"toast.permissions.autoaccept.on.description": "ستتم الموافقة على طلبات الأذونات تلقائيًا", "toast.permissions.autoaccept.on.description": "ستتم الموافقة على طلبات الأذونات تلقائيًا",
"toast.permissions.autoaccept.off.title": "تم إيقاف قبول الأذونات تلقائيًا", "toast.permissions.autoaccept.off.title": "تم إيقاف قبول الأذونات تلقائيًا",
@@ -487,9 +613,14 @@ export const dict = {
"toast.session.unshare.success.description": "تم إلغاء مشاركة الجلسة بنجاح!", "toast.session.unshare.success.description": "تم إلغاء مشاركة الجلسة بنجاح!",
"toast.session.unshare.failed.title": "فشل إلغاء مشاركة الجلسة", "toast.session.unshare.failed.title": "فشل إلغاء مشاركة الجلسة",
"toast.session.unshare.failed.description": "حدث خطأ أثناء إلغاء مشاركة الجلسة", "toast.session.unshare.failed.description": "حدث خطأ أثناء إلغاء مشاركة الجلسة",
"toast.session.export.success.title": "تم تصدير الجلسة",
"toast.session.export.success.description": "تم حفظ الجلسة في \u2068{{filename}}\u2069",
"toast.session.export.failed.title": "فشل تصدير الجلسة",
"toast.session.export.failed.description": "حدث خطأ أثناء تصدير الجلسة",
"toast.session.listFailed.title": "فشل تحميل الجلسات لـ {{project}}", "toast.session.listFailed.title": "فشل تحميل الجلسات لـ {{project}}",
"toast.update.title": "تحديث متاح", "toast.update.title": "تحديث متاح",
"toast.update.description": "نسخة جديدة من OpenCode ({{version}}) متاحة الآن للتثبيت.", "toast.update.description": "يتوفر الآن إصدار جديد من OpenCode ({{version}}) للتثبيت.",
"toast.update.action.installRestart": "تثبيت وإعادة تشغيل", "toast.update.action.installRestart": "تثبيت وإعادة تشغيل",
"toast.update.action.notYet": "ليس الآن", "toast.update.action.notYet": "ليس الآن",
"error.page.title": "حدث خطأ ما", "error.page.title": "حدث خطأ ما",
@@ -509,7 +640,7 @@ export const dict = {
"error.dev.rootNotFound": "error.dev.rootNotFound":
"لم يتم العثور على العنصر الجذري. هل نسيت إضافته إلى index.html؟ أو ربما تمت كتابة سمة id بشكل خاطئ؟", "لم يتم العثور على العنصر الجذري. هل نسيت إضافته إلى index.html؟ أو ربما تمت كتابة سمة id بشكل خاطئ؟",
"error.serverSync.connectFailed": "تعذر الاتصال بالخادم. هل هناك خادم يعمل في `{{url}}`؟", "error.serverSync.connectFailed": "تعذر الاتصال بالخادم. هل هناك خادم يعمل في `{{url}}`؟",
"directory.error.invalidUrl": ليل غير صالح في عنوان URL.", "directory.error.invalidUrl": "مجلد غير صالح في عنوان URL.",
"error.chain.unknown": "خطأ غير معروف", "error.chain.unknown": "خطأ غير معروف",
"error.chain.causedBy": "بسبب:", "error.chain.causedBy": "بسبب:",
"error.chain.apiError": "خطأ API", "error.chain.apiError": "خطأ API",
@@ -537,7 +668,7 @@ export const dict = {
"notification.session.responseReady.title": "الاستجابة جاهزة", "notification.session.responseReady.title": "الاستجابة جاهزة",
"notification.session.error.title": "خطأ في الجلسة", "notification.session.error.title": "خطأ في الجلسة",
"notification.session.error.fallbackDescription": "حدث خطأ", "notification.session.error.fallbackDescription": "حدث خطأ",
"home.recentProjects": "المشاريع الحديثة", "home.recentProjects": "المشاريع الأخيرة",
"home.empty.title": "لا توجد مشاريع حديثة", "home.empty.title": "لا توجد مشاريع حديثة",
"home.empty.description": "ابدأ بفتح مشروع محلي", "home.empty.description": "ابدأ بفتح مشروع محلي",
"home.title": "الرئيسية", "home.title": "الرئيسية",
@@ -555,7 +686,7 @@ export const dict = {
"home.sessions.group.today": "اليوم", "home.sessions.group.today": "اليوم",
"home.sessions.group.yesterday": "أمس", "home.sessions.group.yesterday": "أمس",
"home.sessions.group.older": "الأقدم", "home.sessions.group.older": "الأقدم",
"home.providerTip": "اتصل بأكثر من 75 مزودًا لاستخدام نماذج أخرى، بما فيها Claude وGPT وGemini وغيرها", "home.providerTip": "اتصل بأكثر من 75 موفرًا لاستخدام نماذج أخرى، بما فيها Claude وGPT وGemini وغيرها",
"session.tab.session": "جلسة", "session.tab.session": "جلسة",
"session.tab.review": "مراجعة", "session.tab.review": "مراجعة",
"session.tab.context": "سياق", "session.tab.context": "سياق",
@@ -565,7 +696,7 @@ export const dict = {
"session.error.notFound.description": "يشير هذا التبويب إلى جلسة لم تعد موجودة على هذا الخادم.", "session.error.notFound.description": "يشير هذا التبويب إلى جلسة لم تعد موجودة على هذا الخادم.",
"session.error.notFound.closeTab": "إغلاق علامة التبويب", "session.error.notFound.closeTab": "إغلاق علامة التبويب",
"session.error.serverConnection": "تعذر الاتصال بهذا الخادم", "session.error.serverConnection": "تعذر الاتصال بهذا الخادم",
"session.review.filesChanged": "تم تغيير {{count}} ملفات", "session.review.filesChanged": "الملفات المتغيرة: {{count}}",
"session.review.change.one": "تغيير", "session.review.change.one": "تغيير",
"session.review.change.other": "تغييرات", "session.review.change.other": "تغييرات",
"session.review.loadingChanges": "جارٍ تحميل التغييرات...", "session.review.loadingChanges": "جارٍ تحميل التغييرات...",
@@ -573,7 +704,7 @@ export const dict = {
"session.review.noChanges": "لا توجد تغييرات", "session.review.noChanges": "لا توجد تغييرات",
"session.review.noUncommittedChanges": "لا توجد تغييرات غير مُودَعة بعد", "session.review.noUncommittedChanges": "لا توجد تغييرات غير مُودَعة بعد",
"session.review.noBranchChanges": "لا توجد تغييرات في الفرع بعد", "session.review.noBranchChanges": "لا توجد تغييرات في الفرع بعد",
"session.review.noVcs": "لم يتم اكتشاف نظام التحكم في الإصدار Git، لن يتم عرض التغييرات", "session.review.noVcs": "لم يُكتشف نظام Git للتحكم في الإصدارات، لذا لن تُعرض التغييرات",
"session.review.noSnapshot": "تم تعطيل تتبع اللقطات في التكوين، لذا فإن تغييرات الجلسة غير متوفرة", "session.review.noSnapshot": "تم تعطيل تتبع اللقطات في التكوين، لذا فإن تغييرات الجلسة غير متوفرة",
"session.files.selectToOpen": "اختر ملفًا لفتحه", "session.files.selectToOpen": "اختر ملفًا لفتحه",
"session.files.all": "كل الملفات", "session.files.all": "كل الملفات",
@@ -591,14 +722,26 @@ export const dict = {
"session.question.minimize": "تصغير السؤال", "session.question.minimize": "تصغير السؤال",
"session.question.restore": "استعادة السؤال", "session.question.restore": "استعادة السؤال",
"session.question.pending.one": "{{count}} سؤال معلق", "session.question.pending.one": "{{count}} سؤال معلق",
"session.question.pending.zero": "عدد الأسئلة المعلقة: {{count}}",
"session.question.pending.two": "عدد الأسئلة المعلقة: {{count}}",
"session.question.pending.few": "{{count}} أسئلة معلقة",
"session.question.pending.many": "{{count}} سؤالًا معلقًا",
"session.question.pending.other": "الأسئلة المعلقة: {{count}}", "session.question.pending.other": "الأسئلة المعلقة: {{count}}",
"session.followupDock.summary.one": "{{count}} رسالة في الانتظار", "session.followupDock.summary.one": "{{count}} رسالة في قائمة الانتظار",
"session.followupDock.summary.other": "{{count}} رسائل في الانتظار", "session.followupDock.summary.zero": "عدد الرسائل في قائمة الانتظار: {{count}}",
"session.followupDock.summary.two": "عدد الرسائل في قائمة الانتظار: {{count}}",
"session.followupDock.summary.few": "{{count}} رسائل في قائمة الانتظار",
"session.followupDock.summary.many": "{{count}} رسالةً في قائمة الانتظار",
"session.followupDock.summary.other": "{{count}} رسائل في قائمة الانتظار",
"session.followupDock.sendNow": "إرسال الآن", "session.followupDock.sendNow": "إرسال الآن",
"session.followupDock.edit": "تحرير", "session.followupDock.edit": "تحرير",
"session.followupDock.collapse": "طي الرسائل المنتظرة", "session.followupDock.collapse": "طي الرسائل المنتظرة",
"session.followupDock.expand": "توسيع الرسائل المنتظرة", "session.followupDock.expand": "توسيع الرسائل المنتظرة",
"session.revertDock.summary.one": "{{count}} رسالة تم التراجع عنها", "session.revertDock.summary.one": "{{count}} رسالة تم التراجع عنها",
"session.revertDock.summary.zero": "عدد الرسائل التي تم التراجع عنها: {{count}}",
"session.revertDock.summary.two": "عدد الرسائل التي تم التراجع عنها: {{count}}",
"session.revertDock.summary.few": "{{count}} رسائل تم التراجع عنها",
"session.revertDock.summary.many": "{{count}} رسالةً تم التراجع عنها",
"session.revertDock.summary.other": "{{count}} رسائل تم التراجع عنها", "session.revertDock.summary.other": "{{count}} رسائل تم التراجع عنها",
"session.revertDock.collapse": "طي الرسائل التي تم التراجع عنها", "session.revertDock.collapse": "طي الرسائل التي تم التراجع عنها",
"session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها", "session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها",
@@ -650,12 +793,19 @@ export const dict = {
"terminal.close": "إغلاق المحطة الطرفية", "terminal.close": "إغلاق المحطة الطرفية",
"terminal.connectionLost.title": "فقد الاتصال", "terminal.connectionLost.title": "فقد الاتصال",
"terminal.connectionLost.description": "انقطع اتصال المحطة الطرفية. يمكن أن يحدث هذا عند إعادة تشغيل الخادم.", "terminal.connectionLost.description": "انقطع اتصال المحطة الطرفية. يمكن أن يحدث هذا عند إعادة تشغيل الخادم.",
"terminal.connectTicket.csrfError": "رُفضت تذكرة اتصال PTY بسبب فحوصات الأصل أو CSRF. تحقق من إعداد CORS للخادم.",
"terminal.connectTicket.statusError": "فشلت تذكرة اتصال PTY بالحالة {{status}}",
"titlebar.update": "تحديث",
"titlebar.updateVersion": "تحديث {{version}}",
"common.closeTab": "إغلاق علامة التبويب", "common.closeTab": "إغلاق علامة التبويب",
"common.dismiss": "رفض", "common.dismiss": "إهمال",
"common.requestFailed": "فشل الطلب", "common.requestFailed": "فشل الطلب",
"common.moreOptions": "مزيد من الخيارات", "common.moreOptions": "مزيد من الخيارات",
"common.learnMore": "اعرف المزيد", "common.learnMore": "اطّلع على المزيد",
"common.rename": "إعادة تسمية", "common.rename": "إعادة تسمية",
"common.export": "تصدير",
"common.reset": "إعادة تعيين", "common.reset": "إعادة تعيين",
"common.archive": "أرشفة", "common.archive": "أرشفة",
"common.delete": "حذف", "common.delete": "حذف",
@@ -672,7 +822,7 @@ export const dict = {
"sidebar.gettingStarted.title": "البدء", "sidebar.gettingStarted.title": "البدء",
"sidebar.gettingStarted.line1": "يتضمن OpenCode نماذج مجانية حتى تتمكن من البدء فورًا.", "sidebar.gettingStarted.line1": "يتضمن OpenCode نماذج مجانية حتى تتمكن من البدء فورًا.",
"sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.", "sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.",
"sidebar.project.recentSessions": "الجلسات الحديثة", "sidebar.project.recentSessions": "الجلسات الأخيرة",
"sidebar.project.viewAllSessions": "عرض جميع الجلسات", "sidebar.project.viewAllSessions": "عرض جميع الجلسات",
"sidebar.project.clearNotifications": "مسح الإشعارات", "sidebar.project.clearNotifications": "مسح الإشعارات",
"sidebar.empty.title": "لا توجد مشاريع مفتوحة", "sidebar.empty.title": "لا توجد مشاريع مفتوحة",
@@ -691,7 +841,7 @@ export const dict = {
"settings.general.section.updates": "التحديثات", "settings.general.section.updates": "التحديثات",
"settings.general.section.sounds": "المؤثرات الصوتية", "settings.general.section.sounds": "المؤثرات الصوتية",
"settings.general.section.feed": "الخلاصة", "settings.general.section.feed": "الخلاصة",
"settings.general.section.display": "شاشة العرض", "settings.general.section.display": "العرض",
"settings.general.row.language.title": "اللغة", "settings.general.row.language.title": "اللغة",
"settings.general.row.language.description": "تغيير لغة العرض لـ OpenCode", "settings.general.row.language.description": "تغيير لغة العرض لـ OpenCode",
"settings.general.row.shell.title": "Shell المحطة الطرفية", "settings.general.row.shell.title": "Shell المحطة الطرفية",
@@ -728,10 +878,11 @@ export const dict = {
"settings.general.row.mobileTitlebarBottom.title": "التنقل السفلي", "settings.general.row.mobileTitlebarBottom.title": "التنقل السفلي",
"settings.general.row.mobileTitlebarBottom.description": "settings.general.row.mobileTitlebarBottom.description":
"وضع شريط العنوان وعلامات تبويب الجلسات أسفل الشاشة على الأجهزة المحمولة", "وضع شريط العنوان وعلامات تبويب الجلسات أسفل الشاشة على الأجهزة المحمولة",
"settings.general.row.showCustomAgents.title": "الوكلاء المخصصون", "settings.general.row.showCustomAgents.title": "إظهار الوكيل",
"settings.general.row.showCustomAgents.description": "إظهار محدد الوكلاء في محرر الرسائل", "settings.general.row.showCustomAgents.description":
"settings.general.row.reasoningSummaries.title": "إظهار ملخصات الاستنتاج", "التبديل بين الوكلاء في محرر الرسائل. عند إخفائه، يُستخدم وكيل Build افتراضيًا.",
"settings.general.row.reasoningSummaries.description": "عرض ملخصات استنتاج النموذج في الشريط الزمني", "settings.general.row.reasoningSummaries.title": "إظهار ملخصات الاستدلال",
"settings.general.row.reasoningSummaries.description": "عرض ملخصات استدلال النموذج في الشريط الزمني",
"settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell", "settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
"settings.general.row.shellToolPartsExpanded.description": "settings.general.row.shellToolPartsExpanded.description":
"إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني", "إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني",
@@ -744,11 +895,12 @@ export const dict = {
"استخدم علامات التبويب الجديدة وتخطيط الصفحة الرئيسية. يمكنك التبديل بين التخطيطات لفترة محدودة.", "استخدم علامات التبويب الجديدة وتخطيط الصفحة الرئيسية. يمكنك التبديل بين التخطيطات لفترة محدودة.",
"settings.general.row.newInterfaceNotice.title": "أنت تستخدم الآن التخطيط الجديد", "settings.general.row.newInterfaceNotice.title": "أنت تستخدم الآن التخطيط الجديد",
"settings.general.row.newInterfaceNotice.description": "التخطيط السابق لم يعد متاحًا", "settings.general.row.newInterfaceNotice.description": "التخطيط السابق لم يعد متاحًا",
"settings.general.row.newInterfaceNotice.dismiss": "رفض", "settings.general.row.newInterfaceNotice.dismiss": "إهمال",
"settings.general.row.pinchZoom.title": "التكبير بإيماءة القرص", "settings.general.row.pinchZoom.title": "التكبير بالمقاربة والمباعدة",
"settings.general.row.pinchZoom.description": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير", "settings.general.row.pinchZoom.description":
"السماح بإيماءات مقاربة الأصابع ومباعدتها على لوحة التعقب والتمرير مع الضغط على Ctrl للتكبير",
"settings.general.row.wayland.title": "استخدام Wayland الأصلي", "settings.general.row.wayland.title": "استخدام Wayland الأصلي",
"settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.", "settings.general.row.wayland.description": "تعطيل الرجوع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
"settings.general.row.wayland.tooltip": "settings.general.row.wayland.tooltip":
"على Linux مع شاشات بمعدلات تحديث مختلطة، يمكن أن يكون Wayland الأصلي أكثر استقرارًا.", "على Linux مع شاشات بمعدلات تحديث مختلطة، يمكن أن يكون Wayland الأصلي أكثر استقرارًا.",
"settings.general.row.releaseNotes.title": "ملاحظات الإصدار", "settings.general.row.releaseNotes.title": "ملاحظات الإصدار",
@@ -761,7 +913,7 @@ export const dict = {
"settings.updates.action.checking": "جارٍ التحقق...", "settings.updates.action.checking": "جارٍ التحقق...",
"settings.updates.action.downloading": "جارٍ التنزيل...", "settings.updates.action.downloading": "جارٍ التنزيل...",
"settings.updates.action.installing": "جارٍ التثبيت...", "settings.updates.action.installing": "جارٍ التثبيت...",
"settings.updates.toast.latest.title": "أنت على آخر إصدار", "settings.updates.toast.latest.title": "لديك أحدث إصدار",
"settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.", "settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.",
"sound.option.none": "بلا", "sound.option.none": "بلا",
"sound.option.alert01": "تنبيه 01", "sound.option.alert01": "تنبيه 01",
@@ -823,17 +975,17 @@ export const dict = {
"settings.general.sounds.errors.description": "تشغيل صوت عند حدوث خطأ", "settings.general.sounds.errors.description": "تشغيل صوت عند حدوث خطأ",
"settings.shortcuts.title": "اختصارات لوحة المفاتيح", "settings.shortcuts.title": "اختصارات لوحة المفاتيح",
"settings.shortcuts.reset.button": "إعادة التعيين إلى الافتراضيات", "settings.shortcuts.reset.button": "إعادة التعيين إلى الافتراضيات",
"settings.shortcuts.reset.toast.title": "تم إعادة تعيين الاختصارات", "settings.shortcuts.reset.toast.title": "أُعيد تعيين الاختصارات",
"settings.shortcuts.reset.toast.description": "تم إعادة تعيين اختصارات لوحة المفاتيح إلى الافتراضيات.", "settings.shortcuts.reset.toast.description": "أُعيد تعيين اختصارات لوحة المفاتيح إلى الإعدادات الافتراضية.",
"settings.shortcuts.conflict.title": "الاختصار قيد الاستخدام بالفعل", "settings.shortcuts.conflict.title": "الاختصار قيد الاستخدام بالفعل",
"settings.shortcuts.conflict.description": "{{keybind}} معين بالفعل لـ {{titles}}.", "settings.shortcuts.conflict.description": "الاختصار {{keybind}} مخصص بالفعل لـ {{titles}}.",
"settings.shortcuts.unassigned": "غير معين", "settings.shortcuts.unassigned": "غير مخصص",
"settings.shortcuts.pressKeys": "اضغط على المفاتيح", "settings.shortcuts.pressKeys": "اضغط على المفاتيح",
"settings.shortcuts.search.placeholder": "البحث في الاختصارات", "settings.shortcuts.search.placeholder": "البحث في الاختصارات",
"settings.shortcuts.search.empty": "لم يتم العثور على اختصارات", "settings.shortcuts.search.empty": "لم يتم العثور على اختصارات",
"settings.shortcuts.group.general": "عام", "settings.shortcuts.group.general": "عام",
"settings.shortcuts.group.session": "جلسة", "settings.shortcuts.group.session": "جلسة",
"settings.shortcuts.group.navigation": "تصفح", "settings.shortcuts.group.navigation": "التنقل",
"settings.shortcuts.group.modelAndAgent": "النموذج والوكيل", "settings.shortcuts.group.modelAndAgent": "النموذج والوكيل",
"settings.shortcuts.group.terminal": "المحطة الطرفية", "settings.shortcuts.group.terminal": "المحطة الطرفية",
"settings.shortcuts.group.prompt": "موجه", "settings.shortcuts.group.prompt": "موجه",
@@ -858,19 +1010,18 @@ export const dict = {
"settings.permissions.description": "تحكم في الأدوات التي يمكن للخادم استخدامها بشكل افتراضي.", "settings.permissions.description": "تحكم في الأدوات التي يمكن للخادم استخدامها بشكل افتراضي.",
"settings.permissions.section.tools": "الأدوات", "settings.permissions.section.tools": "الأدوات",
"settings.permissions.toast.updateFailed.title": "فشل تحديث الأذونات", "settings.permissions.toast.updateFailed.title": "فشل تحديث الأذونات",
"settings.permissions.action.allow": "سماح", "settings.permissions.action.allow": "السماح",
"settings.permissions.action.ask": "سؤال", "settings.permissions.action.ask": "طلب الإذن",
"settings.permissions.action.deny": "رفض", "settings.permissions.action.deny": "الرفض",
"settings.permissions.tool.read.title": "قراءة", "settings.permissions.tool.read.title": "قراءة",
"settings.permissions.tool.read.description": "قراءة ملف (يطابق مسار الملف)", "settings.permissions.tool.read.description": "قراءة ملف (يطابق مسار الملف)",
"settings.permissions.tool.edit.title": "تحرير", "settings.permissions.tool.edit.title": "تحرير",
"settings.permissions.tool.edit.description": "settings.permissions.tool.edit.description": "تعديل الملفات، بما في ذلك عمليات التحرير والكتابة وتطبيق الرقع",
"تعديل الملفات، بما في ذلك التحرير والكتابة والتصحيحات والتحرير المتعدد",
"settings.permissions.tool.glob.title": "Glob", "settings.permissions.tool.glob.title": "Glob",
"settings.permissions.tool.glob.description": "مطابقة الملفات باستخدام أنماط glob", "settings.permissions.tool.glob.description": "مطابقة الملفات باستخدام أنماط glob",
"settings.permissions.tool.grep.title": "Grep", "settings.permissions.tool.grep.title": "Grep",
"settings.permissions.tool.grep.description": "البحث في محتويات الملف باستخدام التعبيرات العادية", "settings.permissions.tool.grep.description": "البحث في محتويات الملف باستخدام التعبيرات العادية",
"settings.permissions.tool.list.title": "قائمة", "settings.permissions.tool.list.title": "سرد",
"settings.permissions.tool.list.description": "سرد الملفات داخل دليل", "settings.permissions.tool.list.description": "سرد الملفات داخل دليل",
"settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.title": "Bash",
"settings.permissions.tool.bash.description": "تشغيل أوامر shell", "settings.permissions.tool.bash.description": "تشغيل أوامر shell",
@@ -888,7 +1039,7 @@ export const dict = {
"settings.permissions.tool.websearch.description": "البحث في الويب", "settings.permissions.tool.websearch.description": "البحث في الويب",
"settings.permissions.tool.external_directory.title": "دليل خارجي", "settings.permissions.tool.external_directory.title": "دليل خارجي",
"settings.permissions.tool.external_directory.description": "الوصول إلى الملفات خارج دليل المشروع", "settings.permissions.tool.external_directory.description": "الوصول إلى الملفات خارج دليل المشروع",
"settings.permissions.tool.doom_loop.title": "حلقة الموت", "settings.permissions.tool.doom_loop.title": "حلقة مفرغة",
"settings.permissions.tool.doom_loop.description": "اكتشاف استدعاءات الأدوات المتكررة بمدخلات متطابقة", "settings.permissions.tool.doom_loop.description": "اكتشاف استدعاءات الأدوات المتكررة بمدخلات متطابقة",
"session.delete.failed.title": "فشل حذف الجلسة", "session.delete.failed.title": "فشل حذف الجلسة",
"session.delete.title": "حذف الجلسة", "session.delete.title": "حذف الجلسة",
@@ -896,7 +1047,7 @@ export const dict = {
"session.delete.button": "حذف الجلسة", "session.delete.button": "حذف الجلسة",
"workspace.new": "مساحة عمل جديدة", "workspace.new": "مساحة عمل جديدة",
"workspace.type.local": "محلي", "workspace.type.local": "محلي",
"workspace.type.sandbox": "صندوق رمل", "workspace.type.sandbox": "بيئة معزولة",
"workspace.create.failed.title": "فشل إنشاء مساحة العمل", "workspace.create.failed.title": "فشل إنشاء مساحة العمل",
"workspace.delete.failed.title": "فشل حذف مساحة العمل", "workspace.delete.failed.title": "فشل حذف مساحة العمل",
"workspace.resetting.title": "إعادة تعيين مساحة العمل", "workspace.resetting.title": "إعادة تعيين مساحة العمل",
@@ -906,7 +1057,7 @@ export const dict = {
"workspace.reset.success.description": "مساحة العمل تطابق الآن الفرع الافتراضي.", "workspace.reset.success.description": "مساحة العمل تطابق الآن الفرع الافتراضي.",
"workspace.error.stillPreparing": "مساحة العمل لا تزال قيد الإعداد", "workspace.error.stillPreparing": "مساحة العمل لا تزال قيد الإعداد",
"workspace.status.checking": "التحقق من التغييرات غير المدمجة...", "workspace.status.checking": "التحقق من التغييرات غير المدمجة...",
"workspace.status.error": "تعذر التحقق من حالة git.", "workspace.status.error": "تعذر التحقق من حالة Git.",
"workspace.status.clean": "لم يتم اكتشاف تغييرات غير مدمجة.", "workspace.status.clean": "لم يتم اكتشاف تغييرات غير مدمجة.",
"workspace.status.dirty": "تم اكتشاف تغييرات غير مدمجة في مساحة العمل هذه.", "workspace.status.dirty": "تم اكتشاف تغييرات غير مدمجة في مساحة العمل هذه.",
"workspace.delete.title": "حذف مساحة العمل", "workspace.delete.title": "حذف مساحة العمل",
@@ -922,7 +1073,7 @@ export const dict = {
"common.open": "فتح", "common.open": "فتح",
"dialog.releaseNotes.action.getStarted": "البدء", "dialog.releaseNotes.action.getStarted": "البدء",
"dialog.releaseNotes.action.next": "التالي", "dialog.releaseNotes.action.next": "التالي",
"dialog.releaseNotes.action.hideFuture": "عدم إظهار هذا في المستقبل", "dialog.releaseNotes.action.hideFuture": "عدم إظهارها مستقبلاً",
"dialog.releaseNotes.media.alt": "معاينة الإصدار", "dialog.releaseNotes.media.alt": "معاينة الإصدار",
"toast.project.reloadFailed.title": "فشل في إعادة تحميل {{project}}", "toast.project.reloadFailed.title": "فشل في إعادة تحميل {{project}}",
"error.server.invalidConfiguration": "تكوين غير صالح", "error.server.invalidConfiguration": "تكوين غير صالح",
@@ -932,22 +1083,25 @@ export const dict = {
"common.time.hoursAgo.short": "قبل {{count}} س", "common.time.hoursAgo.short": "قبل {{count}} س",
"common.time.daysAgo.short": "قبل {{count}} ي", "common.time.daysAgo.short": "قبل {{count}} ي",
"settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك", "settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك",
"settings.providers.custom.description": "أضف مزود متوافق مع OpenAI بواسطة عنوان URL الأساسي.", "settings.providers.custom.description": "أضف موفرًا متوافقًا مع OpenAI باستخدام عنوان URL الأساسي.",
"app.server.unreachable": "تعذر الوصول إلى {{server}}", "app.server.unreachable": "تعذر الوصول إلى {{server}}",
"app.server.retrying": "جاري إعادة المحاولة تلقائيًا...", "app.server.retrying": "جارٍ إعادة المحاولة تلقائيًا...",
"app.server.otherServers": "خوادم أخرى", "app.server.otherServers": "خوادم أخرى",
"dialog.server.add.usernamePlaceholder": "اسم المستخدم", "dialog.server.add.usernamePlaceholder": "اسم المستخدم",
"dialog.server.add.passwordPlaceholder": "كلمة المرور", "dialog.server.add.passwordPlaceholder": "كلمة المرور",
"server.row.noUsername": "لا يوجد اسم مستخدم", "server.row.noUsername": "لا يوجد اسم مستخدم",
"session.review.noVcs.createGit.title": "إنشاء مستودع Git", "session.review.noVcs.createGit.title": "إنشاء مستودع Git",
"session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع", "session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع",
"session.review.noVcs.createGit.actionLoading": "جاري إنشاء مستودع Git...", "session.review.noVcs.createGit.actionLoading": "جارٍ إنشاء مستودع Git...",
"session.review.noVcs.createGit.action": "إنشاء مستودع Git", "session.review.noVcs.createGit.action": "إنشاء مستودع Git",
"session.todo.progress": "تم إكمال {{done}} من {{total}} مهام", "session.todo.progress": "تم إكمال {{done}} من {{total}} مهام",
"session.question.progress": "{{current}} من {{total}} أسئلة", "session.question.progress": "{{current}} من {{total}} أسئلة",
"session.header.open.finder": "Finder", "session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "مستكشف الملفات", "session.header.open.fileExplorer": "مستكشف الملفات",
"session.header.reveal.finder": "إظهار في Finder",
"session.header.reveal.fileExplorer": "إظهار في مستكشف الملفات",
"session.header.reveal.containingFolder": "فتح المجلد الحاوي",
"session.header.open.fileManager": "مدير الملفات", "session.header.open.fileManager": "مدير الملفات",
"session.header.open.app.vscode": "VS Code", "session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor", "session.header.open.app.cursor": "Cursor",
@@ -965,9 +1119,9 @@ export const dict = {
"debugBar.ariaLabel": "تشخيص أداء التطوير", "debugBar.ariaLabel": "تشخيص أداء التطوير",
"debugBar.na": "غير متاح", "debugBar.na": "غير متاح",
"debugBar.nav.label": "NAV", "debugBar.nav.label": "NAV",
"debugBar.nav.tip": "آخر انتقال مكتمل للمسار يمس صفحة جلسة، مُقاسًا من بدء التوجيه حتى أول رسم بعد استقراره.", "debugBar.nav.tip": "آخر انتقال مكتمل للمسار يتضمن صفحة جلسة، مُقاسًا من بدء التوجيه حتى أول رسم بعد استقراره.",
"debugBar.fps.label": "FPS", "debugBar.fps.label": "FPS",
"debugBar.fps.tip": "الإطارات المتجددة في الثانية خلال آخر 5 ثوانٍ.", "debugBar.fps.tip": "متوسط متحرك لمعدل الإطارات في الثانية خلال آخر 5 ثوانٍ.",
"debugBar.frame.label": "FRAME", "debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "أسوأ وقت للإطار خلال آخر 5 ثوانٍ.", "debugBar.frame.tip": "أسوأ وقت للإطار خلال آخر 5 ثوانٍ.",
"debugBar.jank.label": "JANK", "debugBar.jank.label": "JANK",
@@ -979,10 +1133,18 @@ export const dict = {
"debugBar.inp.label": "INP", "debugBar.inp.label": "INP",
"debugBar.inp.tip": "مدة التفاعل التقريبية خلال آخر 5 ثوانٍ. هذا يشبه INP، وليس Web Vitals INP الرسمي.", "debugBar.inp.tip": "مدة التفاعل التقريبية خلال آخر 5 ثوانٍ. هذا يشبه INP، وليس Web Vitals INP الرسمي.",
"debugBar.cls.label": "CLS", "debugBar.cls.label": "CLS",
"debugBar.cls.tip": "التحول التخطيطي التراكمي لعمر التطبيق الحالي.", "debugBar.cls.tip": "التحول التخطيطي التراكمي منذ تشغيل التطبيق الحالي.",
"debugBar.mem.label": "MEM", "debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "كومة JS المستخدمة مقابل حد الكومة. Chromium فقط.", "debugBar.mem.tipUnavailable": "كومة JS المستخدمة مقابل حد الكومة. Chromium فقط.",
"debugBar.mem.tip": "كومة JS المستخدمة مقابل حد الكومة. {{used}} من {{limit}}.", "debugBar.mem.tip": "كومة JS المستخدمة مقابل حد الكومة. {{used}} من {{limit}}.",
"debugBar.focus.label": "FOCUS",
"debugBar.focus.tip": "فرض أنماط التركيز على جميع العناصر التفاعلية",
"debugBar.focus.on": "تشغيل",
"debugBar.focus.off": "إيقاف",
"debugBar.direction.label": "DIR",
"debugBar.direction.tip": "فرض اتجاه التخطيط على التطبيق بالكامل دون تغيير اللغة المحددة",
"debugBar.direction.ltr": "LTR",
"debugBar.direction.rtl": "RTL",
"common.key.ctrl": "Ctrl", "common.key.ctrl": "Ctrl",
"common.key.alt": "Alt", "common.key.alt": "Alt",
"common.key.shift": "Shift", "common.key.shift": "Shift",
@@ -998,7 +1160,7 @@ export const dict = {
"common.key.pageDown": "Page Down", "common.key.pageDown": "Page Down",
"common.key.insert": "Insert", "common.key.insert": "Insert",
"common.unknown": "غير معروف", "common.unknown": "غير معروف",
"error.page.circular": "[دائري]", "error.page.circular": "[مرجع دائري]",
"error.serverSDK.noServerAvailable": "لا يوجد خادم متاح", "error.serverSDK.noServerAvailable": "لا يوجد خادم متاح",
"error.serverSDK.serverNotAvailable": "الخادم غير متاح", "error.serverSDK.serverNotAvailable": "الخادم غير متاح",
"error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة", "error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+200 -44
View File
@@ -1,4 +1,104 @@
export const dict = { export const dict = {
"desktop.menu.app": "OpenCode",
"desktop.menu.file": "Arquivo",
"desktop.menu.edit": "Editar",
"desktop.menu.view": "Visualizar",
"desktop.menu.go": "Ir",
"desktop.menu.window": "Janela",
"desktop.menu.help": "Ajuda",
"desktop.menu.checkForUpdates": "Verificar atualizações...",
"desktop.menu.settings": "Configurações",
"desktop.menu.reloadWebview": "Recarregar WebView",
"desktop.menu.restart": "Reiniciar",
"desktop.menu.exportLogs": "Exportar logs...",
"desktop.menu.newSession": "Nova sessão",
"desktop.menu.openProject": "Abrir projeto...",
"desktop.menu.newWindow": "Nova janela",
"desktop.menu.closeWindow": "Fechar janela",
"desktop.menu.undo": "Desfazer",
"desktop.menu.redo": "Refazer",
"desktop.menu.cut": "Recortar",
"desktop.menu.copy": "Copiar",
"desktop.menu.paste": "Colar",
"desktop.menu.delete": "Excluir",
"desktop.menu.selectAll": "Selecionar tudo",
"desktop.menu.toggleSidebar": "Alternar barra lateral",
"desktop.menu.toggleTerminal": "Alternar terminal",
"desktop.menu.toggleFileTree": "Alternar árvore de arquivos",
"desktop.menu.reload": "Recarregar",
"desktop.menu.toggleDeveloperTools": "Alternar ferramentas de desenvolvedor",
"desktop.menu.actualSize": "Tamanho real",
"desktop.menu.zoomIn": "Ampliar",
"desktop.menu.zoomOut": "Reduzir",
"desktop.menu.toggleFullScreen": "Alternar tela inteira",
"desktop.menu.back": "Voltar",
"desktop.menu.forward": "Avançar",
"desktop.menu.previousSession": "Sessão anterior",
"desktop.menu.nextSession": "Próxima sessão",
"desktop.menu.previousProject": "Projeto anterior",
"desktop.menu.nextProject": "Próximo projeto",
"desktop.menu.minimize": "Minimizar",
"desktop.menu.maximize": "Maximizar",
"desktop.menu.documentation": "Documentação do OpenCode",
"desktop.menu.supportForum": "Fórum de suporte",
"desktop.menu.shareFeedback": "Enviar comentários",
"desktop.menu.reportBug": "Relatar um bug",
"desktop.menu.ariaLabel": "Menu do OpenCode",
"desktop.updater.dialog.checkFailed.message": "Falha ao verificar atualizações.",
"desktop.updater.dialog.checkFailed.title": "Erro de atualização",
"desktop.updater.dialog.upToDate.message": "Você está em dia.",
"desktop.updater.dialog.upToDate.title": "Nenhuma atualização",
"desktop.updater.dialog.ready.message": "A atualização {{version}} foi baixada. Deseja reiniciar agora?",
"desktop.updater.dialog.ready.title": "Atualização pronta",
"desktop.updater.dialog.restart": "Reiniciar",
"desktop.updater.dialog.later": "Mais tarde",
"desktop.recovery.action.relaunch": "Reabrir",
"desktop.recovery.action.exportLogs": "Exportar logs",
"desktop.recovery.action.keepWaiting": "Continuar aguardando",
"desktop.recovery.action.quit": "Encerrar",
"desktop.recovery.loadFailed": "Falha ao carregar o OpenCode",
"desktop.recovery.terminated": "A janela do OpenCode foi encerrada inesperadamente",
"desktop.recovery.unresponsive": "O OpenCode não está respondendo",
"desktop.recovery.unresponsive.detail": "Você pode reabrir o aplicativo, abrir os logs ou continuar aguardando.",
"desktop.recovery.loadFailed.detail": "Janela: {{window}}\nURL: {{url}}\nErro: {{code}} {{description}}",
"desktop.recovery.terminated.detail": "Janela: {{window}}\nMotivo: {{reason}}\nCódigo: {{code}}",
"desktop.recovery.unknown": "<desconhecido>",
"desktop.dialog.chooseFolder": "Escolher uma pasta",
"desktop.dialog.chooseFile": "Escolher um arquivo",
"desktop.dialog.saveFile": "Salvar arquivo",
"desktop.dialog.files": "Arquivos",
"desktop.server.local": "Servidor local",
"desktop.wsl.error.windowsOnly": "O WSL está disponível somente no Windows",
"desktop.wsl.error.unavailable": "O WSL está indisponível",
"desktop.wsl.error.listInstalled": "Falha ao listar as distribuições WSL instaladas",
"desktop.wsl.error.listOnline": "Falha ao listar as distribuições WSL disponíveis online",
"desktop.wsl.error.executeDistro": "Não é possível executar comandos na distribuição",
"desktop.wsl.error.installWsl": "Falha na instalação do WSL",
"desktop.wsl.error.installDistro": "Falha ao instalar a distribuição: {{distro}}",
"desktop.wsl.error.installOpencode": "Falha na instalação do OpenCode",
"desktop.wsl.error.alreadyAdded": "{{distro}} já foi adicionada",
"desktop.wsl.error.opencodeMissing": "O opencode não está instalado nesta distribuição",
"desktop.wsl.error.opencodeCannotRun": "O opencode está instalado, mas não pôde ser executado",
"desktop.wsl.error.opencodeNotInstalled": "O OpenCode não está instalado em {{distro}}",
"desktop.wsl.error.updateVersion":
"A atualização do OpenCode foi concluída, mas {{distro}} ainda informa a versão {{installed}}; a versão esperada é {{expected}}",
"desktop.wsl.error.noVersion": "sem versão",
"desktop.wsl.error.serverExited":
"O servidor WSL foi encerrado após a inicialização (código={{code}} sinal={{signal}})",
"desktop.wsl.error.serverExitedBeforeHealthy":
"O servidor WSL foi encerrado antes de ficar íntegro (código={{code}} sinal={{signal}}){{output}}",
"desktop.wsl.error.healthTimeout":
"O tempo limite da verificação de integridade do sidecar de {{distro}} foi excedido após {{timeout}}ms",
"desktop.wsl.error.commandTimeout": "O tempo limite de {{command}} {{args}} foi excedido após {{timeout}}ms",
"desktop.wsl.error.failedPort": "Falha ao obter a porta",
"desktop.picker.error.notSelected": "O arquivo não foi selecionado pelo seletor",
"desktop.picker.error.sizeLimit": "Os anexos selecionados excedem o limite de {{limit}} MB",
"command.category.suggested": "Sugerido", "command.category.suggested": "Sugerido",
"command.category.view": "Visualizar", "command.category.view": "Visualizar",
"command.category.project": "Projeto", "command.category.project": "Projeto",
@@ -16,6 +116,7 @@ export const dict = {
"command.category.permissions": "Permissões", "command.category.permissions": "Permissões",
"command.category.workspace": "Espaço de trabalho", "command.category.workspace": "Espaço de trabalho",
"command.category.settings": "Configurações", "command.category.settings": "Configurações",
"command.logs.export": "Exportar logs",
"theme.scheme.system": "Sistema", "theme.scheme.system": "Sistema",
"theme.scheme.light": "Claro", "theme.scheme.light": "Claro",
"theme.scheme.dark": "Escuro", "theme.scheme.dark": "Escuro",
@@ -85,6 +186,9 @@ export const dict = {
"command.session.share.description": "Compartilhar esta sessão e copiar a URL para a área de transferência", "command.session.share.description": "Compartilhar esta sessão e copiar a URL para a área de transferência",
"command.session.unshare": "Parar de compartilhar sessão", "command.session.unshare": "Parar de compartilhar sessão",
"command.session.unshare.description": "Parar de compartilhar esta sessão", "command.session.unshare.description": "Parar de compartilhar esta sessão",
"command.session.export": "Exportar sessão",
"command.session.export.description": "Exportar a transcrição completa da sessão como JSON",
"palette.search.placeholder": "Buscar arquivos, comandos e sessões", "palette.search.placeholder": "Buscar arquivos, comandos e sessões",
"palette.search.placeholder.home": "Buscar comandos e sessões", "palette.search.placeholder.home": "Buscar comandos e sessões",
"palette.empty": "Nenhum resultado encontrado", "palette.empty": "Nenhum resultado encontrado",
@@ -94,13 +198,14 @@ export const dict = {
"dialog.provider.empty": "Nenhum provedor encontrado", "dialog.provider.empty": "Nenhum provedor encontrado",
"dialog.provider.group.popular": "Popular", "dialog.provider.group.popular": "Popular",
"dialog.provider.group.other": "Outro", "dialog.provider.group.other": "Outro",
"dialog.provider.custom.label": "Provedor personalizado compatível com OpenAI",
"dialog.provider.tag.recommended": "Recomendado", "dialog.provider.tag.recommended": "Recomendado",
"dialog.provider.opencode.note": "Modelos selecionados incluindo Claude, GPT, Gemini e mais", "dialog.provider.opencode.note": "Modelos selecionados incluindo Claude, GPT, Gemini e mais",
"dialog.provider.opencode.tagline": "Modelos otimizados e confiáveis", "dialog.provider.opencode.tagline": "Modelos otimizados e confiáveis",
"dialog.provider.opencodeGo.tagline": "Assinatura de baixo custo para todos", "dialog.provider.opencodeGo.tagline": "Assinatura de baixo custo para todos",
"dialog.provider.anthropic.note": "Conectar com Claude Pro/Max ou chave de API", "dialog.provider.anthropic.note": "Acesso direto aos modelos Claude, incluindo Pro e Max",
"dialog.provider.copilot.note": "Conectar com Copilot ou chave de API", "dialog.provider.copilot.note": "Modelos de IA para auxílio à programação pelo GitHub Copilot",
"dialog.provider.openai.note": "Conectar com ChatGPT Pro/Plus ou chave de API", "dialog.provider.openai.note": "Modelos GPT rápidos e avançados para tarefas gerais de IA",
"dialog.provider.google.note": "Modelos Gemini para respostas rápidas e estruturadas", "dialog.provider.google.note": "Modelos Gemini para respostas rápidas e estruturadas",
"dialog.provider.openrouter.note": "Acesse todos os modelos suportados de um único provedor", "dialog.provider.openrouter.note": "Acesse todos os modelos suportados de um único provedor",
"dialog.provider.vercel.note": "Acesso unificado a modelos de IA com roteamento inteligente", "dialog.provider.vercel.note": "Acesso unificado a modelos de IA com roteamento inteligente",
@@ -112,11 +217,14 @@ export const dict = {
"dialog.model.manage.provider.toggle": "Alternar todos os modelos {{provider}}", "dialog.model.manage.provider.toggle": "Alternar todos os modelos {{provider}}",
"dialog.model.unpaid.freeModels.title": "Modelos gratuitos fornecidos pelo OpenCode", "dialog.model.unpaid.freeModels.title": "Modelos gratuitos fornecidos pelo OpenCode",
"dialog.model.unpaid.addMore.title": "Adicionar mais modelos de provedores populares", "dialog.model.unpaid.addMore.title": "Adicionar mais modelos de provedores populares",
"dialog.model.unpaid.viewMoreProviders": "Ver mais de 70 provedores",
"dialog.provider.viewAll": "Ver mais provedores", "dialog.provider.viewAll": "Ver mais provedores",
"provider.connect.title": "Conectar {{provider}}", "provider.connect.title": "Conectar {{provider}}",
"provider.connect.title.anthropicProMax": "Entrar com Claude Pro/Max", "provider.connect.title.anthropicProMax": "Entrar com Claude Pro/Max",
"provider.connect.selectMethod": "Selecionar método de login para {{provider}}.", "provider.connect.selectMethod": "Selecionar método de login para {{provider}}.",
"provider.connect.method.apiKey": "Chave de API", "provider.connect.method.apiKey": "Chave de API",
"provider.connect.method.browser": "Navegador",
"provider.connect.method.headless": "Sem interface gráfica",
"provider.connect.status.inProgress": "Autorização em andamento...", "provider.connect.status.inProgress": "Autorização em andamento...",
"provider.connect.status.waiting": "Aguardando autorização...", "provider.connect.status.waiting": "Aguardando autorização...",
"provider.connect.status.failed": "Autorização falhou: {{error}}", "provider.connect.status.failed": "Autorização falhou: {{error}}",
@@ -148,15 +256,16 @@ export const dict = {
"provider.connect.toast.connected.title": "{{provider}} conectado", "provider.connect.toast.connected.title": "{{provider}} conectado",
"provider.connect.toast.connected.description": "Modelos do {{provider}} agora estão disponíveis para uso.", "provider.connect.toast.connected.description": "Modelos do {{provider}} agora estão disponíveis para uso.",
"provider.custom.title": "Provedor personalizado", "provider.custom.title": "Provedor personalizado",
"provider.custom.unavailable": "Provedores personalizados não estão disponíveis neste servidor",
"provider.custom.description.prefix": "Configure um provedor compatível com OpenAI. Veja a ", "provider.custom.description.prefix": "Configure um provedor compatível com OpenAI. Veja a ",
"provider.custom.description.link": "documentação de configuração do provedor", "provider.custom.description.link": "documentação de configuração do provedor",
"provider.custom.description.suffix": ".", "provider.custom.description.suffix": ".",
"provider.custom.field.providerID.label": "ID do Provedor", "provider.custom.field.providerID.label": "ID do provedor",
"provider.custom.field.providerID.placeholder": "meuprovedor", "provider.custom.field.providerID.placeholder": "meuprovedor",
"provider.custom.field.providerID.description": "Letras minúsculas, números, hifens ou sublinhados", "provider.custom.field.providerID.description": "Letras minúsculas, números, hifens ou sublinhados",
"provider.custom.field.name.label": "Nome de exibição", "provider.custom.field.name.label": "Nome de exibição",
"provider.custom.field.name.placeholder": "Meu Provedor de IA", "provider.custom.field.name.placeholder": "Meu provedor de IA",
"provider.custom.field.baseURL.label": "URL Base", "provider.custom.field.baseURL.label": "URL base",
"provider.custom.field.baseURL.placeholder": "https://api.meuprovedor.com/v1", "provider.custom.field.baseURL.placeholder": "https://api.meuprovedor.com/v1",
"provider.custom.field.apiKey.label": "Chave de API", "provider.custom.field.apiKey.label": "Chave de API",
"provider.custom.field.apiKey.placeholder": "Chave de API", "provider.custom.field.apiKey.placeholder": "Chave de API",
@@ -165,7 +274,7 @@ export const dict = {
"provider.custom.models.id.label": "ID", "provider.custom.models.id.label": "ID",
"provider.custom.models.id.placeholder": "id-do-modelo", "provider.custom.models.id.placeholder": "id-do-modelo",
"provider.custom.models.name.label": "Nome", "provider.custom.models.name.label": "Nome",
"provider.custom.models.name.placeholder": "Nome de Exibição", "provider.custom.models.name.placeholder": "Nome de exibição",
"provider.custom.models.remove": "Remover modelo", "provider.custom.models.remove": "Remover modelo",
"provider.custom.models.add": "Adicionar modelo", "provider.custom.models.add": "Adicionar modelo",
"provider.custom.headers.label": "Cabeçalhos (opcional)", "provider.custom.headers.label": "Cabeçalhos (opcional)",
@@ -175,11 +284,11 @@ export const dict = {
"provider.custom.headers.value.placeholder": "valor", "provider.custom.headers.value.placeholder": "valor",
"provider.custom.headers.remove": "Remover cabeçalho", "provider.custom.headers.remove": "Remover cabeçalho",
"provider.custom.headers.add": "Adicionar cabeçalho", "provider.custom.headers.add": "Adicionar cabeçalho",
"provider.custom.error.providerID.required": "ID do Provedor é obrigatório", "provider.custom.error.providerID.required": "O ID do provedor é obrigatório",
"provider.custom.error.providerID.format": "Use letras minúsculas, números, hifens ou sublinhados", "provider.custom.error.providerID.format": "Use letras minúsculas, números, hifens ou sublinhados",
"provider.custom.error.providerID.exists": "Esse ID de provedor já existe", "provider.custom.error.providerID.exists": "Esse ID de provedor já existe",
"provider.custom.error.name.required": "Nome de exibição é obrigatório", "provider.custom.error.name.required": "O nome de exibição é obrigatório",
"provider.custom.error.baseURL.required": "URL Base é obrigatória", "provider.custom.error.baseURL.required": "A URL base é obrigatória",
"provider.custom.error.baseURL.format": "Deve começar com http:// ou https://", "provider.custom.error.baseURL.format": "Deve começar com http:// ou https://",
"provider.custom.error.required": "Obrigatório", "provider.custom.error.required": "Obrigatório",
"provider.custom.error.duplicate": "Duplicado", "provider.custom.error.duplicate": "Duplicado",
@@ -215,7 +324,7 @@ export const dict = {
"common.clear": "Limpar", "common.clear": "Limpar",
"common.connect": "Conectar", "common.connect": "Conectar",
"common.disconnect": "Desconectar", "common.disconnect": "Desconectar",
"common.continue": "Enviar", "common.continue": "Continuar",
"common.submit": "Enviar", "common.submit": "Enviar",
"common.save": "Salvar", "common.save": "Salvar",
"common.saving": "Salvando...", "common.saving": "Salvando...",
@@ -267,7 +376,7 @@ export const dict = {
"prompt.context.includeActiveFile": "Incluir arquivo ativo", "prompt.context.includeActiveFile": "Incluir arquivo ativo",
"prompt.context.removeActiveFile": "Remover arquivo ativo do contexto", "prompt.context.removeActiveFile": "Remover arquivo ativo do contexto",
"prompt.context.removeFile": "Remover arquivo do contexto", "prompt.context.removeFile": "Remover arquivo do contexto",
"prompt.action.attachFile": "Anexar arquivo", "prompt.action.attachFile": "Adicionar arquivos",
"prompt.menu.addImagesAndFiles": "Adicionar arquivos e mais", "prompt.menu.addImagesAndFiles": "Adicionar arquivos e mais",
"prompt.menu.imagesAndFiles": "Imagens e arquivos", "prompt.menu.imagesAndFiles": "Imagens e arquivos",
"prompt.menu.commands": "Comandos", "prompt.menu.commands": "Comandos",
@@ -288,7 +397,7 @@ export const dict = {
"prompt.toast.promptSendFailed.title": "Falha ao enviar prompt", "prompt.toast.promptSendFailed.title": "Falha ao enviar prompt",
"prompt.toast.promptSendFailed.description": "Não foi possível recuperar a sessão", "prompt.toast.promptSendFailed.description": "Não foi possível recuperar a sessão",
"dialog.mcp.title": "MCPs", "dialog.mcp.title": "MCPs",
"dialog.mcp.description": "{{enabled}} of {{total}} habilitados", "dialog.mcp.description": "{{enabled}} de {{total}} ativados",
"dialog.mcp.empty": "Nenhum MCP configurado", "dialog.mcp.empty": "Nenhum MCP configurado",
"dialog.lsp.empty": "LSPs detectados automaticamente pelos tipos de arquivo", "dialog.lsp.empty": "LSPs detectados automaticamente pelos tipos de arquivo",
"dialog.plugins.empty": "Plugins configurados em opencode.json", "dialog.plugins.empty": "Plugins configurados em opencode.json",
@@ -310,11 +419,11 @@ export const dict = {
"dialog.server.search.placeholder": "Buscar servidores", "dialog.server.search.placeholder": "Buscar servidores",
"dialog.server.empty": "Nenhum servidor ainda", "dialog.server.empty": "Nenhum servidor ainda",
"dialog.server.add.title": "Adicionar um servidor", "dialog.server.add.title": "Adicionar um servidor",
"dialog.server.add.url": "URL do servidor", "dialog.server.add.url": "Endereço do servidor",
"dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Não foi possível conectar ao servidor", "dialog.server.add.error": "Não foi possível conectar ao servidor",
"dialog.server.add.checking": "Verificando...", "dialog.server.add.checking": "Verificando...",
"dialog.server.add.button": "Adicionar", "dialog.server.add.button": "Adicionar servidor",
"dialog.server.add.name": "Nome do servidor (opcional)", "dialog.server.add.name": "Nome do servidor (opcional)",
"dialog.server.add.namePlaceholder": "Localhost", "dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Nome de usuário (opcional)", "dialog.server.add.username": "Nome de usuário (opcional)",
@@ -322,7 +431,7 @@ export const dict = {
"dialog.server.edit.title": "Editar servidor", "dialog.server.edit.title": "Editar servidor",
"dialog.server.default.title": "Servidor padrão", "dialog.server.default.title": "Servidor padrão",
"dialog.server.default.description": "dialog.server.default.description":
"Conectar a este servidor na inicialização do aplicativo ao invés de iniciar um servidor local. Requer reinicialização.", "Conectar a este servidor na inicialização do aplicativo em vez de iniciar um servidor local. Requer reinicialização.",
"dialog.server.default.none": "Nenhum servidor selecionado", "dialog.server.default.none": "Nenhum servidor selecionado",
"dialog.server.default.set": "Definir servidor atual como padrão", "dialog.server.default.set": "Definir servidor atual como padrão",
"dialog.server.default.clear": "Limpar", "dialog.server.default.clear": "Limpar",
@@ -402,6 +511,21 @@ export const dict = {
"wsl.onboarding.desktopVersion": "desktop {{version}}", "wsl.onboarding.desktopVersion": "desktop {{version}}",
"wsl.onboarding.versionMismatch": "A versão instalada não corresponde à versão do aplicativo para desktop.", "wsl.onboarding.versionMismatch": "A versão instalada não corresponde à versão do aplicativo para desktop.",
"wsl.onboarding.adding": "Adicionando...", "wsl.onboarding.adding": "Adicionando...",
"help.tabs.toast.ariaLabel": "Apresentamos as abas. Organize seu trabalho e suas sessões ativas com abas",
"help.tabs.toast.dismiss": "Dispensar informações sobre as abas",
"help.tabs.title": "Apresentamos as abas",
"help.tabs.description": "Organize seu trabalho e suas sessões ativas com abas",
"help.tabs.date": "14 de julho",
"help.tabs.introduction": "Agora, o OpenCode Desktop é organizado em abas.",
"help.tabs.sessions":
"Inicie uma nova sessão em uma aba ou abra uma sessão existente de qualquer um dos seus projetos. Abra uma nova aba quando começar algo novo e feche-a quando terminar.",
"help.tabs.organize":
"Manter algumas abas abertas facilita a organização das sessões ativas. Renomeie as abas com nomes fáceis de lembrar se pretende mantê-las abertas.",
"help.tabs.home":
"Você encontrará todas as suas sessões e projetos na nova tela Início. Ao selecionar uma sessão, ela será aberta em uma aba.",
"help.tabs.persistence": "Quando você reabrir o aplicativo, suas abas ainda estarão abertas.",
"help.tabs.worktrees":
"O novo design ainda não oferece suporte a Git Worktrees, mas esse recurso estará disponível em breve. Se preferir continuar usando o layout anterior, você poderá alternar entre os layouts nas Configurações. Lembre-se apenas de que o novo layout se tornará permanente em algumas semanas.",
"dialog.project.edit.title": "Editar projeto", "dialog.project.edit.title": "Editar projeto",
"dialog.project.edit.name": "Nome", "dialog.project.edit.name": "Nome",
"dialog.project.edit.icon": "Ícone", "dialog.project.edit.icon": "Ícone",
@@ -412,33 +536,37 @@ export const dict = {
"dialog.project.edit.color.select": "Selecionar cor {{color}}", "dialog.project.edit.color.select": "Selecionar cor {{color}}",
"dialog.project.edit.worktree.startup": "Script de inicialização do espaço de trabalho", "dialog.project.edit.worktree.startup": "Script de inicialização do espaço de trabalho",
"dialog.project.edit.worktree.startup.description": "Executa após criar um novo espaço de trabalho (worktree).", "dialog.project.edit.worktree.startup.description": "Executa após criar um novo espaço de trabalho (worktree).",
"dialog.project.edit.worktree.startup.placeholder": "ex: bun install", "dialog.project.edit.worktree.startup.placeholder": "Ex.: bun install",
"context.breakdown.title": "Detalhamento do Contexto", "dialog.usageExceeded.dontShowAgain": "Não mostrar novamente",
"context.breakdown.title": "Detalhamento do contexto",
"context.breakdown.note": "context.breakdown.note":
'Detalhamento aproximado dos tokens de entrada. "Outros" inclui definições de ferramentas e overhead.', 'Detalhamento aproximado dos tokens de entrada. "Outros" inclui definições de ferramentas e sobrecarga.',
"context.breakdown.system": "Sistema", "context.breakdown.system": "Sistema",
"context.breakdown.user": "Usuário", "context.breakdown.user": "Usuário",
"context.breakdown.assistant": "Assistente", "context.breakdown.assistant": "Assistente",
"context.breakdown.tool": "Chamadas de Ferramentas", "context.breakdown.tool": "Chamadas de ferramentas",
"context.breakdown.other": "Outros", "context.breakdown.other": "Outros",
"context.systemPrompt.title": "Prompt do Sistema", "context.systemPrompt.title": "Prompt do sistema",
"context.rawMessages.title": "Mensagens brutas", "context.rawMessages.title": "Mensagens brutas",
"context.export.session": "Exportar sessão",
"context.stats.session": "Sessão", "context.stats.session": "Sessão",
"context.stats.messages": "Mensagens", "context.stats.messages": "Mensagens",
"context.stats.provider": "Provedor", "context.stats.provider": "Provedor",
"context.stats.model": "Modelo", "context.stats.model": "Modelo",
"context.stats.limit": "Limite de Contexto", "context.stats.limit": "Limite de contexto",
"context.stats.totalTokens": "Total de Tokens", "context.stats.totalTokens": "Total de tokens",
"context.stats.usage": "Uso", "context.stats.usage": "Uso",
"context.stats.inputTokens": "Tokens de Entrada", "context.stats.inputTokens": "Tokens de entrada",
"context.stats.outputTokens": "Tokens de Saída", "context.stats.outputTokens": "Tokens de saída",
"context.stats.reasoningTokens": "Tokens de Raciocínio", "context.stats.reasoningTokens": "Tokens de raciocínio",
"context.stats.cacheTokens": "Tokens de Cache (leitura/escrita)", "context.stats.cacheTokens": "Tokens de cache (leitura/escrita)",
"context.stats.userMessages": "Mensagens de Usuário", "context.stats.userMessages": "Mensagens do usuário",
"context.stats.assistantMessages": "Mensagens do Assistente", "context.stats.assistantMessages": "Mensagens do assistente",
"context.stats.totalCost": "Custo Total", "context.stats.totalCost": "Custo total",
"context.stats.sessionCreated": "Sessão Criada", "context.stats.sessionCreated": "Sessão criada",
"context.stats.lastActivity": "Última Atividade", "context.stats.lastActivity": "Última atividade",
"context.usage.tokens": "Tokens", "context.usage.tokens": "Tokens",
"context.usage.usage": "Uso", "context.usage.usage": "Uso",
"context.usage.cost": "Custo", "context.usage.cost": "Custo",
@@ -489,6 +617,11 @@ export const dict = {
"toast.session.unshare.success.description": "Sessão deixou de ser compartilhada com sucesso!", "toast.session.unshare.success.description": "Sessão deixou de ser compartilhada com sucesso!",
"toast.session.unshare.failed.title": "Falha ao parar de compartilhar sessão", "toast.session.unshare.failed.title": "Falha ao parar de compartilhar sessão",
"toast.session.unshare.failed.description": "Ocorreu um erro ao parar de compartilhar a sessão", "toast.session.unshare.failed.description": "Ocorreu um erro ao parar de compartilhar a sessão",
"toast.session.export.success.title": "Sessão exportada",
"toast.session.export.success.description": "Sessão salva em {{filename}}",
"toast.session.export.failed.title": "Falha ao exportar sessão",
"toast.session.export.failed.description": "Ocorreu um erro ao exportar a sessão",
"toast.session.listFailed.title": "Falha ao carregar sessões para {{project}}", "toast.session.listFailed.title": "Falha ao carregar sessões para {{project}}",
"toast.update.title": "Atualização disponível", "toast.update.title": "Atualização disponível",
"toast.update.description": "Uma nova versão do OpenCode ({{version}}) está disponível para instalação.", "toast.update.description": "Uma nova versão do OpenCode ({{version}}) está disponível para instalação.",
@@ -497,7 +630,7 @@ export const dict = {
"error.page.title": "Algo deu errado", "error.page.title": "Algo deu errado",
"error.page.description": "Ocorreu um erro ao carregar a aplicação.", "error.page.description": "Ocorreu um erro ao carregar a aplicação.",
"error.page.description.localServerStartup": "Ocorreu um erro ao iniciar o servidor local.", "error.page.description.localServerStartup": "Ocorreu um erro ao iniciar o servidor local.",
"error.page.details.label": "Detalhes do Erro", "error.page.details.label": "Detalhes do erro",
"error.page.action.restart": "Reiniciar", "error.page.action.restart": "Reiniciar",
"error.page.action.report": "Reportar erro", "error.page.action.report": "Reportar erro",
"error.page.action.reported": "Erro reportado", "error.page.action.reported": "Erro reportado",
@@ -570,7 +703,7 @@ export const dict = {
"session.error.notFound.description": "Esta aba aponta para uma sessão que não existe mais neste servidor.", "session.error.notFound.description": "Esta aba aponta para uma sessão que não existe mais neste servidor.",
"session.error.notFound.closeTab": "Fechar aba", "session.error.notFound.closeTab": "Fechar aba",
"session.error.serverConnection": "Não é possível conectar a este servidor", "session.error.serverConnection": "Não é possível conectar a este servidor",
"session.review.filesChanged": "{{count}} Arquivos Alterados", "session.review.filesChanged": "{{count}} arquivos alterados",
"session.review.change.one": "Alteração", "session.review.change.one": "Alteração",
"session.review.change.other": "Alterações", "session.review.change.other": "Alterações",
"session.review.loadingChanges": "Carregando alterações...", "session.review.loadingChanges": "Carregando alterações...",
@@ -597,14 +730,17 @@ export const dict = {
"session.question.minimize": "Minimizar pergunta", "session.question.minimize": "Minimizar pergunta",
"session.question.restore": "Restaurar pergunta", "session.question.restore": "Restaurar pergunta",
"session.question.pending.one": "{{count}} pergunta pendente", "session.question.pending.one": "{{count}} pergunta pendente",
"session.question.pending.many": "{{count}} de perguntas pendentes",
"session.question.pending.other": "{{count}} perguntas pendentes", "session.question.pending.other": "{{count}} perguntas pendentes",
"session.followupDock.summary.one": "{{count}} mensagem na fila", "session.followupDock.summary.one": "{{count}} mensagem na fila",
"session.followupDock.summary.many": "{{count}} de mensagens na fila",
"session.followupDock.summary.other": "{{count}} mensagens na fila", "session.followupDock.summary.other": "{{count}} mensagens na fila",
"session.followupDock.sendNow": "Enviar agora", "session.followupDock.sendNow": "Enviar agora",
"session.followupDock.edit": "Editar", "session.followupDock.edit": "Editar",
"session.followupDock.collapse": "Recolher mensagens na fila", "session.followupDock.collapse": "Recolher mensagens na fila",
"session.followupDock.expand": "Expandir mensagens na fila", "session.followupDock.expand": "Expandir mensagens na fila",
"session.revertDock.summary.one": "{{count}} mensagem revertida", "session.revertDock.summary.one": "{{count}} mensagem revertida",
"session.revertDock.summary.many": "{{count}} de mensagens revertidas",
"session.revertDock.summary.other": "{{count}} mensagens revertidas", "session.revertDock.summary.other": "{{count}} mensagens revertidas",
"session.revertDock.collapse": "Recolher mensagens revertidas", "session.revertDock.collapse": "Recolher mensagens revertidas",
"session.revertDock.expand": "Expandir mensagens revertidas", "session.revertDock.expand": "Expandir mensagens revertidas",
@@ -656,15 +792,23 @@ export const dict = {
"terminal.title": "Terminal", "terminal.title": "Terminal",
"terminal.title.numbered": "Terminal {{number}}", "terminal.title.numbered": "Terminal {{number}}",
"terminal.close": "Fechar terminal", "terminal.close": "Fechar terminal",
"terminal.connectionLost.title": "Conexão Perdida", "terminal.connectionLost.title": "Conexão perdida",
"terminal.connectionLost.description": "terminal.connectionLost.description":
"A conexão do terminal foi interrompida. Isso pode acontecer quando o servidor reinicia.", "A conexão do terminal foi interrompida. Isso pode acontecer quando o servidor reinicia.",
"terminal.connectTicket.csrfError":
"O tíquete de conexão PTY foi rejeitado pelas verificações de origem ou CSRF. Verifique a configuração de CORS do servidor.",
"terminal.connectTicket.statusError": "O tíquete de conexão PTY falhou com o status {{status}}",
"titlebar.update": "Atualizar",
"titlebar.updateVersion": "Atualizar {{version}}",
"common.closeTab": "Fechar aba", "common.closeTab": "Fechar aba",
"common.dismiss": "Descartar", "common.dismiss": "Descartar",
"common.requestFailed": "Requisição falhou", "common.requestFailed": "Requisição falhou",
"common.moreOptions": "Mais opções", "common.moreOptions": "Mais opções",
"common.learnMore": "Saiba mais", "common.learnMore": "Saiba mais",
"common.rename": "Renomear", "common.rename": "Renomear",
"common.export": "Exportar",
"common.reset": "Redefinir", "common.reset": "Redefinir",
"common.archive": "Arquivar", "common.archive": "Arquivar",
"common.delete": "Excluir", "common.delete": "Excluir",
@@ -706,7 +850,7 @@ export const dict = {
"settings.general.row.shell.title": "Shell do terminal", "settings.general.row.shell.title": "Shell do terminal",
"settings.general.row.shell.description": "settings.general.row.shell.description":
"Escolha o shell usado no terminal. Os shells compatíveis também são usados nas chamadas de ferramentas do agente.", "Escolha o shell usado no terminal. Os shells compatíveis também são usados nas chamadas de ferramentas do agente.",
"settings.general.row.shell.autoDefault": "Automático (Padrão)", "settings.general.row.shell.autoDefault": "Automático (padrão)",
"settings.general.row.shell.terminalOnly": "somente no terminal", "settings.general.row.shell.terminalOnly": "somente no terminal",
"settings.general.row.appearance.title": "Aparência", "settings.general.row.appearance.title": "Aparência",
"settings.general.row.appearance.description": "Personalize como o OpenCode aparece no seu dispositivo", "settings.general.row.appearance.description": "Personalize como o OpenCode aparece no seu dispositivo",
@@ -739,8 +883,9 @@ export const dict = {
"settings.general.row.mobileTitlebarBottom.title": "Navegação inferior", "settings.general.row.mobileTitlebarBottom.title": "Navegação inferior",
"settings.general.row.mobileTitlebarBottom.description": "settings.general.row.mobileTitlebarBottom.description":
"Posicionar a barra de título e as abas da sessão na parte inferior da tela em dispositivos móveis", "Posicionar a barra de título e as abas da sessão na parte inferior da tela em dispositivos móveis",
"settings.general.row.showCustomAgents.title": "Agentes personalizados", "settings.general.row.showCustomAgents.title": "Mostrar agente",
"settings.general.row.showCustomAgents.description": "Mostrar o seletor de agentes na área de composição", "settings.general.row.showCustomAgents.description":
"Alternar entre agentes na área de composição. Quando oculto, usa o agente Build como padrão.",
"settings.general.row.reasoningSummaries.title": "Mostrar resumos de raciocínio", "settings.general.row.reasoningSummaries.title": "Mostrar resumos de raciocínio",
"settings.general.row.reasoningSummaries.description": "Exibir resumos de raciocínio do modelo na linha do tempo", "settings.general.row.reasoningSummaries.description": "Exibir resumos de raciocínio do modelo na linha do tempo",
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell", "settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell",
@@ -823,14 +968,15 @@ export const dict = {
"sound.option.yup06": "Sim 06", "sound.option.yup06": "Sim 06",
"settings.general.notifications.agent.title": "Agente", "settings.general.notifications.agent.title": "Agente",
"settings.general.notifications.agent.description": "settings.general.notifications.agent.description":
"Mostrar notificação do sistema quando o agente estiver completo ou precisar de atenção", "Mostrar notificação do sistema quando o agente concluir o trabalho ou precisar de atenção",
"settings.general.notifications.permissions.title": "Permissões", "settings.general.notifications.permissions.title": "Permissões",
"settings.general.notifications.permissions.description": "settings.general.notifications.permissions.description":
"Mostrar notificação do sistema quando uma permissão for necessária", "Mostrar notificação do sistema quando uma permissão for necessária",
"settings.general.notifications.errors.title": "Erros", "settings.general.notifications.errors.title": "Erros",
"settings.general.notifications.errors.description": "Mostrar notificação do sistema quando ocorrer um erro", "settings.general.notifications.errors.description": "Mostrar notificação do sistema quando ocorrer um erro",
"settings.general.sounds.agent.title": "Agente", "settings.general.sounds.agent.title": "Agente",
"settings.general.sounds.agent.description": "Reproduzir som quando o agente estiver completo ou precisar de atenção", "settings.general.sounds.agent.description":
"Reproduzir som quando o agente concluir o trabalho ou precisar de atenção",
"settings.general.sounds.permissions.title": "Permissões", "settings.general.sounds.permissions.title": "Permissões",
"settings.general.sounds.permissions.description": "Reproduzir som quando uma permissão for necessária", "settings.general.sounds.permissions.description": "Reproduzir som quando uma permissão for necessária",
"settings.general.sounds.errors.title": "Erros", "settings.general.sounds.errors.title": "Erros",
@@ -878,8 +1024,7 @@ export const dict = {
"settings.permissions.tool.read.title": "Ler", "settings.permissions.tool.read.title": "Ler",
"settings.permissions.tool.read.description": "Ler um arquivo (corresponde ao caminho do arquivo)", "settings.permissions.tool.read.description": "Ler um arquivo (corresponde ao caminho do arquivo)",
"settings.permissions.tool.edit.title": "Editar", "settings.permissions.tool.edit.title": "Editar",
"settings.permissions.tool.edit.description": "settings.permissions.tool.edit.description": "Modificar arquivos, incluindo edições, escritas e patches",
"Modificar arquivos, incluindo edições, escritas, patches e multi-edições",
"settings.permissions.tool.glob.title": "Glob", "settings.permissions.tool.glob.title": "Glob",
"settings.permissions.tool.glob.description": "Corresponder arquivos usando padrões glob", "settings.permissions.tool.glob.description": "Corresponder arquivos usando padrões glob",
"settings.permissions.tool.grep.title": "Grep", "settings.permissions.tool.grep.title": "Grep",
@@ -889,7 +1034,7 @@ export const dict = {
"settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.title": "Bash",
"settings.permissions.tool.bash.description": "Executar comandos shell", "settings.permissions.tool.bash.description": "Executar comandos shell",
"settings.permissions.tool.task.title": "Tarefa", "settings.permissions.tool.task.title": "Tarefa",
"settings.permissions.tool.task.description": "Lançar sub-agentes", "settings.permissions.tool.task.description": "Iniciar subagentes",
"settings.permissions.tool.skill.title": "Habilidade", "settings.permissions.tool.skill.title": "Habilidade",
"settings.permissions.tool.skill.description": "Carregar uma habilidade por nome", "settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
@@ -946,7 +1091,7 @@ export const dict = {
"common.time.hoursAgo.short": "{{count}}h atrás", "common.time.hoursAgo.short": "{{count}}h atrás",
"common.time.daysAgo.short": "{{count}}d atrás", "common.time.daysAgo.short": "{{count}}d atrás",
"settings.providers.connected.environmentDescription": "Conectado a partir de suas variáveis de ambiente", "settings.providers.connected.environmentDescription": "Conectado a partir de suas variáveis de ambiente",
"settings.providers.custom.description": "Adicionar um provedor compatível com a OpenAI através do URL base.", "settings.providers.custom.description": "Adicionar um provedor compatível com OpenAI por meio da URL base.",
"app.server.unreachable": "Não foi possível conectar a {{server}}", "app.server.unreachable": "Não foi possível conectar a {{server}}",
"app.server.retrying": "Tentando novamente automaticamente...", "app.server.retrying": "Tentando novamente automaticamente...",
@@ -962,6 +1107,9 @@ export const dict = {
"session.question.progress": "{{current}} de {{total}} perguntas", "session.question.progress": "{{current}} de {{total}} perguntas",
"session.header.open.finder": "Finder", "session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Explorador de Arquivos", "session.header.open.fileExplorer": "Explorador de Arquivos",
"session.header.reveal.finder": "Revelar no Finder",
"session.header.reveal.fileExplorer": "Revelar no Explorador de Arquivos",
"session.header.reveal.containingFolder": "Abrir pasta que contém este item",
"session.header.open.fileManager": "Gerenciador de Arquivos", "session.header.open.fileManager": "Gerenciador de Arquivos",
"session.header.open.app.vscode": "VS Code", "session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor", "session.header.open.app.cursor": "Cursor",
@@ -999,6 +1147,14 @@ export const dict = {
"debugBar.mem.label": "MEM", "debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Heap JS usado vs limite de heap. Apenas Chromium.", "debugBar.mem.tipUnavailable": "Heap JS usado vs limite de heap. Apenas Chromium.",
"debugBar.mem.tip": "Heap JS usado vs limite de heap. {{used}} de {{limit}}.", "debugBar.mem.tip": "Heap JS usado vs limite de heap. {{used}} de {{limit}}.",
"debugBar.focus.label": "FOCUS",
"debugBar.focus.tip": "Forçar estilos de foco em todos os elementos interativos",
"debugBar.focus.on": "LIGADO",
"debugBar.focus.off": "DESLIGADO",
"debugBar.direction.label": "DIR",
"debugBar.direction.tip": "Forçar a direção do layout de todo o aplicativo sem alterar o idioma selecionado",
"debugBar.direction.ltr": "LTR",
"debugBar.direction.rtl": "RTL",
"common.key.ctrl": "Ctrl", "common.key.ctrl": "Ctrl",
"common.key.alt": "Alt", "common.key.alt": "Alt",
"common.key.shift": "Shift", "common.key.shift": "Shift",

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