Compare commits

...
Author SHA1 Message Date
Dax Raad ad741cbea0 fix: scope Npm.add() lock to per-package key
Use npm-install:${pkg} instead of a global npm-install lock so
concurrent installs of different packages can run in parallel.
2026-03-19 21:48:43 -04:00
Dax Raad cbc40a5981 chore: extract node entry point into #18324 2026-03-19 21:30:35 -04:00
Dax Raad b9b210a864 Merge branch 'dev' into opencode-2-0 2026-03-19 21:22:28 -04:00
Dax Raad d473b7e971 chore: extract which/global changes into #18320 2026-03-19 21:22:06 -04:00
Dax 6fcc970def fix: include cache bin directory in which() lookups (#18320) 2026-03-19 21:21:55 -04:00
Dax 52a7a04ad8 refactor: replace Bun shell execution with portable Process utilities (#18318) 2026-03-19 21:17:06 -04:00
Dax 37b8662a9d refactor: abstract SQLite behind runtime-conditional #db import (#18316) 2026-03-19 21:15:35 -04:00
Dax Raad f5783c4313 chore: extract portable process changes into #18318 2026-03-19 21:15:31 -04:00
Dax Raad 9439a5647e chore: revert drizzle upgrade (extracted to sqlite PR) 2026-03-19 21:10:53 -04:00
Dax Raad 2bfe81ee5c chore: extract SQLite abstraction into separate PR (#refactor/sqlite-abstraction) 2026-03-19 21:02:58 -04:00
Dax Raad fcf1bb010c chore: update lockfile and package.json 2026-03-19 20:53:04 -04:00
Dax Raad 08b6d9c6dc sync 2026-03-19 20:48:44 -04:00
Dax Raad 0293a8bb80 chore: revert changes overlapping with #18308 2026-03-19 20:48:23 -04:00
Dax Raad 850dbb93eb Merge remote-tracking branch 'origin/dev' into opencode-2-0 2026-03-19 19:33:19 -04:00
Dax ddcb32ae0b refactor(tui): replace Bun-specific APIs with portable alternatives (#18304) 2026-03-19 19:32:59 -04:00
Frank 2c056c90da doc: update translator to gpt model 2026-03-19 19:07:02 -04:00
Dax Raad 48e867ee20 Merge remote-tracking branch 'origin/dev' into opencode-2-0
# Conflicts:
#	.opencode/tool/github-pr-search.ts
#	.opencode/tool/github-triage.ts
2026-03-19 19:03:48 -04:00
Dax 812d1bb32a chore: inline tool descriptions, remove separate .txt files (#18303) 2026-03-19 19:02:42 -04:00
Frank 9a58c43ef4 go: upi translation 2026-03-19 18:54:32 -04:00
Dax Raad b5ebc541b9 Merge remote-tracking branch 'origin/dev' into opencode-2-0 2026-03-19 18:52:18 -04:00
Dax 63585db6a7 refactor: replace Bun.sleep with node:timers/promises sleep (#18301) 2026-03-19 18:50:40 -04:00
Frank bd44489ada go: upi payment 2026-03-19 18:44:24 -04:00
Dax Raad bd7a4cec90 sync 2026-03-19 17:53:35 -04:00
opencode-agent[bot] a6ef9e9206 chore: generate 2026-03-19 20:21:10 +00:00
Kit Langton 6e09a1d904 fix(account): handle pending console login polling (#18281) 2026-03-19 16:18:28 -04:00
Dax Raad 63af295a17 Merge origin/dev into opencode-2-0 2026-03-19 16:07:13 -04:00
opencode-agent[bot] 4f21757e0d chore: generate 2026-03-19 20:05:33 +00:00
2dbcd79fd2 fix: stabilize agent and skill ordering in prompt descriptions (#18261)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-19 15:04:03 -05:00
James Long 48a7f0fd93 Fix base64Decode import in workspaces.spec.ts (#18274) 2026-03-19 15:38:54 -04:00
James Long d69962b0f7 fix(core): disable chunk timeout by default (#18264) 2026-03-19 14:30:08 -04:00
opencode-agent[bot] a6f23cb08e chore: generate 2026-03-19 17:52:50 +00:00
James Long 0540751897 fix(core): use a queue to process events in event routes (#18259) 2026-03-19 13:51:14 -04:00
opencode-agent[bot] baa204193c chore: generate 2026-03-19 16:16:29 +00:00
Aiden Cline aeece6166b ignore: revert 3 commits that broke dev branch (#18260) 2026-03-19 11:15:07 -05:00
Kit Langton 0d7e62a532 fix forked prompt attachments losing file parts (#17815) 2026-03-19 12:00:32 -04:00
Shoubhit Dash 41aa254db4 fix(app): show review on the empty session route (#18251) 2026-03-19 19:51:32 +05:30
opencode-agent[bot] d178d8249f chore: generate 2026-03-19 14:00:32 +00:00
Shoubhit Dash e6f5214779 feat: add git-backed session review modes (#17961) 2026-03-19 19:29:29 +05:30
Brendan AllanandShoubhit Dash 84f60d97a0 app: fix workspace flicker when switching directories (#18207)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-19 19:29:14 +05:30
Brendan Allan cbf4b68fee electron: lazily read updater enabled 2026-03-19 21:47:47 +08:00
OpeOginniandBrendan Allan bd4527b4f2 fix(desktop): remote server switching (#17214)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-03-19 13:32:11 +00:00
Andrew MaguireandShoubhit Dash f4a9fe29a3 fix(app): ignore repeated Enter submits in prompt input (#18148)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-19 11:53:44 +00:00
opencode-agent[bot] 5a0bfa7061 chore: generate 2026-03-19 04:46:39 +00:00
Dax 1ac1a0287c anthropic legal requests (#18186) 2026-03-19 04:45:24 +00:00
Aiden Cline 8e09e8c612 feat: integrate multistep auth flows into desktop app (#18103) 2026-03-19 13:47:51 +10:00
Kit Langton 84e62fc662 fix(session): preserve tagged error messages (#18165) 2026-03-18 20:36:53 -04:00
Frank a7ea93528b zen: add mimo pro/omni models 2026-03-18 20:28:41 -04:00
opencode-agent[bot] d90e3a2833 chore: update nix node_modules hashes 2026-03-19 00:08:27 +00:00
opencode-agent[bot] 1c74c2741a chore: update nix node_modules hashes 2026-03-19 00:07:30 +00:00
Luke Parker 5d2f8d77f9 fix: restore recent test regressions and upgrade effect beta (#18158) 2026-03-19 09:54:01 +10:00
Kit Langton 81be544981 feat(filesystem): add AppFileSystem service, migrate Snapshot (#18138) 2026-03-18 19:52:43 -04:00
opencode-agent[bot] 773c1192dc chore: generate 2026-03-18 23:45:03 +00:00
Kit Langton 5ddfe4ada5 chore: type Provider.list() as Record<ProviderID, Info>, delete dead code (#18123) 2026-03-18 19:43:12 -04:00
opencode-agent[bot] a93d98bd94 chore: update nix node_modules hashes 2026-03-18 23:03:55 +00:00
Luke Parker 54ed87d53c fix(windows): use cross-spawn for shim-backed commands (#18010) 2026-03-19 08:49:16 +10:00
Aiden Cline 8ee939c741 tweak: remove unnecessary parts from the fallback system prompt (#18140) 2026-03-18 17:27:47 -05:00
Frank 1b0096bf61 docs: update go models 2026-03-18 17:48:39 -04:00
Frank 8006c29db3 fix: docs 2026-03-18 16:30:33 -04:00
Frank 3f1c96a0bb zen: minimax m2.7 2026-03-18 14:30:55 -04:00
Frank 3558deba4a zen: minimax m2.7 2026-03-18 14:15:00 -04:00
opencode-agent[bot] c3ddc85cca chore: generate 2026-03-18 17:37:40 +00:00
Kit Langton a800583aea refactor(effect): unify service namespaces and align naming (#18093) 2026-03-18 13:34:36 -04:00
Aiden Cline 171e69c2fc feat: integrate support for multi step auth flows for providers that require additional questions (#18035) 2026-03-18 11:36:19 -05:00
Aiden Cline 822bb7b336 tweak: update gpt subscription model list (#18101) 2026-03-18 10:51:39 -05:00
Frank 47cf267c23 zen: fix routing non OC traffic 2026-03-18 10:59:56 -04:00
OpeOginni 976aae7e42 fix(desktop): fix error handling by adding errorName function to identify NotFoundError rather than statusCode (#17591) 2026-03-18 19:27:56 +05:30
David Hill 0ca51eebcf tweak(ui): theme overrides (#17958) 2026-03-18 19:26:32 +05:30
David Hill 3256886e25 tui: make the title bar search easier to scan without a redundant icon 2026-03-18 13:52:57 +00:00
David Hill d2194f6dde Revert "tui: clean up search button in session header by removing magnifying glass icon and excess padding"
This reverts commit bfd4787fcd.
2026-03-18 13:52:36 +00:00
David Hill bfd4787fcd tui: clean up search button in session header by removing magnifying glass icon and excess padding 2026-03-18 13:49:58 +00:00
opencode-agent[bot] 58dce0148a chore: generate 2026-03-18 12:58:46 +00:00
OpeOginni 79635b8b41 docs(cli): update experimental TY LSP flag description for clarity across multiple languages (#14770) 2026-03-18 18:27:42 +05:30
Brendan Allan 331dacf9db app: remove debug text 2026-03-18 17:02:23 +08:00
Brendan Allan 4ba7d3b406 app: replace autoselect effects with single resource 2026-03-18 17:01:38 +08:00
Brendan Allan a43783a6d4 app: initialize command catalog more efficiently
cuts down load times by like 30-50%
2026-03-18 14:46:53 +08:00
Frank 37c5295111 zen: gpt 5.4 mini and nano 2026-03-18 02:12:30 -04:00
Johannes LoherandAiden Cline 56102ff642 fix(core): detect vLLM context overflow errors (#17763)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-17 23:52:16 -05:00
Frank 1b86c27fb8 wip: zen 2026-03-17 23:31:14 -04:00
Frank fe43bdb699 wip: zen 2026-03-17 22:50:54 -04:00
a849a17e93 feat(enterprise): contact form now pushes to salesforce 🙄 (#17964)
Co-authored-by: slickstef11 <stefan@wundergraph.com>
Co-authored-by: Frank <frank@anoma.ly>
2026-03-17 22:43:43 -04:00
opencode-agent[bot] 0292f1b559 chore: generate 2026-03-18 02:01:02 +00:00
Kit Langton 5dfe86dcb1 refactor(truncation): effectify TruncateService, delete Scheduler (#17957) 2026-03-17 21:59:54 -04:00
Ariane Emory 4b4dd2b882 fix: Add apply_patch to EDIT_TOOLS filter (#18009) 2026-03-17 20:11:42 -05:00
opencode-agent[bot] bc949af623 chore: generate 2026-03-18 01:05:16 +00:00
Kit Langton 9e7c136de7 refactor(snapshot): effectify SnapshotService (#17878) 2026-03-17 21:04:16 -04:00
Kit Langton fee3c196c5 add prompt schema validation debug logs (#17812) 2026-03-17 19:18:16 -04:00
Frank 6c047391bb wip: zen 2026-03-17 19:06:22 -04:00
Frank 350df0b261 zen: add missing model lab names 2026-03-17 18:41:38 -04:00
Frank fbabc97c4c zen: error logging 2026-03-17 16:53:10 -04:00
David HillandShoubhit Dash 7daea69e13 tweak(ui): add an empty state to the sidebar when no projects (#17971)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-17 19:54:14 +00:00
Frank 0772a95918 wip: zen 2026-03-17 15:00:44 -04:00
Frank dadddc9c8c zen: deprecate gemini 3 pro 2026-03-17 12:31:54 -04:00
OpeOginni 6708c3f6cf docs: mark tools config as deprecated (#17951) 2026-03-17 10:07:35 -05:00
Shoubhit Dash ba22976568 fix: inline review comment submit and layout (#17948) 2026-03-17 19:54:20 +05:30
Brendan Allan 0afeaea21f app: inherit owner when creating prompt session 2026-03-17 19:47:06 +08:00
opencode-agent[bot] b07b5a9b7f chore: generate 2026-03-17 11:15:35 +00:00
Luke Parker dbbe931a18 fix(app): avoid prompt tooltip Switch on startup (#17857) 2026-03-17 06:14:30 -05:00
opencode-agent[bot] e14e874e51 chore: generate 2026-03-17 03:47:33 +00:00
Kyle AltendorfandAiden Cline 544315dff7 docs: add describe annotation to snapshot config field (#17861)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-16 22:46:09 -05:00
Aiden Cline f13da808ff chore: denounce ai spammer (#17901) 2026-03-16 22:38:15 -05:00
Luke Parker e416e59ea6 test(app): deflake slash terminal toggle flow (#17881) 2026-03-17 12:55:58 +10:00
Luke Parker cb69501098 test(opencode): deflake file and tool timing (#17859) 2026-03-17 00:49:04 +00:00
Kyle Altendorf a64f604d54 fix(tui): check for selected text instead of any selection in dialog escape handler (#16779) 2026-03-17 10:25:03 +10:00
opencode-agent[bot] d7093abf61 chore: update nix node_modules hashes 2026-03-17 00:05:19 +00:00
opencode-agent[bot] 60af447908 chore: update nix node_modules hashes 2026-03-16 23:54:30 +00:00
opencode-agent[bot] 1cdc558ac0 chore: generate 2026-03-16 23:52:10 +00:00
Kit Langton 3849822769 refactor(skill): effectify SkillService as scoped service (#17849) 2026-03-16 23:51:07 +00:00
AbigailJixiangyuyu e9a17e4480 fix(windows): restore /editor support on Windows (#17146) 2026-03-17 08:11:02 +10:00
Aiden Cline 68809365df fix: github copilot enterprise integration (#17847) 2026-03-16 17:05:14 -05:00
opencode-agent[bot] 8da511dfa8 chore: generate 2026-03-16 20:19:50 +00:00
Kit Langton 69381f6aea refactor(file): effectify FileService as scoped service (#17845) 2026-03-16 16:18:39 -04:00
opencode-agent[bot] df6508530f chore: generate 2026-03-16 19:59:49 +00:00
Kit Langton 335356280c refactor(format): effectify FormatService as scoped service (#17675) 2026-03-16 15:58:36 -04:00
opencode-agent[bot] 03d84f49c2 chore: generate 2026-03-16 18:24:21 +00:00
Kit Langton 2cbdf04ec9 refactor(file-time): effectify FileTimeService with Semaphore locks (#17835) 2026-03-16 18:23:13 +00:00
opencode-agent[bot] 410fbd8a00 chore: generate 2026-03-16 18:00:18 +00:00
Kit Langton e5cbecf17c fix+refactor(vcs): fix HEAD filter bug and effectify VcsService (#17829) 2026-03-16 13:59:11 -04:00
opencode-agent[bot] ca3af5dc6a chore: generate 2026-03-16 17:19:44 +00:00
Kit Langton 9e740d9947 stack: effectify-file-watcher-service (#17827) 2026-03-16 13:18:40 -04:00
opencode-agent[bot] d4694d058c chore: generate 2026-03-16 16:56:12 +00:00
Kit Langton 469c3a4204 refactor(instance): move scoped services to LayerMap (#17544) 2026-03-16 12:55:14 -04:00
DS 4cb29967f6 fix(opencode): apply message transforms during compaction (#17823) 2026-03-16 11:32:53 -05:00
Johannes Loher e718db624f fix(core): consider code: context_length_exceeded as context overflow in API call errors (#17748) 2026-03-16 10:25:24 -05:00
Michal Šlesár 15b27e0d18 fix(app): agent switch should not reset thinking level (#17470) 2026-03-16 19:43:29 +05:30
Kit Langton c523aac586 fix(cli): scope active org labels to the active account (#16957) 2026-03-16 10:01:42 -04:00
Shoubhit Dash 51fcd04a70 Wrap question option descriptions instead of truncating (#17782) 2026-03-16 11:29:18 +00:00
Luke Parker 4d7cbdcbef fix(ci): workaround by using hoisted Bun linker on Windows (#17751) 2026-03-16 08:39:06 +00:00
Luke Parker 59c530cc6c fix(opencode): teach Kit's test what an ID is (#17745) 2026-03-16 18:08:27 +10:00
Jason Quenseandjquense c2ca1494e5 fix(opencode): preserve prompt tool enables with empty agent permissions (#17064)
Co-authored-by: jquense <jquense@ramp.com>
2026-03-15 23:01:46 -05:00
opencode 4ee426ba54 release: v1.2.27 2026-03-16 02:33:48 +00:00
Aiden Cline 510374207d fix: vcs watcher if statement (#17673) 2026-03-15 19:20:39 -05:00
Erik Engervall aedbecedf7 docs: Add opencode-firecrawl to ecosystem documentation (#17672) 2026-03-15 18:56:24 -05:00
Frank 9c00669927 zen: update claude prices 2026-03-15 10:54:40 -04:00
David Hill b9f6b40e3a tweak(ui): remove open label (#17512) 2026-03-15 08:25:40 -05:00
Orlando Ascanio ad06d8f496 docs(es): fix Spanish intro page translation, grammar, and terminology (#17563) 2026-03-15 08:22:32 -05:00
Kit Langton 2fc06c5a17 chore(permission): delete legacy permission module (#17534) 2026-03-14 20:56:52 -05:00
Kit Langton 52877d8765 fix(question): clean up pending entry on abort (#17533) 2026-03-15 00:49:49 +00:00
Sebastian 8f957b8f90 remove sighup exit (#17254) 2026-03-15 00:52:28 +01:00
opencode-agent[bot] 0befa1e57e chore: generate 2026-03-14 18:29:06 +00:00
Kit Langton f015154314 refactor(permission): effectify PermissionNext + fix InstanceState ALS bug (#17511) 2026-03-14 18:28:00 +00:00
Shoubhit Dash 689d9e14ea fix(app): handle multiline web paste in prompt composer (#17509) 2026-03-14 22:51:45 +05:30
Kit Langton 66e8c57ed1 refactor(schema): inline branded ID schemas (#17504) 2026-03-14 16:14:46 +00:00
opencode-agent[bot] b698f14e55 chore: generate 2026-03-14 15:59:01 +00:00
Kit Langton cec1255b36 refactor(question): effectify QuestionService (#17432) 2026-03-14 11:58:00 -04:00
Aiden Cline 88226f3061 tweak: ensure that compaction message is tracked as agent initiated (#17431) 2026-03-14 10:46:24 -05:00
James Long 8c53b2b470 fix(core): increase default chunk timeout from 2 min to 5 min (#17490) 2026-03-14 14:27:06 +00:00
Marcus Schiesser f2d3a4c70f fix(ui): prevent long filenames from overlapping actions (#17151) 2026-03-13 16:17:15 -05:00
Michael DwanandAiden Cline 4b9b86b544 fix(opencode): lost sessions across worktrees and orphan branches (#16389)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-13 15:51:55 -04:00
David Hill f54abe58cf tui: update compaction status message to use Session instead of History across all languages
The compaction message now correctly indicates the current session was compacted rather than the entire history, making it clearer to users which conversation data was optimized.
2026-03-13 16:33:01 +00:00
opencode d954026dd8 release: v1.2.26 2026-03-13 16:32:53 +00:00
Adam 4ad8116ce3 fix(app): model selection persist by session (#17348) 2026-03-13 11:05:08 -05:00
David Hill 5c7088338c fix(app): polish prompt composer controls (#17388) 2026-03-13 10:48:10 -05:00
Adam 389daa03df fix(app): sidebar sync 2026-03-13 10:47:45 -05:00
David Hill 1cbe7b0854 tweak(ui): use new-session icon in sidebar buttons 2026-03-13 10:18:08 -05:00
David Hill 050d71bcf9 fix(app): avoid clipping new session during sidebar anim 2026-03-13 10:18:03 -05:00
David Hill ffde837e83 fix(app): animate titlebar controls on sidebar open 2026-03-13 10:17:56 -05:00
David Hill 536abea2e2 fix(app): restore sidebar dash and sync session spinner colors (#17384) 2026-03-13 10:08:23 -05:00
Kit Langton c7a52b6a2d feat(schema): scaffold effect-to-zod bridge (#17273) 2026-03-13 10:59:57 -04:00
Adam c4ccb50c37 fix(app): fork should copy prompt into new session (#17375) 2026-03-13 09:59:11 -05:00
Jack 5aaf1ddfb7 fix(ui): force wasm highlighter for markdown code blocks (#17373) 2026-03-13 09:57:14 -05:00
David Hill f5f07310e0 fix(app): sidebar spacing + session list spinner transition (#17355) 2026-03-13 09:19:02 -05:00
Adam c9e9dbeee1 fix(app): terminal cloning without retry (#17354) 2026-03-13 08:56:48 -05:00
Adam b88b323049 fix(app): scroll falls behind prompt input 2026-03-13 08:39:42 -05:00
Adam 6653f868ae fix(app): tooltip quirks 2026-03-13 08:38:32 -05:00
Adam af29d91dca fix(app): todo spacing 2026-03-13 07:43:50 -05:00
Adam 1a3735b619 fix(app): better optimistic prompt submit (#17337) 2026-03-13 07:38:03 -05:00
Shoubhit Dash d4ae13f2a0 fix(opencode): serialize config bun installs (#17342) 2026-03-13 17:58:00 +05:30
Adam f4804dac85 fix(app): oc-2 went too dark 2026-03-13 07:25:42 -05:00
Adam 843f188aaa fix(app): support text attachments (#17335) 2026-03-13 06:58:24 -05:00
Adam 05cb3c87ca chore(app): i18n sync (#17283) 2026-03-13 06:48:38 -05:00
Adam 270cb0b8b4 chore: cleanup (#17284) 2026-03-13 06:27:58 -05:00
Shoubhit Dash 46ba9c8170 perf(app): use cursor session history loading (#17329) 2026-03-13 16:43:41 +05:30
David Hill 80f91d3fd9 Remove prompt mode toggle (#17216) 2026-03-13 05:38:34 -05:00
opencode-agent[bot] a564231caf chore: generate 2026-03-13 10:19:52 +00:00
Shoubhit Dash 9457493696 perf(server): paginate session history (#17134) 2026-03-13 15:48:43 +05:30
Adam ff748b82ca fix(app): simplify themes (#17274) 2026-03-13 10:12:58 +00:00
Frank 9fafa57562 go: upi pay 2026-03-13 03:24:33 -04:00
Aiden Cline f8475649da chore: cleanup migrate from global code (#17292) 2026-03-12 23:54:11 -05:00
Michael DwanandAiden Cline b94e110a4c fix(opencode): sessions lost after git init in existing project (#16814)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-12 23:18:59 -05:00
Luke Parker f0bba10b12 fix(e2e): fail fast on config dependency installs (#17280) 2026-03-13 14:08:51 +10:00
Adam d961981e25 fix(app): list item background colors 2026-03-12 22:23:51 -05:00
Adam 5576662200 feat(app): missing themes (#17275) 2026-03-12 22:21:02 -05:00
Tom Ryder 4a2a046d79 fix: filter empty content blocks for Bedrock provider (#14586) 2026-03-12 22:13:09 -05:00
opencode-agent[bot] 8f8c74cfb8 chore: generate 2026-03-13 02:33:45 +00:00
Kit Langton 092f654f63 fix(cli): hide console command from help output (#17277) 2026-03-12 22:22:16 -04:00
Luke Parker 96b1d8f639 fix(app): stabilize todo dock e2e with composer probe (#17267) 2026-03-13 12:21:50 +10:00
opencode-agent[bot] dcb17c6a67 chore: generate 2026-03-13 02:10:27 +00:00
Kit Langton dd68b85f58 refactor(provider): effectify ProviderAuthService (#17227) 2026-03-13 02:08:37 +00:00
Brendan Allan 84df96eaef desktop: multi-window support in electron (#17155) 2026-03-13 09:18:27 +08:00
Dax Raad 04954a9620 Merge remote-tracking branch 'origin/opencode-2-0' into opencode-2-0 2026-03-11 14:32:38 -04:00
Dax Raad fb63fd79a3 cleanup 2026-03-11 14:29:35 -04:00
Dax Raad 2e04b66eab sync 2026-03-11 14:29:03 -04:00
Dax Raad f0b7c8c374 refactor(npm): inline pkgPath and lockPath variables 2026-03-11 14:29:03 -04:00
Dax Raad be6f59035a unbreak 2026-03-11 14:29:03 -04:00
Dax Raad 27ab51f490 sync 2026-03-11 14:29:03 -04:00
Dax Raad bca723e8fe core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-11 14:29:03 -04:00
Dax Raad 1ac39718d8 sync 2026-03-11 14:29:03 -04:00
Dax Raad 190319fb56 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-11 14:29:03 -04:00
Dax Raad 3154f0a61c core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-11 14:29:03 -04:00
Dax Raad 0b686b8178 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-11 14:29:03 -04:00
Dax Raad 4cba56171b sync 2026-03-11 14:29:03 -04:00
Dax Raad 66342acd31 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-11 14:29:03 -04:00
Dax Raad 88dae67549 refactor(server): replace Bun serve with Hono node adapters 2026-03-11 14:29:03 -04:00
Dax Raad 0ec42582f3 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-11 14:29:02 -04:00
Luke Parker 4f82248a68 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-11 14:29:02 -04:00
Dax Raad 5e069aab97 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-11 14:29:02 -04:00
Dax Raad 5325b2ec99 core: fix custom tool loading to properly resolve module paths 2026-03-11 14:29:02 -04:00
Dax Raad 2a98920922 sync 2026-03-11 14:29:02 -04:00
Dax Raad 5ea92ea6cb sync 2026-03-11 14:29:02 -04:00
Dax Raad a18528a7ee sync 2026-03-11 14:29:02 -04:00
Dax Raad ced125a974 core: log npm install errors to console for debugging dependency failures 2026-03-11 14:29:02 -04:00
Dax Raad 655fe20beb sync 2026-03-11 14:29:02 -04:00
Dax Raad dd0c258e23 core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-11 14:29:02 -04:00
Dax Raad 791e27d289 sync 2026-03-11 14:29:02 -04:00
Dax Raad fac0aec69f tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-11 14:29:02 -04:00
Dax Raad ca26e639f6 core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-11 14:29:02 -04:00
Dax Raad 0b5d54f2cb core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-11 14:29:02 -04:00
Dax Raad 1b408cf06b core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-11 14:29:02 -04:00
Dax Raad 8e102d19ed core: disable npm bin links to fix package installation in sandboxed environments 2026-03-11 14:29:02 -04:00
Dax Raad 721b2406e9 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-11 14:29:01 -04:00
Dax Raad 4a6a18cd79 sync 2026-03-11 14:28:37 -04:00
Daxandgreptile-apps[bot] c10b5880cc Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 14:28:37 -04:00
Daxandgreptile-apps[bot] e6bf83084c Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 14:28:37 -04:00
Dax Raad 6722ee22ee sync 2026-03-11 14:28:36 -04:00
Dax Raad 870a5731ac refactor: lsp server and core improvements 2026-03-11 14:28:24 -04:00
Luke Parker 7910ce5d36 fix: guard Npm.which() against infinite loop when .bin is empty (#16961) 2026-03-11 09:34:58 -04:00
Dax 6ad171dba9 Merge branch 'dev' into opencode-2-0 2026-03-10 17:20:19 -04:00
Dax Raad cb5674edc7 sync 2026-03-10 17:00:15 -04:00
Dax Raad b99de4118e refactor(npm): inline pkgPath and lockPath variables 2026-03-10 16:59:01 -04:00
Dax Raad 040700dbc4 unbreak 2026-03-10 16:07:25 -04:00
Dax Raad 4d5da9697e sync 2026-03-10 16:02:40 -04:00
Dax Raad a28648f530 core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-10 16:02:40 -04:00
Dax Raad 4d81e2d4d9 sync 2026-03-10 16:02:40 -04:00
Dax Raad 21e72cbf42 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-10 16:02:40 -04:00
Dax Raad 5f277d1e62 core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-10 16:02:40 -04:00
Dax Raad d67e877e28 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-10 16:02:40 -04:00
Dax Raad d4e51e04b3 sync 2026-03-10 16:02:40 -04:00
Dax Raad 070c1679e4 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-10 16:02:40 -04:00
Dax Raad 406d216cd2 refactor(server): replace Bun serve with Hono node adapters 2026-03-10 16:02:40 -04:00
Dax Raad 5dc8b4ef29 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-10 16:02:39 -04:00
Luke Parker 2f41d89163 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-10 16:02:39 -04:00
Dax Raad b2eae867a1 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-10 16:02:39 -04:00
Dax Raad 3c2fda4d91 core: fix custom tool loading to properly resolve module paths 2026-03-10 16:02:39 -04:00
Dax Raad 2678ceb45e sync 2026-03-10 16:02:39 -04:00
Dax Raad 58a4cd00b6 sync 2026-03-10 16:02:39 -04:00
Dax Raad 0faa191b6d sync 2026-03-10 16:02:39 -04:00
Dax Raad 58cf092105 core: log npm install errors to console for debugging dependency failures 2026-03-10 16:02:39 -04:00
Dax Raad 0ff8bfe1d9 sync 2026-03-10 16:02:39 -04:00
Dax Raad ceb79c786a core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-10 16:02:39 -04:00
Dax Raad b1a15d559b sync 2026-03-10 16:02:39 -04:00
Dax Raad 124a8abf9b tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-10 16:02:39 -04:00
Dax Raad 85c2bb342b core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad 4c57e39466 core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad 0cdd4e4e16 core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-10 16:02:39 -04:00
Dax Raad a9b01be0c2 core: disable npm bin links to fix package installation in sandboxed environments 2026-03-10 16:02:39 -04:00
Dax Raad 528daf5490 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-10 16:02:39 -04:00
Dax Raad 0e176d3ac3 sync 2026-03-10 16:02:39 -04:00
Daxandgreptile-apps[bot] 27f359852e Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Daxandgreptile-apps[bot] 173128d431 Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Dax Raad e8ee1e239f sync 2026-03-10 16:02:39 -04:00
Dax Raad 656fa191c1 refactor: lsp server and core improvements 2026-03-10 16:02:39 -04:00
482 changed files with 20145 additions and 8314 deletions
+1
View File
@@ -21,3 +21,4 @@ r44vc0rp
rekram1-node rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr thdxr
-OpenCode2026
+9 -1
View File
@@ -41,5 +41,13 @@ runs:
shell: bash shell: bash
- name: Install dependencies - name: Install dependencies
run: bun install run: |
# Workaround for patched peer variants
# e.g. ./patches/ for standard-openapi
# https://github.com/oven-sh/bun/issues/28147
if [ "$RUNNER_OS" = "Windows" ]; then
bun install --linker hoisted
else
bun install
fi
shell: bash shell: bash
+1 -1
View File
@@ -17,7 +17,7 @@ ts-dist
/result /result
refs refs
Session.vim Session.vim
opencode.json /opencode.json
a.out a.out
target target
.scripts .scripts
+5 -3
View File
@@ -1,4 +1,6 @@
plans/ node_modules
bun.lock plans
package.json package.json
package-lock.json bun.lock
.gitignore
package-lock.json
+1 -1
View File
@@ -1,7 +1,7 @@
--- ---
description: Translate content for a specified locale while preserving technical terms description: Translate content for a specified locale while preserving technical terms
mode: subagent mode: subagent
model: opencode/gemini-3-pro model: opencode/gpt-5.4
--- ---
You are a professional translator and localization specialist. You are a professional translator and localization specialist.
+10 -3
View File
@@ -1,7 +1,5 @@
/// <reference path="../env.d.ts" /> /// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) { async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, { const response = await fetch(`https://api.github.com${endpoint}`, {
...options, ...options,
@@ -24,7 +22,16 @@ interface PR {
} }
export default tool({ export default tool({
description: DESCRIPTION, description: `Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
args: { args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"), query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10), limit: tool.schema.number().describe("Maximum number of results to return").default(10),
-10
View File
@@ -1,10 +0,0 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
+6 -3
View File
@@ -1,7 +1,5 @@
/// <reference path="../env.d.ts" /> /// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = { const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"], zen: ["fwang", "MrMushrooooom"],
@@ -40,7 +38,12 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
} }
export default tool({ export default tool({
description: DESCRIPTION, description: `Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
args: { args: {
assignee: tool.schema assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]]) .enum(ASSIGNEES as [string, ...string[]])
-6
View File
@@ -1,6 +0,0 @@
Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
+1 -1
View File
@@ -128,7 +128,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
#### How is this different from Claude Code? #### How is this different from Claude Code?
It's very similar to Claude Code in terms of capability. Here are the key differences: It's very similar to Claude Code in terms of capability. Here are the key differences::
- 100% open source - 100% open source
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important. - Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
+408 -324
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -122,6 +122,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: { properties: {
product: zenLiteProduct.id, product: zenLiteProduct.id,
price: zenLitePrice.id, price: zenLitePrice.id,
priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id, firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
}, },
}) })
@@ -201,6 +202,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID") const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY") const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
const logProcessor = new sst.cloudflare.Worker("LogProcessor", { const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts", handler: "packages/console/function/src/log-processor.ts",
link: [new sst.Secret("HONEYCOMB_API_KEY")], link: [new sst.Secret("HONEYCOMB_API_KEY")],
@@ -219,6 +224,9 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY, EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID, AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY, AWS_SES_SECRET_ACCESS_KEY,
SALESFORCE_CLIENT_ID,
SALESFORCE_CLIENT_SECRET,
SALESFORCE_INSTANCE_URL,
ZEN_BLACK_PRICE, ZEN_BLACK_PRICE,
ZEN_LITE_PRICE, ZEN_LITE_PRICE,
new sst.Secret("ZEN_LIMITS"), new sst.Secret("ZEN_LIMITS"),
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=", "x86_64-linux": "sha256-yfA50QKqylmaioxi+6d++W8Xv4Wix1hl3hEF6Zz7Ue0=",
"aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=", "aarch64-linux": "sha256-b5sO7V+/zzJClHHKjkSz+9AUBYC8cb7S3m5ab1kpAyk=",
"aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=", "aarch64-darwin": "sha256-V66nmRX6kAjrc41ARVeuTElWK7KD8qG/DVk9K7Fu+J8=",
"x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI=" "x86_64-darwin": "sha256-cFyh60WESiqZ5XWZi1+g3F/beSDL1+UPG8KhRivhK8w="
} }
} }
+5 -3
View File
@@ -9,6 +9,7 @@
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev", "dev:desktop": "bun --cwd packages/desktop tauri dev",
"dev:web": "bun --cwd packages/app dev", "dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook", "dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"prepare": "husky", "prepare": "husky",
@@ -24,6 +25,7 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@effect/platform-node": "4.0.0-beta.35",
"@types/bun": "1.3.9", "@types/bun": "1.3.9",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
@@ -41,9 +43,9 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.16-ea816b6", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.31", "effect": "4.0.0-beta.35",
"ai": "5.0.124", "ai": "5.0.124",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
+29
View File
@@ -174,8 +174,37 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
- In terminal tests, type through the browser. Do not write to the PTY through the SDK. - In terminal tests, type through the browser. Do not write to the PTY through the SDK.
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`. - Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles. - These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters.
- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts.
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks. - Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
### Wait on state
- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass
- Avoid race-prone flows that assume work is finished after an action
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops
- Do not treat a visible element as proof that the app will route the next action to it
- When fixing a flake, validate with `--repeat-each` and multiple workers when practical
### Add hooks
- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable
- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states
### Prefer helpers
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
- Use direct locators when the interaction is simple and a helper would not add clarity
- Prefer helpers that both perform an action and verify the app consumed it
- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state
- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions
## Writing New Tests ## Writing New Tests
1. Choose appropriate folder or create new one 1. Choose appropriate folder or create new one
+122 -4
View File
@@ -1,3 +1,4 @@
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { expect, type Locator, type Page } from "@playwright/test" import { expect, type Locator, type Page } from "@playwright/test"
import fs from "node:fs/promises" import fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
@@ -16,6 +17,7 @@ import {
listItemSelector, listItemSelector,
listItemKeySelector, listItemKeySelector,
listItemKeyStartsWithSelector, listItemKeyStartsWithSelector,
promptSelector,
terminalSelector, terminalSelector,
workspaceItemSelector, workspaceItemSelector,
workspaceMenuTriggerSelector, workspaceMenuTriggerSelector,
@@ -36,6 +38,22 @@ async function terminalID(term: Locator) {
throw new Error(`Active terminal missing ${terminalAttr}`) throw new Error(`Active terminal missing ${terminalAttr}`)
} }
export async function terminalConnects(page: Page, input?: { term?: Locator }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const id = await terminalID(term)
return page.evaluate((id) => {
return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0
}, id)
}
export async function disconnectTerminal(page: Page, input?: { term?: Locator }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const id = await terminalID(term)
await page.evaluate((id) => {
;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.()
}, id)
}
async function terminalReady(page: Page, term?: Locator) { async function terminalReady(page: Page, term?: Locator) {
const next = term ?? page.locator(terminalSelector).first() const next = term ?? page.locator(terminalSelector).first()
const id = await terminalID(next) const id = await terminalID(next)
@@ -45,6 +63,15 @@ async function terminalReady(page: Page, term?: Locator) {
}, id) }, id)
} }
async function terminalFocusIdle(page: Page, term?: Locator) {
const next = term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
return (state?.focusing ?? 0) === 0
}, id)
}
async function terminalHas(page: Page, input: { term?: Locator; token: string }) { async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
const next = input.term ?? page.locator(terminalSelector).first() const next = input.term ?? page.locator(terminalSelector).first()
const id = await terminalID(next) const id = await terminalID(next)
@@ -57,6 +84,29 @@ async function terminalHas(page: Page, input: { term?: Locator; token: string })
) )
} }
async function promptSlashActive(page: Page, id: string) {
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
if (state?.popover !== "slash") return false
if (!state.slash.ids.includes(id)) return false
return state.slash.active === id
}, id)
}
async function promptSlashSelects(page: Page) {
return page.evaluate(() => {
return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
})
}
async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
return page.evaluate((input) => {
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
if (!state) return false
return state.selected === input.id && state.selects >= input.count
}, input)
}
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) { export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
const term = input?.term ?? page.locator(terminalSelector).first() const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000 const timeout = input?.timeout ?? 10_000
@@ -65,6 +115,43 @@ export async function waitTerminalReady(page: Page, input?: { term?: Locator; ti
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true) await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
} }
export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000
await waitTerminalReady(page, { term, timeout })
await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
}
export async function showPromptSlash(
page: Page,
input: { id: string; text: string; prompt?: Locator; timeout?: number },
) {
const prompt = input.prompt ?? page.locator(promptSelector)
const timeout = input.timeout ?? 10_000
await expect
.poll(
async () => {
await prompt.click().catch(() => false)
await prompt.fill(input.text).catch(() => false)
return promptSlashActive(page, input.id).catch(() => false)
},
{ timeout },
)
.toBe(true)
}
export async function runPromptSlash(
page: Page,
input: { id: string; text: string; prompt?: Locator; timeout?: number },
) {
const prompt = input.prompt ?? page.locator(promptSelector)
const timeout = input.timeout ?? 10_000
const count = await promptSlashSelects(page)
await showPromptSlash(page, input)
await prompt.press("Enter")
await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
}
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) { export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
const term = input.term ?? page.locator(terminalSelector).first() const term = input.term ?? page.locator(terminalSelector).first()
const timeout = input.timeout ?? 10_000 const timeout = input.timeout ?? 10_000
@@ -275,6 +362,30 @@ export async function waitSlug(page: Page, skip: string[] = []) {
return next return next
} }
export async function resolveSlug(slug: string) {
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
const resolved = await resolveDirectory(directory)
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
}
export async function waitDir(page: Page, directory: string) {
const target = await resolveDirectory(directory)
await expect
.poll(
async () => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
return resolveSlug(slug)
.then((item) => item.directory)
.catch(() => "")
},
{ timeout: 45_000 },
)
.toBe(target)
return { directory: target, slug: base64Encode(target) }
}
export function sessionIDFromUrl(url: string) { export function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url) const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1] return match?.[1]
@@ -588,12 +699,19 @@ export async function seedSessionTask(
.flatMap((message) => message.parts) .flatMap((message) => message.parts)
.find((part) => { .find((part) => {
if (part.type !== "tool" || part.tool !== "task") return false if (part.type !== "tool" || part.tool !== "task") return false
if (part.state.input?.description !== input.description) return false if (!("state" in part) || !part.state || typeof part.state !== "object") return false
return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0 if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false
if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
return false
if (!("sessionId" in part.state.metadata)) return false
return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
}) })
if (!part) return if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return
const id = part.state.metadata?.sessionId if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
if (!("sessionId" in part.state.metadata)) return
const id = part.state.metadata.sessionId
if (typeof id !== "string" || !id) return if (typeof id !== "string" || !id) return
const child = await sdk.session const child = await sdk.session
.get({ sessionID: id }) .get({ sessionID: id })
+3
View File
@@ -3,8 +3,11 @@ import { serverNamePattern } from "../utils"
test("home renders and shows core entrypoints", async ({ page }) => { test("home renders and shows core entrypoints", async ({ page }) => {
await page.goto("/") await page.goto("/")
const nav = page.locator('[data-component="sidebar-nav-desktop"]')
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
await expect(nav.getByText("No projects open")).toBeVisible()
await expect(nav.getByText("Open a project to get started")).toBeVisible()
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible() await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
}) })
+6
View File
@@ -95,6 +95,12 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
const win = window as E2EWindow const win = window as E2EWindow
win.__opencode_e2e = { win.__opencode_e2e = {
...win.__opencode_e2e, ...win.__opencode_e2e,
model: {
enabled: true,
},
prompt: {
enabled: true,
},
terminal: { terminal: {
enabled: true, enabled: true,
terminals: {}, terminals: {},
@@ -1,7 +1,15 @@
import { base64Decode } from "@opencode-ai/util/encode" import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions" import {
defocus,
createTestProject,
cleanupTestProject,
openSidebar,
sessionIDFromUrl,
waitDir,
waitSlug,
} from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils" import { dirSlug, resolveDirectory } from "../utils"
@@ -100,11 +108,8 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(btn).toBeVisible() await expect(btn).toBeVisible()
await btn.click({ force: true }) await btn.click({ force: true })
// A new workspace can be discovered via a transient slug before the route and sidebar
// settle to the canonical workspace path on Windows, so interact with either and assert
// against the resolved workspace slug.
await waitSlug(page) await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) await waitDir(page, space)
// Create a session by sending a prompt // Create a session by sending a prompt
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
@@ -132,6 +137,7 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(rootButton).toBeVisible() await expect(rootButton).toBeVisible()
await rootButton.click() await rootButton.click()
await waitDir(page, space)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created) await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
}, },
@@ -1,18 +1,25 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions" import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils" import { createSdk } from "../utils"
async function waitWorkspaceReady(page: Page, slug: string) { function item(space: { slug: string; raw: string }) {
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
}
function button(space: { slug: string; raw: string }) {
return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
}
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
await openSidebar(page) await openSidebar(page)
await expect await expect
.poll( .poll(
async () => { async () => {
const item = page.locator(workspaceItemSelector(slug)).first() const row = page.locator(item(space)).first()
try { try {
await item.hover({ timeout: 500 }) await row.hover({ timeout: 500 })
return true return true
} catch { } catch {
return false return false
@@ -27,29 +34,30 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page) await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [root, ...seen]) const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
const directory = base64Decode(slug) await waitDir(page, next.directory)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory }
}
async function openWorkspaceNewSession(page: Page, slug: string) {
await waitWorkspaceReady(page, slug)
const item = page.locator(workspaceItemSelector(slug)).first()
await item.hover()
const button = page.locator(workspaceNewSessionSelector(slug)).first()
await expect(button).toBeVisible()
await button.click({ force: true })
const next = await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
return next return next
} }
async function createSessionFromWorkspace(page: Page, slug: string, text: string) { async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
const next = await openWorkspaceNewSession(page, slug) await waitWorkspaceReady(page, space)
const row = page.locator(item(space)).first()
await row.hover()
const next = page.locator(button(space)).first()
await expect(next).toBeVisible()
await next.click({ force: true })
return waitDir(page, space.directory)
}
async function createSessionFromWorkspace(
page: Page,
space: { slug: string; raw: string; directory: string },
text: string,
) {
const next = await openWorkspaceNewSession(page, space)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible() await expect(prompt).toBeVisible()
@@ -60,13 +68,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text) await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter") await prompt.press("Enter")
await expect.poll(() => slugFromUrl(page.url())).toBe(next) await waitDir(page, next.directory)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url()) const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
return { sessionID, slug: next } return { sessionID, slug: next.slug }
} }
async function sessionDirectory(directory: string, sessionID: string) { async function sessionDirectory(directory: string, sessionID: string) {
@@ -87,11 +95,11 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
const first = await createWorkspace(page, root, []) const first = await createWorkspace(page, root, [])
trackDirectory(first.directory) trackDirectory(first.directory)
await waitWorkspaceReady(page, first.slug) await waitWorkspaceReady(page, first)
const second = await createWorkspace(page, root, [first.slug]) const second = await createWorkspace(page, root, [first.slug])
trackDirectory(second.directory) trackDirectory(second.directory)
await waitWorkspaceReady(page, second.slug) await waitWorkspaceReady(page, second)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`) const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
trackSession(firstSession.sessionID, first.directory) trackSession(firstSession.sessionID, first.directory)
+16 -14
View File
@@ -1,7 +1,7 @@
import { base64Decode } from "@opencode-ai/util/encode"
import fs from "node:fs/promises" import fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
@@ -13,8 +13,10 @@ import {
confirmDialog, confirmDialog,
openSidebar, openSidebar,
openWorkspaceMenu, openWorkspaceMenu,
resolveSlug,
setWorkspacesEnabled, setWorkspacesEnabled,
slugFromUrl, slugFromUrl,
waitDir,
waitSlug, waitSlug,
} from "../actions" } from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
@@ -27,15 +29,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
await setWorkspacesEnabled(page, rootSlug, true) await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [rootSlug]) const next = await resolveSlug(await waitSlug(page, [rootSlug]))
const dir = base64Decode(slug) await waitDir(page, next.directory)
await openSidebar(page) await openSidebar(page)
await expect await expect
.poll( .poll(
async () => { async () => {
const item = page.locator(workspaceItemSelector(slug)).first() const item = page.locator(workspaceItemSelector(next.slug)).first()
try { try {
await item.hover({ timeout: 500 }) await item.hover({ timeout: 500 })
return true return true
@@ -47,7 +49,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
) )
.toBe(true) .toBe(true)
return { rootSlug, slug, directory: dir } return { rootSlug, slug: next.slug, directory: next.directory }
} }
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => { test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
@@ -79,15 +81,15 @@ test("can create a workspace", async ({ page, withProject }) => {
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const workspaceSlug = await waitSlug(page, [slug]) const next = await resolveSlug(await waitSlug(page, [slug]))
const workspaceDir = base64Decode(workspaceSlug) await waitDir(page, next.directory)
await openSidebar(page) await openSidebar(page)
await expect await expect
.poll( .poll(
async () => { async () => {
const item = page.locator(workspaceItemSelector(workspaceSlug)).first() const item = page.locator(workspaceItemSelector(next.slug)).first()
try { try {
await item.hover({ timeout: 500 }) await item.hover({ timeout: 500 })
return true return true
@@ -99,9 +101,9 @@ test("can create a workspace", async ({ page, withProject }) => {
) )
.toBe(true) .toBe(true)
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible() await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
await cleanupTestProject(workspaceDir) await cleanupTestProject(next.directory)
}) })
}) })
@@ -119,7 +121,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
const activeDir = base64Decode(slugFromUrl(page.url())) const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-") expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
await openSidebar(page) await openSidebar(page)
@@ -331,9 +333,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
for (const _ of [0, 1]) { for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url()) const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [rootSlug, prev]) const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
const dir = base64Decode(slug) await waitDir(page, next.directory)
workspaces.push({ slug, directory: dir }) workspaces.push(next)
await openSidebar(page) await openSidebar(page)
} }
@@ -7,12 +7,18 @@ test("shift+enter inserts a newline without submitting", async ({ page, gotoSess
await expect(page).toHaveURL(/\/session\/?$/) await expect(page).toHaveURL(/\/session\/?$/)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await prompt.click() await prompt.focus()
await page.keyboard.type("line one") await expect(prompt).toBeFocused()
await page.keyboard.press("Shift+Enter")
await page.keyboard.type("line two") await prompt.pressSequentially("line one")
await expect(prompt).toBeFocused()
await prompt.press("Shift+Enter")
await expect(page).toHaveURL(/\/session\/?$/)
await expect(prompt).toBeFocused()
await prompt.pressSequentially("line two")
await expect(page).toHaveURL(/\/session\/?$/) await expect(page).toHaveURL(/\/session\/?$/)
await expect(prompt).toContainText("line one") await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two")
await expect(prompt).toContainText("line two")
}) })
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions" import { runPromptSlash, waitTerminalFocusIdle } from "../actions"
import { promptSelector, terminalSelector } from "../selectors" import { promptSelector, terminalSelector } from "../selectors"
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => { test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
@@ -7,29 +7,12 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
const terminal = page.locator(terminalSelector) const terminal = page.locator(terminalSelector)
const slash = page.locator('[data-slash-id="terminal.toggle"]').first()
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
await prompt.fill("/terminal") await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
await expect(slash).toBeVisible() await waitTerminalFocusIdle(page, { term: terminal })
await page.keyboard.press("Enter")
await waitTerminalReady(page, { term: terminal })
// Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening, await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
// which can steal focus from the prompt and prevent fill() from triggering
// the slash popover. Re-attempt click+fill until all retries are exhausted
// and the popover appears.
await expect
.poll(
async () => {
await prompt.click().catch(() => false)
await prompt.fill("/terminal").catch(() => false)
return slash.isVisible().catch(() => false)
},
{ timeout: 10_000 },
)
.toBe(true)
await page.keyboard.press("Enter")
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
}) })
+3
View File
@@ -13,6 +13,9 @@ export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggl
export const sessionTodoListSelector = '[data-slot="session-todo-list"]' export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]' export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
export const promptModelSelector = '[data-component="prompt-model-control"]'
export const promptVariantSelector = '[data-component="prompt-variant-control"]'
export const settingsLanguageSelectSelector = '[data-action="settings-language"]' export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]' export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
export const settingsThemeSelector = '[data-action="settings-theme"]' export const settingsThemeSelector = '[data-action="settings-theme"]'
@@ -1,12 +1,16 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions" import {
composerEvent,
type ComposerDriverState,
type ComposerProbeState,
type ComposerWindow,
} from "../../src/testing/session-composer"
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
import { import {
permissionDockSelector, permissionDockSelector,
promptSelector, promptSelector,
questionDockSelector, questionDockSelector,
sessionComposerDockSelector, sessionComposerDockSelector,
sessionTodoDockSelector,
sessionTodoListSelector,
sessionTodoToggleButtonSelector, sessionTodoToggleButtonSelector,
} from "../selectors" } from "../selectors"
@@ -42,12 +46,8 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
async function clearPermissionDock(page: any, label: RegExp) { async function clearPermissionDock(page: any, label: RegExp) {
const dock = page.locator(permissionDockSelector) const dock = page.locator(permissionDockSelector)
for (let i = 0; i < 3; i++) { await expect(dock).toBeVisible()
const count = await dock.count() await dock.getByRole("button", { name: label }).click()
if (count === 0) return
await dock.getByRole("button", { name: label }).click()
await page.waitForTimeout(150)
}
} }
async function setAutoAccept(page: any, enabled: boolean) { async function setAutoAccept(page: any, enabled: boolean) {
@@ -59,6 +59,120 @@ async function setAutoAccept(page: any, enabled: boolean) {
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false") await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
} }
async function expectQuestionBlocked(page: any) {
await expect(page.locator(questionDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toHaveCount(0)
}
async function expectQuestionOpen(page: any) {
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(promptSelector)).toBeVisible()
}
async function expectPermissionBlocked(page: any) {
await expect(page.locator(permissionDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toHaveCount(0)
}
async function expectPermissionOpen(page: any) {
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await expect(page.locator(promptSelector)).toBeVisible()
}
async function todoDock(page: any, sessionID: string) {
await page.addInitScript(() => {
const win = window as ComposerWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
composer: {
enabled: true,
sessions: {},
},
}
})
const write = async (driver: ComposerDriverState | undefined) => {
await page.evaluate(
(input) => {
const win = window as ComposerWindow
const composer = win.__opencode_e2e?.composer
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
composer.sessions ??= {}
const prev = composer.sessions[input.sessionID] ?? {}
if (!input.driver) {
if (!prev.probe) {
delete composer.sessions[input.sessionID]
} else {
composer.sessions[input.sessionID] = { probe: prev.probe }
}
} else {
composer.sessions[input.sessionID] = {
...prev,
driver: input.driver,
}
}
window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } }))
},
{ event: composerEvent, sessionID, driver },
)
}
const read = () =>
page.evaluate((sessionID) => {
const win = window as ComposerWindow
return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
}, sessionID) as Promise<ComposerProbeState | null>
const api = {
async clear() {
await write(undefined)
return api
},
async open(todos: NonNullable<ComposerDriverState["todos"]>) {
await write({ live: true, todos })
return api
},
async finish(todos: NonNullable<ComposerDriverState["todos"]>) {
await write({ live: false, todos })
return api
},
async expectOpen(states: ComposerProbeState["states"]) {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
mounted: true,
collapsed: false,
hidden: false,
count: states.length,
states,
})
return api
},
async expectCollapsed(states: ComposerProbeState["states"]) {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
mounted: true,
collapsed: true,
hidden: true,
count: states.length,
states,
})
return api
},
async expectClosed() {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({ mounted: false })
return api
},
async collapse() {
await page.locator(sessionTodoToggleButtonSelector).click()
return api
},
async expand() {
await page.locator(sessionTodoToggleButtonSelector).click()
return api
},
}
return api
}
async function withMockPermission<T>( async function withMockPermission<T>(
page: any, page: any,
request: { request: {
@@ -70,7 +184,7 @@ async function withMockPermission<T>(
always?: string[] always?: string[]
}, },
opts: { child?: any } | undefined, opts: { child?: any } | undefined,
fn: () => Promise<T>, fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
) { ) {
let pending = [ let pending = [
{ {
@@ -119,8 +233,14 @@ async function withMockPermission<T>(
if (sessionList) await page.route("**/session?*", sessionList) if (sessionList) await page.route("**/session?*", sessionList)
const state = {
async resolved() {
await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0)
},
}
try { try {
return await fn() return await fn(state)
} finally { } finally {
await page.unroute("**/permission", list) await page.unroute("**/permission", list)
await page.unroute("**/session/*/permissions/*", reply) await page.unroute("**/session/*/permissions/*", reply)
@@ -173,14 +293,12 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
}) })
const dock = page.locator(questionDockSelector) const dock = page.locator(questionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1) await expectQuestionBlocked(page)
await expect(page.locator(promptSelector)).toHaveCount(0)
await dock.locator('[data-slot="question-option"]').first().click() await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click() await dock.getByRole("button", { name: /submit/i }).click()
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0) await expectQuestionOpen(page)
await expect(page.locator(promptSelector)).toBeVisible()
}) })
}) })
}) })
@@ -199,15 +317,14 @@ test("blocked permission flow supports allow once", async ({ page, sdk, gotoSess
metadata: { description: "Need permission for command" }, metadata: { description: "Need permission for command" },
}, },
undefined, undefined,
async () => { async (state) => {
await page.goto(page.url()) await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) await expectPermissionBlocked(page)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /allow once/i) await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url()) await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) await expectPermissionOpen(page)
await expect(page.locator(promptSelector)).toBeVisible()
}, },
) )
}) })
@@ -226,15 +343,14 @@ test("blocked permission flow supports reject", async ({ page, sdk, gotoSession
patterns: ["/tmp/opencode-e2e-perm-reject"], patterns: ["/tmp/opencode-e2e-perm-reject"],
}, },
undefined, undefined,
async () => { async (state) => {
await page.goto(page.url()) await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) await expectPermissionBlocked(page)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /deny/i) await clearPermissionDock(page, /deny/i)
await state.resolved()
await page.goto(page.url()) await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) await expectPermissionOpen(page)
await expect(page.locator(promptSelector)).toBeVisible()
}, },
) )
}) })
@@ -254,15 +370,14 @@ test("blocked permission flow supports allow always", async ({ page, sdk, gotoSe
metadata: { description: "Need permission for command" }, metadata: { description: "Need permission for command" },
}, },
undefined, undefined,
async () => { async (state) => {
await page.goto(page.url()) await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) await expectPermissionBlocked(page)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /allow always/i) await clearPermissionDock(page, /allow always/i)
await state.resolved()
await page.goto(page.url()) await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) await expectPermissionOpen(page)
await expect(page.locator(promptSelector)).toBeVisible()
}, },
) )
}) })
@@ -301,14 +416,12 @@ test("child session question request blocks parent dock and unblocks after submi
}) })
const dock = page.locator(questionDockSelector) const dock = page.locator(questionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1) await expectQuestionBlocked(page)
await expect(page.locator(promptSelector)).toHaveCount(0)
await dock.locator('[data-slot="question-option"]').first().click() await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click() await dock.getByRole("button", { name: /submit/i }).click()
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0) await expectQuestionOpen(page)
await expect(page.locator(promptSelector)).toBeVisible()
}) })
} finally { } finally {
await cleanupSession({ sdk, sessionID: child.id }) await cleanupSession({ sdk, sessionID: child.id })
@@ -344,17 +457,15 @@ test("child session permission request blocks parent dock and supports allow onc
metadata: { description: "Need child permission" }, metadata: { description: "Need child permission" },
}, },
{ child }, { child },
async () => { async (state) => {
await page.goto(page.url()) await page.goto(page.url())
const dock = page.locator(permissionDockSelector) await expectPermissionBlocked(page)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /allow once/i) await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url()) await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) await expectPermissionOpen(page)
await expect(page.locator(promptSelector)).toBeVisible()
}, },
) )
} finally { } finally {
@@ -365,36 +476,31 @@ test("child session permission request blocks parent dock and supports allow onc
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => { test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock todo", async (session) => { await withDockSession(sdk, "e2e composer dock todo", async (session) => {
await withDockSeed(sdk, session.id, async () => { const dock = await todoDock(page, session.id)
await gotoSession(session.id) await gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await seedSessionTodos(sdk, { try {
sessionID: session.id, await dock.open([
todos: [ { content: "first task", status: "pending", priority: "high" },
{ content: "first task", status: "pending", priority: "high" }, { content: "second task", status: "in_progress", priority: "medium" },
{ content: "second task", status: "in_progress", priority: "medium" }, ])
], await dock.expectOpen(["pending", "in_progress"])
})
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1) await dock.collapse()
await expect(page.locator(sessionTodoListSelector)).toBeVisible() await dock.expectCollapsed(["pending", "in_progress"])
await page.locator(sessionTodoToggleButtonSelector).click() await dock.expand()
await expect(page.locator(sessionTodoListSelector)).toBeHidden() await dock.expectOpen(["pending", "in_progress"])
await page.locator(sessionTodoToggleButtonSelector).click() await dock.finish([
await expect(page.locator(sessionTodoListSelector)).toBeVisible() { content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" },
await seedSessionTodos(sdk, { ])
sessionID: session.id, await dock.expectClosed()
todos: [ } finally {
{ content: "first task", status: "completed", priority: "high" }, await dock.clear()
{ content: "second task", status: "cancelled", priority: "medium" }, }
],
})
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
})
}) })
}) })
@@ -414,8 +520,7 @@ test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSe
], ],
}) })
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1) await expectQuestionBlocked(page)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page.locator("main").click({ position: { x: 5, y: 5 } }) await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc") await page.keyboard.type("abc")
@@ -0,0 +1,371 @@
import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
import {
promptAgentSelector,
promptModelSelector,
promptSelector,
promptVariantSelector,
workspaceItemSelector,
workspaceNewSessionSelector,
} from "../selectors"
import { createSdk, sessionPath } from "../utils"
type Footer = {
agent: string
model: string
variant: string
}
type Probe = {
dir?: string
sessionID?: string
model?: { providerID: string; modelID: string }
}
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const text = async (locator: Locator) => ((await locator.textContent()) ?? "").trim()
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
const dirKey = (state: Probe | null) => state?.dir ?? ""
async function probe(page: Page): Promise<Probe | null> {
return page.evaluate(() => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
current?: Probe
}
}
}
return win.__opencode_e2e?.model?.current ?? null
})
}
async function currentDir(page: Page) {
let hit = ""
await expect
.poll(
async () => {
const next = dirKey(await probe(page))
if (next) hit = next
return next
},
{ timeout: 30_000 },
)
.not.toBe("")
return hit
}
async function read(page: Page): Promise<Footer> {
return {
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
model: await text(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()),
variant: await text(page.locator(`${promptVariantSelector} [data-slot="select-select-trigger-value"]`).first()),
}
}
async function waitFooter(page: Page, expected: Partial<Footer>) {
let hit: Footer | null = null
await expect
.poll(
async () => {
const state = await read(page)
const ok = Object.entries(expected).every(([key, value]) => state[key as keyof Footer] === value)
if (ok) hit = state
return ok
},
{ timeout: 30_000 },
)
.toBe(true)
if (!hit) throw new Error("Failed to resolve prompt footer state")
return hit
}
async function waitModel(page: Page, value: string) {
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).toBe(value)
}
async function choose(page: Page, root: string, value: string) {
const select = page.locator(root)
await expect(select).toBeVisible()
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
const item = page
.locator('[data-slot="select-select-item"]')
.filter({ hasText: new RegExp(`^\\s*${escape(value)}\\s*$`) })
.first()
await expect(item).toBeVisible()
await item.click()
}
async function variantCount(page: Page) {
const select = page.locator(promptVariantSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
const count = await page.locator('[data-slot="select-select-item"]').count()
await page.keyboard.press("Escape")
return count
}
async function agents(page: Page) {
const select = page.locator(promptAgentSelector)
await expect(select).toBeVisible()
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
const labels = await page.locator('[data-slot="select-select-item-label"]').allTextContents()
await page.keyboard.press("Escape")
return labels.map((item) => item.trim()).filter(Boolean)
}
async function ensureVariant(page: Page, directory: string): Promise<Footer> {
const current = await read(page)
if ((await variantCount(page)) >= 2) return current
const cfg = await createSdk(directory)
.config.get()
.then((x) => x.data)
const visible = new Set(await agents(page))
const entry = Object.entries(cfg?.agent ?? {}).find((item) => {
const value = item[1]
return !!value && typeof value === "object" && "variant" in value && "model" in value && visible.has(item[0])
})
const name = entry?.[0]
test.skip(!name, "no agent with alternate variants available")
if (!name) return current
await choose(page, promptAgentSelector, name)
await expect.poll(() => variantCount(page), { timeout: 30_000 }).toBeGreaterThanOrEqual(2)
return waitFooter(page, { agent: name })
}
async function chooseDifferentVariant(page: Page): Promise<Footer> {
const current = await read(page)
const select = page.locator(promptVariantSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
const count = await items.count()
if (count < 2) throw new Error("Current model has no alternate variant to select")
for (let i = 0; i < count; i++) {
const item = items.nth(i)
const next = await text(item.locator('[data-slot="select-select-item-label"]').first())
if (!next || next === current.variant) continue
await item.click()
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
}
throw new Error("Failed to choose a different variant")
}
async function chooseOtherModel(page: Page): Promise<Footer> {
const current = await read(page)
const button = page.locator(`${promptModelSelector} [data-action="prompt-model"]`)
await expect(button).toBeVisible()
await button.click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const items = dialog.locator('[data-slot="list-item"]')
const count = await items.count()
expect(count).toBeGreaterThan(1)
for (let i = 0; i < count; i++) {
const item = items.nth(i)
const selected = (await item.getAttribute("data-selected")) === "true"
if (selected) continue
await item.click()
await expect(dialog).toHaveCount(0)
await expect.poll(async () => (await read(page)).model !== current.model, { timeout: 30_000 }).toBe(true)
return read(page)
}
throw new Error("Failed to choose a different model")
}
async function goto(page: Page, directory: string, sessionID?: string) {
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
await expect.poll(async () => dirKey(await probe(page)), { timeout: 30_000 }).toBe(directory)
}
async function submit(page: Page, value: string) {
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.click()
await prompt.fill(value)
await prompt.press("Enter")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
return id
}
async function waitUser(directory: string, sessionID: string) {
const sdk = createSdk(directory)
await expect
.poll(
async () => {
const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
return items.some((item) => item.info.role === "user")
},
{ timeout: 30_000 },
)
.toBe(true)
await sdk.session.abort({ sessionID }).catch(() => undefined)
await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
}
async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
return next
}
async function waitWorkspace(page: Page, slug: string) {
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
async function newWorkspaceSession(page: Page, slug: string) {
await waitWorkspace(page, slug)
const item = page.locator(workspaceItemSelector(slug)).first()
await item.hover()
const button = page.locator(workspaceNewSessionSelector(slug)).first()
await expect(button).toBeVisible()
await button.click({ force: true })
const next = await resolveSlug(await waitSlug(page))
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
return currentDir(page)
}
test("session model and variant restore per session without leaking into new sessions", async ({
page,
withProject,
}) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory, gotoSession, trackSession }) => {
await gotoSession()
await ensureVariant(page, directory)
const firstState = await chooseDifferentVariant(page)
const first = await submit(page, `session variant ${Date.now()}`)
trackSession(first)
await waitUser(directory, first)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
await waitFooter(page, firstState)
await gotoSession()
const fresh = await ensureVariant(page, directory)
expect(fresh.variant).not.toBe(firstState.variant)
const secondState = await chooseOtherModel(page)
const second = await submit(page, `session model ${Date.now()}`)
trackSession(second)
await waitUser(directory, second)
await goto(page, directory, first)
await waitFooter(page, firstState)
await goto(page, directory, second)
await waitFooter(page, secondState)
await gotoSession()
await waitFooter(page, fresh)
})
})
test("session model restore across workspaces", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
await gotoSession()
await ensureVariant(page, root)
const firstState = await chooseDifferentVariant(page)
const first = await submit(page, `root session ${Date.now()}`)
trackSession(first, root)
await waitUser(root, first)
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
const one = await createWorkspace(page, slug, [])
const oneDir = await newWorkspaceSession(page, one.slug)
trackDirectory(oneDir)
const secondState = await chooseOtherModel(page)
const second = await submit(page, `workspace one ${Date.now()}`)
trackSession(second, oneDir)
await waitUser(oneDir, second)
const two = await createWorkspace(page, slug, [one.slug])
const twoDir = await newWorkspaceSession(page, two.slug)
trackDirectory(twoDir)
await ensureVariant(page, twoDir)
const thirdState = await chooseDifferentVariant(page)
const third = await submit(page, `workspace two ${Date.now()}`)
trackSession(third, twoDir)
await waitUser(twoDir, third)
await goto(page, root, first)
await waitFooter(page, firstState)
await goto(page, oneDir, second)
await waitFooter(page, secondState)
await goto(page, twoDir, third)
await waitFooter(page, thirdState)
await goto(page, root, first)
await waitFooter(page, firstState)
})
})
test("variant preserved when switching agent modes", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory, gotoSession }) => {
await gotoSession()
await ensureVariant(page, directory)
const updated = await chooseDifferentVariant(page)
const available = await agents(page)
const other = available.find((name) => name !== updated.agent)
test.skip(!other, "only one agent available")
if (!other) return
await choose(page, promptAgentSelector, other)
await waitFooter(page, { agent: other, variant: updated.variant })
await choose(page, promptAgentSelector, updated.agent)
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
})
})
@@ -123,6 +123,101 @@ async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
}, file) }, file)
} }
async function comment(page: Parameters<typeof test>[0]["page"], file: string, note: string) {
const row = page.locator(`[data-file="${file}"]`).first()
await expect(row).toBeVisible()
const line = row.locator('diffs-container [data-line="2"]').first()
await expect(line).toBeVisible()
await line.hover()
const add = row.getByRole("button", { name: /^Comment$/ }).first()
await expect(add).toBeVisible()
await add.click()
const area = row.locator('[data-slot="line-comment-textarea"]').first()
await expect(area).toBeVisible()
await area.fill(note)
const submit = row.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
await expect(submit).toBeEnabled()
await submit.click()
await expect(row.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
await expect(row.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
}
async function overflow(page: Parameters<typeof test>[0]["page"], file: string) {
const row = page.locator(`[data-file="${file}"]`).first()
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
const pop = row.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
const tools = row.locator('[data-slot="line-comment-tools"]').first()
const [width, viewBox, popBox, toolsBox] = await Promise.all([
view.evaluate((el) => el.scrollWidth - el.clientWidth),
view.boundingBox(),
pop.boundingBox(),
tools.boundingBox(),
])
if (!viewBox || !popBox || !toolsBox) return null
return {
width,
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
}
}
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.setTimeout(180_000)
const tag = `review-comment-${Date.now()}`
const file = `review-comment-${tag}.txt`
const note = `comment ${tag}`
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
await expand(page)
await waitMark(page, file, tag)
await comment(page, file, note)
await expect
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
})
})
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => { test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.") test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000) test.setTimeout(180_000)
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions" import { openSettings, closeDialog, waitTerminalFocusIdle, withSession } from "../actions"
import { keybindButtonSelector, terminalSelector } from "../selectors" import { keybindButtonSelector, terminalSelector } from "../selectors"
import { modKey } from "../utils" import { modKey } from "../utils"
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
await page.keyboard.press(`${modKey}+Y`) await page.keyboard.press(`${modKey}+Y`)
await waitTerminalReady(page, { term: terminal }) await waitTerminalFocusIdle(page, { term: terminal })
await page.keyboard.press(`${modKey}+Y`) await page.keyboard.press(`${modKey}+Y`)
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions" import { waitTerminalFocusIdle, waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors" import { promptSelector, terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils" import { terminalToggleKey } from "../utils"
@@ -14,7 +14,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await page.keyboard.press(terminalToggleKey) await page.keyboard.press(terminalToggleKey)
} }
await waitTerminalReady(page, { term: terminals.first() }) await waitTerminalFocusIdle(page, { term: terminals.first() })
await expect(terminals).toHaveCount(1) await expect(terminals).toHaveCount(1)
// Ghostty captures a lot of keybinds when focused; move focus back // Ghostty captures a lot of keybinds when focused; move focus back
@@ -0,0 +1,46 @@
import type { Page } from "@playwright/test"
import { disconnectTerminal, runTerminal, terminalConnects, waitTerminalReady } from "../actions"
import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
async function open(page: Page) {
const term = page.locator(terminalSelector).first()
const visible = await term.isVisible().catch(() => false)
if (!visible) await page.keyboard.press(terminalToggleKey)
await waitTerminalReady(page, { term })
return term
}
test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
await withProject(async ({ gotoSession }) => {
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
const token = `E2E_RECONNECT_${Date.now()}`
await gotoSession()
const term = await open(page)
const id = await term.getAttribute("data-pty-id")
if (!id) throw new Error("Active terminal missing data-pty-id")
const prev = await terminalConnects(page, { term })
await runTerminal(page, {
term,
cmd: `export ${name}=${token}; echo ${token}`,
token,
})
await disconnectTerminal(page, { term })
await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
await runTerminal(page, {
term,
cmd: `echo $${name}`,
token,
timeout: 15_000,
})
})
})
+2 -1
View File
@@ -2,7 +2,8 @@
"extends": "../tsconfig.json", "extends": "../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"noEmit": true, "noEmit": true,
"rootDir": "..",
"types": ["node", "bun"] "types": ["node", "bun"]
}, },
"include": ["./**/*.ts"] "include": ["./**/*.ts", "../src/testing/terminal.ts"]
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.2.25", "version": "1.2.27",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -56,7 +56,7 @@
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"effect": "4.0.0-beta.31", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main", "ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:", "luxon": "catalog:",
+1
View File
@@ -73,6 +73,7 @@ const serverEnv = {
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e", OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano", OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
OPENCODE_CLIENT: "app", OPENCODE_CLIENT: "app",
OPENCODE_STRICT_CONFIG_DEPS: "true",
} satisfies Record<string, string> } satisfies Record<string, string>
const runnerEnv = { const runnerEnv = {
+61 -46
View File
@@ -12,6 +12,7 @@ import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { type Duration, Effect } from "effect" import { type Duration, Effect } from "effect"
import { import {
type Component, type Component,
createMemo,
createResource, createResource,
createSignal, createSignal,
ErrorBoundary, ErrorBoundary,
@@ -45,21 +46,13 @@ import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { useCheckServerHealth } from "./utils/server-health"
const Home = lazy(() => import("@/pages/home")) const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" /> const Loading = () => <div class="size-full" />
const HomeRoute = () => (
<Suspense fallback={<Loading />}>
<Home />
</Suspense>
)
const SessionRoute = () => ( const SessionRoute = () => (
<SessionProviders> <SessionProviders>
<Suspense fallback={<Loading />}> <Session />
<Session />
</Suspense>
</SessionProviders> </SessionProviders>
) )
@@ -67,7 +60,7 @@ const SessionIndexRoute = () => <Navigate href="session" />
function UiI18nBridge(props: ParentProps) { function UiI18nBridge(props: ParentProps) {
const language = useLanguage() const language = useLanguage()
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider> return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
} }
declare global { declare global {
@@ -123,8 +116,10 @@ function SessionProviders(props: ParentProps) {
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
return ( return (
<AppShellProviders> <AppShellProviders>
{props.appChildren} <Suspense fallback={<Loading />}>
{props.children} {props.appChildren}
{props.children}
</Suspense>
</AppShellProviders> </AppShellProviders>
) )
} }
@@ -159,7 +154,7 @@ const effectMinDuration =
<A, E, R>(e: Effect.Effect<A, E, R>) => <A, E, R>(e: Effect.Effect<A, E, R>) =>
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0])) Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
function ConnectionGate(props: ParentProps) { function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
const server = useServer() const server = useServer()
const checkServerHealth = useCheckServerHealth() const checkServerHealth = useCheckServerHealth()
@@ -168,21 +163,23 @@ function ConnectionGate(props: ParentProps) {
// performs repeated health check with a grace period for // performs repeated health check with a grace period for
// non-http connections, otherwise fails instantly // non-http connections, otherwise fails instantly
const [startupHealthCheck, healthCheckActions] = createResource(() => const [startupHealthCheck, healthCheckActions] = createResource(() =>
Effect.gen(function* () { props.disableHealthCheck
if (!server.current) return true ? true
const { http, type } = server.current : Effect.gen(function* () {
if (!server.current) return true
const { http, type } = server.current
while (true) { while (true) {
const res = yield* Effect.promise(() => checkServerHealth(http)) const res = yield* Effect.promise(() => checkServerHealth(http))
if (res.healthy) return true if (res.healthy) return true
if (checkMode() === "background" || type === "http") return false if (checkMode() === "background" || type === "http") return false
} }
}).pipe( }).pipe(
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0), effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }), Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))), Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise, Effect.runPromise,
), ),
) )
return ( return (
@@ -216,8 +213,12 @@ function ConnectionGate(props: ParentProps) {
} }
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) { function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
const language = useLanguage()
const server = useServer() const server = useServer()
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key) const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
const name = createMemo(() => server.name || server.key)
const serverToken = "\u0000server\u0000"
const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
const timer = setInterval(() => props.onRetry?.(), 1000) const timer = setInterval(() => props.onRetry?.(), 1000)
onCleanup(() => clearInterval(timer)) onCleanup(() => clearInterval(timer))
@@ -227,13 +228,15 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
<div class="flex flex-col items-center max-w-md text-center"> <div class="flex flex-col items-center max-w-md text-center">
<Splash class="w-12 h-15 mb-4" /> <Splash class="w-12 h-15 mb-4" />
<p class="text-14-regular text-text-base"> <p class="text-14-regular text-text-base">
Could not reach <span class="text-text-strong font-medium">{server.name || server.key}</span> {unreachable()[0]}
<span class="text-text-strong font-medium">{name()}</span>
{unreachable()[1]}
</p> </p>
<p class="mt-1 text-12-regular text-text-weak">Retrying automatically...</p> <p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
</div> </div>
<Show when={others().length > 0}> <Show when={others().length > 0}>
<div class="flex flex-col gap-2 w-full max-w-sm"> <div class="flex flex-col gap-2 w-full max-w-sm">
<span class="text-12-regular text-text-base text-center">Other servers</span> <span class="text-12-regular text-text-base text-center">{language.t("app.server.otherServers")}</span>
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2"> <div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
<For each={others()}> <For each={others()}>
{(conn) => { {(conn) => {
@@ -256,29 +259,41 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
) )
} }
function ServerKey(props: ParentProps) {
const server = useServer()
return (
<Show when={server.key} keyed>
{props.children}
</Show>
)
}
export function AppInterface(props: { export function AppInterface(props: {
children?: JSX.Element children?: JSX.Element
defaultServer: ServerConnection.Key defaultServer: ServerConnection.Key
servers?: Array<ServerConnection.Any> servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps> router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
}) { }) {
return ( return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}> <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ConnectionGate> <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<GlobalSDKProvider> <ServerKey>
<GlobalSyncProvider> <GlobalSDKProvider>
<Dynamic <GlobalSyncProvider>
component={props.router ?? Router} <Dynamic
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>} component={props.router ?? Router}
> root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
<Route path="/" component={HomeRoute} /> >
<Route path="/:dir" component={DirectoryLayout}> <Route path="/" component={HomeRoute} />
<Route path="/" component={SessionIndexRoute} /> <Route path="/:dir" component={DirectoryLayout}>
<Route path="/session/:id?" component={SessionRoute} /> <Route path="/" component={SessionIndexRoute} />
</Route> <Route path="/session/:id?" component={SessionRoute} />
</Dynamic> </Route>
</GlobalSyncProvider> </Dynamic>
</GlobalSDKProvider> </GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
</ConnectionGate> </ConnectionGate>
</ServerProvider> </ServerProvider>
) )
+38 -32
View File
@@ -2,6 +2,7 @@ import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount } from "solid-js" import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/context/language"
type Mem = Performance & { type Mem = Performance & {
memory?: { memory?: {
@@ -27,17 +28,17 @@ type Obs = PerformanceObserverInit & {
const span = 5000 const span = 5000
const ms = (n?: number, d = 0) => { const ms = (n?: number, d = 0) => {
if (n === undefined || Number.isNaN(n)) return "n/a" if (n === undefined || Number.isNaN(n)) return
return `${n.toFixed(d)}ms` return `${n.toFixed(d)}ms`
} }
const time = (n?: number) => { const time = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return "n/a" if (n === undefined || Number.isNaN(n)) return
return `${Math.round(n)}` return `${Math.round(n)}`
} }
const mb = (n?: number) => { const mb = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return "n/a" if (n === undefined || Number.isNaN(n)) return
const v = n / 1024 / 1024 const v = n / 1024 / 1024
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB` return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
} }
@@ -74,6 +75,7 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string;
} }
export function DebugBar() { export function DebugBar() {
const language = useLanguage()
const location = useLocation() const location = useLocation()
const routing = useIsRouting() const routing = useIsRouting()
const [state, setState] = createStore({ const [state, setState] = createStore({
@@ -98,14 +100,15 @@ export function DebugBar() {
}, },
}) })
const na = () => language.t("debugBar.na")
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined) const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
const heapv = () => { const heapv = () => {
const value = heap() const value = heap()
if (value === undefined) return "n/a" if (value === undefined) return na()
return `${Math.round(value * 100)}%` return `${Math.round(value * 100)}%`
} }
const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`) const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
const navv = () => (state.nav.pending ? "..." : time(state.nav.dur)) const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
let prev = "" let prev = ""
let start = 0 let start = 0
@@ -359,7 +362,7 @@ export function DebugBar() {
return ( return (
<aside <aside
aria-label="Development performance diagnostics" aria-label={language.t("debugBar.ariaLabel")}
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]" class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
style={{ style={{
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)", "background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
@@ -368,67 +371,70 @@ export function DebugBar() {
> >
<div class="grid grid-cols-5 gap-px font-mono"> <div class="grid grid-cols-5 gap-px font-mono">
<Cell <Cell
label="NAV" label={language.t("debugBar.nav.label")}
tip="Last completed route transition touching a session page, measured from router start until the first paint after it settles." tip={language.t("debugBar.nav.tip")}
value={navv()} value={navv()}
bad={bad(state.nav.dur, 400)} bad={bad(state.nav.dur, 400)}
dim={state.nav.dur === undefined && !state.nav.pending} dim={state.nav.dur === undefined && !state.nav.pending}
/> />
<Cell <Cell
label="FPS" label={language.t("debugBar.fps.label")}
tip="Rolling frames per second over the last 5 seconds." tip={language.t("debugBar.fps.tip")}
value={state.fps === undefined ? "n/a" : `${Math.round(state.fps)}`} value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
bad={bad(state.fps, 50, true)} bad={bad(state.fps, 50, true)}
dim={state.fps === undefined} dim={state.fps === undefined}
/> />
<Cell <Cell
label="FRAME" label={language.t("debugBar.frame.label")}
tip="Worst frame time over the last 5 seconds." tip={language.t("debugBar.frame.tip")}
value={time(state.gap)} value={time(state.gap) ?? na()}
bad={bad(state.gap, 50)} bad={bad(state.gap, 50)}
dim={state.gap === undefined} dim={state.gap === undefined}
/> />
<Cell <Cell
label="JANK" label={language.t("debugBar.jank.label")}
tip="Frames over 32ms in the last 5 seconds." tip={language.t("debugBar.jank.tip")}
value={state.jank === undefined ? "n/a" : `${state.jank}`} value={state.jank === undefined ? na() : `${state.jank}`}
bad={bad(state.jank, 8)} bad={bad(state.jank, 8)}
dim={state.jank === undefined} dim={state.jank === undefined}
/> />
<Cell <Cell
label="LONG" label={language.t("debugBar.long.label")}
tip={`Blocked time and long-task count in the last 5 seconds. Max task: ${ms(state.long.max)}.`} tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
value={longv()} value={longv()}
bad={bad(state.long.block, 200)} bad={bad(state.long.block, 200)}
dim={state.long.count === undefined} dim={state.long.count === undefined}
/> />
<Cell <Cell
label="DELAY" label={language.t("debugBar.delay.label")}
tip="Worst observed input delay in the last 5 seconds." tip={language.t("debugBar.delay.tip")}
value={time(state.delay)} value={time(state.delay) ?? na()}
bad={bad(state.delay, 100)} bad={bad(state.delay, 100)}
dim={state.delay === undefined} dim={state.delay === undefined}
/> />
<Cell <Cell
label="INP" label={language.t("debugBar.inp.label")}
tip="Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP." tip={language.t("debugBar.inp.tip")}
value={time(state.inp)} value={time(state.inp) ?? na()}
bad={bad(state.inp, 200)} bad={bad(state.inp, 200)}
dim={state.inp === undefined} dim={state.inp === undefined}
/> />
<Cell <Cell
label="CLS" label={language.t("debugBar.cls.label")}
tip="Cumulative layout shift for the current app lifetime." tip={language.t("debugBar.cls.tip")}
value={state.cls === undefined ? "n/a" : state.cls.toFixed(2)} value={state.cls === undefined ? na() : state.cls.toFixed(2)}
bad={bad(state.cls, 0.1)} bad={bad(state.cls, 0.1)}
dim={state.cls === undefined} dim={state.cls === undefined}
/> />
<Cell <Cell
label="MEM" label={language.t("debugBar.mem.label")}
tip={ tip={
state.heap.used === undefined state.heap.used === undefined
? "Used JS heap vs heap limit. Chromium only." ? language.t("debugBar.mem.tipUnavailable")
: `Used JS heap vs heap limit. ${mb(state.heap.used)} of ${mb(state.heap.limit)}.` : language.t("debugBar.mem.tip", {
used: mb(state.heap.used) ?? na(),
limit: mb(state.heap.limit) ?? na(),
})
} }
value={heapv()} value={heapv()}
bad={bad(heap(), 0.8)} bad={bad(heap(), 0.8)}
@@ -15,7 +15,6 @@ import { Link } from "@/components/link"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { usePlatform } from "@/context/platform"
import { DialogSelectModel } from "./dialog-select-model" import { DialogSelectModel } from "./dialog-select-model"
import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectProvider } from "./dialog-select-provider"
@@ -23,7 +22,6 @@ export function DialogConnectProvider(props: { provider: string }) {
const dialog = useDialog() const dialog = useDialog()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const alive = { value: true } const alive = { value: true }
@@ -49,13 +47,14 @@ export function DialogConnectProvider(props: { provider: string }) {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
methodIndex: undefined as undefined | number, methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization, authorization: undefined as undefined | ProviderAuthAuthorization,
state: "pending" as undefined | "pending" | "complete" | "error", state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
type Action = type Action =
| { type: "method.select"; index: number } | { type: "method.select"; index: number }
| { type: "method.reset" } | { type: "method.reset" }
| { type: "auth.prompt" }
| { type: "auth.pending" } | { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization } | { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.error"; error: string } | { type: "auth.error"; error: string }
@@ -77,6 +76,11 @@ export function DialogConnectProvider(props: { provider: string }) {
draft.error = undefined draft.error = undefined
return return
} }
if (action.type === "auth.prompt") {
draft.state = "prompt"
draft.error = undefined
return
}
if (action.type === "auth.pending") { if (action.type === "auth.pending") {
draft.state = "pending" draft.state = "pending"
draft.error = undefined draft.error = undefined
@@ -120,7 +124,7 @@ export function DialogConnectProvider(props: { provider: string }) {
return fallback return fallback
} }
async function selectMethod(index: number) { async function selectMethod(index: number, inputs?: Record<string, string>) {
if (timer.current !== undefined) { if (timer.current !== undefined) {
clearTimeout(timer.current) clearTimeout(timer.current)
timer.current = undefined timer.current = undefined
@@ -130,6 +134,10 @@ export function DialogConnectProvider(props: { provider: string }) {
dispatch({ type: "method.select", index }) dispatch({ type: "method.select", index })
if (method.type === "oauth") { if (method.type === "oauth") {
if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.pending" }) dispatch({ type: "auth.pending" })
const start = Date.now() const start = Date.now()
await globalSDK.client.provider.oauth await globalSDK.client.provider.oauth
@@ -137,6 +145,7 @@ export function DialogConnectProvider(props: { provider: string }) {
{ {
providerID: props.provider, providerID: props.provider,
method: index, method: index,
inputs,
}, },
{ throwOnError: true }, { throwOnError: true },
) )
@@ -163,6 +172,122 @@ export function DialogConnectProvider(props: { provider: string }) {
} }
} }
function OAuthPromptsView() {
const [formStore, setFormStore] = createStore({
value: {} as Record<string, string>,
index: 0,
})
const prompts = createMemo(() => method()?.prompts ?? [])
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
const actual = value[prompt.when.key]
if (actual === undefined) return false
return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
}
const current = createMemo(() => {
const all = prompts()
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
if (index === -1) return
return {
index,
prompt: all[index],
}
})
const valid = createMemo(() => {
const item = current()
if (!item || item.prompt.type !== "text") return false
const value = formStore.value[item.prompt.key] ?? ""
return value.trim().length > 0
})
async function next(index: number, value: Record<string, string>) {
if (store.methodIndex === undefined) return
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
if (next !== -1) {
setFormStore("index", next)
return
}
await selectMethod(store.methodIndex, value)
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const item = current()
if (!item || item.prompt.type !== "text") return
if (!valid()) return
await next(item.index, formStore.value)
}
const item = () => current()
const text = createMemo(() => {
const prompt = item()?.prompt
if (!prompt || prompt.type !== "text") return
return prompt
})
const select = createMemo(() => {
const prompt = item()?.prompt
if (!prompt || prompt.type !== "select") return
return prompt
})
return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<Switch>
<Match when={item()?.prompt.type === "text"}>
<TextField
type="text"
label={text()?.message ?? ""}
placeholder={text()?.placeholder}
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
onChange={(value) => {
const prompt = text()
if (!prompt) return
setFormStore("value", prompt.key, value)
}}
/>
<Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
{language.t("common.continue")}
</Button>
</Match>
<Match when={item()?.prompt.type === "select"}>
<div class="w-full flex flex-col gap-1.5">
<div class="text-14-regular text-text-base">{select()?.message}</div>
<div>
<List
items={select()?.options ?? []}
key={(x) => x.value}
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
onSelect={(value) => {
if (!value) return
const prompt = select()
if (!prompt) return
const nextValue = {
...formStore.value,
[prompt.key]: value.value,
}
setFormStore("value", prompt.key, value.value)
void next(item()!.index, nextValue)
}}
>
{(option) => (
<div class="w-full flex items-center gap-x-2">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{option.label}</span>
<span class="text-14-regular text-text-weak">{option.hint}</span>
</div>
)}
</List>
</div>
</div>
</Match>
</Switch>
</form>
)
}
let listRef: ListRef | undefined let listRef: ListRef | undefined
function handleKey(e: KeyboardEvent) { function handleKey(e: KeyboardEvent) {
if (e.key === "Enter" && e.target instanceof HTMLInputElement) { if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
@@ -301,7 +426,7 @@ export function DialogConnectProvider(props: { provider: string }) {
error={formStore.error} error={formStore.error}
/> />
<Button class="w-auto" type="submit" size="large" variant="primary"> <Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")} {language.t("common.continue")}
</Button> </Button>
</form> </form>
</div> </div>
@@ -314,12 +439,6 @@ export function DialogConnectProvider(props: { provider: string }) {
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
onMount(() => {
if (store.authorization?.method === "code" && store.authorization?.url) {
platform.openLink(store.authorization.url)
}
})
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
@@ -368,7 +487,7 @@ export function DialogConnectProvider(props: { provider: string }) {
error={formStore.error} error={formStore.error}
/> />
<Button class="w-auto" type="submit" size="large" variant="primary"> <Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")} {language.t("common.continue")}
</Button> </Button>
</form> </form>
</div> </div>
@@ -386,10 +505,6 @@ export function DialogConnectProvider(props: { provider: string }) {
onMount(() => { onMount(() => {
void (async () => { void (async () => {
if (store.authorization?.url) {
platform.openLink(store.authorization.url)
}
const result = await globalSDK.client.provider.oauth const result = await globalSDK.client.provider.oauth
.callback({ .callback({
providerID: props.provider, providerID: props.provider,
@@ -470,6 +585,9 @@ export function DialogConnectProvider(props: { provider: string }) {
</div> </div>
</div> </div>
</Match> </Match>
<Match when={store.state === "prompt"}>
<OAuthPromptsView />
</Match>
<Match when={store.state === "error"}> <Match when={store.state === "error"}>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2"> <div class="flex items-center gap-x-2">
+3 -4
View File
@@ -66,6 +66,7 @@ export const DialogFork: Component = () => {
directory: sdk.directory, directory: sdk.directory,
attachmentName: language.t("common.attachment"), attachmentName: language.t("common.attachment"),
}) })
const dir = base64Encode(sdk.directory)
sdk.client.session sdk.client.session
.fork({ sessionID, messageID: item.id }) .fork({ sessionID, messageID: item.id })
@@ -75,10 +76,8 @@ export const DialogFork: Component = () => {
return return
} }
dialog.close() dialog.close()
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`) prompt.set(restored, undefined, { dir, id: forked.data.id })
requestAnimationFrame(() => { navigate(`/${dir}/session/${forked.data.id}`)
prompt.set(restored)
})
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
@@ -426,7 +426,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</Show> </Show>
</div> </div>
<Show when={item.keybind}> <Show when={item.keybind}>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind> <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
</Show> </Show>
</div> </div>
</Match> </Match>
@@ -13,8 +13,10 @@ import { DialogSelectProvider } from "./dialog-select-provider"
import { ModelTooltip } from "./model-tooltip" import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
export const DialogSelectModelUnpaid: Component = () => { type ModelState = ReturnType<typeof useLocal>["model"]
const local = useLocal()
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
const model = props.model ?? useLocal().model
const dialog = useDialog() const dialog = useDialog()
const providers = useProviders() const providers = useProviders()
const language = useLanguage() const language = useLanguage()
@@ -35,8 +37,8 @@ export const DialogSelectModelUnpaid: Component = () => {
<List <List
class="[&_[data-slot=list-scroll]]:overflow-visible" class="[&_[data-slot=list-scroll]]:overflow-visible"
ref={(ref) => (listRef = ref)} ref={(ref) => (listRef = ref)}
items={local.model.list} items={model.list}
current={local.model.current()} current={model.current()}
key={(x) => `${x.provider.id}:${x.id}`} key={(x) => `${x.provider.id}:${x.id}`}
itemWrapper={(item, node) => ( itemWrapper={(item, node) => (
<Tooltip <Tooltip
@@ -55,7 +57,7 @@ export const DialogSelectModelUnpaid: Component = () => {
</Tooltip> </Tooltip>
)} )}
onSelect={(x) => { onSelect={(x) => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true, recent: true,
}) })
dialog.close() dialog.close()
@@ -18,19 +18,22 @@ import { useLanguage } from "@/context/language"
const isFree = (provider: string, cost: { input: number } | undefined) => const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0) provider === "opencode" && (!cost || cost.input === 0)
type ModelState = ReturnType<typeof useLocal>["model"]
const ModelList: Component<{ const ModelList: Component<{
provider?: string provider?: string
class?: string class?: string
onSelect: () => void onSelect: () => void
action?: JSX.Element action?: JSX.Element
model?: ModelState
}> = (props) => { }> = (props) => {
const local = useLocal() const model = props.model ?? useLocal().model
const language = useLanguage() const language = useLanguage()
const models = createMemo(() => const models = createMemo(() =>
local.model model
.list() .list()
.filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id })) .filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id }))
.filter((m) => (props.provider ? m.provider.id === props.provider : true)), .filter((m) => (props.provider ? m.provider.id === props.provider : true)),
) )
@@ -41,7 +44,7 @@ const ModelList: Component<{
emptyMessage={language.t("dialog.model.empty")} emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x.provider.id}:${x.id}`} key={(x) => `${x.provider.id}:${x.id}`}
items={models} items={models}
current={local.model.current()} current={model.current()}
filterKeys={["provider.name", "name", "id"]} filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)} sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name} groupBy={(x) => x.provider.name}
@@ -63,7 +66,7 @@ const ModelList: Component<{
</Tooltip> </Tooltip>
)} )}
onSelect={(x) => { onSelect={(x) => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true, recent: true,
}) })
props.onSelect() props.onSelect()
@@ -88,6 +91,7 @@ type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "a
export function ModelSelectorPopover(props: { export function ModelSelectorPopover(props: {
provider?: string provider?: string
model?: ModelState
children?: JSX.Element children?: JSX.Element
triggerAs?: ValidComponent triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps triggerProps?: ModelSelectorTriggerProps
@@ -151,6 +155,7 @@ export function ModelSelectorPopover(props: {
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title> <Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
<ModelList <ModelList
provider={props.provider} provider={props.provider}
model={props.model}
onSelect={() => setStore("open", false)} onSelect={() => setStore("open", false)}
class="p-1" class="p-1"
action={ action={
@@ -184,7 +189,7 @@ export function ModelSelectorPopover(props: {
) )
} }
export const DialogSelectModel: Component<{ provider?: string }> = (props) => { export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
@@ -202,7 +207,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
</Button> </Button>
} }
> >
<ModelList provider={props.provider} onSelect={() => dialog.close()} /> <ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
<Button <Button
variant="ghost" variant="ghost"
class="ml-3 mt-5 mb-6 text-text-base self-start" class="ml-3 mt-5 mb-6 text-text-base self-start"
@@ -121,7 +121,7 @@ function ServerForm(props: ServerFormProps) {
return ( return (
<div class="px-5"> <div class="px-5">
<div class="bg-surface-raised-base rounded-md p-5 flex flex-col gap-3"> <div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative"> <div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<TextField <TextField
type="text" type="text"
@@ -149,7 +149,7 @@ function ServerForm(props: ServerFormProps) {
<TextField <TextField
type="text" type="text"
label={language.t("dialog.server.add.username")} label={language.t("dialog.server.add.username")}
placeholder="username" placeholder={language.t("dialog.server.add.usernamePlaceholder")}
value={props.username} value={props.username}
disabled={props.busy} disabled={props.busy}
onChange={props.onUsernameChange} onChange={props.onUsernameChange}
@@ -158,7 +158,7 @@ function ServerForm(props: ServerFormProps) {
<TextField <TextField
type="password" type="password"
label={language.t("dialog.server.add.password")} label={language.t("dialog.server.add.password")}
placeholder="password" placeholder={language.t("dialog.server.add.passwordPlaceholder")}
value={props.password} value={props.password}
disabled={props.busy} disabled={props.busy}
onChange={props.onPasswordChange} onChange={props.onPasswordChange}
@@ -291,8 +291,8 @@ export function DialogSelectServer() {
navigate("/") navigate("/")
return return
} }
server.setActive(ServerConnection.key(conn))
navigate("/") navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
} }
const handleAddChange = (value: string) => { const handleAddChange = (value: string) => {
@@ -542,7 +542,7 @@ export function DialogSelectServer() {
if (x) select(x) if (x) select(x)
}} }}
divider={true} divider={true}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent" class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
> >
{(i) => { {(i) => {
const key = ServerConnection.key(i) const key = ServerConnection.key(i)
+209 -157
View File
@@ -1,8 +1,7 @@
import { useFilteredList } from "@opencode-ai/ui/hooks" import { useFilteredList } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js" import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
import { import {
@@ -37,8 +36,10 @@ import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { promptEnabled, promptProbe } from "@/testing/prompt"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" import { createPromptAttachments } from "./prompt-input/attachments"
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
import { import {
canNavigateHistoryAtCursor, canNavigateHistoryAtCursor,
navigatePromptHistory, navigatePromptHistory,
@@ -120,7 +121,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let slashPopoverRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement
const mirror = { input: false } const mirror = { input: false }
const inset = 44 const inset = 56
const space = `${inset}px`
const scrollCursorIntoView = () => { const scrollCursorIntoView = () => {
const container = scrollRef const container = scrollRef
@@ -155,8 +157,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const queueScroll = () => { const queueScroll = (count = 2) => {
requestAnimationFrame(scrollCursorIntoView) requestAnimationFrame(() => {
scrollCursorIntoView()
if (count > 1) queueScroll(count - 1)
})
} }
const activeFileTab = createSessionTabs({ const activeFileTab = createSessionTabs({
@@ -239,6 +244,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}, },
) )
const working = createMemo(() => status()?.type !== "idle") const working = createMemo(() => status()?.type !== "idle")
const tip = () => {
if (working()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
)
}
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
)
}
const imageAttachments = createMemo(() => const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
) )
@@ -406,7 +428,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const isFocused = createFocusSignal(() => editorRef)
const escBlur = () => platform.platform === "desktop" && platform.os === "macos" const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
const pick = () => fileInputRef?.click() const pick = () => fileInputRef?.click()
@@ -601,6 +622,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleSlashSelect = (cmd: SlashCommand | undefined) => { const handleSlashSelect = (cmd: SlashCommand | undefined) => {
if (!cmd) return if (!cmd) return
promptProbe.select(cmd.id)
closePopover() closePopover()
if (cmd.type === "custom") { if (cmd.type === "custom") {
@@ -689,6 +711,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
}) })
if (promptEnabled()) {
createEffect(() => {
promptProbe.set({
popover: store.popover,
slash: {
active: slashActive() ?? null,
ids: slashFlat().map((cmd) => cmd.id),
},
})
})
onCleanup(() => promptProbe.clear())
}
const selectPopoverActive = () => { const selectPopoverActive = () => {
if (store.popover === "at") { if (store.popover === "at") {
const items = atFlat() const items = atFlat()
@@ -1007,9 +1043,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true return true
} }
const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({ const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
editor: () => editorRef, editor: () => editorRef,
isFocused,
isDialogActive: () => !!dialog.active, isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type), setDraggingType: (type) => setStore("draggingType", type),
focusEditor: () => { focusEditor: () => {
@@ -1026,6 +1061,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
return permission.isAutoAccepting(id, sdk.directory) return permission.isAutoAccepting(id, sdk.directory)
}) })
const acceptLabel = createMemo(() =>
language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"),
)
const toggleAccept = () => {
if (!params.id) {
permission.toggleAutoAcceptDirectory(sdk.directory)
return
}
permission.toggleAutoAccept(params.id, sdk.directory)
}
const { abort, handleSubmit } = createPromptSubmit({ const { abort, handleSubmit } = createPromptSubmit({
info, info,
@@ -1195,6 +1241,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
// Note: Shift+Enter is handled earlier, before IME check // Note: Shift+Enter is handled earlier, before IME check
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
if (event.repeat) return
if (
working() &&
prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
.trim().length === 0 &&
imageAttachments().length === 0 &&
commentCount() === 0
) {
return
}
handleSubmit(event) handleSubmit(event)
} }
} }
@@ -1247,7 +1307,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onOpen={(attachment) => onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />) dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
} }
onRemove={removeImageAttachment} onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")} removeLabel={language.t("prompt.attachment.remove")}
/> />
<div <div
@@ -1265,7 +1325,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef?.focus() editorRef?.focus()
}} }}
> >
<div class="relative max-h-[240px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}> <div
class="relative max-h-[240px] overflow-y-auto no-scrollbar"
ref={(el) => (scrollRef = el)}
style={{ "scroll-padding-bottom": space }}
>
<div <div
data-component="prompt-input" data-component="prompt-input"
ref={(el) => { ref={(el) => {
@@ -1287,22 +1351,34 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
classList={{ classList={{
"select-text": true, "select-text": true,
"w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true, "[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true, "[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell", "font-mono!": store.mode === "shell",
}} }}
style={{ "padding-bottom": space }}
/> />
<Show when={!prompt.dirty()}> <Show when={!prompt.dirty()}>
<div <div
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate" class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
classList={{ "font-mono!": store.mode === "shell" }} classList={{ "font-mono!": store.mode === "shell" }}
style={{ "padding-bottom": space }}
> >
{placeholder()} {placeholder()}
</div> </div>
</Show> </Show>
</div> </div>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 bottom-0"
style={{
height: space,
background:
"linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
}}
/>
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2"> <div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
<input <input
ref={fileInputRef} ref={fileInputRef}
@@ -1311,14 +1387,32 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="hidden" class="hidden"
onChange={(e) => { onChange={(e) => {
const file = e.currentTarget.files?.[0] const file = e.currentTarget.files?.[0]
if (file) addImageAttachment(file) if (file) void addAttachment(file)
e.currentTarget.value = "" e.currentTarget.value = ""
}} }}
/> />
<div class="flex items-center gap-1 pointer-events-auto">
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={buttons()}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</div>
<div class="pointer-events-none absolute bottom-2 left-2">
<div <div
aria-hidden={store.mode !== "normal"} aria-hidden={store.mode !== "normal"}
class="flex items-center gap-1" class="pointer-events-auto"
style={{ style={{
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}} }}
@@ -1342,81 +1436,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Icon name="plus" class="size-4.5" /> <Icon name="plus" class="size-4.5" />
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
value={
<Switch>
<Match when={working()}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
</Match>
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
</Match>
</Switch>
}
>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={buttons()}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</div>
<div class="pointer-events-none absolute bottom-2 left-2">
<div class="pointer-events-auto">
<TooltipKeybind
placement="top"
gutter={8}
title={language.t(
accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable",
)}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
data-action="prompt-permissions"
variant="ghost"
onClick={() => {
if (!params.id) {
permission.toggleAutoAcceptDirectory(sdk.directory)
return
}
permission.toggleAutoAccept(params.id, sdk.directory)
}}
classList={{
"size-6 flex items-center justify-center": true,
"text-text-base": !accepting(),
"hover:bg-surface-success-base": accepting(),
}}
aria-label={
accepting()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable")
}
aria-pressed={accepting()}
>
<Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": accepting() }}
/>
</Button>
</TooltipKeybind>
</div> </div>
</div> </div>
</div> </div>
@@ -1436,39 +1455,76 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="size-4 shrink-0" /> <div class="size-4 shrink-0" />
</div> </div>
<div class="flex items-center gap-1.5 min-w-0 flex-1"> <div class="flex items-center gap-1.5 min-w-0 flex-1">
<TooltipKeybind <div data-component="prompt-agent-control">
placement="top" <TooltipKeybind
gutter={4} placement="top"
title={language.t("command.agent.cycle")} gutter={4}
keybind={command.keybind("agent.cycle")} title={language.t("command.agent.cycle")}
> keybind={command.keybind("agent.cycle")}
<Select >
size="normal" <Select
options={agentNames()} size="normal"
current={local.agent.current()?.name ?? ""} options={agentNames()}
onSelect={local.agent.set} current={local.agent.current()?.name ?? ""}
class="capitalize max-w-[160px]" onSelect={local.agent.set}
valueClass="truncate text-13-regular" class="capitalize max-w-[160px] text-text-base"
triggerStyle={control()} valueClass="truncate text-13-regular text-text-base"
variant="ghost" triggerStyle={control()}
/> triggerProps={{ "data-action": "prompt-agent" }}
</TooltipKeybind> variant="ghost"
<Show />
when={providers.paid().length > 0} </TooltipKeybind>
fallback={ </div>
<div data-component="prompt-model-control">
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid model={local.model} />)}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
title={language.t("command.model.choose")} title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")} keybind={command.keybind("model.choose")}
> >
<Button <ModelSelectorPopover
as="div" model={local.model}
variant="ghost" triggerAs={Button}
size="normal" triggerProps={{
class="min-w-0 max-w-[320px] text-13-regular group" variant: "ghost",
style={control()} size: "normal",
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)} style: control(),
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model",
}}
> >
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon <ProviderIcon
@@ -1481,56 +1537,52 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
{local.model.current()?.name ?? language.t("dialog.model.select.title")} {local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span> </span>
<Icon name="chevron-down" size="small" class="shrink-0" /> <Icon name="chevron-down" size="small" class="shrink-0" />
</Button> </ModelSelectorPopover>
</TooltipKeybind> </TooltipKeybind>
} </Show>
> </div>
<div data-component="prompt-variant-control">
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
title={language.t("command.model.choose")} title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.choose")} keybind={command.keybind("model.variant.cycle")}
> >
<ModelSelectorPopover <Select
triggerAs={Button} size="normal"
triggerProps={{ options={variants()}
variant: "ghost", current={local.model.variant.current() ?? "default"}
size: "normal", label={(x) => (x === "default" ? language.t("common.default") : x)}
style: control(), onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class: "min-w-0 max-w-[320px] text-13-regular group", class="capitalize max-w-[160px] text-text-base"
}} valueClass="truncate text-13-regular text-text-base"
> triggerStyle={control()}
<Show when={local.model.current()?.provider?.id}> triggerProps={{ "data-action": "prompt-model-variant" }}
<ProviderIcon variant="ghost"
id={local.model.current()!.provider.id} />
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind> </TooltipKeybind>
</Show> </div>
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={8}
title={language.t("command.model.variant.cycle")} title={acceptLabel()}
keybind={command.keybind("model.variant.cycle")} keybind={command.keybind("permissions.autoaccept")}
> >
<Select <Button
size="normal" data-action="prompt-permissions"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={control()}
variant="ghost" variant="ghost"
/> onClick={toggleAccept}
classList={{
"h-7 w-7 p-0 shrink-0 flex items-center justify-center": true,
"text-text-base": !accepting(),
"hover:bg-surface-success-base": accepting(),
}}
style={control()}
aria-label={acceptLabel()}
aria-pressed={accepting()}
>
<Icon name="shield" size="small" classList={{ "text-icon-success-base": accepting() }} />
</Button>
</TooltipKeybind> </TooltipKeybind>
</div> </div>
</div> </div>
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test"
import { attachmentMime } from "./files"
import { pasteMode } from "./paste"
describe("attachmentMime", () => {
test("keeps PDFs when the browser reports the mime", async () => {
const file = new File(["%PDF-1.7"], "guide.pdf", { type: "application/pdf" })
expect(await attachmentMime(file)).toBe("application/pdf")
})
test("normalizes structured text types to text/plain", async () => {
const file = new File(['{"ok":true}\n'], "data.json", { type: "application/json" })
expect(await attachmentMime(file)).toBe("text/plain")
})
test("accepts text files even with a misleading browser mime", async () => {
const file = new File(["export const x = 1\n"], "main.ts", { type: "video/mp2t" })
expect(await attachmentMime(file)).toBe("text/plain")
})
test("rejects binary files", async () => {
const file = new File([Uint8Array.of(0, 255, 1, 2)], "blob.bin", { type: "application/octet-stream" })
expect(await attachmentMime(file)).toBeUndefined()
})
})
describe("pasteMode", () => {
test("uses native paste for short single-line text", () => {
expect(pasteMode("hello world")).toBe("native")
})
test("uses manual paste for multiline text", () => {
expect(
pasteMode(`{
"ok": true
}`),
).toBe("manual")
expect(pasteMode("a\r\nb")).toBe("manual")
})
test("uses manual paste for large text", () => {
expect(pasteMode("x".repeat(8000))).toBe("manual")
})
})
@@ -4,26 +4,28 @@ import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom" import { getCursorPosition } from "./editor-dom"
import { attachmentMime } from "./files"
import { normalizePaste, pasteMode } from "./paste"
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] function dataUrl(file: File, mime: string) {
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] return new Promise<string>((resolve) => {
const LARGE_PASTE_CHARS = 8000 const reader = new FileReader()
const LARGE_PASTE_BREAKS = 120 reader.addEventListener("error", () => resolve(""))
reader.addEventListener("load", () => {
function largePaste(text: string) { const value = typeof reader.result === "string" ? reader.result : ""
if (text.length >= LARGE_PASTE_CHARS) return true const idx = value.indexOf(",")
let breaks = 0 if (idx === -1) {
for (const char of text) { resolve(value)
if (char !== "\n") continue return
breaks += 1 }
if (breaks >= LARGE_PASTE_BREAKS) return true resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
} })
return false reader.readAsDataURL(file)
})
} }
type PromptAttachmentsInput = { type PromptAttachmentsInput = {
editor: () => HTMLDivElement | undefined editor: () => HTMLDivElement | undefined
isFocused: () => boolean
isDialogActive: () => boolean isDialogActive: () => boolean
setDraggingType: (type: "image" | "@mention" | null) => void setDraggingType: (type: "image" | "@mention" | null) => void
focusEditor: () => void focusEditor: () => void
@@ -35,35 +37,47 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const prompt = usePrompt() const prompt = usePrompt()
const language = useLanguage() const language = useLanguage()
const addImageAttachment = async (file: File) => { const warn = () => {
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
const reader = new FileReader() description: language.t("prompt.toast.pasteUnsupported.description"),
reader.onload = () => { })
const editor = input.editor()
if (!editor) return
const dataUrl = reader.result as string
const attachment: ImageAttachmentPart = {
type: "image",
id: uuid(),
filename: file.name,
mime: file.type,
dataUrl,
}
const cursorPosition = prompt.cursor() ?? getCursorPosition(editor)
prompt.set([...prompt.current(), attachment], cursorPosition)
}
reader.readAsDataURL(file)
} }
const removeImageAttachment = (id: string) => { const add = async (file: File, toast = true) => {
const mime = await attachmentMime(file)
if (!mime) {
if (toast) warn()
return false
}
const editor = input.editor()
if (!editor) return false
const url = await dataUrl(file, mime)
if (!url) return false
const attachment: ImageAttachmentPart = {
type: "image",
id: uuid(),
filename: file.name,
mime,
dataUrl: url,
}
const cursor = prompt.cursor() ?? getCursorPosition(editor)
prompt.set([...prompt.current(), attachment], cursor)
return true
}
const addAttachment = (file: File) => add(file)
const removeAttachment = (id: string) => {
const current = prompt.current() const current = prompt.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id) const next = current.filter((part) => part.type !== "image" || part.id !== id)
prompt.set(next, prompt.cursor()) prompt.set(next, prompt.cursor())
} }
const handlePaste = async (event: ClipboardEvent) => { const handlePaste = async (event: ClipboardEvent) => {
if (!input.isFocused()) return
const clipboardData = event.clipboardData const clipboardData = event.clipboardData
if (!clipboardData) return if (!clipboardData) return
@@ -72,21 +86,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const items = Array.from(clipboardData.items) const items = Array.from(clipboardData.items)
const fileItems = items.filter((item) => item.kind === "file") const fileItems = items.filter((item) => item.kind === "file")
const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
if (imageItems.length > 0) {
for (const item of imageItems) {
const file = item.getAsFile()
if (file) await addImageAttachment(file)
}
return
}
if (fileItems.length > 0) { if (fileItems.length > 0) {
showToast({ let found = false
title: language.t("prompt.toast.pasteUnsupported.title"), for (const item of fileItems) {
description: language.t("prompt.toast.pasteUnsupported.description"), const file = item.getAsFile()
}) if (!file) continue
const ok = await add(file, false)
if (ok) found = true
}
if (!found) warn()
return return
} }
@@ -96,23 +105,30 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
if (input.readClipboardImage && !plainText) { if (input.readClipboardImage && !plainText) {
const file = await input.readClipboardImage() const file = await input.readClipboardImage()
if (file) { if (file) {
await addImageAttachment(file) await addAttachment(file)
return return
} }
} }
if (!plainText) return if (!plainText) return
if (largePaste(plainText)) { const text = normalizePaste(plainText)
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
const put = () => {
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
input.focusEditor() input.focusEditor()
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return return input.addPart({ type: "text", content: text, start: 0, end: 0 })
} }
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText) if (pasteMode(text) === "manual") {
put()
return
}
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text)
if (inserted) return if (inserted) return
input.addPart({ type: "text", content: plainText, start: 0, end: 0 }) put()
} }
const handleGlobalDragOver = (event: DragEvent) => { const handleGlobalDragOver = (event: DragEvent) => {
@@ -153,11 +169,12 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dropped = event.dataTransfer?.files const dropped = event.dataTransfer?.files
if (!dropped) return if (!dropped) return
let found = false
for (const file of Array.from(dropped)) { for (const file of Array.from(dropped)) {
if (ACCEPTED_FILE_TYPES.includes(file.type)) { const ok = await add(file, false)
await addImageAttachment(file) if (ok) found = true
}
} }
if (!found && dropped.length > 0) warn()
} }
onMount(() => { onMount(() => {
@@ -173,8 +190,8 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
}) })
return { return {
addImageAttachment, addAttachment,
removeImageAttachment, removeAttachment,
handlePaste, handlePaste,
} }
} }
@@ -0,0 +1,119 @@
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([
["gif", "image/gif"],
["jpeg", "image/jpeg"],
["jpg", "image/jpeg"],
["png", "image/png"],
["webp", "image/webp"],
])
const TEXT_MIMES = new Set([
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
])
export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
const SAMPLE = 4096
function kind(type: string) {
return type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
}
function ext(name: string) {
const idx = name.lastIndexOf(".")
if (idx === -1) return ""
return name.slice(idx + 1).toLowerCase()
}
function textMime(type: string) {
if (!type) return false
if (type.startsWith("text/")) return true
if (TEXT_MIMES.has(type)) return true
if (type.endsWith("+json")) return true
return type.endsWith("+xml")
}
function textBytes(bytes: Uint8Array) {
if (bytes.length === 0) return true
let count = 0
for (const byte of bytes) {
if (byte === 0) return false
if (byte < 9 || (byte > 13 && byte < 32)) count += 1
}
return count / bytes.length <= 0.3
}
export async function attachmentMime(file: File) {
const type = kind(file.type)
if (IMAGE_MIMES.has(type)) return type
if (type === "application/pdf") return type
const suffix = ext(file.name)
const fallback = IMAGE_EXTS.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined)
if ((!type || type === "application/octet-stream") && fallback) return fallback
if (textMime(type)) return "text/plain"
const bytes = new Uint8Array(await file.slice(0, SAMPLE).arrayBuffer())
if (!textBytes(bytes)) return
return "text/plain"
}
@@ -0,0 +1,24 @@
const LARGE_PASTE_CHARS = 8000
const LARGE_PASTE_BREAKS = 120
function largePaste(text: string) {
if (text.length >= LARGE_PASTE_CHARS) return true
let breaks = 0
for (const char of text) {
if (char !== "\n") continue
breaks += 1
if (breaks >= LARGE_PASTE_BREAKS) return true
}
return false
}
export function normalizePaste(text: string) {
if (!text.includes("\r")) return text
return text.replace(/\r\n?/g, "\n")
}
export function pasteMode(text: string) {
if (largePaste(text)) return "manual"
if (text.includes("\n") || text.includes("\r")) return "manual"
return "native"
}
@@ -7,12 +7,17 @@ const createdClients: string[] = []
const createdSessions: string[] = [] const createdSessions: string[] = []
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = [] const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
const optimistic: Array<{ const optimistic: Array<{
directory?: string
sessionID?: string
message: { message: {
agent: string agent: string
model: { providerID: string; modelID: string } model: { providerID: string; modelID: string }
variant?: string variant?: string
} }
}> = [] }> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: string[] = [] const sentShell: string[] = []
const syncedDirectories: string[] = [] const syncedDirectories: string[] = []
@@ -28,7 +33,12 @@ const clientFor = (directory: string) => {
session: { session: {
create: async () => { create: async () => {
createdSessions.push(directory) createdSessions.push(directory)
return { data: { id: `session-${createdSessions.length}` } } return {
data: {
id: `session-${createdSessions.length}`,
title: `New session ${createdSessions.length}`,
},
}
}, },
shell: async () => { shell: async () => {
sentShell.push(directory) sentShell.push(directory)
@@ -77,6 +87,11 @@ beforeAll(async () => {
agent: { agent: {
current: () => ({ name: "agent" }), current: () => ({ name: "agent" }),
}, },
session: {
promote(directory: string, sessionID: string) {
promoted.push({ directory, sessionID })
},
},
}), }),
})) }))
@@ -129,9 +144,16 @@ beforeAll(async () => {
session: { session: {
optimistic: { optimistic: {
add: (value: { add: (value: {
directory?: string
sessionID?: string
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string } message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
}) => { }) => {
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,
}, },
@@ -144,7 +166,21 @@ beforeAll(async () => {
useGlobalSync: () => ({ useGlobalSync: () => ({
child: (directory: string) => { child: (directory: string) => {
syncedDirectories.push(directory) syncedDirectories.push(directory)
return [{}, () => undefined] storedSessions[directory] ??= []
return [
{ session: storedSessions[directory] },
(...args: unknown[]) => {
if (args[0] !== "session") return
const next = args[1]
if (typeof next === "function") {
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
return
}
if (Array.isArray(next)) {
storedSessions[directory] = next as Array<{ id: string; title?: string }>
}
},
]
}, },
}), }),
})) }))
@@ -170,11 +206,14 @@ beforeEach(() => {
createdSessions.length = 0 createdSessions.length = 0
enabledAutoAccept.length = 0 enabledAutoAccept.length = 0
optimistic.length = 0 optimistic.length = 0
optimisticSeeded.length = 0
promoted.length = 0
params = {} params = {}
sentShell.length = 0 sentShell.length = 0
syncedDirectories.length = 0 syncedDirectories.length = 0
selected = "/repo/worktree-a" selected = "/repo/worktree-a"
variant = undefined variant = undefined
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
}) })
describe("prompt submit worktree selection", () => { describe("prompt submit worktree selection", () => {
@@ -207,7 +246,12 @@ describe("prompt submit worktree selection", () => {
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
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("applies auto-accept to newly created sessions", async () => {
@@ -271,4 +315,32 @@ describe("prompt submit worktree selection", () => {
}, },
}) })
}) })
test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({
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)
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
expect(optimisticSeeded).toEqual([true])
})
}) })
@@ -1,6 +1,7 @@
import type { Message } from "@opencode-ai/sdk/v2/client" import type { Message, Session } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { Binary } from "@opencode-ai/util/binary"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
@@ -266,6 +267,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
} }
const seed = (dir: string, info: Session) => {
const [, setStore] = globalSync.child(dir)
setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
if (result.found) {
next[result.index] = info
return next
}
next.splice(result.index, 0, info)
return next
})
}
const handleSubmit = async (event: Event) => { const handleSubmit = async (event: Event) => {
event.preventDefault() event.preventDefault()
@@ -281,6 +296,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const currentModel = local.model.current() const currentModel = local.model.current()
const currentAgent = local.agent.current() const currentAgent = local.agent.current()
const variant = local.model.variant.current()
if (!currentModel || !currentAgent) { if (!currentModel || !currentAgent) {
showToast({ showToast({
title: language.t("prompt.toast.modelAgentRequired.title"), title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -341,7 +357,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let session = input.info() let session = input.info()
if (!session && isNewSession) { if (!session && isNewSession) {
session = await client.session const created = await client.session
.create() .create()
.then((x) => x.data ?? undefined) .then((x) => x.data ?? undefined)
.catch((err) => { .catch((err) => {
@@ -351,8 +367,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}) })
return undefined return undefined
}) })
if (session) { if (created) {
seed(sessionDirectory, created)
session = created
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
} }
@@ -370,7 +389,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
providerID: currentModel.provider.id, providerID: currentModel.provider.id,
} }
const agent = currentAgent.name const agent = currentAgent.name
const variant = local.model.variant.current()
const context = prompt.context.items().slice() const context = prompt.context.items().slice()
const draft: FollowupDraft = { const draft: FollowupDraft = {
sessionID: session.id, sessionID: session.id,
@@ -10,6 +10,7 @@ import {
type ParentProps, type ParentProps,
Show, Show,
} from "solid-js" } from "solid-js"
import { useLanguage } from "@/context/language"
import { type ServerConnection, serverName } from "@/context/server" import { type ServerConnection, serverName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health" import type { ServerHealth } from "@/utils/server-health"
@@ -25,6 +26,7 @@ interface ServerRowProps extends ParentProps {
} }
export function ServerRow(props: ServerRowProps) { export function ServerRow(props: ServerRowProps) {
const language = useLanguage()
const [truncated, setTruncated] = createSignal(false) const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined let versionRef: HTMLSpanElement | undefined
@@ -100,7 +102,7 @@ export function ServerRow(props: ServerRowProps) {
{conn().http.username ? ( {conn().http.username ? (
<span class="text-text-weak">{conn().http.username}</span> <span class="text-text-weak">{conn().http.username}</span>
) : ( ) : (
<span class="text-text-weaker">no username</span> <span class="text-text-weaker">{language.t("server.row.noUsername")}</span>
)} )}
</span> </span>
{conn().http.password && <span class="text-text-weak"></span>} {conn().http.password && <span class="text-text-weak"></span>}
@@ -16,9 +16,11 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal" import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers" import { focusTerminalById } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { StatusPopover } from "../status-popover" import { StatusPopover } from "../status-popover"
@@ -46,63 +48,63 @@ type OS = "macos" | "windows" | "linux" | "unknown"
const MAC_APPS = [ const MAC_APPS = [
{ {
id: "vscode", id: "vscode",
label: "VS Code", label: "session.header.open.app.vscode",
icon: "vscode", icon: "vscode",
openWith: "Visual Studio Code", openWith: "Visual Studio Code",
}, },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" }, { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" }, { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" }, { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
{ {
id: "antigravity", id: "antigravity",
label: "Antigravity", label: "session.header.open.app.antigravity",
icon: "antigravity", icon: "antigravity",
openWith: "Antigravity", openWith: "Antigravity",
}, },
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" }, { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" }, { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" }, { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "warp", label: "Warp", icon: "warp", openWith: "Warp" }, { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" }, { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
{ {
id: "android-studio", id: "android-studio",
label: "Android Studio", label: "session.header.open.app.androidStudio",
icon: "android-studio", icon: "android-studio",
openWith: "Android Studio", openWith: "Android Studio",
}, },
{ {
id: "sublime-text", id: "sublime-text",
label: "Sublime Text", label: "session.header.open.app.sublimeText",
icon: "sublime-text", icon: "sublime-text",
openWith: "Sublime Text", openWith: "Sublime Text",
}, },
] as const ] as const
const WINDOWS_APPS = [ const WINDOWS_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{ {
id: "powershell", id: "powershell",
label: "PowerShell", label: "session.header.open.app.powershell",
icon: "powershell", icon: "powershell",
openWith: "powershell", openWith: "powershell",
}, },
{ {
id: "sublime-text", id: "sublime-text",
label: "Sublime Text", label: "session.header.open.app.sublimeText",
icon: "sublime-text", icon: "sublime-text",
openWith: "Sublime Text", openWith: "Sublime Text",
}, },
] as const ] as const
const LINUX_APPS = [ const LINUX_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{ {
id: "sublime-text", id: "sublime-text",
label: "Sublime Text", label: "session.header.open.app.sublimeText",
icon: "sublime-text", icon: "sublime-text",
openWith: "Sublime Text", openWith: "Sublime Text",
}, },
@@ -132,6 +134,7 @@ export function SessionHeader() {
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const sync = useSync()
const terminal = useTerminal() const terminal = useTerminal()
const { params, view } = useSessionLayout() const { params, view } = useSessionLayout()
@@ -160,9 +163,9 @@ export function SessionHeader() {
}) })
const fileManager = createMemo(() => { const fileManager = createMemo(() => {
if (os() === "macos") return { label: "Finder", icon: "finder" as const } if (os() === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
if (os() === "windows") return { label: "File Explorer", icon: "file-explorer" as const } if (os() === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
return { label: "File Manager", icon: "finder" as const } return { label: "session.header.open.fileManager", icon: "finder" as const }
}) })
createEffect(() => { createEffect(() => {
@@ -187,8 +190,10 @@ export function SessionHeader() {
const options = createMemo(() => { const options = createMemo(() => {
return [ return [
{ id: "finder", label: fileManager().label, icon: fileManager().icon }, { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
...apps().filter((app) => exists[app.id]), ...apps()
.filter((app) => exists[app.id])
.map((app) => ({ ...app, label: language.t(app.label) })),
] as const ] as const
}) })
@@ -216,6 +221,9 @@ export function SessionHeader() {
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
) )
const opening = createMemo(() => openRequest.app !== undefined) const opening = createMemo(() => openRequest.app !== undefined)
const tint = createMemo(() =>
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
)
const selectApp = (app: OpenApp) => { const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return if (!options().some((item) => item.id === app)) return
@@ -266,12 +274,11 @@ export function SessionHeader() {
type="button" type="button"
variant="ghost" variant="ghost"
size="small" size="small"
class="hidden md:flex w-[240px] max-w-full min-w-0 pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default" class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
onClick={() => command.trigger("file.open")} onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")} aria-label={language.t("session.header.searchFiles")}
> >
<div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible"> <div class="flex min-w-0 flex-1 items-center overflow-visible">
<Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" />
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left"> <span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
{language.t("session.header.search.placeholder", { {language.t("session.header.search.placeholder", {
project: name(), project: name(),
@@ -318,7 +325,7 @@ export function SessionHeader() {
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden"> <div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button <Button
variant="ghost" variant="ghost"
class="rounded-none h-full py-0 pr-1.5 pl-px gap-1.5 border-none shadow-none disabled:!cursor-default" class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
classList={{ classList={{
"bg-surface-raised-base-active": opening(), "bg-surface-raised-base-active": opening(),
}} }}
@@ -328,10 +335,9 @@ export function SessionHeader() {
> >
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5"> <div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}> <Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<Spinner class="size-3.5 text-icon-base" /> <Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
</Show> </Show>
</div> </div>
<span class="text-12-regular text-text-strong">{language.t("common.open")}</span>
</Button> </Button>
<DropdownMenu <DropdownMenu
gutter={4} gutter={4}
@@ -6,6 +6,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title"
import { useTerminal, type LocalPTY } from "@/context/terminal" import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { focusTerminalById } from "@/pages/session/helpers" import { focusTerminalById } from "@/pages/session/helpers"
@@ -27,11 +28,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
const isDefaultTitle = () => { const isDefaultTitle = () => {
const number = props.terminal.titleNumber const number = props.terminal.titleNumber
if (!Number.isFinite(number) || number <= 0) return false if (!Number.isFinite(number) || number <= 0) return false
const match = props.terminal.title.match(/^Terminal (\d+)$/) return isDefaultTerminalTitle(props.terminal.title, number)
if (!match) return false
const parsed = Number(match[1])
if (!Number.isFinite(parsed) || parsed <= 0) return false
return parsed === number
} }
const label = () => { const label = () => {
@@ -12,6 +12,7 @@ import { usePlatform } from "@/context/platform"
import { useSettings, monoFontFamily } from "@/context/settings" import { useSettings, monoFontFamily } from "@/context/settings"
import { playSound, SOUND_OPTIONS } from "@/utils/sound" import { playSound, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link" import { Link } from "./link"
import { SettingsList } from "./settings-list"
let demoSoundState = { let demoSoundState = {
cleanup: undefined as (() => void) | undefined, cleanup: undefined as (() => void) | undefined,
@@ -177,7 +178,7 @@ export const SettingsGeneral: Component = () => {
const GeneralSection = () => ( const GeneralSection = () => (
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="bg-surface-raised-base px-4 rounded-lg"> <SettingsList>
<SettingsRow <SettingsRow
title={language.t("settings.general.row.language.title")} title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")} description={language.t("settings.general.row.language.description")}
@@ -248,7 +249,7 @@ export const SettingsGeneral: Component = () => {
triggerStyle={{ "min-width": "180px" }} triggerStyle={{ "min-width": "180px" }}
/> />
</SettingsRow> </SettingsRow>
</div> </SettingsList>
</div> </div>
) )
@@ -256,7 +257,7 @@ export const SettingsGeneral: Component = () => {
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3> <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg"> <SettingsList>
<SettingsRow <SettingsRow
title={language.t("settings.general.row.colorScheme.title")} title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")} description={language.t("settings.general.row.colorScheme.description")}
@@ -333,7 +334,7 @@ export const SettingsGeneral: Component = () => {
)} )}
</Select> </Select>
</SettingsRow> </SettingsRow>
</div> </SettingsList>
</div> </div>
) )
@@ -341,7 +342,7 @@ export const SettingsGeneral: Component = () => {
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3> <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg"> <SettingsList>
<SettingsRow <SettingsRow
title={language.t("settings.general.notifications.agent.title")} title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")} description={language.t("settings.general.notifications.agent.description")}
@@ -377,7 +378,7 @@ export const SettingsGeneral: Component = () => {
/> />
</div> </div>
</SettingsRow> </SettingsRow>
</div> </SettingsList>
</div> </div>
) )
@@ -385,7 +386,7 @@ export const SettingsGeneral: Component = () => {
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3> <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg"> <SettingsList>
<SettingsRow <SettingsRow
title={language.t("settings.general.sounds.agent.title")} title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")} description={language.t("settings.general.sounds.agent.description")}
@@ -430,7 +431,7 @@ export const SettingsGeneral: Component = () => {
)} )}
/> />
</SettingsRow> </SettingsRow>
</div> </SettingsList>
</div> </div>
) )
@@ -438,7 +439,7 @@ export const SettingsGeneral: Component = () => {
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3> <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg"> <SettingsList>
<SettingsRow <SettingsRow
title={language.t("settings.updates.row.startup.title")} title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")} description={language.t("settings.updates.row.startup.description")}
@@ -474,7 +475,7 @@ export const SettingsGeneral: Component = () => {
: language.t("settings.updates.action.checkNow")} : language.t("settings.updates.action.checkNow")}
</Button> </Button>
</SettingsRow> </SettingsRow>
</div> </SettingsList>
</div> </div>
) )
@@ -504,7 +505,7 @@ export const SettingsGeneral: Component = () => {
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3> <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg"> <SettingsList>
<SettingsRow <SettingsRow
title={language.t("settings.desktop.wsl.title")} title={language.t("settings.desktop.wsl.title")}
description={language.t("settings.desktop.wsl.description")} description={language.t("settings.desktop.wsl.description")}
@@ -517,7 +518,7 @@ export const SettingsGeneral: Component = () => {
/> />
</div> </div>
</SettingsRow> </SettingsRow>
</div> </SettingsList>
</div> </div>
) )
}} }}
@@ -537,7 +538,7 @@ export const SettingsGeneral: Component = () => {
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3> <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg"> <SettingsList>
<SettingsRow <SettingsRow
title={ title={
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -555,7 +556,7 @@ export const SettingsGeneral: Component = () => {
<Switch checked={value() === "wayland"} onChange={onChange} /> <Switch checked={value() === "wayland"} onChange={onChange} />
</div> </div>
</SettingsRow> </SettingsRow>
</div> </SettingsList>
</div> </div>
) )
}} }}
@@ -9,6 +9,7 @@ import fuzzysort from "fuzzysort"
import { formatKeybind, parseKeybind, useCommand } from "@/context/command" import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { SettingsList } from "./settings-list"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette" const PALETTE_ID = "command.palette"
@@ -238,7 +239,7 @@ function useKeyCapture(input: {
showToast({ showToast({
title: input.language.t("settings.shortcuts.conflict.title"), title: input.language.t("settings.shortcuts.conflict.title"),
description: input.language.t("settings.shortcuts.conflict.description", { description: input.language.t("settings.shortcuts.conflict.description", {
keybind: formatKeybind(next), keybind: formatKeybind(next, input.language.t),
titles: [...conflicts.values()].join(", "), titles: [...conflicts.values()].join(", "),
}), }),
}) })
@@ -406,7 +407,7 @@ export const SettingsKeybinds: Component = () => {
<Show when={(filtered().get(group) ?? []).length > 0}> <Show when={(filtered().get(group) ?? []).length > 0}>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t(groupKey[group])}</h3> <h3 class="text-14-medium text-text-strong pb-2">{language.t(groupKey[group])}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg"> <SettingsList>
<For each={filtered().get(group) ?? []}> <For each={filtered().get(group) ?? []}>
{(id) => ( {(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none"> <div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
@@ -432,7 +433,7 @@ export const SettingsKeybinds: Component = () => {
</div> </div>
)} )}
</For> </For>
</div> </SettingsList>
</div> </div>
</Show> </Show>
)} )}
@@ -0,0 +1,5 @@
import { type Component, type JSX } from "solid-js"
export const SettingsList: Component<{ children: JSX.Element }> = (props) => {
return <div class="bg-surface-base px-4 rounded-lg">{props.children}</div>
}
@@ -8,6 +8,7 @@ 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 { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
import { SettingsList } from "./settings-list"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number] type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
@@ -100,7 +101,7 @@ export const SettingsModels: Component = () => {
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" /> <ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span> <span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
</div> </div>
<div class="bg-surface-raised-base px-4 rounded-lg"> <SettingsList>
<For each={group.items}> <For each={group.items}>
{(item) => { {(item) => {
const key = { providerID: item.provider.id, modelID: item.id } const key = { providerID: item.provider.id, modelID: item.id }
@@ -124,7 +125,7 @@ export const SettingsModels: Component = () => {
) )
}} }}
</For> </For>
</div> </SettingsList>
</div> </div>
)} )}
</For> </For>
@@ -11,6 +11,7 @@ import { useGlobalSync } from "@/context/global-sync"
import { DialogConnectProvider } from "./dialog-connect-provider" import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider" import { DialogCustomProvider } from "./dialog-custom-provider"
import { SettingsList } from "./settings-list"
type ProviderSource = "env" | "api" | "config" | "custom" type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number] type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
@@ -136,7 +137,7 @@ export const SettingsProviders: Component = () => {
<div class="flex flex-col gap-8 max-w-[720px]"> <div class="flex flex-col gap-8 max-w-[720px]">
<div class="flex flex-col gap-1" data-component="connected-providers-section"> <div class="flex flex-col gap-1" data-component="connected-providers-section">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3> <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg"> <SettingsList>
<Show <Show
when={connected().length > 0} when={connected().length > 0}
fallback={ fallback={
@@ -169,12 +170,12 @@ export const SettingsProviders: Component = () => {
)} )}
</For> </For>
</Show> </Show>
</div> </SettingsList>
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.popular")}</h3> <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.popular")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg"> <SettingsList>
<For each={popular()}> <For each={popular()}>
{(item) => ( {(item) => (
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none"> <div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
@@ -232,7 +233,7 @@ export const SettingsProviders: Component = () => {
{language.t("common.connect")} {language.t("common.connect")}
</Button> </Button>
</div> </div>
</div> </SettingsList>
<Button <Button
variant="ghost" variant="ghost"
+11 -9
View File
@@ -86,15 +86,17 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
const useDefaultServerKey = ( const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined, get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => { ) => {
const [url, setUrl] = createSignal<string | undefined>() const [state, setState] = createStore({
const [tick, setTick] = createSignal(0) url: undefined as string | undefined,
tick: 0,
})
createEffect(() => { createEffect(() => {
tick() state.tick
let dead = false let dead = false
const result = get?.() const result = get?.()
if (!result) { if (!result) {
setUrl(undefined) setState("url", undefined)
onCleanup(() => { onCleanup(() => {
dead = true dead = true
}) })
@@ -104,7 +106,7 @@ const useDefaultServerKey = (
if (result instanceof Promise) { if (result instanceof Promise) {
void result.then((next) => { void result.then((next) => {
if (dead) return if (dead) return
setUrl(next ? normalizeServerUrl(next) : undefined) setState("url", next ? normalizeServerUrl(next) : undefined)
}) })
onCleanup(() => { onCleanup(() => {
dead = true dead = true
@@ -112,7 +114,7 @@ const useDefaultServerKey = (
return return
} }
setUrl(normalizeServerUrl(result)) setState("url", normalizeServerUrl(result))
onCleanup(() => { onCleanup(() => {
dead = true dead = true
}) })
@@ -120,11 +122,11 @@ const useDefaultServerKey = (
return { return {
key: () => { key: () => {
const u = url() const u = state.url
if (!u) return if (!u) return
return ServerConnection.key({ type: "http", http: { url: u } }) return ServerConnection.key({ type: "http", http: { url: u } })
}, },
refresh: () => setTick((value) => value + 1), refresh: () => setState("tick", (value) => value + 1),
} }
} }
@@ -275,8 +277,8 @@ export function StatusPopover() {
aria-disabled={isBlocked()} aria-disabled={isBlocked()}
onClick={() => { onClick={() => {
if (isBlocked()) return if (isBlocked()) return
server.setActive(key)
navigate("/") navigate("/")
queueMicrotask(() => server.setActive(key))
}} }}
> >
<ServerHealthIndicator health={health[key]} /> <ServerHealthIndicator health={health[key]} />
+139 -70
View File
@@ -65,6 +65,13 @@ const debugTerminal = (...values: unknown[]) => {
console.debug("[terminal]", ...values) console.debug("[terminal]", ...values)
} }
const errorName = (err: unknown) => {
if (!err || typeof err !== "object") return
if (!("name" in err)) return
const errorName = err.name
return typeof errorName === "string" ? errorName : undefined
}
const useTerminalUiBindings = (input: { const useTerminalUiBindings = (input: {
container: HTMLDivElement container: HTMLDivElement
term: Term term: Term
@@ -158,6 +165,12 @@ export const Terminal = (props: TerminalProps) => {
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const server = useServer() const server = useServer()
const directory = sdk.directory
const client = sdk.client
const url = sdk.url
const auth = server.current?.http
const username = auth?.username ?? "opencode"
const password = auth?.password ?? ""
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id const id = local.pty.id
@@ -189,7 +202,11 @@ export const Terminal = (props: TerminalProps) => {
const start = const start =
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
let cursor = start ?? 0 let cursor = start ?? 0
let seek = start !== undefined ? start : restore ? -1 : 0
let output: ReturnType<typeof terminalWriter> | undefined let output: ReturnType<typeof terminalWriter> | undefined
let drop: VoidFunction | undefined
let reconn: ReturnType<typeof setTimeout> | undefined
let tries = 0
const cleanup = () => { const cleanup = () => {
if (!cleanups.length) return if (!cleanups.length) return
@@ -204,7 +221,7 @@ export const Terminal = (props: TerminalProps) => {
} }
const pushSize = (cols: number, rows: number) => { const pushSize = (cols: number, rows: number) => {
return sdk.client.pty return client.pty
.update({ .update({
ptyID: id, ptyID: id,
size: { cols, rows }, size: { cols, rows },
@@ -453,85 +470,135 @@ export const Terminal = (props: TerminalProps) => {
} }
const once = { value: false } const once = { value: false }
let closing = false
const url = new URL(sdk.url + `/pty/${id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? "opencode"
url.password = server.current?.http.password ?? ""
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
ws = socket
const handleOpen = () => {
probe.connect()
local.onConnect?.()
scheduleSize(t.cols, t.rows)
}
socket.addEventListener("open", handleOpen)
if (socket.readyState === WebSocket.OPEN) handleOpen()
const decoder = new TextDecoder() const decoder = new TextDecoder()
const handleMessage = (event: MessageEvent) => {
if (disposed) return
if (closing) return
if (event.data instanceof ArrayBuffer) {
const bytes = new Uint8Array(event.data)
if (bytes[0] !== 0) return
const json = decoder.decode(bytes.subarray(1))
try {
const meta = JSON.parse(json) as { cursor?: unknown }
const next = meta?.cursor
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
}
} catch (err) {
debugTerminal("invalid websocket control frame", err)
}
return
}
const data = typeof event.data === "string" ? event.data : "" const fail = (err: unknown) => {
if (!data) return
output?.push(data)
cursor += data.length
}
socket.addEventListener("message", handleMessage)
const handleError = (error: Event) => {
if (disposed) return if (disposed) return
if (closing) return
if (once.value) return if (once.value) return
once.value = true once.value = true
console.error("WebSocket error:", error) local.onConnectError?.(err)
local.onConnectError?.(error)
} }
socket.addEventListener("error", handleError)
const handleClose = (event: CloseEvent) => { const gone = () =>
client.pty
.get({ ptyID: id })
.then(() => false)
.catch((err) => {
if (errorName(err) === "NotFoundError") return true
debugTerminal("failed to inspect terminal session", err)
return false
})
const retry = (err: unknown) => {
if (disposed) return if (disposed) return
if (closing) return if (reconn !== undefined) return
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
// For other codes (network issues, server restart), trigger error handler
if (event.code !== 1000) {
if (once.value) return
once.value = true
local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`))
}
}
socket.addEventListener("close", handleClose)
cleanups.push(() => { const ms = Math.min(250 * 2 ** Math.min(tries, 4), 4_000)
closing = true reconn = setTimeout(async () => {
socket.removeEventListener("open", handleOpen) reconn = undefined
socket.removeEventListener("message", handleMessage) if (disposed) return
socket.removeEventListener("error", handleError) if (await gone()) {
socket.removeEventListener("close", handleClose) if (disposed) return
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000) fail(err)
return
}
if (disposed) return
tries += 1
open()
}, ms)
}
const open = () => {
if (disposed) return
drop?.()
const next = new URL(url + `/pty/${id}/connect`)
next.searchParams.set("directory", directory)
next.searchParams.set("cursor", String(seek))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
next.username = username
next.password = password
const socket = new WebSocket(next)
socket.binaryType = "arraybuffer"
ws = socket
const handleOpen = () => {
if (disposed) return
tries = 0
probe.connect()
local.onConnect?.()
scheduleSize(t.cols, t.rows)
}
const handleMessage = (event: MessageEvent) => {
if (disposed) return
if (event.data instanceof ArrayBuffer) {
const bytes = new Uint8Array(event.data)
if (bytes[0] !== 0) return
const json = decoder.decode(bytes.subarray(1))
try {
const meta = JSON.parse(json) as { cursor?: unknown }
const next = meta?.cursor
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
seek = next
}
} catch (err) {
debugTerminal("invalid websocket control frame", err)
}
return
}
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
output?.push(data)
cursor += data.length
seek = cursor
}
const handleError = (error: Event) => {
if (disposed) return
debugTerminal("websocket error", error)
}
const stop = () => {
socket.removeEventListener("open", handleOpen)
socket.removeEventListener("message", handleMessage)
socket.removeEventListener("error", handleError)
socket.removeEventListener("close", handleClose)
if (ws === socket) ws = undefined
if (drop === stop) drop = undefined
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
}
const handleClose = (event: CloseEvent) => {
if (ws === socket) ws = undefined
if (drop === stop) drop = undefined
socket.removeEventListener("open", handleOpen)
socket.removeEventListener("message", handleMessage)
socket.removeEventListener("error", handleError)
socket.removeEventListener("close", handleClose)
if (disposed) return
if (event.code === 1000) return
retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code })))
}
drop = stop
socket.addEventListener("open", handleOpen)
socket.addEventListener("message", handleMessage)
socket.addEventListener("error", handleError)
socket.addEventListener("close", handleClose)
}
probe.control({
disconnect: () => {
if (!ws) return
ws.close(4_000, "e2e")
},
}) })
open()
} }
void run().catch((err) => { void run().catch((err) => {
@@ -549,6 +616,8 @@ export const Terminal = (props: TerminalProps) => {
disposed = true disposed = true
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame) if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer) if (sizeTimer !== undefined) clearTimeout(sizeTimer)
if (reconn !== undefined) clearTimeout(reconn)
drop?.()
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000) if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
const finalize = () => { const finalize = () => {
+64 -38
View File
@@ -77,6 +77,7 @@ export function Titlebar() {
const canBack = createMemo(() => history.index > 0) const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1) const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const back = () => { const back = () => {
const next = backPath(history) const next = backPath(history)
@@ -217,47 +218,72 @@ export function Titlebar() {
</TooltipKeybind> </TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0"> <div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}> <Show when={params.dir}>
<TooltipKeybind <div
placement="bottom" class="flex items-center shrink-0 w-8 mr-1"
title={language.t("command.session.new")} aria-hidden={layout.sidebar.opened() ? "true" : undefined}
keybind={command.keybind("session.new")}
openDelay={2000}
> >
<Button <div
variant="ghost" class="transition-opacity"
icon={creating() ? "new-session-active" : "new-session"} classList={{
class="titlebar-icon w-8 h-6 p-0 box-border" "opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
onClick={() => { "opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
if (!params.dir) return
navigate(`/${params.dir}/session`)
}} }}
aria-label={language.t("command.session.new")} >
aria-current={creating() ? "page" : undefined} <TooltipKeybind
/> placement="bottom"
</TooltipKeybind> title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
>
<Button
variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
/>
</TooltipKeybind>
</div>
</div>
</Show>
<Show when={hasProjects()}>
<div
class="flex items-center gap-0 transition-transform"
classList={{
"translate-x-0": !layout.sidebar.opened(),
"-translate-x-[36px]": layout.sidebar.opened(),
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</Show> </Show>
<div class="flex items-center gap-0" classList={{ "ml-1": !!params.dir }}>
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</div> </div>
</div> </div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" /> <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
+69 -24
View File
@@ -1,9 +1,10 @@
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { dict as en } from "@/i18n/en"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
@@ -13,6 +14,27 @@ const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested." const SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"]) const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
type KeyLabel =
| "common.key.ctrl"
| "common.key.alt"
| "common.key.shift"
| "common.key.meta"
| "common.key.space"
| "common.key.backspace"
| "common.key.enter"
| "common.key.tab"
| "common.key.delete"
| "common.key.home"
| "common.key.end"
| "common.key.pageUp"
| "common.key.pageDown"
| "common.key.insert"
| "common.key.esc"
function keyText(key: KeyLabel, t?: (key: KeyLabel) => string) {
return t ? t(key) : en[key]
}
function actionId(id: string) { function actionId(id: string) {
if (!id.startsWith(SUGGESTED_PREFIX)) return id if (!id.startsWith(SUGGESTED_PREFIX)) return id
return id.slice(SUGGESTED_PREFIX.length) return id.slice(SUGGESTED_PREFIX.length)
@@ -145,7 +167,7 @@ export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean
return false return false
} }
export function formatKeybind(config: string): string { export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string {
if (!config || config === "none") return "" if (!config || config === "none") return ""
const keybinds = parseKeybind(config) const keybinds = parseKeybind(config)
@@ -154,10 +176,10 @@ export function formatKeybind(config: string): string {
const kb = keybinds[0] const kb = keybinds[0]
const parts: string[] = [] const parts: string[] = []
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl") if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t))
if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt") if (kb.alt) parts.push(IS_MAC ? "⌥" : keyText("common.key.alt", t))
if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift") if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t))
if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta") if (kb.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t))
if (kb.key) { if (kb.key) {
const keys: Record<string, string> = { const keys: Record<string, string> = {
@@ -167,10 +189,29 @@ export function formatKeybind(config: string): string {
arrowright: "→", arrowright: "→",
comma: ",", comma: ",",
plus: "+", plus: "+",
space: "Space", }
const named: Record<string, KeyLabel> = {
backspace: "common.key.backspace",
delete: "common.key.delete",
end: "common.key.end",
enter: "common.key.enter",
esc: "common.key.esc",
escape: "common.key.esc",
home: "common.key.home",
insert: "common.key.insert",
pagedown: "common.key.pageDown",
pageup: "common.key.pageUp",
space: "common.key.space",
tab: "common.key.tab",
} }
const key = kb.key.toLowerCase() const key = kb.key.toLowerCase()
const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1)) const displayKey =
keys[key] ??
(named[key]
? keyText(named[key], t)
: key.length === 1
? key.toUpperCase()
: key.charAt(0).toUpperCase() + key.slice(1))
parts.push(displayKey) parts.push(displayKey)
} }
@@ -197,9 +238,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}) })
const warnedDuplicates = new Set<string>() const warnedDuplicates = new Set<string>()
type CommandCatalog = Record<string, CommandCatalogItem>
const [catalog, setCatalog, _, catalogReady] = persisted( const [catalog, setCatalog, _, catalogReady] = persisted(
Persist.global("command.catalog.v1"), Persist.global("command.catalog.v1"),
createStore<Record<string, CommandCatalogItem>>({}), createStore<CommandCatalog>({}),
) )
const bind = (id: string, def: KeybindConfig | undefined) => { const bind = (id: string, def: KeybindConfig | undefined) => {
@@ -218,7 +260,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
if (seen.has(opt.id)) { if (seen.has(opt.id)) {
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) { if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
warnedDuplicates.add(opt.id) warnedDuplicates.add(opt.id)
console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`) console.warn(`[command] duplicate command id "${opt.id}" registered; keeping first entry`)
} }
continue continue
} }
@@ -233,16 +275,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
createEffect(() => { createEffect(() => {
if (!catalogReady()) return if (!catalogReady()) return
for (const opt of registered()) { setCatalog(
const id = actionId(opt.id) registered().reduce((acc, opt) => {
setCatalog(id, { const id = actionId(opt.id)
title: opt.title, acc[id] = {
description: opt.description, title: opt.title,
category: opt.category, description: opt.description,
keybind: opt.keybind, category: opt.category,
slash: opt.slash, keybind: opt.keybind,
}) slash: opt.slash,
} }
return acc
}, {} as CommandCatalog),
)
}) })
const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta }))) const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
@@ -364,17 +409,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}, },
keybind(id: string) { keybind(id: string) {
if (id === PALETTE_ID) { if (id === PALETTE_ID) {
return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND) return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND, language.t)
} }
const base = actionId(id) const base = actionId(id)
const option = options().find((x) => actionId(x.id) === base) const option = options().find((x) => actionId(x.id) === base)
if (option?.keybind) return formatKeybind(option.keybind) if (option?.keybind) return formatKeybind(option.keybind, language.t)
const meta = catalog[base] const meta = catalog[base]
const config = bind(base, meta?.keybind) const config = bind(base, meta?.keybind)
if (!config) return "" if (!config) return ""
return formatKeybind(config) return formatKeybind(config, language.t)
}, },
show: showPalette, show: showPalette,
keybinds(enabled: boolean) { keybinds(enabled: boolean) {
+3 -3
View File
@@ -43,10 +43,10 @@ export {
touchFileContent, touchFileContent,
} }
function errorMessage(error: unknown) { function errorMessage(error: unknown, fallback: string) {
if (error instanceof Error && error.message) return error.message if (error instanceof Error && error.message) return error.message
if (typeof error === "string" && error) return error if (typeof error === "string" && error) return error
return "Unknown error" return fallback
} }
export const { use: useFile, provider: FileProvider } = createSimpleContext({ export const { use: useFile, provider: FileProvider } = createSimpleContext({
@@ -184,7 +184,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}) })
.catch((e) => { .catch((e) => {
if (scope() !== directory) return if (scope() !== directory) return
setLoadError(file, errorMessage(e)) setLoadError(file, errorMessage(e, language.t("error.chain.unknown")))
}) })
.finally(() => { .finally(() => {
inflight.delete(key) inflight.delete(key)
+4 -2
View File
@@ -4,6 +4,7 @@ import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup } from "solid-js" import { batch, onCleanup } from "solid-js"
import z from "zod" import z from "zod"
import { createSdkForServer } from "@/utils/server" import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { useServer } from "./server" import { useServer } from "./server"
@@ -14,6 +15,7 @@ const abortError = z.object({
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK", name: "GlobalSDK",
init: () => { init: () => {
const language = useLanguage()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const abort = new AbortController() const abort = new AbortController()
@@ -30,7 +32,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
})() })()
const currentServer = server.current const currentServer = server.current
if (!currentServer) throw new Error("No server available") if (!currentServer) throw new Error(language.t("error.globalSDK.noServerAvailable"))
const eventSdk = createSdkForServer({ const eventSdk = createSdkForServer({
signal: abort.signal, signal: abort.signal,
@@ -218,7 +220,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
event: emitter, event: emitter,
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) { createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current const s = server.current
if (!s) throw new Error("Server not available") if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
return createSdkForServer({ return createSdkForServer({
server: s.http, server: s.http,
fetch: platform.fetch, fetch: platform.fetch,
+1
View File
@@ -164,6 +164,7 @@ function createGlobalSync() {
sdkCache.delete(directory) sdkCache.delete(directory)
clearSessionPrefetchDirectory(directory) clearSessionPrefetchDirectory(directory)
}, },
translate: language.t,
}) })
const sdkFor = (directory: string) => { const sdkFor = (directory: string) => {
@@ -139,7 +139,7 @@ export async function bootstrapDirectory(input: {
const project = getFilename(input.directory) const project = getFilename(input.directory)
showToast({ showToast({
variant: "error", variant: "error",
title: `Failed to reload ${project}`, title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate), description: formatServerError(err, input.translate),
}) })
input.setStore("status", "partial") input.setStore("status", "partial")
@@ -21,6 +21,7 @@ describe("createChildStoreManager", () => {
isLoadingSessions: () => false, isLoadingSessions: () => false,
onBootstrap() {}, onBootstrap() {},
onDispose() {}, onDispose() {},
translate: (key) => key,
}) })
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
@@ -21,6 +21,7 @@ export function createChildStoreManager(input: {
isLoadingSessions: (directory: string) => boolean isLoadingSessions: (directory: string) => boolean
onBootstrap: (directory: string) => void onBootstrap: (directory: string) => void
onDispose: (directory: string) => void onDispose: (directory: string) => void
translate: (key: string, vars?: Record<string, string | number>) => string
}) { }) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {} const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
const vcsCache = new Map<string, VcsCache>() const vcsCache = new Map<string, VcsCache>()
@@ -129,7 +130,7 @@ export function createChildStoreManager(input: {
createStore({ value: undefined as VcsInfo | undefined }), createStore({ value: undefined as VcsInfo | undefined }),
), ),
) )
if (!vcs) throw new Error("Failed to create persisted cache") if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
const vcsStore = vcs[0] const vcsStore = vcs[0]
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
@@ -139,7 +140,7 @@ export function createChildStoreManager(input: {
createStore({ value: undefined as ProjectMeta | undefined }), createStore({ value: undefined as ProjectMeta | undefined }),
), ),
) )
if (!meta) throw new Error("Failed to create persisted project metadata") if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed"))
metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] }) metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
const icon = runWithOwner(input.owner, () => const icon = runWithOwner(input.owner, () =>
@@ -148,7 +149,7 @@ export function createChildStoreManager(input: {
createStore({ value: undefined as string | undefined }), createStore({ value: undefined as string | undefined }),
), ),
) )
if (!icon) throw new Error("Failed to create persisted project icon") if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] }) iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
const init = () => const init = () =>
@@ -211,7 +212,7 @@ export function createChildStoreManager(input: {
} }
mark(directory) mark(directory)
const childStore = children[directory] const childStore = children[directory]
if (!childStore) throw new Error("Failed to create store") if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed"))
return childStore return childStore
} }
@@ -5,6 +5,7 @@ import {
getSessionPrefetch, getSessionPrefetch,
runSessionPrefetch, runSessionPrefetch,
setSessionPrefetch, setSessionPrefetch,
shouldSkipSessionPrefetch,
} from "./session-prefetch" } from "./session-prefetch"
describe("session prefetch", () => { describe("session prefetch", () => {
@@ -16,11 +17,12 @@ describe("session prefetch", () => {
directory: "/tmp/a", directory: "/tmp/a",
sessionID: "ses_1", sessionID: "ses_1",
limit: 200, limit: 200,
cursor: "abc",
complete: false, complete: false,
at: 123, at: 123,
}) })
expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, complete: false, at: 123 }) expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, cursor: "abc", complete: false, at: 123 })
expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined() expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
clearSessionPrefetch("/tmp/a", ["ses_1"]) clearSessionPrefetch("/tmp/a", ["ses_1"])
@@ -38,26 +40,57 @@ describe("session prefetch", () => {
sessionID: "ses_2", sessionID: "ses_2",
task: async () => { task: async () => {
calls += 1 calls += 1
return { limit: 100, complete: true, at: 456 } return { limit: 100, cursor: "next", complete: true, at: 456 }
}, },
}) })
const [a, b] = await Promise.all([run(), run()]) const [a, b] = await Promise.all([run(), run()])
expect(calls).toBe(1) expect(calls).toBe(1)
expect(a).toEqual({ limit: 100, complete: true, at: 456 }) expect(a).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
expect(b).toEqual({ limit: 100, complete: true, at: 456 }) expect(b).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
}) })
test("clears a whole directory", () => { test("clears a whole directory", () => {
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, complete: true, at: 1 }) setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, cursor: "a", complete: true, at: 1 })
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, complete: false, at: 2 }) setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, cursor: "b", complete: false, at: 2 })
setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, complete: true, at: 3 }) setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, cursor: "c", complete: true, at: 3 })
clearSessionPrefetchDirectory("/tmp/d") clearSessionPrefetchDirectory("/tmp/d")
expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined() expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined() expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, complete: true, at: 3 }) expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
})
test("refreshes stale first-page prefetched history", () => {
expect(
shouldSkipSessionPrefetch({
message: true,
info: { limit: 200, cursor: "x", complete: false, at: 1 },
chunk: 200,
now: 1 + 15_001,
}),
).toBe(false)
})
test("keeps deeper or complete history cached", () => {
expect(
shouldSkipSessionPrefetch({
message: true,
info: { limit: 400, cursor: "x", complete: false, at: 1 },
chunk: 200,
now: 1 + 15_001,
}),
).toBe(true)
expect(
shouldSkipSessionPrefetch({
message: true,
info: { limit: 120, complete: true, at: 1 },
chunk: 200,
now: 1 + 15_001,
}),
).toBe(true)
}) })
}) })
@@ -4,10 +4,23 @@ export const SESSION_PREFETCH_TTL = 15_000
type Meta = { type Meta = {
limit: number limit: number
cursor?: string
complete: boolean complete: boolean
at: number at: number
} }
export function shouldSkipSessionPrefetch(input: { message: boolean; info?: Meta; chunk: number; now?: number }) {
if (input.message) {
if (!input.info) return true
if (input.info.complete) return true
if (input.info.limit > input.chunk) return true
} else {
if (!input.info) return false
}
return (input.now ?? Date.now()) - input.info.at < SESSION_PREFETCH_TTL
}
const cache = new Map<string, Meta>() const cache = new Map<string, Meta>()
const inflight = new Map<string, Promise<Meta | undefined>>() const inflight = new Map<string, Promise<Meta | undefined>>()
const rev = new Map<string, number>() const rev = new Map<string, number>()
@@ -53,11 +66,13 @@ export function setSessionPrefetch(input: {
directory: string directory: string
sessionID: string sessionID: string
limit: number limit: number
cursor?: string
complete: boolean complete: boolean
at?: number at?: number
}) { }) {
cache.set(key(input.directory, input.sessionID), { cache.set(key(input.directory, input.sessionID), {
limit: input.limit, limit: input.limit,
cursor: input.cursor,
complete: input.complete, complete: input.complete,
at: input.at ?? Date.now(), at: input.at ?? Date.now(),
}) })
+373 -203
View File
@@ -1,252 +1,422 @@
import { createStore } from "solid-js/store"
import { batch, createMemo } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useModels } from "@/context/models"
import { useProviders } from "@/hooks/use-providers"
import { modelEnabled, modelProbe } from "@/testing/model-selection"
import { Persist, persisted } from "@/utils/persist"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { useModels } from "@/context/models"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
export type ModelKey = { providerID: string; modelID: string } export type ModelKey = { providerID: string; modelID: string }
type State = {
agent?: string
model?: ModelKey
variant?: string | null
}
type Saved = {
session: Record<string, State | undefined>
}
const WORKSPACE_KEY = "__workspace__"
const handoff = new Map<string, State>()
const handoffKey = (dir: string, id: string) => `${dir}\n${id}`
const migrate = (value: unknown) => {
if (!value || typeof value !== "object") return { session: {} }
const item = value as {
session?: Record<string, State | undefined>
pick?: Record<string, State | undefined>
}
if (item.session && typeof item.session === "object") return { session: item.session }
if (!item.pick || typeof item.pick !== "object") return { session: {} }
return {
session: Object.fromEntries(Object.entries(item.pick).filter(([key]) => key !== WORKSPACE_KEY)),
}
}
const clone = (value: State | undefined) => {
if (!value) return undefined
return {
...value,
model: value.model ? { ...value.model } : undefined,
} satisfies State
}
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local", name: "Local",
init: () => { init: () => {
const params = useParams()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const providers = useProviders() const providers = useProviders()
const connected = createMemo(() => new Set(providers.connected().map((provider) => provider.id))) const models = useModels()
function isModelValid(model: ModelKey) { const id = createMemo(() => params.id || undefined)
const provider = providers.all().find((x) => x.id === model.providerID) const list = createMemo(() => sync.data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved] = persisted(
{
...Persist.workspace(sdk.directory, "model-selection", ["model-selection.v1"]),
migrate,
},
createStore<Saved>({
session: {},
}),
)
const [store, setStore] = createStore<{
current?: string
draft?: State
last?: {
type: "agent" | "model" | "variant"
agent?: string
model?: ModelKey | null
variant?: string | null
}
}>({
current: list()[0]?.name,
draft: undefined,
last: undefined,
})
const validModel = (model: ModelKey) => {
const provider = providers.all().find((item) => item.id === model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID) return !!provider?.models[model.modelID] && connected().has(model.providerID)
} }
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) { const firstModel = (...items: Array<() => ModelKey | undefined>) => {
for (const modelFn of modelFns) { for (const item of items) {
const model = modelFn() const model = item()
if (!model) continue if (!model) continue
if (isModelValid(model)) return model if (validModel(model)) return model
} }
} }
let setModel: (model: ModelKey | undefined, options?: { recent?: boolean }) => void = () => undefined const pickAgent = (name: string | undefined) => {
const items = list()
if (items.length === 0) return undefined
return items.find((item) => item.name === name) ?? items[0]
}
const agent = (() => { createEffect(() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const items = list()
const models = useModels() if (items.length === 0) {
if (store.current !== undefined) setStore("current", undefined)
return
}
if (items.some((item) => item.name === store.current)) return
setStore("current", items[0]?.name)
})
const [store, setStore] = createStore<{ const scope = createMemo<State | undefined>(() => {
current?: string const session = id()
}>({ if (!session) return store.draft
current: list()[0]?.name, return saved.session[session] ?? handoff.get(handoffKey(sdk.directory, session))
})
createEffect(() => {
const session = id()
if (!session) return
const key = handoffKey(sdk.directory, session)
const next = handoff.get(key)
if (!next) return
if (saved.session[session] !== undefined) {
handoff.delete(key)
return
}
setSaved("session", session, clone(next))
handoff.delete(key)
})
const configuredModel = () => {
if (!sync.data.config.model) return
const [providerID, modelID] = sync.data.config.model.split("/")
const model = { providerID, modelID }
if (validModel(model)) return model
}
const recentModel = () => {
for (const item of models.recent.list()) {
if (validModel(item)) return item
}
}
const defaultModel = () => {
const defaults = providers.default()
for (const provider of providers.connected()) {
const configured = defaults[provider.id]
if (configured) {
const model = { providerID: provider.id, modelID: configured }
if (validModel(model)) return model
}
const first = Object.values(provider.models)[0]
if (!first) continue
const model = { providerID: provider.id, modelID: first.id }
if (validModel(model)) return model
}
}
const fallback = createMemo<ModelKey | undefined>(() => configuredModel() ?? recentModel() ?? defaultModel())
const agent = {
list,
current() {
return pickAgent(scope()?.agent ?? store.current)
},
set(name: string | undefined) {
const item = pickAgent(name)
if (!item) {
setStore("current", undefined)
return
}
batch(() => {
setStore("current", item.name)
setStore("last", {
type: "agent",
agent: item.name,
model: item.model,
variant: item.variant ?? null,
})
const prev = scope()
const next = {
agent: item.name,
model: item.model ?? prev?.model,
variant: item.variant ?? prev?.variant,
} satisfies State
const session = id()
if (session) {
setSaved("session", session, next)
return
}
setStore("draft", next)
})
},
move(direction: 1 | -1) {
const items = list()
if (items.length === 0) {
setStore("current", undefined)
return
}
let next = items.findIndex((item) => item.name === agent.current()?.name) + direction
if (next < 0) next = items.length - 1
if (next >= items.length) next = 0
const item = items[next]
if (!item) return
agent.set(item.name)
},
}
const current = () => {
const item = firstModel(
() => scope()?.model,
() => agent.current()?.model,
fallback,
)
if (!item) return undefined
return models.find(item)
}
const configured = () => {
const item = agent.current()
const model = current()
if (!item || !model) return undefined
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
}) })
}
const selected = () => scope()?.variant
const snapshot = () => {
const model = current()
return { return {
list, agent: agent.current()?.name,
current() { model: model ? { providerID: model.provider.id, modelID: model.id } : undefined,
const available = list() variant: selected(),
if (available.length === 0) return undefined } satisfies State
return available.find((x) => x.name === store.current) ?? available[0] }
},
set(name: string | undefined) { const write = (next: Partial<State>) => {
const available = list() const state = {
if (available.length === 0) { ...(scope() ?? { agent: agent.current()?.name }),
setStore("current", undefined) ...next,
return } satisfies State
}
const match = name ? available.find((x) => x.name === name) : undefined const session = id()
const value = match ?? available[0] if (session) {
if (!value) return setSaved("session", session, state)
setStore("current", value.name) return
if (!value.model) return
setModel({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
if (value.variant)
models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
},
move(direction: 1 | -1) {
const available = list()
if (available.length === 0) {
setStore("current", undefined)
return
}
let next = available.findIndex((x) => x.name === store.current) + direction
if (next < 0) next = available.length - 1
if (next >= available.length) next = 0
const value = available[next]
if (!value) return
setStore("current", value.name)
if (!value.model) return
setModel({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
if (value.variant)
models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
},
} }
})() setStore("draft", state)
}
const model = (() => { const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
const models = useModels()
const [ephemeral, setEphemeral] = createStore<{ const model = {
model: Record<string, ModelKey | undefined> ready: models.ready,
}>({ current,
model: {}, recent,
}) list: models.list,
cycle(direction: 1 | -1) {
const items = recent()
const item = current()
if (!item) return
const resolveConfigured = () => { const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id)
if (!sync.data.config.model) return
const [providerID, modelID] = sync.data.config.model.split("/")
const key = { providerID, modelID }
if (isModelValid(key)) return key
}
const resolveRecent = () => {
for (const item of models.recent.list()) {
if (isModelValid(item)) return item
}
}
const resolveDefault = () => {
const defaults = providers.default()
for (const provider of providers.connected()) {
const configured = defaults[provider.id]
if (configured) {
const key = { providerID: provider.id, modelID: configured }
if (isModelValid(key)) return key
}
const first = Object.values(provider.models)[0]
if (!first) continue
const key = { providerID: provider.id, modelID: first.id }
if (isModelValid(key)) return key
}
}
const fallbackModel = createMemo<ModelKey | undefined>(() => {
return resolveConfigured() ?? resolveRecent() ?? resolveDefault()
})
const current = createMemo(() => {
const a = agent.current()
if (!a) return undefined
const key = getFirstValidModel(
() => ephemeral.model[a.name],
() => a.model,
fallbackModel,
)
if (!key) return undefined
return models.find(key)
})
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
const cycle = (direction: 1 | -1) => {
const recentList = recent()
const currentModel = current()
if (!currentModel) return
const index = recentList.findIndex(
(x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id,
)
if (index === -1) return if (index === -1) return
let next = index + direction let next = index + direction
if (next < 0) next = recentList.length - 1 if (next < 0) next = items.length - 1
if (next >= recentList.length) next = 0 if (next >= items.length) next = 0
const val = recentList[next] const entry = items[next]
if (!val) return if (!entry) return
model.set({ providerID: entry.provider.id, modelID: entry.id })
model.set({ },
providerID: val.provider.id, set(item: ModelKey | undefined, options?: { recent?: boolean }) {
modelID: val.id,
})
}
const set = (model: ModelKey | undefined, options?: { recent?: boolean }) => {
batch(() => { batch(() => {
const currentAgent = agent.current() setStore("last", {
const next = model ?? fallbackModel() type: "model",
if (currentAgent) setEphemeral("model", currentAgent.name, next) agent: agent.current()?.name,
if (model) models.setVisibility(model, true) model: item ?? null,
if (options?.recent && model) models.recent.push(model) variant: selected(),
})
write({ model: item })
if (!item) return
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
}) })
} },
visible(item: ModelKey) {
setModel = set return models.visible(item)
},
return { setVisibility(item: ModelKey, visible: boolean) {
ready: models.ready, models.setVisibility(item, visible)
current, },
recent, variant: {
list: models.list, configured,
cycle, selected,
set, current() {
visible(model: ModelKey) { return resolveModelVariant({
return models.visible(model) variants: this.list(),
selected: this.selected(),
configured: this.configured(),
})
}, },
setVisibility(model: ModelKey, visible: boolean) { list() {
models.setVisibility(model, visible) const item = current()
if (!item?.variants) return []
return Object.keys(item.variants)
}, },
variant: { set(value: string | undefined) {
configured() { batch(() => {
const a = agent.current() const model = current()
const m = current() setStore("last", {
if (!a || !m) return undefined type: "variant",
return getConfiguredAgentVariant({ agent: agent.current()?.name,
agent: { model: a.model, variant: a.variant }, model: model ? { providerID: model.provider.id, modelID: model.id } : null,
model: { providerID: m.provider.id, modelID: m.id, variants: m.variants }, variant: value ?? null,
}) })
}, write({ variant: value ?? null })
selected() { })
const m = current() },
if (!m) return undefined cycle() {
return models.variant.get({ providerID: m.provider.id, modelID: m.id }) const items = this.list()
}, if (items.length === 0) return
current() { this.set(
return resolveModelVariant({ cycleModelVariant({
variants: this.list(), variants: items,
selected: this.selected(), selected: this.selected(),
configured: this.configured(), configured: this.configured(),
}) }),
}, )
list() {
const m = current()
if (!m) return []
if (!m.variants) return []
return Object.keys(m.variants)
},
set(value: string | undefined) {
const m = current()
if (!m) return
models.variant.set({ providerID: m.provider.id, modelID: m.id }, value)
},
cycle() {
const variants = this.list()
if (variants.length === 0) return
this.set(
cycleModelVariant({
variants,
selected: this.selected(),
configured: this.configured(),
}),
)
},
}, },
} },
})() }
const result = { const result = {
slug: createMemo(() => base64Encode(sdk.directory)), slug: createMemo(() => base64Encode(sdk.directory)),
model, model,
agent, agent,
session: {
reset() {
setStore("draft", undefined)
},
promote(dir: string, session: string) {
const next = clone(snapshot())
if (!next) return
if (dir === sdk.directory) {
setSaved("session", session, next)
setStore("draft", undefined)
return
}
handoff.set(handoffKey(dir, session), next)
setStore("draft", undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey; variant?: string }) {
const session = id()
if (!session) return
if (msg.sessionID !== session) return
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(sdk.directory, session))) return
setSaved("session", session, {
agent: msg.agent,
model: msg.model,
variant: msg.variant ?? null,
})
},
},
} }
if (modelEnabled()) {
createEffect(() => {
const agent = result.agent.current()
const model = result.model.current()
modelProbe.set({
dir: sdk.directory,
sessionID: id(),
last: store.last,
agent: agent?.name,
model: model
? {
providerID: model.provider.id,
modelID: model.id,
name: model.name,
}
: undefined,
variant: result.model.variant.current() ?? null,
selected: result.model.variant.selected(),
configured: result.model.variant.configured(),
pick: scope(),
base: undefined,
current: store.current,
})
})
onCleanup(() => modelProbe.clear())
}
return result return result
}, },
}) })
@@ -44,6 +44,16 @@ describe("model variant", () => {
expect(value).toBe("high") expect(value).toBe("high")
}) })
test("lets an explicit default override the configured variant", () => {
const value = resolveModelVariant({
variants: ["low", "high", "xhigh"],
selected: null,
configured: "xhigh",
})
expect(value).toBeUndefined()
})
test("cycles from configured variant to next", () => { test("cycles from configured variant to next", () => {
const value = cycleModelVariant({ const value = cycleModelVariant({
variants: ["low", "high", "xhigh"], variants: ["low", "high", "xhigh"],
@@ -63,4 +73,14 @@ describe("model variant", () => {
expect(value).toBe("low") expect(value).toBe("low")
}) })
test("cycles from an explicit default to the first variant", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: null,
configured: "xhigh",
})
expect(value).toBe("low")
})
}) })
+3 -1
View File
@@ -14,7 +14,7 @@ type Model = AgentModel & {
type VariantInput = { type VariantInput = {
variants: string[] variants: string[]
selected: string | undefined selected: string | null | undefined
configured: string | undefined configured: string | undefined
} }
@@ -29,6 +29,7 @@ export function getConfiguredAgentVariant(input: { agent: Agent | undefined; mod
} }
export function resolveModelVariant(input: VariantInput) { export function resolveModelVariant(input: VariantInput) {
if (input.selected === null) return undefined
if (input.selected && input.variants.includes(input.selected)) return input.selected if (input.selected && input.variants.includes(input.selected)) return input.selected
if (input.configured && input.variants.includes(input.configured)) return input.configured if (input.configured && input.variants.includes(input.configured)) return input.configured
return undefined return undefined
@@ -36,6 +37,7 @@ export function resolveModelVariant(input: VariantInput) {
export function cycleModelVariant(input: VariantInput) { export function cycleModelVariant(input: VariantInput) {
if (input.variants.length === 0) return undefined if (input.variants.length === 0) return undefined
if (input.selected === null) return input.variants[0]
if (input.selected && input.variants.includes(input.selected)) { if (input.selected && input.variants.includes(input.selected)) {
const index = input.variants.indexOf(input.selected) const index = input.variants.indexOf(input.selected)
if (index === input.variants.length - 1) return undefined if (index === input.variants.length - 1) return undefined
+19 -9
View File
@@ -1,10 +1,10 @@
import { createStore, type SetStoreFunction } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { checksum } from "@opencode-ai/util/encode"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { checksum } from "@opencode-ai/util/encode"
interface PartBase { interface PartBase {
content: string content: string
@@ -151,6 +151,11 @@ const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession> type PromptSession = ReturnType<typeof createPromptSession>
type Scope = {
dir: string
id?: string
}
type PromptCacheEntry = { type PromptCacheEntry = {
value: PromptSession value: PromptSession
dispose: VoidFunction dispose: VoidFunction
@@ -245,6 +250,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
} }
const owner = getOwner()
const load = (dir: string, id: string | undefined) => { const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}` const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = cache.get(key) const existing = cache.get(key)
@@ -254,10 +260,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return existing.value return existing.value
} }
const entry = createRoot((dispose) => ({ const entry = createRoot(
value: createPromptSession(dir, id), (dispose) => ({
dispose, value: createPromptSession(dir, id),
})) dispose,
}),
owner,
)
cache.set(key, entry) cache.set(key, entry)
prune() prune()
@@ -265,6 +274,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
const session = createMemo(() => load(params.dir!, params.id)) const session = createMemo(() => load(params.dir!, params.id))
const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session())
return { return {
ready: () => session().ready(), ready: () => session().ready(),
@@ -280,8 +290,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
session().context.updateComment(path, commentID, next), session().context.updateComment(path, commentID, next),
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items), replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
}, },
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition), set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition),
reset: () => session().reset(), reset: (scope?: Scope) => pick(scope).reset(),
} }
}, },
}) })
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { applyOptimisticAdd, applyOptimisticRemove } from "./sync" import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
type Text = Extract<Part, { type: "text" }>
const userMessage = (id: string, sessionID: string): Message => ({ const userMessage = (id: string, sessionID: string): Message => ({
id, id,
@@ -11,7 +13,7 @@ const userMessage = (id: string, sessionID: string): Message => ({
model: { providerID: "openai", modelID: "gpt" }, model: { providerID: "openai", modelID: "gpt" },
}) })
const textPart = (id: string, sessionID: string, messageID: string): Part => ({ const textPart = (id: string, sessionID: string, messageID: string): Text => ({
id, id,
sessionID, sessionID,
messageID, messageID,
@@ -53,4 +55,69 @@ describe("sync optimistic reducers", () => {
expect(draft.part.msg_1).toBeUndefined() expect(draft.part.msg_1).toBeUndefined()
expect(draft.part.msg_2).toHaveLength(1) expect(draft.part.msg_2).toHaveLength(1)
}) })
test("mergeOptimisticPage keeps pending messages in fetched timelines", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_1", sessionID)],
part: [{ id: "msg_1", part: [textPart("prt_1", sessionID, "msg_1")] }],
complete: true,
},
[{ message: userMessage("msg_2", sessionID), parts: [textPart("prt_2", sessionID, "msg_2")] }],
)
expect(page.session.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_2"])
expect(page.confirmed).toEqual([])
expect(page.complete).toBe(true)
})
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_2", sessionID)],
part: [{ id: "msg_2", part: [textPart("prt_2", sessionID, "msg_2")] }],
complete: true,
},
[
{
message: userMessage("msg_2", sessionID),
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
},
],
)
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
expect(page.confirmed).toEqual([])
})
test("mergeOptimisticPage confirms echoed messages once all parts arrive", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_2", sessionID)],
part: [
{
id: "msg_2",
part: [{ ...textPart("prt_1", sessionID, "msg_2"), text: "server" }, textPart("prt_2", sessionID, "msg_2")],
},
],
complete: true,
},
[
{
message: userMessage("msg_2", sessionID),
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
},
],
)
expect(page.confirmed).toEqual(["msg_2"])
expect(page.part.find((x) => x.id === "msg_2")?.part).toMatchObject([
{ id: "prt_1", type: "text", text: "server" },
{ id: "prt_2", type: "text", text: "prt_2" },
])
})
}) })
+138 -10
View File
@@ -32,6 +32,12 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}`
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)
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const map = new Map(a.map((item) => [item.id, item] as const))
for (const item of b) map.set(item.id, item)
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
}
type OptimisticStore = { type OptimisticStore = {
message: Record<string, Message[] | undefined> message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
@@ -48,6 +54,67 @@ type OptimisticRemoveInput = {
messageID: string messageID: string
} }
type OptimisticItem = {
message: Message
parts: Part[]
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
let changed = false
for (const part of want) {
const result = Binary.search(next, part.id, (item) => item.id)
if (result.found) continue
next.splice(result.index, 0, part)
changed = true
}
if (!changed) return parts
return next
}
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, mergeParts(current, item.parts))
}
return {
cursor: page.cursor,
complete: page.complete,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
confirmed,
}
}
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) {
@@ -115,10 +182,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>() const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>() const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const maxDirs = 30 const maxDirs = 30
const seen = new Map<string, Set<string>>() const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({ const [meta, setMeta] = createStore({
limit: {} as Record<string, number>, limit: {} as Record<string, number>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean>, complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>, loading: {} as Record<string, boolean>,
}) })
@@ -130,6 +199,33 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return undefined return undefined
} }
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
const key = keyFor(directory, sessionID)
const list = optimistic.get(key)
if (list) {
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
return
}
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
}
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
const key = keyFor(directory, sessionID)
if (!messageID) {
optimistic.delete(key)
return
}
const list = optimistic.get(key)
if (!list) return
list.delete(messageID)
if (list.size === 0) optimistic.delete(key)
}
const getOptimistic = (directory: string, sessionID: string) => [
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
]
const seenFor = (directory: string) => { const seenFor = (directory: string) => {
const existing = seen.get(directory) const existing = seen.get(directory)
if (existing) { if (existing) {
@@ -152,11 +248,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const clearMeta = (directory: string, sessionIDs: string[]) => { const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return if (sessionIDs.length === 0) return
for (const sessionID of sessionIDs) {
clearOptimistic(directory, sessionID)
}
setMeta( setMeta(
produce((draft) => { produce((draft) => {
for (const sessionID of sessionIDs) { for (const sessionID of sessionIDs) {
const key = keyFor(directory, sessionID) const key = keyFor(directory, sessionID)
delete draft.limit[key] delete draft.limit[key]
delete draft.cursor[key]
delete draft.complete[key] delete draft.complete[key]
delete draft.loading[key] delete draft.loading[key]
} }
@@ -187,17 +287,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
evict(directory, setStore, stale) evict(directory, setStore, stale)
} }
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => { const fetchMessages = async (input: {
client: typeof sdk.client
sessionID: string
limit: number
before?: string
}) => {
const messages = await retry(() => const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }), input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
) )
const items = (messages.data ?? []).filter((x) => !!x?.info?.id) const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id)) const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) })) const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return { return {
session, session,
part, part,
complete: session.length < input.limit, cursor,
complete: !cursor,
} }
} }
@@ -209,25 +316,36 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
setStore: Setter setStore: Setter
sessionID: string sessionID: string
limit: number limit: number
before?: string
mode?: "replace" | "prepend"
}) => { }) => {
const key = keyFor(input.directory, input.sessionID) const key = keyFor(input.directory, input.sessionID)
if (meta.loading[key]) return if (meta.loading[key]) return
setMeta("loading", key, true) setMeta("loading", key, true)
await fetchMessages(input) await fetchMessages(input)
.then((next) => { .then((page) => {
if (!tracked(input.directory, input.sessionID)) return if (!tracked(input.directory, input.sessionID)) return
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID)
}
const [store] = globalSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => { batch(() => {
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" })) input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) { for (const p of next.part) {
input.setStore("part", p.id, p.part) input.setStore("part", p.id, p.part)
} }
setMeta("limit", key, input.limit) setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete) setMeta("complete", key, next.complete)
setSessionPrefetch({ setSessionPrefetch({
directory: input.directory, directory: input.directory,
sessionID: input.sessionID, sessionID: input.sessionID,
limit: input.limit, limit: message.length,
cursor: next.cursor,
complete: next.complete, complete: next.complete,
}) })
}) })
@@ -268,11 +386,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get: getSession, get: getSession,
optimistic: { optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) { add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory) const [, setStore] = target(input.directory)
setOptimistic(directory, input.sessionID, { message: input.message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, input) setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
}, },
remove(input: { directory?: string; sessionID: string; messageID: string }) { remove(input: { directory?: string; sessionID: string; messageID: string }) {
const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory) const [, setStore] = target(input.directory)
clearOptimistic(directory, input.sessionID, input.messageID)
setOptimisticRemove(setStore as (...args: unknown[]) => void, input) setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
}, },
}, },
@@ -294,6 +416,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
variant: input.variant, variant: input.variant,
} }
const [, setStore] = target() const [, setStore] = target()
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, { setOptimisticAdd(setStore as (...args: unknown[]) => void, {
sessionID: input.sessionID, sessionID: input.sessionID,
message, message,
@@ -312,6 +435,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => { batch(() => {
setMeta("limit", key, seeded.limit) setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete) setMeta("complete", key, seeded.complete)
setMeta("loading", key, false) setMeta("loading", key, false)
}) })
@@ -325,6 +449,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => { batch(() => {
setMeta("limit", key, seeded.limit) setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete) setMeta("complete", key, seeded.complete)
setMeta("loading", key, false) setMeta("loading", key, false)
}) })
@@ -420,7 +545,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (store.message[sessionID] === undefined) return false if (store.message[sessionID] === undefined) return false
if (meta.limit[key] === undefined) return false if (meta.limit[key] === undefined) return false
if (meta.complete[key]) return false if (meta.complete[key]) return false
return true return !!meta.cursor[key]
}, },
loading(sessionID: string) { loading(sessionID: string) {
const key = keyFor(sdk.directory, sessionID) const key = keyFor(sdk.directory, sessionID)
@@ -435,14 +560,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const step = count ?? messagePageSize const step = count ?? messagePageSize
if (meta.loading[key]) return if (meta.loading[key]) return
if (meta.complete[key]) return if (meta.complete[key]) return
const before = meta.cursor[key]
if (!before) return
const currentLimit = meta.limit[key] ?? messagePageSize
await loadMessages({ await loadMessages({
directory, directory,
client, client,
setStore, setStore,
sessionID, sessionID,
limit: currentLimit + step, limit: step,
before,
mode: "prepend",
}) })
}, },
}, },
@@ -0,0 +1,51 @@
import { dict as ar } from "@/i18n/ar"
import { dict as br } from "@/i18n/br"
import { dict as bs } from "@/i18n/bs"
import { dict as da } from "@/i18n/da"
import { dict as de } from "@/i18n/de"
import { dict as en } from "@/i18n/en"
import { dict as es } from "@/i18n/es"
import { dict as fr } from "@/i18n/fr"
import { dict as ja } from "@/i18n/ja"
import { dict as ko } from "@/i18n/ko"
import { dict as no } from "@/i18n/no"
import { dict as pl } from "@/i18n/pl"
import { dict as ru } from "@/i18n/ru"
import { dict as th } from "@/i18n/th"
import { dict as tr } from "@/i18n/tr"
import { dict as zh } from "@/i18n/zh"
import { dict as zht } from "@/i18n/zht"
const numbered = Array.from(
new Set([
en["terminal.title.numbered"],
ar["terminal.title.numbered"],
br["terminal.title.numbered"],
bs["terminal.title.numbered"],
da["terminal.title.numbered"],
de["terminal.title.numbered"],
es["terminal.title.numbered"],
fr["terminal.title.numbered"],
ja["terminal.title.numbered"],
ko["terminal.title.numbered"],
no["terminal.title.numbered"],
pl["terminal.title.numbered"],
ru["terminal.title.numbered"],
th["terminal.title.numbered"],
tr["terminal.title.numbered"],
zh["terminal.title.numbered"],
zht["terminal.title.numbered"],
]),
)
export function defaultTitle(number: number) {
return en["terminal.title.numbered"].replace("{{number}}", String(number))
}
export function isDefaultTitle(title: string, number: number) {
return numbered.some((text) => title === text.replace("{{number}}", String(number)))
}
export function titleNumber(title: string, max: number) {
return Array.from({ length: max }, (_, idx) => idx + 1).find((number) => isDefaultTitle(title, number))
}
+77 -56
View File
@@ -4,6 +4,7 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import type { Platform } from "./platform" import type { Platform } from "./platform"
import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist" import { Persist, persisted, removePersisted } from "@/utils/persist"
export type LocalPTY = { export type LocalPTY = {
@@ -33,11 +34,7 @@ function num(value: unknown) {
} }
function numberFromTitle(title: string) { function numberFromTitle(title: string) {
const match = title.match(/^Terminal (\d+)$/) return titleNumber(title, MAX_TERMINAL_SESSIONS)
if (!match) return
const value = Number(match[1])
if (!Number.isFinite(value) || value <= 0) return
return value
} }
function pty(value: unknown): LocalPTY | undefined { function pty(value: unknown): LocalPTY | undefined {
@@ -188,6 +185,60 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
}) })
onCleanup(unsub) onCleanup(unsub)
const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
const index = store.all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
setStore("all", index, (item) => ({ ...item, ...pty }))
}
client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((error: unknown) => {
if (previous) {
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
console.error("Failed to update terminal", error)
})
}
const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const next = await client.pty
.create({
title: pty.title,
})
.catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
if (!next?.data) return
const active = store.active === pty.id
batch(() => {
setStore("all", index, {
id: next.data.id,
title: next.data.title ?? pty.title,
titleNumber: pty.titleNumber,
buffer: undefined,
cursor: undefined,
scrollY: undefined,
rows: undefined,
cols: undefined,
})
if (active) {
setStore("active", next.data.id)
}
})
}
return { return {
ready, ready,
all: createMemo(() => store.all), all: createMemo(() => store.all),
@@ -202,13 +253,13 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
const nextNumber = pickNextTerminalNumber() const nextNumber = pickNextTerminalNumber()
sdk.client.pty sdk.client.pty
.create({ title: `Terminal ${nextNumber}` }) .create({ title: defaultTitle(nextNumber) })
.then((pty: { data?: { id?: string; title?: string } }) => { .then((pty: { data?: { id?: string; title?: string } }) => {
const id = pty.data?.id const id = pty.data?.id
if (!id) return if (!id) return
const newTerminal = { const newTerminal = {
id, id,
title: pty.data?.title ?? "Terminal", title: pty.data?.title ?? defaultTitle(nextNumber),
titleNumber: nextNumber, titleNumber: nextNumber,
} }
setStore("all", store.all.length, newTerminal) setStore("all", store.all.length, newTerminal)
@@ -219,24 +270,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
}) })
}, },
update(pty: Partial<LocalPTY> & { id: string }) { update(pty: Partial<LocalPTY> & { id: string }) {
const index = store.all.findIndex((x) => x.id === pty.id) update(sdk.client, pty)
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
setStore("all", index, (item) => ({ ...item, ...pty }))
}
sdk.client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((error: unknown) => {
if (previous) {
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
console.error("Failed to update terminal", error)
})
}, },
trim(id: string) { trim(id: string) {
const index = store.all.findIndex((x) => x.id === id) const index = store.all.findIndex((x) => x.id === id)
@@ -251,37 +285,23 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
}) })
}, },
async clone(id: string) { async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id) await clone(sdk.client, id)
const pty = store.all[index] },
if (!pty) return bind() {
const clone = await sdk.client.pty const client = sdk.client
.create({ return {
title: pty.title, trim(id: string) {
}) const index = store.all.findIndex((x) => x.id === id)
.catch((error: unknown) => { if (index === -1) return
console.error("Failed to clone terminal", error) setStore("all", index, (pty) => trimTerminal(pty))
return undefined },
}) update(pty: Partial<LocalPTY> & { id: string }) {
if (!clone?.data) return update(client, pty)
},
const active = store.active === pty.id async clone(id: string) {
await clone(client, id)
batch(() => { },
setStore("all", index, { }
id: clone.data.id,
title: clone.data.title ?? pty.title,
titleNumber: pty.titleNumber,
// New PTY process, so start clean.
buffer: undefined,
cursor: undefined,
scrollY: undefined,
rows: undefined,
cols: undefined,
})
if (active) {
setStore("active", clone.data.id)
}
})
}, },
open(id: string) { open(id: string) {
setStore("active", id) setStore("active", id)
@@ -406,6 +426,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
trim: (id: string) => workspace().trim(id), trim: (id: string) => workspace().trim(id),
trimAll: () => workspace().trimAll(), trimAll: () => workspace().trimAll(),
clone: (id: string) => workspace().clone(id), clone: (id: string) => workspace().clone(id),
bind: () => workspace(),
open: (id: string) => workspace().open(id), open: (id: string) => workspace().open(id),
close: (id: string) => workspace().close(id), close: (id: string) => workspace().close(id),
move: (id: string, to: number) => workspace().move(id, to), move: (id: string, to: number) => workspace().move(id, to),
+5 -2
View File
@@ -1,6 +1,5 @@
// @refresh reload // @refresh reload
import { iife } from "@opencode-ai/util/iife"
import { render } from "solid-js/web" import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app" import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform" import { type Platform, PlatformProvider } from "@/context/platform"
@@ -132,7 +131,11 @@ if (root instanceof HTMLElement) {
() => ( () => (
<PlatformProvider value={platform}> <PlatformProvider value={platform}>
<AppBaseProviders> <AppBaseProviders>
<AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())} servers={[server]} /> <AppInterface
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
servers={[server]}
disableHealthCheck
/>
</AppBaseProviders> </AppBaseProviders>
</PlatformProvider> </PlatformProvider>
), ),
+77 -3
View File
@@ -204,6 +204,7 @@ export const dict = {
"common.cancel": "إلغاء", "common.cancel": "إلغاء",
"common.connect": "اتصال", "common.connect": "اتصال",
"common.disconnect": "قطع الاتصال", "common.disconnect": "قطع الاتصال",
"common.continue": "إرسال",
"common.submit": "إرسال", "common.submit": "إرسال",
"common.save": "حفظ", "common.save": "حفظ",
"common.saving": "جارٍ الحفظ...", "common.saving": "جارٍ الحفظ...",
@@ -244,7 +245,7 @@ export const dict = {
"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": "مهارة",
@@ -257,8 +258,8 @@ export const dict = {
"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.pasteUnsupported.description": "يمكن لصق الصور أو ملفات PDF فقط هنا.", "prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
"prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً", "prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً",
"prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.", "prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.",
"prompt.toast.worktreeCreateFailed.title": "فشل إنشاء شجرة العمل", "prompt.toast.worktreeCreateFailed.title": "فشل إنشاء شجرة العمل",
@@ -778,4 +779,77 @@ export const dict = {
"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.retrying": "جاري إعادة المحاولة تلقائيًا...",
"app.server.otherServers": "خوادم أخرى",
"dialog.server.add.usernamePlaceholder": "اسم المستخدم",
"dialog.server.add.passwordPlaceholder": "كلمة المرور",
"server.row.noUsername": "لا يوجد اسم مستخدم",
"session.review.noVcs.createGit.title": "إنشاء مستودع Git",
"session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع",
"session.review.noVcs.createGit.actionLoading": "جاري إنشاء مستودع Git...",
"session.review.noVcs.createGit.action": "إنشاء مستودع Git",
"session.todo.progress": "تم إكمال {{done}} من {{total}} مهام",
"session.question.progress": "{{current}} من {{total}} أسئلة",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "مستكشف الملفات",
"session.header.open.fileManager": "مدير الملفات",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "المحطة الطرفية",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "تشخيص أداء التطوير",
"debugBar.na": "غير متاح",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip": "آخر انتقال مكتمل للمسار يمس صفحة جلسة، مُقاسًا من بدء التوجيه حتى أول رسم بعد استقراره.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "الإطارات المتجددة في الثانية خلال آخر 5 ثوانٍ.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "أسوأ وقت للإطار خلال آخر 5 ثوانٍ.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "الإطارات التي تزيد عن 32 مللي ثانية في آخر 5 ثوانٍ.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "الوقت المحظور وعدد المهام الطويلة في آخر 5 ثوانٍ. أقصى مهمة: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "أسوأ تأخير إدخال تمت ملاحظته في آخر 5 ثوانٍ.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip": "مدة التفاعل التقريبية خلال آخر 5 ثوانٍ. هذا يشبه INP، وليس Web Vitals INP الرسمي.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "التحول التخطيطي التراكمي لعمر التطبيق الحالي.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "كومة JS المستخدمة مقابل حد الكومة. Chromium فقط.",
"debugBar.mem.tip": "كومة JS المستخدمة مقابل حد الكومة. {{used}} من {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Space",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "غير معروف",
"error.page.circular": "[دائري]",
"error.globalSDK.noServerAvailable": "لا يوجد خادم متاح",
"error.globalSDK.serverNotAvailable": "الخادم غير متاح",
"error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة",
"error.childStore.persistedProjectMetadataCreateFailed": "فشل إنشاء بيانات تعريف المشروع الدائمة",
"error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة",
"error.childStore.storeCreateFailed": "فشل إنشاء المخزن",
"terminal.connectionLost.abnormalClose": "تم إغلاق WebSocket بشكل غير طبيعي: {{code}}",
} }
+79 -3
View File
@@ -204,6 +204,7 @@ export const dict = {
"common.cancel": "Cancelar", "common.cancel": "Cancelar",
"common.connect": "Conectar", "common.connect": "Conectar",
"common.disconnect": "Desconectar", "common.disconnect": "Desconectar",
"common.continue": "Enviar",
"common.submit": "Enviar", "common.submit": "Enviar",
"common.save": "Salvar", "common.save": "Salvar",
"common.saving": "Salvando...", "common.saving": "Salvando...",
@@ -244,7 +245,7 @@ export const dict = {
"prompt.example.25": "Como funcionam as variáveis de ambiente aqui?", "prompt.example.25": "Como funcionam as variáveis de ambiente aqui?",
"prompt.popover.emptyResults": "Nenhum resultado correspondente", "prompt.popover.emptyResults": "Nenhum resultado correspondente",
"prompt.popover.emptyCommands": "Nenhum comando correspondente", "prompt.popover.emptyCommands": "Nenhum comando correspondente",
"prompt.dropzone.label": "Solte imagens ou PDFs aqui", "prompt.dropzone.label": "Arraste imagens, PDFs ou arquivos de texto aqui",
"prompt.dropzone.file.label": "Solte para @mencionar arquivo", "prompt.dropzone.file.label": "Solte para @mencionar arquivo",
"prompt.slash.badge.custom": "personalizado", "prompt.slash.badge.custom": "personalizado",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
@@ -257,8 +258,8 @@ export const dict = {
"prompt.attachment.remove": "Remover anexo", "prompt.attachment.remove": "Remover anexo",
"prompt.action.send": "Enviar", "prompt.action.send": "Enviar",
"prompt.action.stop": "Parar", "prompt.action.stop": "Parar",
"prompt.toast.pasteUnsupported.title": "Colagem não suportada", "prompt.toast.pasteUnsupported.title": "Anexo não suportado",
"prompt.toast.pasteUnsupported.description": "Somente imagens ou PDFs podem ser colados aqui.", "prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.",
"prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo", "prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo",
"prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.", "prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.",
"prompt.toast.worktreeCreateFailed.title": "Falha ao criar worktree", "prompt.toast.worktreeCreateFailed.title": "Falha ao criar worktree",
@@ -788,4 +789,79 @@ export const dict = {
"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 a OpenAI através do URL base.",
"app.server.unreachable": "Não foi possível conectar a {{server}}",
"app.server.retrying": "Tentando novamente automaticamente...",
"app.server.otherServers": "Outros servidores",
"dialog.server.add.usernamePlaceholder": "nome de usuário",
"dialog.server.add.passwordPlaceholder": "senha",
"server.row.noUsername": "sem nome de usuário",
"session.review.noVcs.createGit.title": "Criar um repositório Git",
"session.review.noVcs.createGit.description": "Rastreie, revise e desfaça alterações neste projeto",
"session.review.noVcs.createGit.actionLoading": "Criando repositório Git...",
"session.review.noVcs.createGit.action": "Criar repositório Git",
"session.todo.progress": "{{done}} de {{total}} tarefas concluídas",
"session.question.progress": "{{current}} de {{total}} perguntas",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Explorador de Arquivos",
"session.header.open.fileManager": "Gerenciador de Arquivos",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Diagnóstico de desempenho de desenvolvimento",
"debugBar.na": "n/a",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Última transição de rota concluída tocando em uma página de sessão, medida desde o início do roteador até a primeira pintura após o estabelecimento.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Quadros por segundo nos últimos 5 segundos.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Pior tempo de quadro nos últimos 5 segundos.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Quadros acima de 32ms nos últimos 5 segundos.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Tempo bloqueado e contagem de tarefas longas nos últimos 5 segundos. Tarefa máx: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Pior atraso de entrada observado nos últimos 5 segundos.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Duração aproximada da interação nos últimos 5 segundos. Isso é semelhante ao INP, não o INP oficial do Web Vitals.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Mudança cumulativa de layout para o tempo de vida atual do aplicativo.",
"debugBar.mem.label": "MEM",
"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}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Espaço",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "desconhecido",
"error.page.circular": "[Circular]",
"error.globalSDK.noServerAvailable": "Nenhum servidor disponível",
"error.globalSDK.serverNotAvailable": "Servidor indisponível",
"error.childStore.persistedCacheCreateFailed": "Falha ao criar cache persistente",
"error.childStore.persistedProjectMetadataCreateFailed": "Falha ao criar metadados de projeto persistentes",
"error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente",
"error.childStore.storeCreateFailed": "Falha ao criar armazenamento",
"terminal.connectionLost.abnormalClose": "WebSocket fechado anormalmente: {{code}}",
} }
+79 -3
View File
@@ -221,6 +221,7 @@ export const dict = {
"common.cancel": "Otkaži", "common.cancel": "Otkaži",
"common.connect": "Poveži", "common.connect": "Poveži",
"common.disconnect": "Prekini vezu", "common.disconnect": "Prekini vezu",
"common.continue": "Pošalji",
"common.submit": "Pošalji", "common.submit": "Pošalji",
"common.save": "Sačuvaj", "common.save": "Sačuvaj",
"common.saving": "Čuvanje...", "common.saving": "Čuvanje...",
@@ -264,7 +265,7 @@ export const dict = {
"prompt.popover.emptyResults": "Nema rezultata", "prompt.popover.emptyResults": "Nema rezultata",
"prompt.popover.emptyCommands": "Nema komandi", "prompt.popover.emptyCommands": "Nema komandi",
"prompt.dropzone.label": "Spusti slike ili PDF-ove ovdje", "prompt.dropzone.label": "Ovdje prevucite slike, PDF-ove ili tekstualne datoteke",
"prompt.dropzone.file.label": "Spusti za @spominjanje datoteke", "prompt.dropzone.file.label": "Spusti za @spominjanje datoteke",
"prompt.slash.badge.custom": "prilagođeno", "prompt.slash.badge.custom": "prilagođeno",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
@@ -278,8 +279,8 @@ export const dict = {
"prompt.action.send": "Pošalji", "prompt.action.send": "Pošalji",
"prompt.action.stop": "Zaustavi", "prompt.action.stop": "Zaustavi",
"prompt.toast.pasteUnsupported.title": "Nepodržano lijepljenje", "prompt.toast.pasteUnsupported.title": "Nepodržan prilog",
"prompt.toast.pasteUnsupported.description": "Ovdje se mogu zalijepiti samo slike ili PDF-ovi.", "prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.",
"prompt.toast.modelAgentRequired.title": "Odaberi agenta i model", "prompt.toast.modelAgentRequired.title": "Odaberi agenta i model",
"prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.", "prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.",
"prompt.toast.worktreeCreateFailed.title": "Neuspješno kreiranje worktree-a", "prompt.toast.worktreeCreateFailed.title": "Neuspješno kreiranje worktree-a",
@@ -864,4 +865,79 @@ export const dict = {
"common.time.daysAgo.short": "prije {{count}} d", "common.time.daysAgo.short": "prije {{count}} d",
"settings.providers.connected.environmentDescription": "Povezano sa vašim varijablama okruženja", "settings.providers.connected.environmentDescription": "Povezano sa vašim varijablama okruženja",
"settings.providers.custom.description": "Dodajte provajdera kompatibilnog s OpenAI putem osnovnog URL-a.", "settings.providers.custom.description": "Dodajte provajdera kompatibilnog s OpenAI putem osnovnog URL-a.",
"app.server.unreachable": "Nije moguće pristupiti {{server}}",
"app.server.retrying": "Automatski ponovni pokušaj...",
"app.server.otherServers": "Drugi serveri",
"dialog.server.add.usernamePlaceholder": "korisničko ime",
"dialog.server.add.passwordPlaceholder": "lozinka",
"server.row.noUsername": "nema korisničkog imena",
"session.review.noVcs.createGit.title": "Kreiraj Git repozitorij",
"session.review.noVcs.createGit.description": "Pratite, pregledajte i poništite promjene u ovom projektu",
"session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...",
"session.review.noVcs.createGit.action": "Kreiraj Git repozitorij",
"session.todo.progress": "{{done}} od {{total}} zadataka završeno",
"session.question.progress": "{{current}} od {{total}} pitanja",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "File Explorer",
"session.header.open.fileManager": "File Manager",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Dijagnostika performansi razvoja",
"debugBar.na": "n/a",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Posljednji završeni prelazak rute koji dotiče stranicu sesije, mjeren od početka rutera do prvog iscrtavanja nakon smirivanja.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Kadrovi u sekundi tokom posljednjih 5 sekundi.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Najgore vrijeme kadra u posljednjih 5 sekundi.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Kadrovi duži od 32ms u posljednjih 5 sekundi.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Blokirano vrijeme i broj dugih zadataka u posljednjih 5 sekundi. Maks zadatak: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Najgore zabilježeno kašnjenje unosa u posljednjih 5 sekundi.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Približno trajanje interakcije tokom posljednjih 5 sekundi. Ovo je slično INP-u, nije službeni Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Kumulativni pomak rasporeda za trenutni životni vijek aplikacije.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Korišteni JS heap naspram limita heapa. Samo Chromium.",
"debugBar.mem.tip": "Korišteni JS heap naspram limita heapa. {{used}} od {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Space",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "nepoznato",
"error.page.circular": "[Kružno]",
"error.globalSDK.noServerAvailable": "Nema dostupnog servera",
"error.globalSDK.serverNotAvailable": "Server nije dostupan",
"error.childStore.persistedCacheCreateFailed": "Nije uspjelo kreiranje trajnog keša",
"error.childStore.persistedProjectMetadataCreateFailed": "Nije uspjelo kreiranje trajnih metapodataka projekta",
"error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta",
"error.childStore.storeCreateFailed": "Nije uspjelo kreiranje skladišta",
"terminal.connectionLost.abnormalClose": "WebSocket zatvoren nenormalno: {{code}}",
} }
+79 -3
View File
@@ -219,6 +219,7 @@ export const dict = {
"common.cancel": "Annuller", "common.cancel": "Annuller",
"common.connect": "Forbind", "common.connect": "Forbind",
"common.disconnect": "Frakobl", "common.disconnect": "Frakobl",
"common.continue": "Indsend",
"common.submit": "Indsend", "common.submit": "Indsend",
"common.save": "Gem", "common.save": "Gem",
"common.saving": "Gemmer...", "common.saving": "Gemmer...",
@@ -262,7 +263,7 @@ export const dict = {
"prompt.popover.emptyResults": "Ingen matchende resultater", "prompt.popover.emptyResults": "Ingen matchende resultater",
"prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.popover.emptyCommands": "Ingen matchende kommandoer",
"prompt.dropzone.label": "Slip billeder eller PDF'er her", "prompt.dropzone.label": "Slip billeder, PDF'er eller tekstfiler her",
"prompt.dropzone.file.label": "Slip for at @nævne fil", "prompt.dropzone.file.label": "Slip for at @nævne fil",
"prompt.slash.badge.custom": "brugerdefineret", "prompt.slash.badge.custom": "brugerdefineret",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
@@ -276,8 +277,8 @@ export const dict = {
"prompt.action.send": "Send", "prompt.action.send": "Send",
"prompt.action.stop": "Stop", "prompt.action.stop": "Stop",
"prompt.toast.pasteUnsupported.title": "Ikke understøttet indsæt", "prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning",
"prompt.toast.pasteUnsupported.description": "Kun billeder eller PDF'er kan indsættes her.", "prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.",
"prompt.toast.modelAgentRequired.title": "Vælg en agent og model", "prompt.toast.modelAgentRequired.title": "Vælg en agent og model",
"prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.", "prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.",
"prompt.toast.worktreeCreateFailed.title": "Kunne ikke oprette worktree", "prompt.toast.worktreeCreateFailed.title": "Kunne ikke oprette worktree",
@@ -858,4 +859,79 @@ export const dict = {
"common.time.daysAgo.short": "{{count}}d siden", "common.time.daysAgo.short": "{{count}}d siden",
"settings.providers.connected.environmentDescription": "Tilsluttet fra dine miljøvariabler", "settings.providers.connected.environmentDescription": "Tilsluttet fra dine miljøvariabler",
"settings.providers.custom.description": "Tilføj en OpenAI-kompatibel udbyder via basis-URL.", "settings.providers.custom.description": "Tilføj en OpenAI-kompatibel udbyder via basis-URL.",
"app.server.unreachable": "Kunne ikke nå {{server}}",
"app.server.retrying": "Prøver igen automatisk...",
"app.server.otherServers": "Andre servere",
"dialog.server.add.usernamePlaceholder": "brugernavn",
"dialog.server.add.passwordPlaceholder": "adgangskode",
"server.row.noUsername": "intet brugernavn",
"session.review.noVcs.createGit.title": "Opret et Git-repository",
"session.review.noVcs.createGit.description": "Spor, gennemgå og fortryd ændringer i dette projekt",
"session.review.noVcs.createGit.actionLoading": "Opretter Git-repository...",
"session.review.noVcs.createGit.action": "Opret Git-repository",
"session.todo.progress": "{{done}} af {{total}} opgaver fuldført",
"session.question.progress": "{{current}} af {{total}} spørgsmål",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Stifinder",
"session.header.open.fileManager": "Filhåndtering",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Udviklingsydelsesdiagnostik",
"debugBar.na": "n/a",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Sidste gennemførte ruteovergang, der berører en sessionsside, målt fra routerstart til den første optegning efter den falder til ro.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Rullende billeder pr. sekund over de sidste 5 sekunder.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Værste billedtid over de sidste 5 sekunder.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Billeder over 32ms i de sidste 5 sekunder.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Blokeret tid og antal lange opgaver i de sidste 5 sekunder. Maks opgave: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Værste observerede inputforsinkelse i de sidste 5 sekunder.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Omtrentlig interaktionsvarighed over de sidste 5 sekunder. Dette er INP-lignende, ikke den officielle Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Kumulativt layoutskift for den nuværende app-levetid.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Brugt JS-heap vs heap-grænse. Kun Chromium.",
"debugBar.mem.tip": "Brugt JS-heap vs heap-grænse. {{used}} af {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Mellemrum",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "ukendt",
"error.page.circular": "[Cirkulær]",
"error.globalSDK.noServerAvailable": "Ingen server tilgængelig",
"error.globalSDK.serverNotAvailable": "Server ikke tilgængelig",
"error.childStore.persistedCacheCreateFailed": "Kunne ikke oprette vedvarende cache",
"error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke oprette vedvarende projektmetadata",
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke oprette vedvarende projektikon",
"error.childStore.storeCreateFailed": "Kunne ikke oprette lager",
"terminal.connectionLost.abnormalClose": "WebSocket lukkede unormalt: {{code}}",
} }
+80 -3
View File
@@ -209,6 +209,7 @@ export const dict = {
"common.cancel": "Abbrechen", "common.cancel": "Abbrechen",
"common.connect": "Verbinden", "common.connect": "Verbinden",
"common.disconnect": "Trennen", "common.disconnect": "Trennen",
"common.continue": "Absenden",
"common.submit": "Absenden", "common.submit": "Absenden",
"common.save": "Speichern", "common.save": "Speichern",
"common.saving": "Speichert...", "common.saving": "Speichert...",
@@ -249,7 +250,7 @@ export const dict = {
"prompt.example.25": "Wie funktionieren Umgebungsvariablen hier?", "prompt.example.25": "Wie funktionieren Umgebungsvariablen hier?",
"prompt.popover.emptyResults": "Keine passenden Ergebnisse", "prompt.popover.emptyResults": "Keine passenden Ergebnisse",
"prompt.popover.emptyCommands": "Keine passenden Befehle", "prompt.popover.emptyCommands": "Keine passenden Befehle",
"prompt.dropzone.label": "Bilder oder PDFs hier ablegen", "prompt.dropzone.label": "Bilder, PDFs oder Textdateien hier ablegen",
"prompt.dropzone.file.label": "Ablegen zum @Erwähnen der Datei", "prompt.dropzone.file.label": "Ablegen zum @Erwähnen der Datei",
"prompt.slash.badge.custom": "benutzerdefiniert", "prompt.slash.badge.custom": "benutzerdefiniert",
"prompt.slash.badge.skill": "Skill", "prompt.slash.badge.skill": "Skill",
@@ -262,8 +263,8 @@ export const dict = {
"prompt.attachment.remove": "Anhang entfernen", "prompt.attachment.remove": "Anhang entfernen",
"prompt.action.send": "Senden", "prompt.action.send": "Senden",
"prompt.action.stop": "Stopp", "prompt.action.stop": "Stopp",
"prompt.toast.pasteUnsupported.title": "Nicht unterstütztes Einfügen", "prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang",
"prompt.toast.pasteUnsupported.description": "Hier können nur Bilder oder PDFs eingefügt werden.", "prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.",
"prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell", "prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell",
"prompt.toast.modelAgentRequired.description": "prompt.toast.modelAgentRequired.description":
"Wählen Sie einen Agenten und ein Modell, bevor Sie eine Eingabe senden.", "Wählen Sie einen Agenten und ein Modell, bevor Sie eine Eingabe senden.",
@@ -799,4 +800,80 @@ export const dict = {
"common.time.daysAgo.short": "vor {{count}} Tg", "common.time.daysAgo.short": "vor {{count}} Tg",
"settings.providers.connected.environmentDescription": "Verbunden aus Ihren Umgebungsvariablen", "settings.providers.connected.environmentDescription": "Verbunden aus Ihren Umgebungsvariablen",
"settings.providers.custom.description": "Fügen Sie einen OpenAI-kompatiblen Anbieter per Basis-URL hinzu.", "settings.providers.custom.description": "Fügen Sie einen OpenAI-kompatiblen Anbieter per Basis-URL hinzu.",
"app.server.unreachable": "Konnte {{server}} nicht erreichen",
"app.server.retrying": "Automatische erneute Verbindung...",
"app.server.otherServers": "Andere Server",
"dialog.server.add.usernamePlaceholder": "Benutzername",
"dialog.server.add.passwordPlaceholder": "Passwort",
"server.row.noUsername": "Kein Benutzername",
"session.review.noVcs.createGit.title": "Git-Repository erstellen",
"session.review.noVcs.createGit.description":
"Änderungen in diesem Projekt verfolgen, überprüfen und rückgängig machen",
"session.review.noVcs.createGit.actionLoading": "Git-Repository wird erstellt...",
"session.review.noVcs.createGit.action": "Git-Repository erstellen",
"session.todo.progress": "{{done}} von {{total}} Aufgaben erledigt",
"session.question.progress": "{{current}} von {{total}} Fragen",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Datei-Explorer",
"session.header.open.fileManager": "Dateimanager",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Entwicklungs-Leistungsdiagnose",
"debugBar.na": "n.v.",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Letzter abgeschlossener Routenübergang, der eine Sitzungsseite berührt, gemessen vom Start des Routers bis zum ersten Rendern nach dem Einschwingen.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Gleitende Bilder pro Sekunde in den letzten 5 Sekunden.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Schlechteste Frame-Zeit in den letzten 5 Sekunden.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Frames über 32ms in den letzten 5 Sekunden.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Blockierte Zeit und Anzahl langer Aufgaben in den letzten 5 Sekunden. Max Aufgabe: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Schlechteste beobachtete Eingabeverzögerung in den letzten 5 Sekunden.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Ungefähre Interaktionsdauer in den letzten 5 Sekunden. Dies ist INP-ähnlich, nicht das offizielle Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Kumulative Layoutverschiebung für die aktuelle App-Lebensdauer.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Verwendeter JS-Heap vs Heap-Limit. Nur Chromium.",
"debugBar.mem.tip": "Verwendeter JS-Heap vs Heap-Limit. {{used}} von {{limit}}.",
"common.key.ctrl": "Strg",
"common.key.alt": "Alt",
"common.key.shift": "Umschalt",
"common.key.meta": "Meta",
"common.key.space": "Leertaste",
"common.key.backspace": "Rücktaste",
"common.key.enter": "Eingabe",
"common.key.tab": "Tab",
"common.key.delete": "Entf",
"common.key.home": "Pos1",
"common.key.end": "Ende",
"common.key.pageUp": "Bild auf",
"common.key.pageDown": "Bild ab",
"common.key.insert": "Einfg",
"common.unknown": "unbekannt",
"error.page.circular": "[Zirkulär]",
"error.globalSDK.noServerAvailable": "Kein Server verfügbar",
"error.globalSDK.serverNotAvailable": "Server nicht verfügbar",
"error.childStore.persistedCacheCreateFailed": "Dauerhafter Cache konnte nicht erstellt werden",
"error.childStore.persistedProjectMetadataCreateFailed": "Dauerhafte Projektmetadaten konnten nicht erstellt werden",
"error.childStore.persistedProjectIconCreateFailed": "Dauerhaftes Projekticon konnte nicht erstellt werden",
"error.childStore.storeCreateFailed": "Speicher konnte nicht erstellt werden",
"terminal.connectionLost.abnormalClose": "WebSocket abnormal geschlossen: {{code}}",
} satisfies Partial<Record<Keys, string>> } satisfies Partial<Record<Keys, string>>
+82 -3
View File
@@ -221,6 +221,7 @@ export const dict = {
"common.open": "Open", "common.open": "Open",
"common.connect": "Connect", "common.connect": "Connect",
"common.disconnect": "Disconnect", "common.disconnect": "Disconnect",
"common.continue": "Continue",
"common.submit": "Submit", "common.submit": "Submit",
"common.save": "Save", "common.save": "Save",
"common.saving": "Saving...", "common.saving": "Saving...",
@@ -264,7 +265,7 @@ export const dict = {
"prompt.popover.emptyResults": "No matching results", "prompt.popover.emptyResults": "No matching results",
"prompt.popover.emptyCommands": "No matching commands", "prompt.popover.emptyCommands": "No matching commands",
"prompt.dropzone.label": "Drop images or PDFs here", "prompt.dropzone.label": "Drop images, PDFs, or text files here",
"prompt.dropzone.file.label": "Drop to @mention file", "prompt.dropzone.file.label": "Drop to @mention file",
"prompt.slash.badge.custom": "custom", "prompt.slash.badge.custom": "custom",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
@@ -278,8 +279,8 @@ export const dict = {
"prompt.action.send": "Send", "prompt.action.send": "Send",
"prompt.action.stop": "Stop", "prompt.action.stop": "Stop",
"prompt.toast.pasteUnsupported.title": "Unsupported paste", "prompt.toast.pasteUnsupported.title": "Unsupported attachment",
"prompt.toast.pasteUnsupported.description": "Only images or PDFs can be pasted here.", "prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.",
"prompt.toast.modelAgentRequired.title": "Select an agent and model", "prompt.toast.modelAgentRequired.title": "Select an agent and model",
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.", "prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree", "prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
@@ -306,6 +307,10 @@ export const dict = {
"dialog.directory.search.placeholder": "Search folders", "dialog.directory.search.placeholder": "Search folders",
"dialog.directory.empty": "No folders found", "dialog.directory.empty": "No folders found",
"app.server.unreachable": "Could not reach {{server}}",
"app.server.retrying": "Retrying automatically...",
"app.server.otherServers": "Other servers",
"dialog.server.title": "Servers", "dialog.server.title": "Servers",
"dialog.server.description": "Switch which OpenCode server this app connects to.", "dialog.server.description": "Switch which OpenCode server this app connects to.",
"dialog.server.search.placeholder": "Search servers", "dialog.server.search.placeholder": "Search servers",
@@ -319,7 +324,9 @@ export const dict = {
"dialog.server.add.name": "Server name (optional)", "dialog.server.add.name": "Server name (optional)",
"dialog.server.add.namePlaceholder": "Localhost", "dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Username (optional)", "dialog.server.add.username": "Username (optional)",
"dialog.server.add.usernamePlaceholder": "username",
"dialog.server.add.password": "Password (optional)", "dialog.server.add.password": "Password (optional)",
"dialog.server.add.passwordPlaceholder": "password",
"dialog.server.edit.title": "Edit server", "dialog.server.edit.title": "Edit server",
"dialog.server.default.title": "Default server", "dialog.server.default.title": "Default server",
"dialog.server.default.description": "dialog.server.default.description":
@@ -335,6 +342,7 @@ export const dict = {
"dialog.server.menu.delete": "Delete", "dialog.server.menu.delete": "Delete",
"dialog.server.current": "Current Server", "dialog.server.current": "Current Server",
"dialog.server.status.default": "Default", "dialog.server.status.default": "Default",
"server.row.noUsername": "no username",
"dialog.project.edit.title": "Edit project", "dialog.project.edit.title": "Edit project",
"dialog.project.edit.name": "Name", "dialog.project.edit.name": "Name",
@@ -456,6 +464,7 @@ export const dict = {
"error.page.action.checking": "Checking...", "error.page.action.checking": "Checking...",
"error.page.action.checkUpdates": "Check for updates", "error.page.action.checkUpdates": "Check for updates",
"error.page.action.updateTo": "Update to {{version}}", "error.page.action.updateTo": "Update to {{version}}",
"error.page.circular": "[Circular]",
"error.page.report.prefix": "Please report this error to the OpenCode team", "error.page.report.prefix": "Please report this error to the OpenCode team",
"error.page.report.discord": "on Discord", "error.page.report.discord": "on Discord",
"error.page.version": "Version: {{version}}", "error.page.version": "Version: {{version}}",
@@ -464,6 +473,12 @@ export const dict = {
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
"error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?", "error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?",
"error.globalSDK.noServerAvailable": "No server available",
"error.globalSDK.serverNotAvailable": "Server not available",
"error.childStore.persistedCacheCreateFailed": "Failed to create persisted cache",
"error.childStore.persistedProjectMetadataCreateFailed": "Failed to create persisted project metadata",
"error.childStore.persistedProjectIconCreateFailed": "Failed to create persisted project icon",
"error.childStore.storeCreateFailed": "Failed to create store",
"directory.error.invalidUrl": "Invalid directory in URL.", "directory.error.invalidUrl": "Invalid directory in URL.",
"error.chain.unknown": "Unknown error", "error.chain.unknown": "Unknown error",
@@ -512,6 +527,10 @@ export const dict = {
"session.review.loadingChanges": "Loading changes...", "session.review.loadingChanges": "Loading changes...",
"session.review.empty": "No changes in this session yet", "session.review.empty": "No changes in this session yet",
"session.review.noVcs": "No Git Version Control System detected, changes not displayed", "session.review.noVcs": "No Git Version Control System detected, changes not displayed",
"session.review.noVcs.createGit.title": "Create a Git repository",
"session.review.noVcs.createGit.description": "Track, review, and undo changes in this project",
"session.review.noVcs.createGit.actionLoading": "Creating Git repository...",
"session.review.noVcs.createGit.action": "Create Git repository",
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable", "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
"session.review.noChanges": "No changes", "session.review.noChanges": "No changes",
@@ -530,6 +549,8 @@ export const dict = {
"session.todo.title": "Todos", "session.todo.title": "Todos",
"session.todo.collapse": "Collapse", "session.todo.collapse": "Collapse",
"session.todo.expand": "Expand", "session.todo.expand": "Expand",
"session.todo.progress": "{{done}} of {{total}} todos completed",
"session.question.progress": "{{current}} of {{total}} questions",
"session.followupDock.summary.one": "{{count}} queued message", "session.followupDock.summary.one": "{{count}} queued message",
"session.followupDock.summary.other": "{{count}} queued messages", "session.followupDock.summary.other": "{{count}} queued messages",
"session.followupDock.sendNow": "Send now", "session.followupDock.sendNow": "Send now",
@@ -555,6 +576,22 @@ export const dict = {
"session.header.open.ariaLabel": "Open in {{app}}", "session.header.open.ariaLabel": "Open in {{app}}",
"session.header.open.menu": "Open options", "session.header.open.menu": "Open options",
"session.header.open.copyPath": "Copy path", "session.header.open.copyPath": "Copy path",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "File Explorer",
"session.header.open.fileManager": "File Manager",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"status.popover.trigger": "Status", "status.popover.trigger": "Status",
"status.popover.ariaLabel": "Server configurations", "status.popover.ariaLabel": "Server configurations",
@@ -587,6 +624,7 @@ export const dict = {
"terminal.title.numbered": "Terminal {{number}}", "terminal.title.numbered": "Terminal {{number}}",
"terminal.close": "Close terminal", "terminal.close": "Close terminal",
"terminal.connectionLost.title": "Connection Lost", "terminal.connectionLost.title": "Connection Lost",
"terminal.connectionLost.abnormalClose": "WebSocket closed abnormally: {{code}}",
"terminal.connectionLost.description": "terminal.connectionLost.description":
"The terminal connection was interrupted. This can happen when the server restarts.", "The terminal connection was interrupted. This can happen when the server restarts.",
@@ -604,6 +642,21 @@ export const dict = {
"common.edit": "Edit", "common.edit": "Edit",
"common.loadMore": "Load more", "common.loadMore": "Load more",
"common.key.esc": "ESC", "common.key.esc": "ESC",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Space",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "unknown",
"common.time.justNow": "Just now", "common.time.justNow": "Just now",
"common.time.minutesAgo.short": "{{count}}m ago", "common.time.minutesAgo.short": "{{count}}m ago",
@@ -622,6 +675,32 @@ export const dict = {
"sidebar.project.recentSessions": "Recent sessions", "sidebar.project.recentSessions": "Recent sessions",
"sidebar.project.viewAllSessions": "View all sessions", "sidebar.project.viewAllSessions": "View all sessions",
"sidebar.project.clearNotifications": "Clear notifications", "sidebar.project.clearNotifications": "Clear notifications",
"sidebar.empty.title": "No projects open",
"sidebar.empty.description": "Open a project to get started",
"debugBar.ariaLabel": "Development performance diagnostics",
"debugBar.na": "n/a",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Last completed route transition touching a session page, measured from router start until the first paint after it settles.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Rolling frames per second over the last 5 seconds.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Worst frame time over the last 5 seconds.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Frames over 32ms in the last 5 seconds.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Blocked time and long-task count in the last 5 seconds. Max task: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Worst observed input delay in the last 5 seconds.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Cumulative layout shift for the current app lifetime.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Used JS heap vs heap limit. Chromium only.",
"debugBar.mem.tip": "Used JS heap vs heap limit. {{used}} of {{limit}}.",
"app.name.desktop": "OpenCode Desktop", "app.name.desktop": "OpenCode Desktop",
+79 -3
View File
@@ -220,6 +220,7 @@ export const dict = {
"common.cancel": "Cancelar", "common.cancel": "Cancelar",
"common.connect": "Conectar", "common.connect": "Conectar",
"common.disconnect": "Desconectar", "common.disconnect": "Desconectar",
"common.continue": "Enviar",
"common.submit": "Enviar", "common.submit": "Enviar",
"common.save": "Guardar", "common.save": "Guardar",
"common.saving": "Guardando...", "common.saving": "Guardando...",
@@ -263,7 +264,7 @@ export const dict = {
"prompt.popover.emptyResults": "Sin resultados coincidentes", "prompt.popover.emptyResults": "Sin resultados coincidentes",
"prompt.popover.emptyCommands": "Sin comandos coincidentes", "prompt.popover.emptyCommands": "Sin comandos coincidentes",
"prompt.dropzone.label": "Suelta imágenes o PDFs aquí", "prompt.dropzone.label": "Suelta imágenes, PDFs o archivos de texto aquí",
"prompt.dropzone.file.label": "Suelta para @mencionar archivo", "prompt.dropzone.file.label": "Suelta para @mencionar archivo",
"prompt.slash.badge.custom": "personalizado", "prompt.slash.badge.custom": "personalizado",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
@@ -277,8 +278,8 @@ export const dict = {
"prompt.action.send": "Enviar", "prompt.action.send": "Enviar",
"prompt.action.stop": "Detener", "prompt.action.stop": "Detener",
"prompt.toast.pasteUnsupported.title": "Pegado no soportado", "prompt.toast.pasteUnsupported.title": "Adjunto no compatible",
"prompt.toast.pasteUnsupported.description": "Solo se pueden pegar imágenes o PDFs aquí.", "prompt.toast.pasteUnsupported.description": "Solo se pueden adjuntar imágenes, PDFs o archivos de texto aquí.",
"prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo", "prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo",
"prompt.toast.modelAgentRequired.description": "Elige un agente y modelo antes de enviar un prompt.", "prompt.toast.modelAgentRequired.description": "Elige un agente y modelo antes de enviar un prompt.",
"prompt.toast.worktreeCreateFailed.title": "Fallo al crear el árbol de trabajo", "prompt.toast.worktreeCreateFailed.title": "Fallo al crear el árbol de trabajo",
@@ -871,4 +872,79 @@ export const dict = {
"common.time.daysAgo.short": "hace {{count}} d", "common.time.daysAgo.short": "hace {{count}} d",
"settings.providers.connected.environmentDescription": "Conectado desde tus variables de entorno", "settings.providers.connected.environmentDescription": "Conectado desde tus variables de entorno",
"settings.providers.custom.description": "Añade un proveedor compatible con OpenAI por su URL base.", "settings.providers.custom.description": "Añade un proveedor compatible con OpenAI por su URL base.",
"app.server.unreachable": "No se pudo conectar con {{server}}",
"app.server.retrying": "Reintentando automáticamente...",
"app.server.otherServers": "Otros servidores",
"dialog.server.add.usernamePlaceholder": "usuario",
"dialog.server.add.passwordPlaceholder": "contraseña",
"server.row.noUsername": "sin usuario",
"session.review.noVcs.createGit.title": "Crear repositorio Git",
"session.review.noVcs.createGit.description": "Rastrea, revisa y deshaz cambios en este proyecto",
"session.review.noVcs.createGit.actionLoading": "Creando repositorio Git...",
"session.review.noVcs.createGit.action": "Crear repositorio Git",
"session.todo.progress": "{{done}} de {{total}} tareas completadas",
"session.question.progress": "{{current}} de {{total}} preguntas",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Explorador de archivos",
"session.header.open.fileManager": "Gestor de archivos",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Diagnóstico de rendimiento de desarrollo",
"debugBar.na": "n/d",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Última transición de ruta completada tocando una página de sesión, medida desde el inicio del router hasta el primer pintado después de asentarse.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Cuadros por segundo en los últimos 5 segundos.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Peor tiempo de cuadro en los últimos 5 segundos.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Cuadros superiores a 32ms en los últimos 5 segundos.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Tiempo bloqueado y recuento de tareas largas en los últimos 5 segundos. Tarea máx: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Peor retraso de entrada observado en los últimos 5 segundos.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Duración aproximada de la interacción en los últimos 5 segundos. Esto es similar a INP, no el INP oficial de Web Vitals.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Cambio de diseño acumulativo para la vida útil actual de la aplicación.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Heap JS usado vs límite de heap. Solo Chromium.",
"debugBar.mem.tip": "Heap JS usado vs límite de heap. {{used}} de {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Mayús",
"common.key.meta": "Meta",
"common.key.space": "Espacio",
"common.key.backspace": "Retroceso",
"common.key.enter": "Intro",
"common.key.tab": "Tab",
"common.key.delete": "Supr",
"common.key.home": "Inicio",
"common.key.end": "Fin",
"common.key.pageUp": "RePág",
"common.key.pageDown": "AvPág",
"common.key.insert": "Insert",
"common.unknown": "desconocido",
"error.page.circular": "[Circular]",
"error.globalSDK.noServerAvailable": "Ningún servidor disponible",
"error.globalSDK.serverNotAvailable": "Servidor no disponible",
"error.childStore.persistedCacheCreateFailed": "Error al crear caché persistente",
"error.childStore.persistedProjectMetadataCreateFailed": "Error al crear metadatos de proyecto persistentes",
"error.childStore.persistedProjectIconCreateFailed": "Error al crear icono de proyecto persistente",
"error.childStore.storeCreateFailed": "Error al crear almacén",
"terminal.connectionLost.abnormalClose": "WebSocket cerrado anormalmente: {{code}}",
} }
+82 -3
View File
@@ -204,6 +204,7 @@ export const dict = {
"common.cancel": "Annuler", "common.cancel": "Annuler",
"common.connect": "Connecter", "common.connect": "Connecter",
"common.disconnect": "Déconnecter", "common.disconnect": "Déconnecter",
"common.continue": "Soumettre",
"common.submit": "Soumettre", "common.submit": "Soumettre",
"common.save": "Enregistrer", "common.save": "Enregistrer",
"common.saving": "Enregistrement...", "common.saving": "Enregistrement...",
@@ -244,7 +245,7 @@ export const dict = {
"prompt.example.25": "Comment fonctionnent les variables d'environnement ici ?", "prompt.example.25": "Comment fonctionnent les variables d'environnement ici ?",
"prompt.popover.emptyResults": "Aucun résultat correspondant", "prompt.popover.emptyResults": "Aucun résultat correspondant",
"prompt.popover.emptyCommands": "Aucune commande correspondante", "prompt.popover.emptyCommands": "Aucune commande correspondante",
"prompt.dropzone.label": "Déposez des images ou des PDF ici", "prompt.dropzone.label": "Déposez des images, des PDF ou des fichiers texte ici",
"prompt.dropzone.file.label": "Déposez pour @mentionner le fichier", "prompt.dropzone.file.label": "Déposez pour @mentionner le fichier",
"prompt.slash.badge.custom": "personnalisé", "prompt.slash.badge.custom": "personnalisé",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
@@ -257,8 +258,9 @@ export const dict = {
"prompt.attachment.remove": "Supprimer la pièce jointe", "prompt.attachment.remove": "Supprimer la pièce jointe",
"prompt.action.send": "Envoyer", "prompt.action.send": "Envoyer",
"prompt.action.stop": "Arrêter", "prompt.action.stop": "Arrêter",
"prompt.toast.pasteUnsupported.title": "Collage non supporté", "prompt.toast.pasteUnsupported.title": "Pièce jointe non prise en charge",
"prompt.toast.pasteUnsupported.description": "Seules les images ou les PDF peuvent être collés ici.", "prompt.toast.pasteUnsupported.description":
"Seules les images, les PDF ou les fichiers texte peuvent être joints ici.",
"prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle", "prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle",
"prompt.toast.modelAgentRequired.description": "Choisissez un agent et un modèle avant d'envoyer un message.", "prompt.toast.modelAgentRequired.description": "Choisissez un agent et un modèle avant d'envoyer un message.",
"prompt.toast.worktreeCreateFailed.title": "Échec de la création de l'arbre de travail", "prompt.toast.worktreeCreateFailed.title": "Échec de la création de l'arbre de travail",
@@ -796,4 +798,81 @@ export const dict = {
"common.time.daysAgo.short": "il y a {{count}}j", "common.time.daysAgo.short": "il y a {{count}}j",
"settings.providers.connected.environmentDescription": "Connecté à partir de vos variables d'environnement", "settings.providers.connected.environmentDescription": "Connecté à partir de vos variables d'environnement",
"settings.providers.custom.description": "Ajouter un fournisseur compatible avec OpenAI via l'URL de base.", "settings.providers.custom.description": "Ajouter un fournisseur compatible avec OpenAI via l'URL de base.",
"app.server.unreachable": "Impossible de joindre {{server}}",
"app.server.retrying": "Nouvelle tentative automatique...",
"app.server.otherServers": "Autres serveurs",
"dialog.server.add.usernamePlaceholder": "nom d'utilisateur",
"dialog.server.add.passwordPlaceholder": "mot de passe",
"server.row.noUsername": "aucun nom d'utilisateur",
"session.review.noVcs.createGit.title": "Créer un dépôt Git",
"session.review.noVcs.createGit.description": "Suivre, examiner et annuler les modifications dans ce projet",
"session.review.noVcs.createGit.actionLoading": "Création du dépôt Git...",
"session.review.noVcs.createGit.action": "Créer un dépôt Git",
"session.todo.progress": "{{done}} tâches sur {{total}} terminées",
"session.question.progress": "{{current}} questions sur {{total}}",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Explorateur de fichiers",
"session.header.open.fileManager": "Gestionnaire de fichiers",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Diagnostics de performance de développement",
"debugBar.na": "n/a",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Dernière transition de route terminée touchant une page de session, mesurée du début du routeur jusqu'au premier affichage après stabilisation.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Images par seconde glissantes sur les 5 dernières secondes.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Pire temps d'image sur les 5 dernières secondes.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Images de plus de 32ms au cours des 5 dernières secondes.",
"debugBar.long.label": "LONG",
"debugBar.long.tip":
"Temps bloqué et nombre de tâches longues au cours des 5 dernières secondes. Tâche max : {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Pire délai d'entrée observé au cours des 5 dernières secondes.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Durée approximative d'interaction au cours des 5 dernières secondes. Ceci est similaire à INP, pas le INP officiel des Web Vitals.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Décalage cumulatif de la mise en page pour la durée de vie actuelle de l'application.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Tas JS utilisé vs limite de tas. Chromium uniquement.",
"debugBar.mem.tip": "Tas JS utilisé vs limite de tas. {{used}} sur {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Maj",
"common.key.meta": "Méta",
"common.key.space": "Espace",
"common.key.backspace": "Retour arrière",
"common.key.enter": "Entrée",
"common.key.tab": "Tab",
"common.key.delete": "Suppr",
"common.key.home": "Début",
"common.key.end": "Fin",
"common.key.pageUp": "Page précédente",
"common.key.pageDown": "Page suivante",
"common.key.insert": "Inser",
"common.unknown": "inconnu",
"error.page.circular": "[Circulaire]",
"error.globalSDK.noServerAvailable": "Aucun serveur disponible",
"error.globalSDK.serverNotAvailable": "Serveur non disponible",
"error.childStore.persistedCacheCreateFailed": "Échec de la création du cache persistant",
"error.childStore.persistedProjectMetadataCreateFailed":
"Échec de la création des métadonnées de projet persistantes",
"error.childStore.persistedProjectIconCreateFailed": "Échec de la création de l'icône de projet persistante",
"error.childStore.storeCreateFailed": "Échec de la création du stockage",
"terminal.connectionLost.abnormalClose": "WebSocket fermé anormalement : {{code}}",
} }
+78 -3
View File
@@ -203,6 +203,7 @@ export const dict = {
"common.cancel": "キャンセル", "common.cancel": "キャンセル",
"common.connect": "接続", "common.connect": "接続",
"common.disconnect": "切断", "common.disconnect": "切断",
"common.continue": "送信",
"common.submit": "送信", "common.submit": "送信",
"common.save": "保存", "common.save": "保存",
"common.saving": "保存中...", "common.saving": "保存中...",
@@ -243,7 +244,7 @@ export const dict = {
"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": "スキル",
@@ -256,8 +257,8 @@ export const dict = {
"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.pasteUnsupported.description": "ここでは画像またはPDFのみ貼り付け可能です。", "prompt.toast.pasteUnsupported.description": "画像、PDF、またはテキストファイルのみ添付できます。",
"prompt.toast.modelAgentRequired.title": "エージェントとモデルを選択", "prompt.toast.modelAgentRequired.title": "エージェントとモデルを選択",
"prompt.toast.modelAgentRequired.description": "プロンプトを送信する前にエージェントとモデルを選択してください。", "prompt.toast.modelAgentRequired.description": "プロンプトを送信する前にエージェントとモデルを選択してください。",
"prompt.toast.worktreeCreateFailed.title": "ワークツリーの作成に失敗しました", "prompt.toast.worktreeCreateFailed.title": "ワークツリーの作成に失敗しました",
@@ -783,4 +784,78 @@ export const dict = {
"common.time.daysAgo.short": "{{count}} 日前", "common.time.daysAgo.short": "{{count}} 日前",
"settings.providers.connected.environmentDescription": "環境変数から接続されました", "settings.providers.connected.environmentDescription": "環境変数から接続されました",
"settings.providers.custom.description": "ベース URL を指定して OpenAI 互換のプロバイダーを追加します。", "settings.providers.custom.description": "ベース URL を指定して OpenAI 互換のプロバイダーを追加します。",
"app.server.unreachable": "{{server}} に到達できませんでした",
"app.server.retrying": "自動的に再試行中...",
"app.server.otherServers": "その他のサーバー",
"dialog.server.add.usernamePlaceholder": "ユーザー名",
"dialog.server.add.passwordPlaceholder": "パスワード",
"server.row.noUsername": "ユーザー名なし",
"session.review.noVcs.createGit.title": "Git リポジトリを作成",
"session.review.noVcs.createGit.description": "このプロジェクトの変更を追跡、レビュー、元に戻す",
"session.review.noVcs.createGit.actionLoading": "Git リポジトリを作成中...",
"session.review.noVcs.createGit.action": "Git リポジトリを作成",
"session.todo.progress": "{{done}} 個中 {{total}} 個の Todo が完了",
"session.question.progress": "{{total}} 問中 {{current}} 問",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "エクスプローラー",
"session.header.open.fileManager": "ファイルマネージャー",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "ターミナル",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "開発パフォーマンス診断",
"debugBar.na": "n/a",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip": "セッションページに触れる最後に完了したルート遷移。ルーター開始から安定後の最初の描画まで測定。",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "過去5秒間のローリングフレーム/秒。",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "過去5秒間の最悪フレーム時間。",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "過去5秒間で32msを超えたフレーム。",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "過去5秒間のブロック時間と長時間タスク数。最大タスク: {{max}}。",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "過去5秒間で観測された最悪の入力遅延。",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"過去5秒間の概算インタラクション時間。これは INP に似ていますが、公式の Web Vitals INP ではありません。",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "現在のアプリ寿命の累積レイアウトシフト。",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "使用中の JS ヒープ対ヒープ制限。Chromium のみ。",
"debugBar.mem.tip": "使用中の JS ヒープ対ヒープ制限。{{limit}} 中 {{used}}。",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Space",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "不明",
"error.page.circular": "[循環]",
"error.globalSDK.noServerAvailable": "利用可能なサーバーがありません",
"error.globalSDK.serverNotAvailable": "サーバーが利用できません",
"error.childStore.persistedCacheCreateFailed": "永続キャッシュの作成に失敗しました",
"error.childStore.persistedProjectMetadataCreateFailed": "永続プロジェクトメタデータの作成に失敗しました",
"error.childStore.persistedProjectIconCreateFailed": "永続プロジェクトアイコンの作成に失敗しました",
"error.childStore.storeCreateFailed": "ストアの作成に失敗しました",
"terminal.connectionLost.abnormalClose": "WebSocket が異常終了しました: {{code}}",
} }
+78 -3
View File
@@ -207,6 +207,7 @@ export const dict = {
"common.cancel": "취소", "common.cancel": "취소",
"common.connect": "연결", "common.connect": "연결",
"common.disconnect": "연결 해제", "common.disconnect": "연결 해제",
"common.continue": "제출",
"common.submit": "제출", "common.submit": "제출",
"common.save": "저장", "common.save": "저장",
"common.saving": "저장 중...", "common.saving": "저장 중...",
@@ -247,7 +248,7 @@ export const dict = {
"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": "스킬",
@@ -260,8 +261,8 @@ export const dict = {
"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.pasteUnsupported.description": "이미지 PDF만 붙여넣을 수 있습니다.", "prompt.toast.pasteUnsupported.description": "이미지, PDF 또는 텍스트 파일만 첨부할 수 있습니다.",
"prompt.toast.modelAgentRequired.title": "에이전트 및 모델 선택", "prompt.toast.modelAgentRequired.title": "에이전트 및 모델 선택",
"prompt.toast.modelAgentRequired.description": "프롬프트를 보내기 전에 에이전트와 모델을 선택하세요.", "prompt.toast.modelAgentRequired.description": "프롬프트를 보내기 전에 에이전트와 모델을 선택하세요.",
"prompt.toast.worktreeCreateFailed.title": "작업 트리 생성 실패", "prompt.toast.worktreeCreateFailed.title": "작업 트리 생성 실패",
@@ -782,4 +783,78 @@ export const dict = {
"common.time.daysAgo.short": "{{count}}일 전", "common.time.daysAgo.short": "{{count}}일 전",
"settings.providers.connected.environmentDescription": "환경 변수에서 연결됨", "settings.providers.connected.environmentDescription": "환경 변수에서 연결됨",
"settings.providers.custom.description": "기본 URL로 OpenAI 호환 공급자를 추가합니다.", "settings.providers.custom.description": "기본 URL로 OpenAI 호환 공급자를 추가합니다.",
"app.server.unreachable": "{{server}}에 연결할 수 없습니다",
"app.server.retrying": "자동으로 재시도 중...",
"app.server.otherServers": "다른 서버",
"dialog.server.add.usernamePlaceholder": "사용자 이름",
"dialog.server.add.passwordPlaceholder": "비밀번호",
"server.row.noUsername": "사용자 이름 없음",
"session.review.noVcs.createGit.title": "Git 저장소 생성",
"session.review.noVcs.createGit.description": "이 프로젝트의 변경 사항을 추적, 검토 및 실행 취소",
"session.review.noVcs.createGit.actionLoading": "Git 저장소 생성 중...",
"session.review.noVcs.createGit.action": "Git 저장소 생성",
"session.todo.progress": "{{total}}개의 할 일 중 {{done}}개 완료",
"session.question.progress": "{{total}}개의 질문 중 {{current}}개",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "파일 탐색기",
"session.header.open.fileManager": "파일 관리자",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "터미널",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "개발 성능 진단",
"debugBar.na": "해당 없음",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"세션 페이지에 닿은 마지막 완료된 라우트 전환. 라우터 시작부터 정착 후 첫 번째 페인트까지 측정됨.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "지난 5초간의 초당 프레임 수.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "지난 5초간의 최악의 프레임 시간.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "지난 5초간 32ms를 초과한 프레임.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "지난 5초간의 차단된 시간 및 긴 작업 수. 최대 작업: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "지난 5초간 관찰된 최악의 입력 지연.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip": "지난 5초간의 대략적인 상호작용 지속 시간. 이것은 공식 Web Vitals INP가 아닌 INP와 유사합니다.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "현재 앱 수명 동안의 누적 레이아웃 이동.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "사용된 JS 힙 대 힙 제한. Chromium 전용.",
"debugBar.mem.tip": "사용된 JS 힙 대 힙 제한. {{limit}} 중 {{used}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Space",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "알 수 없음",
"error.page.circular": "[순환]",
"error.globalSDK.noServerAvailable": "사용 가능한 서버 없음",
"error.globalSDK.serverNotAvailable": "서버를 사용할 수 없음",
"error.childStore.persistedCacheCreateFailed": "영구 캐시 생성 실패",
"error.childStore.persistedProjectMetadataCreateFailed": "영구 프로젝트 메타데이터 생성 실패",
"error.childStore.persistedProjectIconCreateFailed": "영구 프로젝트 아이콘 생성 실패",
"error.childStore.storeCreateFailed": "저장소 생성 실패",
"terminal.connectionLost.abnormalClose": "WebSocket이 비정상적으로 닫힘: {{code}}",
} }
+79 -3
View File
@@ -223,6 +223,7 @@ export const dict = {
"common.cancel": "Avbryt", "common.cancel": "Avbryt",
"common.connect": "Koble til", "common.connect": "Koble til",
"common.disconnect": "Koble fra", "common.disconnect": "Koble fra",
"common.continue": "Send inn",
"common.submit": "Send inn", "common.submit": "Send inn",
"common.save": "Lagre", "common.save": "Lagre",
"common.saving": "Lagrer...", "common.saving": "Lagrer...",
@@ -266,7 +267,7 @@ export const dict = {
"prompt.popover.emptyResults": "Ingen matchende resultater", "prompt.popover.emptyResults": "Ingen matchende resultater",
"prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.popover.emptyCommands": "Ingen matchende kommandoer",
"prompt.dropzone.label": "Slipp bilder eller PDF-er her", "prompt.dropzone.label": "Slipp bilder, PDF-er eller tekstfiler her",
"prompt.dropzone.file.label": "Slipp for å @nevne fil", "prompt.dropzone.file.label": "Slipp for å @nevne fil",
"prompt.slash.badge.custom": "egendefinert", "prompt.slash.badge.custom": "egendefinert",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
@@ -280,8 +281,8 @@ export const dict = {
"prompt.action.send": "Send", "prompt.action.send": "Send",
"prompt.action.stop": "Stopp", "prompt.action.stop": "Stopp",
"prompt.toast.pasteUnsupported.title": "Liming ikke støttet", "prompt.toast.pasteUnsupported.title": "Ikke støttet vedlegg",
"prompt.toast.pasteUnsupported.description": "Kun bilder eller PDF-er kan limes inn her.", "prompt.toast.pasteUnsupported.description": "Kun bilder, PDF-er eller tekstfiler kan legges ved her.",
"prompt.toast.modelAgentRequired.title": "Velg en agent og modell", "prompt.toast.modelAgentRequired.title": "Velg en agent og modell",
"prompt.toast.modelAgentRequired.description": "Velg en agent og modell før du sender en forespørsel.", "prompt.toast.modelAgentRequired.description": "Velg en agent og modell før du sender en forespørsel.",
"prompt.toast.worktreeCreateFailed.title": "Kunne ikke opprette worktree", "prompt.toast.worktreeCreateFailed.title": "Kunne ikke opprette worktree",
@@ -865,4 +866,79 @@ export const dict = {
"common.time.daysAgo.short": "{{count}} d siden", "common.time.daysAgo.short": "{{count}} d siden",
"settings.providers.connected.environmentDescription": "Koblet til fra miljøvariablene dine", "settings.providers.connected.environmentDescription": "Koblet til fra miljøvariablene dine",
"settings.providers.custom.description": "Legg til en OpenAI-kompatibel leverandør via basis-URL.", "settings.providers.custom.description": "Legg til en OpenAI-kompatibel leverandør via basis-URL.",
"app.server.unreachable": "Kunne ikke nå {{server}}",
"app.server.retrying": "Prøver på nytt automatisk...",
"app.server.otherServers": "Andre servere",
"dialog.server.add.usernamePlaceholder": "brukernavn",
"dialog.server.add.passwordPlaceholder": "passord",
"server.row.noUsername": "inget brukernavn",
"session.review.noVcs.createGit.title": "Opprett et Git-depot",
"session.review.noVcs.createGit.description": "Spor, gjennomgå og angre endringer i dette prosjektet",
"session.review.noVcs.createGit.actionLoading": "Oppretter Git-depot...",
"session.review.noVcs.createGit.action": "Opprett Git-depot",
"session.todo.progress": "{{done}} av {{total}} oppgaver fullført",
"session.question.progress": "{{current}} av {{total}} spørsmål",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Filutforsker",
"session.header.open.fileManager": "Filbehandler",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Utviklingsytelsesdiagnostikk",
"debugBar.na": "i/t",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Siste fullførte ruteovergang som berører en sesjonsside, målt fra ruterstart til første opptegning etter at den har roet seg.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Rullende bilder per sekund over de siste 5 sekundene.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Verste bildetid over de siste 5 sekundene.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Bilder over 32ms i de siste 5 sekundene.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Blokkert tid og antall lange oppgaver i de siste 5 sekundene. Maks oppgave: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Verste observerte inndataforsinkelse i de siste 5 sekundene.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Omtrentlig interaksjonsvarighet over de siste 5 sekundene. Dette er INP-lignende, ikke den offisielle Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Kumulativ layoutforskyvning for gjeldende app-levetid.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Brukt JS-heap vs heap-grense. Kun Chromium.",
"debugBar.mem.tip": "Brukt JS-heap vs heap-grense. {{used}} av {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Mellomrom",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "ukjent",
"error.page.circular": "[Sirkulær]",
"error.globalSDK.noServerAvailable": "Ingen server tilgjengelig",
"error.globalSDK.serverNotAvailable": "Server ikke tilgjengelig",
"error.childStore.persistedCacheCreateFailed": "Kunne ikke opprette vedvarende hurtigbuffer",
"error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke opprette vedvarende prosjektmetadata",
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke opprette vedvarende prosjektikon",
"error.childStore.storeCreateFailed": "Kunne ikke opprette lager",
"terminal.connectionLost.abnormalClose": "WebSocket lukket unormalt: {{code}}",
} satisfies Partial<Record<Keys, string>> } satisfies Partial<Record<Keys, string>>
+80 -3
View File
@@ -205,6 +205,7 @@ export const dict = {
"common.cancel": "Anuluj", "common.cancel": "Anuluj",
"common.connect": "Połącz", "common.connect": "Połącz",
"common.disconnect": "Rozłącz", "common.disconnect": "Rozłącz",
"common.continue": "Prześlij",
"common.submit": "Prześlij", "common.submit": "Prześlij",
"common.save": "Zapisz", "common.save": "Zapisz",
"common.saving": "Zapisywanie...", "common.saving": "Zapisywanie...",
@@ -245,7 +246,7 @@ export const dict = {
"prompt.example.25": "Jak działają tutaj zmienne środowiskowe?", "prompt.example.25": "Jak działają tutaj zmienne środowiskowe?",
"prompt.popover.emptyResults": "Brak pasujących wyników", "prompt.popover.emptyResults": "Brak pasujących wyników",
"prompt.popover.emptyCommands": "Brak pasujących poleceń", "prompt.popover.emptyCommands": "Brak pasujących poleceń",
"prompt.dropzone.label": "Upuść obrazy lub pliki PDF tutaj", "prompt.dropzone.label": "Upuść tutaj obrazy, pliki PDF lub pliki tekstowe",
"prompt.dropzone.file.label": "Upuść, aby @wspomnieć plik", "prompt.dropzone.file.label": "Upuść, aby @wspomnieć plik",
"prompt.slash.badge.custom": "własne", "prompt.slash.badge.custom": "własne",
"prompt.slash.badge.skill": "skill", "prompt.slash.badge.skill": "skill",
@@ -258,8 +259,8 @@ export const dict = {
"prompt.attachment.remove": "Usuń załącznik", "prompt.attachment.remove": "Usuń załącznik",
"prompt.action.send": "Wyślij", "prompt.action.send": "Wyślij",
"prompt.action.stop": "Zatrzymaj", "prompt.action.stop": "Zatrzymaj",
"prompt.toast.pasteUnsupported.title": "Nieobsługiwane wklejanie", "prompt.toast.pasteUnsupported.title": "Nieobsługiwany załącznik",
"prompt.toast.pasteUnsupported.description": "Tylko obrazy lub pliki PDF mogą być tutaj wklejane.", "prompt.toast.pasteUnsupported.description": "Można tutaj załączać tylko obrazy, pliki PDF lub pliki tekstowe.",
"prompt.toast.modelAgentRequired.title": "Wybierz agenta i model", "prompt.toast.modelAgentRequired.title": "Wybierz agenta i model",
"prompt.toast.modelAgentRequired.description": "Wybierz agenta i model przed wysłaniem zapytania.", "prompt.toast.modelAgentRequired.description": "Wybierz agenta i model przed wysłaniem zapytania.",
"prompt.toast.worktreeCreateFailed.title": "Nie udało się utworzyć drzewa roboczego", "prompt.toast.worktreeCreateFailed.title": "Nie udało się utworzyć drzewa roboczego",
@@ -785,4 +786,80 @@ export const dict = {
"common.time.daysAgo.short": "{{count}} dni temu", "common.time.daysAgo.short": "{{count}} dni temu",
"settings.providers.connected.environmentDescription": "Połączono ze zmiennymi środowiskowymi", "settings.providers.connected.environmentDescription": "Połączono ze zmiennymi środowiskowymi",
"settings.providers.custom.description": "Dodaj dostawcę zgodnego z OpenAI poprzez podstawowy URL.", "settings.providers.custom.description": "Dodaj dostawcę zgodnego z OpenAI poprzez podstawowy URL.",
"app.server.unreachable": "Nie można połączyć z {{server}}",
"app.server.retrying": "Ponawianie automatycznie...",
"app.server.otherServers": "Inne serwery",
"dialog.server.add.usernamePlaceholder": "nazwa użytkownika",
"dialog.server.add.passwordPlaceholder": "hasło",
"server.row.noUsername": "brak nazwy użytkownika",
"session.review.noVcs.createGit.title": "Utwórz repozytorium Git",
"session.review.noVcs.createGit.description": "Śledź, przeglądaj i cofaj zmiany w tym projekcie",
"session.review.noVcs.createGit.actionLoading": "Tworzenie repozytorium Git...",
"session.review.noVcs.createGit.action": "Utwórz repozytorium Git",
"session.todo.progress": "Ukończono {{done}} z {{total}} zadań",
"session.question.progress": "{{current}} z {{total}} pytań",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Eksplorator plików",
"session.header.open.fileManager": "Menedżer plików",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Diagnostyka wydajności deweloperskiej",
"debugBar.na": "n.d.",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Ostatnie zakończone przejście trasy dotykające strony sesji, mierzone od startu routera do pierwszego odrysowania po ustaleniu.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Średnia liczba klatek na sekundę w ciągu ostatnich 5 sekund.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Najgorszy czas klatki w ciągu ostatnich 5 sekund.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Klatki powyżej 32ms w ciągu ostatnich 5 sekund.",
"debugBar.long.label": "LONG",
"debugBar.long.tip":
"Zablokowany czas i liczba długich zadań w ciągu ostatnich 5 sekund. Maksymalne zadanie: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Najgorsze zaobserwowane opóźnienie wejścia w ciągu ostatnich 5 sekund.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Przybliżony czas trwania interakcji w ciągu ostatnich 5 sekund. Jest to podobne do INP, a nie oficjalne Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Skumulowane przesunięcie układu dla bieżącego czasu życia aplikacji.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Użyta sterta JS vs limit sterty. Tylko Chromium.",
"debugBar.mem.tip": "Użyta sterta JS vs limit sterty. {{used}} z {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Spacja",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "nieznany",
"error.page.circular": "[Cykliczne]",
"error.globalSDK.noServerAvailable": "Brak dostępnego serwera",
"error.globalSDK.serverNotAvailable": "Serwer niedostępny",
"error.childStore.persistedCacheCreateFailed": "Nie udało się utworzyć trwałej pamięci podręcznej",
"error.childStore.persistedProjectMetadataCreateFailed": "Nie udało się utworzyć trwałych metadanych projektu",
"error.childStore.persistedProjectIconCreateFailed": "Nie udało się utworzyć trwałej ikony projektu",
"error.childStore.storeCreateFailed": "Nie udało się utworzyć magazynu",
"terminal.connectionLost.abnormalClose": "WebSocket zamknięty nieprawidłowo: {{code}}",
} }
+79 -3
View File
@@ -220,6 +220,7 @@ export const dict = {
"common.cancel": "Отмена", "common.cancel": "Отмена",
"common.connect": "Подключить", "common.connect": "Подключить",
"common.disconnect": "Отключить", "common.disconnect": "Отключить",
"common.continue": "Отправить",
"common.submit": "Отправить", "common.submit": "Отправить",
"common.save": "Сохранить", "common.save": "Сохранить",
"common.saving": "Сохранение...", "common.saving": "Сохранение...",
@@ -263,7 +264,7 @@ export const dict = {
"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": "навык",
@@ -277,8 +278,8 @@ export const dict = {
"prompt.action.send": "Отправить", "prompt.action.send": "Отправить",
"prompt.action.stop": "Остановить", "prompt.action.stop": "Остановить",
"prompt.toast.pasteUnsupported.title": "Неподдерживаемая вставка", "prompt.toast.pasteUnsupported.title": "Неподдерживаемое вложение",
"prompt.toast.pasteUnsupported.description": "Сюда можно вставлять только изображения или PDF.", "prompt.toast.pasteUnsupported.description": "Здесь можно прикрепить только изображения, PDF или текстовые файлы.",
"prompt.toast.modelAgentRequired.title": "Выберите агента и модель", "prompt.toast.modelAgentRequired.title": "Выберите агента и модель",
"prompt.toast.modelAgentRequired.description": "Выберите агента и модель перед отправкой запроса.", "prompt.toast.modelAgentRequired.description": "Выберите агента и модель перед отправкой запроса.",
"prompt.toast.worktreeCreateFailed.title": "Не удалось создать worktree", "prompt.toast.worktreeCreateFailed.title": "Не удалось создать worktree",
@@ -867,4 +868,79 @@ export const dict = {
"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.retrying": "Автоматическая повторная попытка...",
"app.server.otherServers": "Другие серверы",
"dialog.server.add.usernamePlaceholder": "имя пользователя",
"dialog.server.add.passwordPlaceholder": "пароль",
"server.row.noUsername": "нет имени пользователя",
"session.review.noVcs.createGit.title": "Создать репозиторий Git",
"session.review.noVcs.createGit.description": "Отслеживайте, просматривайте и отменяйте изменения в этом проекте",
"session.review.noVcs.createGit.actionLoading": "Создание репозитория Git...",
"session.review.noVcs.createGit.action": "Создать репозиторий Git",
"session.todo.progress": "Выполнено {{done}} из {{total}} задач",
"session.question.progress": "{{current}} из {{total}} вопросов",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Проводник",
"session.header.open.fileManager": "Файловый менеджер",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Терминал",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Диагностика производительности разработки",
"debugBar.na": "н/д",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Последний завершенный переход маршрута, затрагивающий страницу сеанса, измеренный от запуска маршрутизатора до первой отрисовки после стабилизации.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Скользящая частота кадров в секунду за последние 5 секунд.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Худшее время кадра за последние 5 секунд.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Кадры более 32 мс за последние 5 секунд.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Заблокированное время и количество длинных задач за последние 5 секунд. Макс. задача: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Худшая наблюдаемая задержка ввода за последние 5 секунд.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Приблизительная продолжительность взаимодействия за последние 5 секунд. Это похоже на INP, а не официальный Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Кумулятивный сдвиг макета за текущее время жизни приложения.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Используемая куча JS по сравнению с лимитом кучи. Только Chromium.",
"debugBar.mem.tip": "Используемая куча JS по сравнению с лимитом кучи. {{used}} из {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Пробел",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "неизвестно",
"error.page.circular": "[Циклично]",
"error.globalSDK.noServerAvailable": "Нет доступного сервера",
"error.globalSDK.serverNotAvailable": "Сервер недоступен",
"error.childStore.persistedCacheCreateFailed": "Не удалось создать постоянный кэш",
"error.childStore.persistedProjectMetadataCreateFailed": "Не удалось создать постоянные метаданные проекта",
"error.childStore.persistedProjectIconCreateFailed": "Не удалось создать постоянный значок проекта",
"error.childStore.storeCreateFailed": "Не удалось создать хранилище",
"terminal.connectionLost.abnormalClose": "WebSocket закрыт аварийно: {{code}}",
} }
+79 -3
View File
@@ -220,6 +220,7 @@ export const dict = {
"common.cancel": "ยกเลิก", "common.cancel": "ยกเลิก",
"common.connect": "เชื่อมต่อ", "common.connect": "เชื่อมต่อ",
"common.disconnect": "ยกเลิกการเชื่อมต่อ", "common.disconnect": "ยกเลิกการเชื่อมต่อ",
"common.continue": "ส่ง",
"common.submit": "ส่ง", "common.submit": "ส่ง",
"common.save": "บันทึก", "common.save": "บันทึก",
"common.saving": "กำลังบันทึก...", "common.saving": "กำลังบันทึก...",
@@ -263,7 +264,7 @@ export const dict = {
"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": "ทักษะ",
@@ -277,8 +278,8 @@ export const dict = {
"prompt.action.send": "ส่ง", "prompt.action.send": "ส่ง",
"prompt.action.stop": "หยุด", "prompt.action.stop": "หยุด",
"prompt.toast.pasteUnsupported.title": "การวางไม่รองรับ", "prompt.toast.pasteUnsupported.title": "ไฟล์แนบที่ไม่รองรับ",
"prompt.toast.pasteUnsupported.description": "สามารถวางรูปภาพหรือ PDF เท่านั้น", "prompt.toast.pasteUnsupported.description": "แนบได้เฉพาะรูปภาพ, PDF หรือไฟล์ข้อความเท่านั้น",
"prompt.toast.modelAgentRequired.title": "เลือกเอเจนต์และโมเดล", "prompt.toast.modelAgentRequired.title": "เลือกเอเจนต์และโมเดล",
"prompt.toast.modelAgentRequired.description": "เลือกเอเจนต์และโมเดลก่อนส่งพร้อมท์", "prompt.toast.modelAgentRequired.description": "เลือกเอเจนต์และโมเดลก่อนส่งพร้อมท์",
"prompt.toast.worktreeCreateFailed.title": "ไม่สามารถสร้าง worktree", "prompt.toast.worktreeCreateFailed.title": "ไม่สามารถสร้าง worktree",
@@ -854,4 +855,79 @@ export const dict = {
"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.retrying": "กำลังลองใหม่โดยอัตโนมัติ...",
"app.server.otherServers": "เซิร์ฟเวอร์อื่น ๆ",
"dialog.server.add.usernamePlaceholder": "ชื่อผู้ใช้",
"dialog.server.add.passwordPlaceholder": "รหัสผ่าน",
"server.row.noUsername": "ไม่มีชื่อผู้ใช้",
"session.review.noVcs.createGit.title": "สร้าง Git repository",
"session.review.noVcs.createGit.description": "ติดตาม ตรวจสอบ และเลิกทำสิ่งเปลี่ยนแปลงในโปรเจกต์นี้",
"session.review.noVcs.createGit.actionLoading": "กำลังสร้าง Git repository...",
"session.review.noVcs.createGit.action": "สร้าง Git repository",
"session.todo.progress": "เสร็จสิ้น {{done}} จาก {{total}} รายการ",
"session.question.progress": "{{current}} จาก {{total}} คำถาม",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "File Explorer",
"session.header.open.fileManager": "File Manager",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "การวินิจฉัยประสิทธิภาพการพัฒนา",
"debugBar.na": "n/a",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"การเปลี่ยนเส้นทางที่เสร็จสมบูรณ์ล่าสุดที่สัมผัสหน้าเซสชัน วัดจากจุดเริ่มต้นเราเตอร์จนถึงการวาดครั้งแรกหลังจากที่นิ่ง",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "เฟรมต่อวินาทีแบบต่อเนื่องในช่วง 5 วินาทีที่ผ่านมา",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "เวลาเฟรมที่แย่ที่สุดในช่วง 5 วินาทีที่ผ่านมา",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "เฟรมที่เกิน 32ms ในช่วง 5 วินาทีที่ผ่านมา",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "เวลาที่ถูกบล็อกและจำนวนงานยาวในช่วง 5 วินาทีที่ผ่านมา งานสูงสุด: {{max}}",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "ความล่าช้าในการป้อนข้อมูลที่แย่ที่สุดที่สังเกตได้ในช่วง 5 วินาทีที่ผ่านมา",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"ระยะเวลาการโต้ตอบโดยประมาณในช่วง 5 วินาทีที่ผ่านมา นี่เป็นเหมือน INP ไม่ใช่ Web Vitals INP อย่างเป็นทางการ",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "การเลื่อนเลย์เอาต์สะสมสำหรับอายุการใช้งานของแอปปัจจุบัน",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "JS heap ที่ใช้เทียบกับขีดจำกัด heap เฉพาะ Chromium",
"debugBar.mem.tip": "JS heap ที่ใช้เทียบกับขีดจำกัด heap {{used}} จาก {{limit}}",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Space",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "ไม่ทราบ",
"error.page.circular": "[วงกลม]",
"error.globalSDK.noServerAvailable": "ไม่มีเซิร์ฟเวอร์",
"error.globalSDK.serverNotAvailable": "เซิร์ฟเวอร์ไม่พร้อมใช้งาน",
"error.childStore.persistedCacheCreateFailed": "ไม่สามารถสร้างแคชถาวร",
"error.childStore.persistedProjectMetadataCreateFailed": "ไม่สามารถสร้างเมตาดาต้าโปรเจกต์ถาวร",
"error.childStore.persistedProjectIconCreateFailed": "ไม่สามารถสร้างไอคอนโปรเจกต์ถาวร",
"error.childStore.storeCreateFailed": "ไม่สามารถสร้างที่เก็บ",
"terminal.connectionLost.abnormalClose": "WebSocket ปิดอย่างผิดปกติ: {{code}}",
} }
+78 -3
View File
@@ -225,6 +225,7 @@ export const dict = {
"common.cancel": "İptal", "common.cancel": "İptal",
"common.connect": "Bağlan", "common.connect": "Bağlan",
"common.disconnect": "Bağlantı Kes", "common.disconnect": "Bağlantı Kes",
"common.continue": "Gönder",
"common.submit": "Gönder", "common.submit": "Gönder",
"common.save": "Kaydet", "common.save": "Kaydet",
"common.saving": "Kaydediliyor...", "common.saving": "Kaydediliyor...",
@@ -268,7 +269,7 @@ export const dict = {
"prompt.popover.emptyResults": "Eşleşen sonuç yok", "prompt.popover.emptyResults": "Eşleşen sonuç yok",
"prompt.popover.emptyCommands": "Eşleşen komut yok", "prompt.popover.emptyCommands": "Eşleşen komut yok",
"prompt.dropzone.label": "Görsel veya PDF'leri buraya bırakın", "prompt.dropzone.label": "Resimleri, PDF'leri veya metin dosyalarını buraya bırakın",
"prompt.dropzone.file.label": "@bahsetmek için dosyayı bırakın", "prompt.dropzone.file.label": "@bahsetmek için dosyayı bırakın",
"prompt.slash.badge.custom": "özel", "prompt.slash.badge.custom": "özel",
"prompt.slash.badge.skill": "beceri", "prompt.slash.badge.skill": "beceri",
@@ -282,8 +283,8 @@ export const dict = {
"prompt.action.send": "Gönder", "prompt.action.send": "Gönder",
"prompt.action.stop": "Durdur", "prompt.action.stop": "Durdur",
"prompt.toast.pasteUnsupported.title": "Desteklenmeyen yapıştırma", "prompt.toast.pasteUnsupported.title": "Desteklenmeyen ek",
"prompt.toast.pasteUnsupported.description": "Buraya sadece görsel veya PDF yapıştırılabilir.", "prompt.toast.pasteUnsupported.description": "Buraya yalnızca resimler, PDF'ler veya metin dosyaları eklenebilir.",
"prompt.toast.modelAgentRequired.title": "Bir ajan ve model seçin", "prompt.toast.modelAgentRequired.title": "Bir ajan ve model seçin",
"prompt.toast.modelAgentRequired.description": "Komut göndermeden önce bir ajan ve model seçin.", "prompt.toast.modelAgentRequired.description": "Komut göndermeden önce bir ajan ve model seçin.",
"prompt.toast.worktreeCreateFailed.title": "Çalışma ağacı oluşturulamadı", "prompt.toast.worktreeCreateFailed.title": "Çalışma ağacı oluşturulamadı",
@@ -874,4 +875,78 @@ export const dict = {
"common.time.daysAgo.short": "{{count}}g önce", "common.time.daysAgo.short": "{{count}}g önce",
"settings.providers.connected.environmentDescription": "Ortam değişkenlerinizden bağlandı", "settings.providers.connected.environmentDescription": "Ortam değişkenlerinizden bağlandı",
"settings.providers.custom.description": "Temel URL üzerinden OpenAI uyumlu bir sağlayıcı ekleyin.", "settings.providers.custom.description": "Temel URL üzerinden OpenAI uyumlu bir sağlayıcı ekleyin.",
"app.server.unreachable": "{{server}} sunucusuna ulaşılamadı",
"app.server.retrying": "Otomatik olarak tekrar deneniyor...",
"app.server.otherServers": "Diğer sunucular",
"dialog.server.add.usernamePlaceholder": "kullanıcı adı",
"dialog.server.add.passwordPlaceholder": "parola",
"server.row.noUsername": "kullanıcı adı yok",
"session.review.noVcs.createGit.title": "Git deposu oluştur",
"session.review.noVcs.createGit.description": "Bu projedeki değişiklikleri takip et, incele ve geri al",
"session.review.noVcs.createGit.actionLoading": "Git deposu oluşturuluyor...",
"session.review.noVcs.createGit.action": "Git deposu oluştur",
"session.todo.progress": "{{total}} görevin {{done}} tanesi tamamlandı",
"session.question.progress": "{{total}} sorunun {{current}} tanesi",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Dosya Gezgini",
"session.header.open.fileManager": "Dosya Yöneticisi",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Geliştirme performansı teşhisi",
"debugBar.na": "yok",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Yönlendirici başlangıcından yerleşme sonrası ilk boyamaya kadar ölçülen, bir oturum sayfasına dokunan son tamamlanmış rota geçişi.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Son 5 saniyedeki kayan saniye başına kare sayısı.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Son 5 saniyedeki en kötü kare süresi.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Son 5 saniyede 32ms üzerindeki kareler.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Son 5 saniyedeki engellenen süre ve uzun görev sayısı. Maksimum görev: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Son 5 saniyede gözlemlenen en kötü giriş gecikmesi.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip": "Son 5 saniyedeki yaklaşık etkileşim süresi. Bu INP benzeridir, resmi Web Vitals INP değildir.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Mevcut uygulama ömrü için kümülatif düzen kayması.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Kullanılan JS yığını vs yığın sınırı. Yalnızca Chromium.",
"debugBar.mem.tip": "Kullanılan JS yığını vs yığın sınırı. {{limit}} içinde {{used}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Boşluk",
"common.key.backspace": "Geri",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "bilinmiyor",
"error.page.circular": "[Döngüsel]",
"error.globalSDK.noServerAvailable": "Sunucu yok",
"error.globalSDK.serverNotAvailable": "Sunucu mevcut değil",
"error.childStore.persistedCacheCreateFailed": "Kalıcı önbellek oluşturulamadı",
"error.childStore.persistedProjectMetadataCreateFailed": "Kalıcı proje meta verileri oluşturulamadı",
"error.childStore.persistedProjectIconCreateFailed": "Kalıcı proje simgesi oluşturulamadı",
"error.childStore.storeCreateFailed": "Depo oluşturulamadı",
"terminal.connectionLost.abnormalClose": "WebSocket anormal şekilde kapandı: {{code}}",
} satisfies Partial<Record<Keys, string>> } satisfies Partial<Record<Keys, string>>
+77 -3
View File
@@ -242,6 +242,7 @@ export const dict = {
"common.cancel": "取消", "common.cancel": "取消",
"common.connect": "连接", "common.connect": "连接",
"common.disconnect": "断开连接", "common.disconnect": "断开连接",
"common.continue": "提交",
"common.submit": "提交", "common.submit": "提交",
"common.save": "保存", "common.save": "保存",
"common.saving": "保存中...", "common.saving": "保存中...",
@@ -283,7 +284,7 @@ export const dict = {
"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": "技能",
@@ -296,8 +297,8 @@ export const dict = {
"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.pasteUnsupported.description": "这里只能粘贴图片或 PDF 文件。", "prompt.toast.pasteUnsupported.description": "此处仅能附加图片、PDF 或文本文件。",
"prompt.toast.modelAgentRequired.title": "请选择智能体和模型", "prompt.toast.modelAgentRequired.title": "请选择智能体和模型",
"prompt.toast.modelAgentRequired.description": "发送提示前请先选择智能体和模型。", "prompt.toast.modelAgentRequired.description": "发送提示前请先选择智能体和模型。",
"prompt.toast.worktreeCreateFailed.title": "创建工作树失败", "prompt.toast.worktreeCreateFailed.title": "创建工作树失败",
@@ -853,4 +854,77 @@ export const dict = {
"common.time.daysAgo.short": "{{count}}天前", "common.time.daysAgo.short": "{{count}}天前",
"settings.providers.connected.environmentDescription": "已通过环境变量连接", "settings.providers.connected.environmentDescription": "已通过环境变量连接",
"settings.providers.custom.description": "通过基础 URL 添加与 OpenAI 兼容的提供商。", "settings.providers.custom.description": "通过基础 URL 添加与 OpenAI 兼容的提供商。",
"app.server.unreachable": "无法连接到 {{server}}",
"app.server.retrying": "正在自动重试...",
"app.server.otherServers": "其他服务器",
"dialog.server.add.usernamePlaceholder": "用户名",
"dialog.server.add.passwordPlaceholder": "密码",
"server.row.noUsername": "无用户名",
"session.review.noVcs.createGit.title": "创建 Git 仓库",
"session.review.noVcs.createGit.description": "在此项目中跟踪、审查和撤消更改",
"session.review.noVcs.createGit.actionLoading": "正在创建 Git 仓库...",
"session.review.noVcs.createGit.action": "创建 Git 仓库",
"session.todo.progress": "已完成 {{done}} 个任务(共 {{total}} 个)",
"session.question.progress": "{{current}}/{{total}} 个问题",
"session.header.open.finder": "访达",
"session.header.open.fileExplorer": "文件资源管理器",
"session.header.open.fileManager": "文件管理器",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "终端",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "开发性能诊断",
"debugBar.na": "不适用",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip": "最后一次完成的涉及会话页面的路由转换,从路由器启动到稳定后的第一次绘制。",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "过去 5 秒内的滚动帧率。",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "过去 5 秒内最差的帧时间。",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "过去 5 秒内超过 32ms 的帧。",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "过去 5 秒内的阻塞时间和长任务计数。最大任务:{{max}}。",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "过去 5 秒内观察到的最差输入延迟。",
"debugBar.inp.label": "INP",
"debugBar.inp.tip": "过去 5 秒内的近似交互持续时间。这类似于 INP,而非官方的 Web Vitals INP。",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "当前应用生命周期的累积布局偏移。",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "使用的 JS 堆与堆限制。仅限 Chromium。",
"debugBar.mem.tip": "使用的 JS 堆与堆限制。{{used}} / {{limit}}。",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "空格",
"common.key.backspace": "退格",
"common.key.enter": "回车",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "未知",
"error.page.circular": "[循环]",
"error.globalSDK.noServerAvailable": "无可用服务器",
"error.globalSDK.serverNotAvailable": "服务器不可用",
"error.childStore.persistedCacheCreateFailed": "创建持久化缓存失败",
"error.childStore.persistedProjectMetadataCreateFailed": "创建持久化项目元数据失败",
"error.childStore.persistedProjectIconCreateFailed": "创建持久化项目图标失败",
"error.childStore.storeCreateFailed": "创建存储失败",
"terminal.connectionLost.abnormalClose": "WebSocket 异常关闭:{{code}}",
} satisfies Partial<Record<Keys, string>> } satisfies Partial<Record<Keys, string>>
+77 -3
View File
@@ -220,6 +220,7 @@ export const dict = {
"common.cancel": "取消", "common.cancel": "取消",
"common.connect": "連線", "common.connect": "連線",
"common.disconnect": "中斷連線", "common.disconnect": "中斷連線",
"common.continue": "提交",
"common.submit": "提交", "common.submit": "提交",
"common.save": "儲存", "common.save": "儲存",
"common.saving": "儲存中...", "common.saving": "儲存中...",
@@ -263,7 +264,7 @@ export const dict = {
"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": "技能",
@@ -277,8 +278,8 @@ export const dict = {
"prompt.action.send": "傳送", "prompt.action.send": "傳送",
"prompt.action.stop": "停止", "prompt.action.stop": "停止",
"prompt.toast.pasteUnsupported.title": "不支援的貼上", "prompt.toast.pasteUnsupported.title": "不支援的附件",
"prompt.toast.pasteUnsupported.description": "這裡只能貼上圖片或 PDF 檔案。", "prompt.toast.pasteUnsupported.description": "此處僅能附加圖片、PDF 或文字檔案。",
"prompt.toast.modelAgentRequired.title": "請選擇代理程式和模型", "prompt.toast.modelAgentRequired.title": "請選擇代理程式和模型",
"prompt.toast.modelAgentRequired.description": "傳送提示前請先選擇代理程式和模型。", "prompt.toast.modelAgentRequired.description": "傳送提示前請先選擇代理程式和模型。",
"prompt.toast.worktreeCreateFailed.title": "建立工作樹失敗", "prompt.toast.worktreeCreateFailed.title": "建立工作樹失敗",
@@ -848,4 +849,77 @@ export const dict = {
"common.time.daysAgo.short": "{{count}}天前", "common.time.daysAgo.short": "{{count}}天前",
"settings.providers.connected.environmentDescription": "已從環境變數連線", "settings.providers.connected.environmentDescription": "已從環境變數連線",
"settings.providers.custom.description": "透過基本 URL 新增與 OpenAI 相容的提供者。", "settings.providers.custom.description": "透過基本 URL 新增與 OpenAI 相容的提供者。",
"app.server.unreachable": "無法連線至 {{server}}",
"app.server.retrying": "正在自動重試...",
"app.server.otherServers": "其他伺服器",
"dialog.server.add.usernamePlaceholder": "使用者名稱",
"dialog.server.add.passwordPlaceholder": "密碼",
"server.row.noUsername": "無使用者名稱",
"session.review.noVcs.createGit.title": "建立 Git 儲存庫",
"session.review.noVcs.createGit.description": "追蹤、檢閱及復原此專案中的變更",
"session.review.noVcs.createGit.actionLoading": "正在建立 Git 儲存庫...",
"session.review.noVcs.createGit.action": "建立 Git 儲存庫",
"session.todo.progress": "已完成 {{done}} 個待辦事項(共 {{total}} 個)",
"session.question.progress": "{{current}}/{{total}} 個問題",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "檔案總管",
"session.header.open.fileManager": "檔案管理員",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "終端機",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "開發效能診斷",
"debugBar.na": "不適用",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip": "最後一次完成的涉及工作階段頁面的路由轉換,從路由器啟動到穩定後的第一次繪製。",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "過去 5 秒內的滾動幀率。",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "過去 5 秒內最差的幀時間。",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "過去 5 秒內超過 32ms 的幀。",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "過去 5 秒內的阻塞時間和長任務計數。最大任務:{{max}}。",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "過去 5 秒內觀察到的最差輸入延遲。",
"debugBar.inp.label": "INP",
"debugBar.inp.tip": "過去 5 秒內的近似互動持續時間。這類似於 INP,而非官方的 Web Vitals INP。",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "目前應用程式生命週期的累積版面配置位移。",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "使用的 JS 堆積與堆積限制。僅限 Chromium。",
"debugBar.mem.tip": "使用的 JS 堆積與堆積限制。{{used}} / {{limit}}。",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "空白鍵",
"common.key.backspace": "退格鍵",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "未知",
"error.page.circular": "[循環]",
"error.globalSDK.noServerAvailable": "無可用的伺服器",
"error.globalSDK.serverNotAvailable": "伺服器無法使用",
"error.childStore.persistedCacheCreateFailed": "建立持續性快取失敗",
"error.childStore.persistedProjectMetadataCreateFailed": "建立持續性專案中繼資料失敗",
"error.childStore.persistedProjectIconCreateFailed": "建立持續性專案圖示失敗",
"error.childStore.storeCreateFailed": "建立儲存區失敗",
"terminal.connectionLost.abnormalClose": "WebSocket 異常關閉:{{code}}",
} satisfies Partial<Record<Keys, string>> } satisfies Partial<Record<Keys, string>>
+48 -53
View File
@@ -1,16 +1,15 @@
import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js" import { DataProvider } from "@opencode-ai/ui/context"
import { createStore } from "solid-js/store" import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createMemo, createResource, type ParentProps, Show } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync" import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { useGlobalSDK } from "@/context/global-sdk"
import { DataProvider } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const navigate = useNavigate() const navigate = useNavigate()
const sync = useSync() const sync = useSync()
@@ -30,61 +29,57 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export default function Layout(props: ParentProps) { export default function Layout(props: ParentProps) {
const params = useParams() const params = useParams()
const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const language = useLanguage() const language = useLanguage()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const directory = createMemo(() => decode64(params.dir) ?? "") const navigate = useNavigate()
const [state, setState] = createStore({ invalid: "", resolved: "" }) let invalid = ""
createEffect(() => { const [resolved] = createResource(
if (!params.dir) return () => {
const raw = directory() if (params.dir) return [location.pathname, params.dir] as const
if (!raw) { },
if (state.invalid === params.dir) return async ([pathname, b64Dir]) => {
setState("invalid", params.dir) const directory = decode64(b64Dir)
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: language.t("directory.error.invalidUrl"),
})
navigate("/", { replace: true })
return
}
const current = params.dir if (!directory) {
globalSDK if (invalid === params.dir) return
.createClient({ invalid = b64Dir
directory: raw, showToast({
throwOnError: true, variant: "error",
}) title: language.t("common.requestFailed"),
.path.get() description: language.t("directory.error.invalidUrl"),
.then((x) => {
if (params.dir !== current) return
const next = x.data?.directory ?? raw
batch(() => {
setState("invalid", "")
setState("resolved", next)
}) })
if (next === raw) return navigate("/", { replace: true })
const path = location.pathname.slice(current.length + 1) return
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) }
})
.catch(() => { return await globalSDK
if (params.dir !== current) return .createClient({
batch(() => { directory,
setState("invalid", "") throwOnError: true,
setState("resolved", raw)
}) })
}) .path.get()
}) .then((x) => {
const next = x.data?.directory ?? directory
invalid = ""
if (next === directory) return next
const path = pathname.slice(b64Dir.length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
.catch(() => {
invalid = ""
return directory
})
},
)
return ( return (
<Show when={state.resolved}> <Show when={resolved()} keyed>
{(resolved) => ( {(resolved) => (
<SDKProvider directory={resolved}> <SDKProvider directory={() => resolved}>
<SyncProvider> <SyncProvider>
<DirectoryDataProvider directory={resolved()}>{props.children}</DirectoryDataProvider> <DirectoryDataProvider directory={resolved}>{props.children}</DirectoryDataProvider>
</SyncProvider> </SyncProvider>
</SDKProvider> </SDKProvider>
)} )}
+17 -15
View File
@@ -35,14 +35,14 @@ function isInitError(error: unknown): error is InitError {
) )
} }
function safeJson(value: unknown): string { function safeJson(value: unknown, circular: string): string {
const seen = new WeakSet<object>() const seen = new WeakSet<object>()
const json = JSON.stringify( const json = JSON.stringify(
value, value,
(_key, val) => { (_key, val) => {
if (typeof val === "bigint") return val.toString() if (typeof val === "bigint") return val.toString()
if (typeof val === "object" && val) { if (typeof val === "object" && val) {
if (seen.has(val)) return "[Circular]" if (seen.has(val)) return circular
seen.add(val) seen.add(val)
} }
return val return val
@@ -54,14 +54,15 @@ function safeJson(value: unknown): string {
function formatInitError(error: InitError, t: Translator): string { function formatInitError(error: InitError, t: Translator): string {
const data = error.data const data = error.data
const json = (value: unknown) => safeJson(value, t("error.page.circular"))
switch (error.name) { switch (error.name) {
case "MCPFailed": { case "MCPFailed": {
const name = typeof data.name === "string" ? data.name : "" const name = typeof data.name === "string" ? data.name : ""
return t("error.chain.mcpFailed", { name }) return t("error.chain.mcpFailed", { name })
} }
case "ProviderAuthError": { case "ProviderAuthError": {
const providerID = typeof data.providerID === "string" ? data.providerID : "unknown" const providerID = typeof data.providerID === "string" ? data.providerID : t("common.unknown")
const message = typeof data.message === "string" ? data.message : safeJson(data.message) const message = typeof data.message === "string" ? data.message : json(data.message)
return t("error.chain.providerAuthFailed", { provider: providerID, message }) return t("error.chain.providerAuthFailed", { provider: providerID, message })
} }
case "APIError": { case "APIError": {
@@ -101,24 +102,24 @@ function formatInitError(error: InitError, t: Translator): string {
].join("\n") ].join("\n")
} }
case "ProviderInitError": { case "ProviderInitError": {
const providerID = typeof data.providerID === "string" ? data.providerID : "unknown" const providerID = typeof data.providerID === "string" ? data.providerID : t("common.unknown")
return t("error.chain.providerInitFailed", { provider: providerID }) return t("error.chain.providerInitFailed", { provider: providerID })
} }
case "ConfigJsonError": { case "ConfigJsonError": {
const path = typeof data.path === "string" ? data.path : safeJson(data.path) const path = typeof data.path === "string" ? data.path : json(data.path)
const message = typeof data.message === "string" ? data.message : "" const message = typeof data.message === "string" ? data.message : ""
if (message) return t("error.chain.configJsonInvalidWithMessage", { path, message }) if (message) return t("error.chain.configJsonInvalidWithMessage", { path, message })
return t("error.chain.configJsonInvalid", { path }) return t("error.chain.configJsonInvalid", { path })
} }
case "ConfigDirectoryTypoError": { case "ConfigDirectoryTypoError": {
const path = typeof data.path === "string" ? data.path : safeJson(data.path) const path = typeof data.path === "string" ? data.path : json(data.path)
const dir = typeof data.dir === "string" ? data.dir : safeJson(data.dir) const dir = typeof data.dir === "string" ? data.dir : json(data.dir)
const suggestion = typeof data.suggestion === "string" ? data.suggestion : safeJson(data.suggestion) const suggestion = typeof data.suggestion === "string" ? data.suggestion : json(data.suggestion)
return t("error.chain.configDirectoryTypo", { dir, path, suggestion }) return t("error.chain.configDirectoryTypo", { dir, path, suggestion })
} }
case "ConfigFrontmatterError": { case "ConfigFrontmatterError": {
const path = typeof data.path === "string" ? data.path : safeJson(data.path) const path = typeof data.path === "string" ? data.path : json(data.path)
const message = typeof data.message === "string" ? data.message : safeJson(data.message) const message = typeof data.message === "string" ? data.message : json(data.message)
return t("error.chain.configFrontmatterError", { path, message }) return t("error.chain.configFrontmatterError", { path, message })
} }
case "ConfigInvalidError": { case "ConfigInvalidError": {
@@ -126,7 +127,7 @@ function formatInitError(error: InitError, t: Translator): string {
? data.issues.filter(isIssue).map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ? data.issues.filter(isIssue).map((issue) => "↳ " + issue.message + " " + issue.path.join("."))
: [] : []
const message = typeof data.message === "string" ? data.message : "" const message = typeof data.message === "string" ? data.message : ""
const path = typeof data.path === "string" ? data.path : safeJson(data.path) const path = typeof data.path === "string" ? data.path : json(data.path)
const line = message const line = message
? t("error.chain.configInvalidWithMessage", { path, message }) ? t("error.chain.configInvalidWithMessage", { path, message })
@@ -135,14 +136,15 @@ function formatInitError(error: InitError, t: Translator): string {
return [line, ...issues].join("\n") return [line, ...issues].join("\n")
} }
case "UnknownError": case "UnknownError":
return typeof data.message === "string" ? data.message : safeJson(data) return typeof data.message === "string" ? data.message : json(data)
default: default:
if (typeof data.message === "string") return data.message if (typeof data.message === "string") return data.message
return safeJson(data) return json(data)
} }
} }
function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessage?: string): string { function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessage?: string): string {
const json = (value: unknown) => safeJson(value, t("error.page.circular"))
if (!error) return t("error.chain.unknown") if (!error) return t("error.chain.unknown")
if (isInitError(error)) { if (isInitError(error)) {
@@ -204,7 +206,7 @@ function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessag
} }
const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : "" const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : ""
return indent + safeJson(error) return indent + json(error)
} }
function formatError(error: unknown, t: Translator): string { function formatError(error: unknown, t: Translator): string {
+349 -269
View File
@@ -2,7 +2,7 @@ import {
batch, batch,
createEffect, createEffect,
createMemo, createMemo,
createSignal, createResource,
For, For,
on, on,
onCleanup, onCleanup,
@@ -10,6 +10,7 @@ import {
ParentProps, ParentProps,
Show, Show,
untrack, untrack,
type Accessor,
} from "solid-js" } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout" import { useLayout, LocalProject } from "@/context/layout"
@@ -41,8 +42,8 @@ import {
getSessionPrefetch, getSessionPrefetch,
isSessionPrefetchCurrent, isSessionPrefetchCurrent,
runSessionPrefetch, runSessionPrefetch,
SESSION_PREFETCH_TTL,
setSessionPrefetch, setSessionPrefetch,
shouldSkipSessionPrefetch,
} from "@/context/global-sync/session-prefetch" } from "@/context/global-sync/session-prefetch"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
@@ -145,6 +146,10 @@ export default function Layout(props: ParentProps) {
hoverProject: undefined as string | undefined, hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined, scrollSessionKey: undefined as string | undefined,
nav: undefined as HTMLElement | undefined, nav: undefined as HTMLElement | undefined,
sortNow: Date.now(),
sizing: false,
peek: undefined as string | undefined,
peeked: false,
}) })
const editor = createInlineEditorController() const editor = createInlineEditorController()
@@ -163,14 +168,13 @@ export default function Layout(props: ParentProps) {
} }
const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)] const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
const navLeave = { current: undefined as number | undefined } const navLeave = { current: undefined as number | undefined }
const [sortNow, setSortNow] = createSignal(Date.now()) const sortNow = () => state.sortNow
const [sizing, setSizing] = createSignal(false)
let sizet: number | undefined let sizet: number | undefined
let sortNowInterval: ReturnType<typeof setInterval> | undefined let sortNowInterval: ReturnType<typeof setInterval> | undefined
const sortNowTimeout = setTimeout( const sortNowTimeout = setTimeout(
() => { () => {
setSortNow(Date.now()) setState("sortNow", Date.now())
sortNowInterval = setInterval(() => setSortNow(Date.now()), 60_000) sortNowInterval = setInterval(() => setState("sortNow", Date.now()), 60_000)
}, },
60_000 - (Date.now() % 60_000), 60_000 - (Date.now() % 60_000),
) )
@@ -196,7 +200,7 @@ export default function Layout(props: ParentProps) {
}) })
onMount(() => { onMount(() => {
const stop = () => setSizing(false) const stop = () => setState("sizing", false)
window.addEventListener("pointerup", stop) window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop) window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop) window.addEventListener("blur", stop)
@@ -234,8 +238,6 @@ export default function Layout(props: ParentProps) {
}, 300) }, 300)
} }
const [peek, setPeek] = createSignal<LocalProject | undefined>(undefined)
const [peeked, setPeeked] = createSignal(false)
let peekt: number | undefined let peekt: number | undefined
const hoverProjectData = createMemo(() => { const hoverProjectData = createMemo(() => {
@@ -244,6 +246,12 @@ export default function Layout(props: ParentProps) {
return layout.projects.list().find((project) => project.worktree === id) return layout.projects.list().find((project) => project.worktree === id)
}) })
const peekProject = createMemo(() => {
const id = state.peek
if (!id) return
return layout.projects.list().find((project) => project.worktree === id)
})
createEffect(() => { createEffect(() => {
const p = hoverProjectData() const p = hoverProjectData()
if (p) { if (p) {
@@ -251,17 +259,17 @@ export default function Layout(props: ParentProps) {
clearTimeout(peekt) clearTimeout(peekt)
peekt = undefined peekt = undefined
} }
setPeek(p) setState("peek", p.worktree)
setPeeked(true) setState("peeked", true)
return return
} }
setPeeked(false) setState("peeked", false)
if (peek() === undefined) return if (state.peek === undefined) return
if (peekt !== undefined) clearTimeout(peekt) if (peekt !== undefined) clearTimeout(peekt)
peekt = window.setTimeout(() => { peekt = window.setTimeout(() => {
peekt = undefined peekt = undefined
setPeek(undefined) setState("peek", undefined)
}, 180) }, 180)
}) })
@@ -270,16 +278,6 @@ export default function Layout(props: ParentProps) {
setHoverProject(undefined) setHoverProject(undefined)
}) })
const autoselecting = createMemo(() => {
if (params.dir) return false
if (!state.autoselect) return false
if (!pageReady()) return true
if (!layoutReady()) return true
const list = layout.projects.list()
if (list.length > 0) return true
return !!server.projects.last()
})
createEffect(() => { createEffect(() => {
if (!state.autoselect) return if (!state.autoselect) return
const dir = params.dir const dir = params.dir
@@ -545,13 +543,14 @@ export default function Layout(props: ParentProps) {
const currentProject = createMemo(() => { const currentProject = createMemo(() => {
const directory = currentDir() const directory = currentDir()
if (!directory) return if (!directory) return
const key = workspaceKey(directory)
const projects = layout.projects.list() const projects = layout.projects.list()
const sandbox = projects.find((p) => p.sandboxes?.includes(directory)) const sandbox = projects.find((p) => p.sandboxes?.some((item) => workspaceKey(item) === key))
if (sandbox) return sandbox if (sandbox) return sandbox
const direct = projects.find((p) => p.worktree === directory) const direct = projects.find((p) => workspaceKey(p.worktree) === key)
if (direct) return direct if (direct) return direct
const [child] = globalSync.child(directory, { bootstrap: false }) const [child] = globalSync.child(directory, { bootstrap: false })
@@ -565,33 +564,23 @@ export default function Layout(props: ParentProps) {
return projects.find((p) => p.worktree === root) return projects.find((p) => p.worktree === root)
}) })
createEffect( const [autoselecting] = createResource(async () => {
on( await ready.promise
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }), await layout.ready.promise
(value) => { if (!untrack(() => state.autoselect)) return
if (!value.ready) return
if (!value.layoutReady) return
if (!state.autoselect) return
if (value.dir) return
const last = server.projects.last() const list = layout.projects.list()
const last = server.projects.last()
if (value.list.length === 0) { if (list.length === 0) {
if (!last) return if (!last) return
setState("autoselect", false) await openProject(last, true)
openProject(last, false) } else {
navigateToProject(last) const next = list.find((project) => project.worktree === last) ?? list[0]
return if (!next) return
} await openProject(next.worktree, true)
}
const next = value.list.find((project) => project.worktree === last) ?? value.list[0] })
if (!next) return
setState("autoselect", false)
openProject(next.worktree, false)
navigateToProject(next.worktree)
},
),
)
const workspaceName = (directory: string, projectId?: string, branch?: string) => { const workspaceName = (directory: string, projectId?: string, branch?: string) => {
const key = workspaceKey(directory) const key = workspaceKey(directory)
@@ -642,7 +631,11 @@ export default function Layout(props: ParentProps) {
const projects = layout.projects.list() const projects = layout.projects.list()
for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) { for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
if (!expanded) continue if (!expanded) continue
const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) const key = workspaceKey(directory)
const project = projects.find(
(item) =>
workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
)
if (!project) continue if (!project) continue
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
setStore("workspaceExpanded", directory, false) setStore("workspaceExpanded", directory, false)
@@ -770,9 +763,11 @@ export default function Layout(props: ParentProps) {
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id) const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
const sorted = mergeByID([], next) const sorted = mergeByID([], next)
const stale = markPrefetched(directory, sessionID) const stale = markPrefetched(directory, sessionID)
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
const meta = { const meta = {
limit: prefetchChunk, limit: sorted.length,
complete: sorted.length < prefetchChunk, cursor,
complete: !cursor,
at: Date.now(), at: Date.now(),
} }
@@ -846,10 +841,12 @@ export default function Layout(props: ParentProps) {
const [store] = globalSync.child(directory, { bootstrap: false }) const [store] = globalSync.child(directory, { bootstrap: false })
const cached = untrack(() => { const cached = untrack(() => {
if (store.message[session.id] === undefined) return false
const info = getSessionPrefetch(directory, session.id) const info = getSessionPrefetch(directory, session.id)
if (!info) return false return shouldSkipSessionPrefetch({
return Date.now() - info.at < SESSION_PREFETCH_TTL message: store.message[session.id] !== undefined,
info,
chunk: prefetchChunk,
})
}) })
if (cached) return if (cached) return
@@ -1163,13 +1160,17 @@ export default function Layout(props: ParentProps) {
} }
function projectRoot(directory: string) { function projectRoot(directory: string) {
const key = workspaceKey(directory)
const project = layout.projects const project = layout.projects
.list() .list()
.find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) .find(
(item) =>
workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
)
if (project) return project.worktree if (project) return project.worktree
const known = Object.entries(store.workspaceOrder).find( const known = Object.entries(store.workspaceOrder).find(
([root, dirs]) => root === directory || dirs.includes(directory), ([root, dirs]) => workspaceKey(root) === key || dirs.some((item) => workspaceKey(item) === key),
) )
if (known) return known[0] if (known) return known[0]
@@ -1185,13 +1186,6 @@ export default function Layout(props: ParentProps) {
return currentProject()?.worktree ?? projectRoot(directory) return currentProject()?.worktree ?? projectRoot(directory)
} }
function touchProjectRoute() {
const root = currentProject()?.worktree
if (!root) return
if (server.projects.last() !== root) server.projects.touch(root)
return root
}
function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) { function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
setStore("lastProjectSession", root, { directory, id, at: Date.now() }) setStore("lastProjectSession", root, { directory, id, at: Date.now() })
return root return root
@@ -1300,7 +1294,7 @@ export default function Layout(props: ParentProps) {
function openProject(directory: string, navigate = true) { function openProject(directory: string, navigate = true) {
layout.projects.open(directory) layout.projects.open(directory)
if (navigate) navigateToProject(directory) if (navigate) return navigateToProject(directory)
} }
const handleDeepLinks = (urls: string[]) => { const handleDeepLinks = (urls: string[]) => {
@@ -1355,8 +1349,9 @@ export default function Layout(props: ParentProps) {
function closeProject(directory: string) { function closeProject(directory: string) {
const list = layout.projects.list() const list = layout.projects.list()
const index = list.findIndex((x) => x.worktree === directory) const key = workspaceKey(directory)
const active = currentProject()?.worktree === directory const index = list.findIndex((x) => workspaceKey(x.worktree) === key)
const active = workspaceKey(currentProject()?.worktree ?? "") === key
if (index === -1) return if (index === -1) return
const next = list[index + 1] const next = list[index + 1]
@@ -1691,38 +1686,55 @@ export default function Layout(props: ParentProps) {
const activeRoute = { const activeRoute = {
session: "", session: "",
sessionProject: "", sessionProject: "",
directory: "",
} }
createEffect( createEffect(
on( on(
() => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const, () => {
([ready, dir, id]) => { const dir = params.dir
if (!ready || !dir) { const directory = dir ? decode64(dir) : undefined
const resolved = directory ? globalSync.child(directory, { bootstrap: false })[0].path.directory : ""
return [pageReady(), dir, params.id, currentProject()?.worktree, directory, resolved] as const
},
([ready, dir, id, root, directory, resolved]) => {
if (!ready || !dir || !directory) {
activeRoute.session = "" activeRoute.session = ""
activeRoute.sessionProject = "" activeRoute.sessionProject = ""
activeRoute.directory = ""
return return
} }
const directory = decode64(dir)
if (!directory) return
const root = touchProjectRoute() ?? activeProjectRoot(directory)
if (!id) { if (!id) {
activeRoute.session = "" activeRoute.session = ""
activeRoute.sessionProject = "" activeRoute.sessionProject = ""
activeRoute.directory = ""
return return
} }
const next = resolved || directory
const session = `${dir}/${id}` const session = `${dir}/${id}`
if (session !== activeRoute.session) {
if (!root) {
activeRoute.session = session activeRoute.session = session
activeRoute.sessionProject = syncSessionRoute(directory, id, root) activeRoute.directory = next
activeRoute.sessionProject = ""
return
}
if (server.projects.last() !== root) server.projects.touch(root)
const changed = session !== activeRoute.session || next !== activeRoute.directory
if (changed) {
activeRoute.session = session
activeRoute.directory = next
activeRoute.sessionProject = syncSessionRoute(next, id, root)
return return
} }
if (root === activeRoute.sessionProject) return if (root === activeRoute.sessionProject) return
activeRoute.sessionProject = rememberSessionRoute(directory, id, root) activeRoute.directory = next
activeRoute.sessionProject = rememberSessionRoute(next, id, root)
}, },
), ),
) )
@@ -1786,8 +1798,13 @@ export default function Layout(props: ParentProps) {
const local = project.worktree const local = project.worktree
const dirs = [local, ...(project.sandboxes ?? [])] const dirs = [local, ...(project.sandboxes ?? [])]
const active = currentProject() const active = currentProject()
const directory = active?.worktree === project.worktree ? currentDir() : undefined const directory = workspaceKey(active?.worktree ?? "") === workspaceKey(project.worktree) ? currentDir() : undefined
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined const extra =
directory &&
workspaceKey(directory) !== workspaceKey(local) &&
!dirs.some((item) => workspaceKey(item) === workspaceKey(directory))
? directory
: undefined
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree]) const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
@@ -1939,17 +1956,33 @@ export default function Layout(props: ParentProps) {
setHoverSession, setHoverSession,
} }
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => { const SidebarPanel = (panelProps: {
project: Accessor<LocalProject | undefined>
mobile?: boolean
merged?: boolean
}) => {
const project = panelProps.project
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened())) const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened()) const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened()) const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
const projectName = createMemo(() => { const projectName = createMemo(() => {
const project = panelProps.project const item = project()
if (!project) return "" if (!item) return ""
return project.name || getFilename(project.worktree) return item.name || getFilename(item.worktree)
})
const projectId = createMemo(() => project()?.id ?? "")
const worktree = createMemo(() => project()?.worktree ?? "")
const slug = createMemo(() => {
const dir = worktree()
if (!dir) return ""
return base64Encode(dir)
})
const workspaces = createMemo(() => {
const item = project()
if (!item) return [] as string[]
return workspaceIds(item)
}) })
const projectId = createMemo(() => panelProps.project?.id ?? "")
const workspaces = createMemo(() => workspaceIds(panelProps.project))
const unseenCount = createMemo(() => const unseenCount = createMemo(() =>
workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
) )
@@ -1958,17 +1991,22 @@ export default function Layout(props: ParentProps) {
.filter((directory) => notification.project.unseenCount(directory) > 0) .filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory)) .forEach((directory) => notification.project.markViewed(directory))
const workspacesEnabled = createMemo(() => { const workspacesEnabled = createMemo(() => {
const project = panelProps.project const item = project()
if (!project) return false if (!item) return false
if (project.vcs !== "git") return false if (item.vcs !== "git") return false
return layout.sidebar.workspaces(project.worktree)() return layout.sidebar.workspaces(item.worktree)()
})
const canToggle = createMemo(() => {
const item = project()
if (!item) return false
return item.vcs === "git" || layout.sidebar.workspaces(item.worktree)()
}) })
const homedir = createMemo(() => globalSync.data.path.home) const homedir = createMemo(() => globalSync.data.path.home)
return ( return (
<div <div
classList={{ classList={{
"flex flex-col min-h-0 min-w-0 box-border rounded-tl-[12px] px-2": true, "flex flex-col min-h-0 min-w-0 box-border rounded-tl-[12px] px-3": true,
"border border-b-0 border-border-weak-base": !merged(), "border border-b-0 border-border-weak-base": !merged(),
"border-l border-t border-border-weaker-base": merged(), "border-l border-t border-border-weaker-base": merged(),
"bg-background-base": merged() || hover(), "bg-background-base": merged() || hover(),
@@ -1980,168 +2018,216 @@ export default function Layout(props: ParentProps) {
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`, width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
}} }}
> >
<Show when={panelProps.project}> <Show
{(p) => ( when={project()}
<> fallback={
<div class="shrink-0 px-2 py-1"> <Show when={empty()}>
<div class="group/project flex items-start justify-between gap-2 p-2 pr-1"> <div class="flex-1 min-h-0 -mt-4 flex items-center justify-center px-6 pb-64 text-center">
<div class="flex flex-col min-w-0"> <div class="mt-8 flex max-w-60 flex-col items-center gap-6 text-center">
<InlineEditor <div class="flex flex-col gap-3">
id={`project:${projectId()}`} <div class="text-14-medium text-text-strong">{language.t("sidebar.empty.title")}</div>
value={projectName} <div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
onSave={(next) => renameProject(p(), next)} {language.t("sidebar.empty.description")}
class="text-14-medium text-text-strong truncate" </div>
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
<Tooltip
placement="bottom"
gutter={2}
value={p().worktree}
class="shrink-0"
contentStyle={{
"max-width": "640px",
transform: "translate3d(52px, 0, 0)",
}}
>
<span class="text-12-regular text-text-base truncate select-text">
{p().worktree.replace(homedir(), "~")}
</span>
</Tooltip>
</div> </div>
<Button size="large" icon="folder-add-left" onClick={chooseProject}>
<DropdownMenu modal={!sidebarHovering()}> {language.t("command.project.open")}
<DropdownMenu.Trigger </Button>
as={IconButton}
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={base64Encode(p().worktree)}
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
}}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(p().worktree)}
disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
onSelect={() => toggleProjectWorkspaces(p())}
>
<DropdownMenu.ItemLabel>
{layout.sidebar.workspaces(p().worktree)()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={base64Encode(p().worktree)}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={base64Encode(p().worktree)}
onSelect={() => closeProject(p().worktree)}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div> </div>
</div> </div>
</Show>
}
>
<>
<div class="shrink-0 pl-1 py-1">
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<div class="flex flex-col min-w-0">
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => {
const item = project()
if (!item) return
renameProject(item, next)
}}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
<div class="flex-1 min-h-0 flex flex-col"> <Tooltip
<Show placement="bottom"
when={workspacesEnabled()} gutter={2}
fallback={ value={worktree()}
<> class="shrink-0"
<div class="shrink-0 py-4 px-3"> contentStyle={{
<Button "max-width": "640px",
size="large" transform: "translate3d(52px, 0, 0)",
icon="plus-small" }}
class="w-full" >
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)} <span class="text-12-regular text-text-base truncate select-text">
> {worktree().replace(homedir(), "~")}
{language.t("command.session.new")} </span>
</Button> </Tooltip>
</div> </div>
<div class="flex-1 min-h-0">
<LocalWorkspace <DropdownMenu modal={!sidebarHovering()}>
ctx={workspaceSidebarCtx} <DropdownMenu.Trigger
project={p()} as={IconButton}
sortNow={sortNow} icon="dot-grid"
mobile={panelProps.mobile} variant="ghost"
popover={popover()} data-action="project-menu"
/> data-project={slug()}
</div> class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
</> classList={{
} "opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
> }}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
const item = project()
if (!item) return
showEditProjectDialog(item)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={slug()}
disabled={!canToggle()}
onSelect={() => {
const item = project()
if (!item) return
toggleProjectWorkspaces(item)
}}
>
<DropdownMenu.ItemLabel>
{workspacesEnabled()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={slug()}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={slug()}
onSelect={() => {
const dir = worktree()
if (!dir) return
closeProject(dir)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
<div class="flex-1 min-h-0 flex flex-col">
<Show
when={workspacesEnabled()}
fallback={
<> <>
<div class="shrink-0 py-4 px-3"> <div class="shrink-0 py-4">
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}> <Button
{language.t("workspace.new")} size="large"
icon="new-session"
class="w-full"
onClick={() => {
const dir = worktree()
if (!dir) return
navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
}}
>
{language.t("command.session.new")}
</Button> </Button>
</div> </div>
<div class="relative flex-1 min-h-0"> <div class="flex-1 min-h-0">
<DragDropProvider <LocalWorkspace
onDragStart={handleWorkspaceDragStart} ctx={workspaceSidebarCtx}
onDragEnd={handleWorkspaceDragEnd} project={project()!}
onDragOver={handleWorkspaceDragOver} sortNow={sortNow}
collisionDetector={closestCenter} mobile={panelProps.mobile}
> popover={popover()}
<DragDropSensors /> />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={p()}
sortNow={sortNow}
mobile={panelProps.mobile}
popover={popover()}
/>
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay
sidebarProject={sidebarProject}
activeWorkspace={() => store.activeWorkspace}
workspaceLabel={workspaceLabel}
/>
</DragOverlay>
</DragDropProvider>
</div> </div>
</> </>
</Show> }
</div> >
</> <>
)} <div class="shrink-0 py-4">
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => {
const item = project()
if (!item) return
createWorkspace(item)
}}
>
{language.t("workspace.new")}
</Button>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
popover={popover()}
/>
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay
sidebarProject={sidebarProject}
activeWorkspace={() => store.activeWorkspace}
workspaceLabel={workspaceLabel}
/>
</DragOverlay>
</DragDropProvider>
</div>
</>
</Show>
</div>
</>
</Show> </Show>
<div <div
@@ -2166,7 +2252,7 @@ export default function Layout(props: ParentProps) {
{language.t("command.provider.connect")} {language.t("command.provider.connect")}
</Button> </Button>
<Button size="large" variant="ghost" onClick={() => setStore("gettingStartedDismissed", true)}> <Button size="large" variant="ghost" onClick={() => setStore("gettingStartedDismissed", true)}>
Not yet {language.t("toast.update.action.notYet")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -2200,13 +2286,7 @@ export default function Layout(props: ParentProps) {
helpLabel={() => language.t("sidebar.help")} helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() => renderPanel={() =>
mobile ? ( mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged />
<SidebarPanel project={currentProject()} mobile />
) : (
<Show when={currentProject()}>
<SidebarPanel project={currentProject()} merged />
</Show>
)
} }
/> />
) )
@@ -2241,7 +2321,7 @@ export default function Layout(props: ParentProps) {
> >
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div> <div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
<Show when={layout.sidebar.opened()}> <Show when={layout.sidebar.opened()}>
<div onPointerDown={() => setSizing(true)}> <div onPointerDown={() => setState("sizing", true)}>
<ResizeHandle <ResizeHandle
direction="horizontal" direction="horizontal"
size={layout.sidebar.width()} size={layout.sidebar.width()}
@@ -2249,9 +2329,9 @@ export default function Layout(props: ParentProps) {
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64} max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
collapseThreshold={244} collapseThreshold={244}
onResize={(w) => { onResize={(w) => {
setSizing(true) setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet) if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setSizing(false), 120) sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w) layout.sidebar.resize(w)
}} }}
onCollapse={layout.sidebar.close} onCollapse={layout.sidebar.close}
@@ -2296,7 +2376,7 @@ export default function Layout(props: ParentProps) {
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true, "xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true, "z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none": "transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!sizing(), !state.sizing,
}} }}
style={{ style={{
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem", "--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
@@ -2307,7 +2387,7 @@ export default function Layout(props: ParentProps) {
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true, "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}} }}
> >
<Show when={!autoselecting()} fallback={<div class="size-full" />}> <Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children} {props.children}
</Show> </Show>
</main> </main>
@@ -2316,11 +2396,11 @@ export default function Layout(props: ParentProps) {
<div <div
classList={{ classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true, "hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(), "opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(), "opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true, "transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": peeked() && !layout.sidebar.opened(), "duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !peeked() || layout.sidebar.opened(), "duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}} }}
onMouseMove={disarm} onMouseMove={disarm}
onMouseEnter={() => { onMouseEnter={() => {
@@ -2332,19 +2412,19 @@ export default function Layout(props: ParentProps) {
arm() arm()
}} }}
> >
<Show when={peek()}> <Show when={peekProject()}>
<SidebarPanel project={peek()} merged={false} /> <SidebarPanel project={peekProject} merged={false} />
</Show> </Show>
</div> </div>
<div <div
classList={{ classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true, "hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(), "opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(), "opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true, "transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": peeked() && !layout.sidebar.opened(), "duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !peeked() || layout.sidebar.opened(), "duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}} }}
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }} style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
> >
@@ -104,14 +104,14 @@ describe("layout deep links", () => {
describe("layout workspace helpers", () => { describe("layout workspace helpers", () => {
test("normalizes trailing slash in workspace key", () => { test("normalizes trailing slash in workspace key", () => {
expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo") expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo")
expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo") expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo")
}) })
test("preserves posix and drive roots in workspace key", () => { test("preserves posix and drive roots in workspace key", () => {
expect(workspaceKey("/")).toBe("/") expect(workspaceKey("/")).toBe("/")
expect(workspaceKey("///")).toBe("/") expect(workspaceKey("///")).toBe("/")
expect(workspaceKey("C:\\")).toBe("C:\\") expect(workspaceKey("C:\\")).toBe("C:/")
expect(workspaceKey("C:\\\\\\")).toBe("C:\\") expect(workspaceKey("C://")).toBe("C:/")
expect(workspaceKey("C:///")).toBe("C:/") expect(workspaceKey("C:///")).toBe("C:/")
}) })
+18 -12
View File
@@ -1,11 +1,17 @@
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { type Session } from "@opencode-ai/sdk/v2/client" import { type Session } from "@opencode-ai/sdk/v2/client"
type SessionStore = {
session?: Session[]
path: { directory: string }
}
export const workspaceKey = (directory: string) => { export const workspaceKey = (directory: string) => {
const drive = directory.match(/^([A-Za-z]:)[\\/]+$/) const value = directory.replaceAll("\\", "/")
if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}` const drive = value.match(/^([A-Za-z]:)\/+$/)
if (/^[\\/]+$/.test(directory)) return directory.includes("\\") ? "\\" : "/" if (drive) return `${drive[1]}/`
return directory.replace(/[\\/]+$/, "") if (/^\/+$/i.test(value)) return "/"
return value.replace(/\/+$/, "")
} }
function sortSessions(now: number) { function sortSessions(now: number) {
@@ -25,13 +31,13 @@ function sortSessions(now: number) {
const isRootVisibleSession = (session: Session, directory: string) => const isRootVisibleSession = (session: Session, directory: string) =>
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => const roots = (store: SessionStore) =>
store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now)) (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
export const latestRootSession = (stores: { session: Session[]; path: { directory: string } }[], now: number) => export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now))
stores
.flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory))) export const latestRootSession = (stores: SessionStore[], now: number) =>
.sort(sortSessions(now))[0] stores.flatMap(roots).sort(sortSessions(now))[0]
export function hasProjectPermissions<T>( export function hasProjectPermissions<T>(
request: Record<string, T[] | undefined>, request: Record<string, T[] | undefined>,
@@ -40,9 +46,9 @@ export function hasProjectPermissions<T>(
return Object.values(request).some((list) => list?.some(include)) return Object.values(request).some((list) => list?.some(include))
} }
export const childMapByParent = (sessions: Session[]) => { export const childMapByParent = (sessions: Session[] | undefined) => {
const map = new Map<string, string[]>() const map = new Map<string, string[]>()
for (const session of sessions) { for (const session of sessions ?? []) {
if (!session.parentID) continue if (!session.parentID) continue
const existing = map.get(session.parentID) const existing = map.get(session.parentID)
if (existing) { if (existing) {
+15 -17
View File
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { agentColor } from "@/utils/agent" import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "../session/composer/session-request-tree" import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { hasProjectPermissions } from "./helpers" import { hasProjectPermissions } from "./helpers"
@@ -204,24 +204,22 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
}) })
const isWorking = createMemo(() => { const isWorking = createMemo(() => {
if (hasPermissions()) return false if (hasPermissions()) return false
const pending = (sessionStore.message[props.session.id] ?? []).findLast(
(message) =>
message.role === "assistant" &&
typeof (message as { time?: { completed?: unknown } }).time?.completed !== "number",
)
const status = sessionStore.session_status[props.session.id] const status = sessionStore.session_status[props.session.id]
return status?.type === "busy" || status?.type === "retry" return (
pending !== undefined ||
status?.type === "busy" ||
status?.type === "retry" ||
(status !== undefined && status.type !== "idle")
)
}) })
const tint = createMemo(() => { const tint = createMemo(() => {
const messages = sessionStore.message[props.session.id] return messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)
if (!messages) return undefined
let user: Message | undefined
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (message.role !== "user") continue
user = message
break
}
if (!user?.agent) return undefined
const agent = sessionStore.agent.find((a) => a.name === user.agent)
return agentColor(user.agent, agent?.color)
}) })
const hoverMessages = createMemo(() => const hoverMessages = createMemo(() =>
@@ -300,7 +298,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
return ( return (
<div <div
data-session-id={props.session.id} data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 class="group/session relative w-full rounded-md cursor-default pl-2 pr-3 transition-colors
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active" hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
> >
<Show <Show
@@ -386,7 +384,7 @@ export const NewSessionItem = (props: {
> >
<div class="flex items-center gap-1 w-full"> <div class="flex items-center gap-1 w-full">
<div class="shrink-0 size-6 flex items-center justify-center"> <div class="shrink-0 size-6 flex items-center justify-center">
<Icon name="plus-small" size="small" class="text-icon-weak" /> <Icon name="new-session" size="small" class="text-icon-weak" />
</div> </div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"> <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{label} {label}

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