Compare commits

...

147 Commits

Author SHA1 Message Date
James Long 12395c37ad refactor(core): route aggregate layers through nodes 2026-06-30 10:25:13 -04:00
James Long c86066efef test(opencode): update help snapshots (#34639) 2026-06-30 10:22:21 -04:00
usrnk1 fa9ba2938e feat(desktop): make error view draggable (#34627) 2026-06-30 22:12:43 +08:00
usrnk1 63e24880e5 feat(desktop): polish inline session title editing (#34607) 2026-06-30 22:12:22 +08:00
opencode-agent[bot] eaf42c4ab0 chore: update nix node_modules hashes 2026-06-30 13:55:24 +00:00
Aiden Cline 8e10ab0aa7 chore: bump venice provider (#34629) 2026-06-30 08:40:09 -05:00
runvip b329fcceb2 fix(docs): fix Russian translation for index.mdx (#34001)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-30 16:47:44 +05:30
opencode-agent[bot] bd83177c48 chore: generate 2026-06-30 11:07:49 +00:00
Brendan Allan 06cb0de0b1 feat(app): autocomplete mcp resources (#34597) 2026-06-30 19:05:53 +08:00
opencode-agent[bot] 3ca89ac796 fix(app): autocomplete configured references (#34308)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-06-30 18:45:37 +08:00
Brendan Allan 3aa2860058 fix(app): restore prompt cursor on focus (#34175) 2026-06-30 18:40:20 +08:00
usrnk1 ad63cf1590 feat(app): hide separators around active tabs (#34591) 2026-06-30 18:38:42 +08:00
Brendan Allan aa56750f8b fix(desktop): persist last active url (#34595) 2026-06-30 18:32:30 +08:00
opencode-agent[bot] 4aaed42640 fix(app): preserve macos titlebar inset (#34594)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 18:17:55 +08:00
usrnk1 f69f677a32 feat(desktop): add hover background to session title and single-click edit (#34589) 2026-06-30 17:46:52 +08:00
opencode-agent[bot] 8289883de8 chore: generate 2026-06-30 09:11:40 +00:00
usrnk1 003c22b4a3 fix(desktop): context menu button / tab intermittent issue (#34420)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-06-30 17:09:38 +08:00
opencode-agent[bot] 1982d9832b fix(session-ui): align code copy tooltip with v2 style (#34459)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 17:01:38 +08:00
opencode-agent[bot] 90f0576222 fix(session-ui): deselect review line on draft cancel (#34585)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-30 16:26:20 +08:00
usrnk1 e34822db6b fix(desktop): preserve selected model during session promotion (#34466) 2026-06-30 16:23:39 +08:00
Frank b19ae6547f sync 2026-06-30 03:23:50 -04:00
opencode-agent[bot] 6b074ed718 fix(app): suppress middle-click tab auxclick (#34355)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 16:32:25 +10:00
opencode-agent[bot] e687eb936b fix(app): register export logs globally (#34352)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 16:31:09 +10:00
opencode-agent[bot] 797cb530e5 chore: generate 2026-06-30 05:09:55 +00:00
Dax 0a5bed2bc2 feat(tui): add yolo permission mode (#33279)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-30 00:08:29 -05:00
opencode-agent[bot] ba41dadc91 ci: skip issue actions for team authors (#34556)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-29 23:48:58 -05:00
James Long 451876b0bd refactor(opencode): remove core service layer exports (#34518) 2026-06-29 23:32:16 -04:00
James Long 91561bbdfb refactor(opencode): remove app service layer exports (#34517) 2026-06-29 23:03:57 -04:00
opencode-agent[bot] 018ce34b39 revert(core): skip fff in node runtime (#34549)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 12:58:57 +10:00
opencode-agent[bot] ce82b21073 chore: generate 2026-06-30 02:55:10 +00:00
James Long 1f37c26574 refactor(opencode): use layer nodes in remaining harnesses (#34516) 2026-06-29 22:53:42 -04:00
opencode-agent[bot] cb3e28d207 fix(app): collapse review pane by default (#34165)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 12:47:00 +10:00
opencode-agent[bot] 8f1e13f299 fix(core): skip fff in node runtime (#34353)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-30 12:38:47 +10:00
Luke Parker 2fe68b5e91 fix(ui): prevent tool status blank frame (#34547)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 12:26:04 +10:00
opencode-agent[bot] 2ace237938 chore: generate 2026-06-30 01:23:03 +00:00
James Long 5b62211167 refactor(opencode): build runtimes from layer nodes (#34515) 2026-06-29 21:21:28 -04:00
Frank 60b6229c4e zen: track region 2026-06-29 19:57:13 -04:00
opencode-agent[bot] 4caec80e67 chore: generate 2026-06-29 23:03:24 +00:00
Frank e9d4ca7e0f zen: track region 2026-06-29 18:57:32 -04:00
Aiden Cline d71454c701 fix(core): expose models.dev modes as models (#34521) 2026-06-29 17:51:06 -05:00
Dustin Deus 884c256033 fix(console): cancel upstream provider requests (#34467)
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
2026-06-29 23:28:04 +02:00
opencode-agent[bot] 78235385dd chore: generate 2026-06-29 20:53:48 +00:00
Aiden Cline fd213e6df6 fix(mcp): prefer content over structured output (#34505) 2026-06-29 15:51:52 -05:00
James Long 3726052307 refactor(opencode): use layer nodes in server tests (#34503) 2026-06-29 16:50:59 -04:00
opencode-agent[bot] 7d33a6f7c9 chore: generate 2026-06-29 20:36:56 +00:00
James Long 7a035d7fc0 refactor(opencode): bind instance bootstrap node (#34502) 2026-06-29 16:35:10 -04:00
opencode-agent[bot] 9151af7045 chore: generate 2026-06-29 20:24:42 +00:00
James Long 15bcbb1d7a refactor(opencode): migrate session tests to layer nodes (#34494) 2026-06-29 16:22:50 -04:00
opencode-agent[bot] 4d3294727c chore: generate 2026-06-29 20:07:10 +00:00
James Long aae0e89519 refactor(opencode): use layer nodes in plugin tests (#34495) 2026-06-29 16:04:58 -04:00
opencode-agent[bot] 93a7b4ab76 chore: generate 2026-06-29 19:58:09 +00:00
James Long 0ebe74b625 refactor(opencode): migrate llm tests to layer nodes (#34479) 2026-06-29 15:55:58 -04:00
James Long b9dae8593c refactor(opencode): migrate compaction and workspace tests to layer nodes (#34478) 2026-06-29 15:42:52 -04:00
opencode-agent[bot] d8f9388610 chore: generate 2026-06-29 18:52:55 +00:00
James Long be14739cce refactor(core): convert config tests to nodes (#34474) 2026-06-29 14:51:13 -04:00
James Long 762588c251 refactor(core): convert prompt tests to nodes (#34470) 2026-06-29 14:25:33 -04:00
opencode-agent[bot] d6e54e9042 chore: generate 2026-06-29 17:58:20 +00:00
James Long d4fd528152 refactor(core): convert more opencode tests to nodes (#34464) 2026-06-29 13:56:28 -04:00
opencode-agent[bot] de185559dd chore: generate 2026-06-29 16:58:51 +00:00
James Long bc5ce5eab1 refactor(core): convert opencode tests to nodes (#34453) 2026-06-29 12:56:44 -04:00
opencode-agent[bot] b10d617c80 chore: generate 2026-06-29 16:17:54 +00:00
Shoubhit Dash 18466b8020 feat(llm): add tool schema projections (#34454) 2026-06-29 21:45:42 +05:30
opencode-agent[bot] 71ec022b47 chore: generate 2026-06-29 15:50:24 +00:00
Shoubhit Dash f7eeb08942 fix(llm): narrow raw overlays (#34448) 2026-06-29 21:18:06 +05:30
opencode-agent[bot] 9205dfe724 chore: generate 2026-06-29 15:37:20 +00:00
James Long a3776429aa refactor(core): finish test layer node conversion (#34385) 2026-06-29 11:35:17 -04:00
opencode-agent[bot] c0e43c0c65 chore: generate 2026-06-29 13:57:06 +00:00
Shoubhit Dash 08c5a2a5e8 feat(llm): enforce request precedence (#34440) 2026-06-29 19:24:37 +05:30
opencode-agent[bot] 7077c70d60 chore: generate 2026-06-29 13:41:59 +00:00
Shoubhit Dash 1fd8bf526d feat(llm): add model defaults and compatibility data (#34436) 2026-06-29 19:10:00 +05:30
opencode-agent[bot] 6d9539f469 fix: exempt org issues from compliance close (#34431) 2026-06-29 12:59:30 +00:00
Shoubhit Dash e5101d9651 test(llm): lock event reducer laws (#34423) 2026-06-29 17:22:46 +05:30
Shoubhit Dash b0151e1d02 test(llm): verify generate reducer law (#34418) 2026-06-29 17:01:45 +05:30
opencode-agent[bot] c3637753bc chore: generate 2026-06-29 10:58:22 +00:00
Shoubhit Dash 48fc9e3cc3 feat(llm): add response reducer (#34417) 2026-06-29 16:26:33 +05:30
opencode-agent[bot] 82a482b36d chore: generate 2026-06-29 10:45:44 +00:00
Aarav Sareen 7fac84319d feat(app): align slash popover to v2 tokens (#34286)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-06-29 18:44:18 +08:00
Aarav Sareen 0a5e617da8 feat(app): update message part ui to v2 (#34394) 2026-06-29 18:43:06 +08:00
Jack b5f92c9f48 docs: fix Kimi K2.7 Go model ID (#34413) 2026-06-29 18:39:43 +08:00
Aarav Sareen 2070fd9bc2 feat(app): improve projects sidebar reactivity (#34391)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-06-29 18:20:26 +08:00
opencode-agent[bot] 48dc05eee7 chore: generate 2026-06-29 10:13:23 +00:00
Aarav Sareen 84bb706537 feat(app): new timeline header (#34192) 2026-06-29 18:11:32 +08:00
opencode-agent[bot] be8cfa7e08 chore: generate 2026-06-29 09:51:40 +00:00
Aarav Sareen 56a789d926 feat(app): show loader on session hover (#34224) 2026-06-29 17:50:14 +08:00
Aarav Sareen 5409151ad4 feat(app): sticky session list header (#34220)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-06-29 17:49:10 +08:00
Frank f90d154465 zen: budget 2026-06-29 05:46:56 -04:00
Frank 078385d386 zen: budget 2026-06-29 05:46:56 -04:00
Filip beaaa174ea fix(app): disable empty server chevron (#34292) 2026-06-29 15:28:01 +08:00
opencode-agent[bot] fb59606bb4 test(core): fix layer node replacement type expectation (#34386)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-29 00:03:02 -05:00
opencode-agent[bot] 846d548154 chore: generate 2026-06-29 03:49:43 +00:00
James Long 84336e4f91 refactor(core): refine layer node replacements (#34377) 2026-06-28 23:48:18 -04:00
OpeOginni 01a5c69244 feat(tui): integrate ServerAuth headers into transport configuration for external served TUI thread (#29876) 2026-06-28 19:24:27 -05:00
opencode-agent[bot] d78f91afeb chore: generate 2026-06-28 23:36:32 +00:00
Brendan Allan 33762292f7 fix(app): wrap model.set in startTransition (#34351) 2026-06-29 07:35:09 +08:00
opencode-agent[bot] 5d6aa3b41a chore: generate 2026-06-28 23:18:35 +00:00
OpeOginni 683aca5dbe feat(desktop): Display stored totals for Tokens and Cost in Desktop Session Context (#28887) 2026-06-28 23:17:08 +00:00
Aiden Cline 92025e9a47 fix(mcp): clarify debug oauth probe (#34350) 2026-06-28 18:16:29 -05:00
Max Anderson 411e053572 fix(mcp): reconnect after OAuth even when server is disabled
Closes #33915
2026-06-28 18:07:10 -05:00
Frank b862d178bf zen: update alert role 2026-06-28 18:53:32 -04:00
Frank bda0ddc207 zen: new inference 2026-06-28 13:48:42 -04:00
Brendan Allan 58ba99e505 fix(desktop): avoid destroyed window permission checks (#34300) 2026-06-28 20:06:15 +08:00
Filip 6ee817d041 fix(app): disable add project when given server is offline (#34294) 2026-06-28 18:45:05 +08:00
Kit Langton dfeb1b5051 feat(client): generate complete protocol client (#34164) 2026-06-27 22:25:40 -04:00
opencode-agent[bot] 61a7f6db35 chore: update nix node_modules hashes 2026-06-28 01:20:08 +00:00
Dax 6446b8ae3b fix(sdk): preserve V2Event name for SSE streams (#34171) 2026-06-27 21:05:47 -04:00
Brendan Allan ae53163cad fix(app): transition draft project updates (#34252) 2026-06-28 02:58:07 +08:00
opencode-agent[bot] 41202819a4 chore: generate 2026-06-27 18:11:37 +00:00
James Long a31698f99b refactor(core): move more tests to nodes (#34248) 2026-06-27 18:10:07 +00:00
opencode-agent[bot] 6248542c49 chore: generate 2026-06-27 17:20:42 +00:00
James Long d25c91e5eb refactor(core): move session test to nodes (#34245) 2026-06-27 13:19:14 -04:00
James Long 5d63020dcd test(core): cover app node builder graphs (#34244) 2026-06-27 16:54:18 +00:00
opencode-agent[bot] 062f54590e chore: generate 2026-06-27 16:30:47 +00:00
James Long a76c6918d2 refactor(core): rename app node modules (#34238) 2026-06-27 12:29:21 -04:00
Ben Guthrie 2b91a6f210 fix(tui): register prompt.skills keybinds (#34180) 2026-06-27 11:06:14 -05:00
opencode-agent[bot] 10579cceb2 chore: generate 2026-06-27 15:38:27 +00:00
Aarav Sareen 25702e01ce feat(app): new debug bar (#34237) 2026-06-27 15:37:02 +00:00
James Long ecc5c44d9a refactor(core): make node build bind maps conditionally (#34218) 2026-06-27 11:09:07 -04:00
Aarav Sareen f5a0b920a2 feat(app): minor visual updates (#34205) 2026-06-27 18:07:17 +08:00
Brendan Allan 2caa016fe1 fix(app): batch new session tab navigation (#34196) 2026-06-27 17:13:09 +08:00
opencode-agent[bot] 6861fedd09 chore: generate 2026-06-27 07:43:42 +00:00
Luke Parker 3d072112ce fix(app): reconcile session pages with concurrent events (#34042) 2026-06-27 17:42:17 +10:00
OpeOginni bdfea046db fix(desktop): recognize normal auth metadata input prompts in connect provider dialog (#33024) 2026-06-27 05:19:59 +00:00
Aarav Sareen a2d08fb63b feat(app): update home screen alignment + markdown styles (#34172) 2026-06-27 13:00:52 +08:00
opencode-agent[bot] 5a55135d89 fix(ui): make select hover feedback immediate (#34121)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-27 12:47:32 +08:00
opencode-agent[bot] 8870d36e0f fix(app): space home sessions from scrollbar (#34132)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-27 12:46:33 +08:00
opencode-agent[bot] eb923c27ca chore: update nix node_modules hashes 2026-06-27 04:27:56 +00:00
opencode-agent[bot] 9903abc704 fix(opencode): allow empty provider config default (#34167)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-26 23:19:42 -05:00
opencode-agent[bot] 225a1fbf35 chore: generate 2026-06-27 04:10:30 +00:00
Dax Raad 93159bccbf feat(core): port v2 runtime fixes onto dev
Cherry-picks the packages/core changes from the v2 branch onto dev:
- combined ordered stdout/stderr in AppProcess + bash structured output
- edit/apply-patch return FileDiff info with status and line stats
- ignore no-op model switches; record reasoning timestamps
- return unexpected local tool defects to the model and continue
- keep OAuth account metadata out of request bodies
- nest OpenAI reasoning effort/summary options
- load OpenCode provider config asynchronously; batch plugin boot
- export latest public event manifest

Includes the supporting schema reasoning time field and regenerated
client/SDK types.
2026-06-27 00:07:25 -04:00
opencode-agent[bot] fab8ec4f54 fix(app): migrate composer tooltips to v2 (#34147)
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-27 14:01:45 +10:00
opencode-agent[bot] a3035c53ea chore: update nix node_modules hashes 2026-06-27 03:47:28 +00:00
Aiden Cline 36c416e143 fix(mcp): request refresh token scope (#34125) 2026-06-26 22:27:43 -05:00
Aiden Cline e1e0304a96 fix(mcp): surface OAuth completion errors (#34145) 2026-06-26 22:21:16 -05:00
opencode-agent[bot] 71c3a7c8f2 chore: generate 2026-06-27 02:47:33 +00:00
James Long ecdfff5a42 refactor(core): separate out location node functionality and integrate into v2 (#34119) 2026-06-26 22:46:07 -04:00
opencode-agent[bot] 4b948c5d74 fix(app): hide home session archive action (#34136)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-27 02:23:36 +00:00
opencode-agent[bot] cd56c51e2d fix(app): keep bare slash as plain inline code (#34122)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-26 22:21:01 +00:00
opencode-agent[bot] 43e39d7f68 fix(tui): use generated event union (#34118)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
2026-06-26 18:04:53 -04:00
Frank 7a17925495 zen: new inference 2026-06-26 16:25:07 -04:00
Brendan Allan 5acb2530b4 fix(app): centralize notification state (#34105) 2026-06-26 19:24:33 +00:00
opencode-agent[bot] 44a6787359 chore: generate 2026-06-26 19:18:18 +00:00
Kit Langton 42e6b7db32 feat(sdk): expose live event stream (#34098) 2026-06-26 21:16:33 +02:00
opencode-agent[bot] 2c02f8bace chore: update nix node_modules hashes 2026-06-26 18:53:34 +00:00
Affan Ali 2ec20e576b fix(app): slow tooltip display for models (#30745)
Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com>
2026-06-27 02:53:08 +08:00
opencode-agent[bot] 20f47fec7a chore: generate 2026-06-26 18:51:42 +00:00
Kit Langton 65210f2d97 feat(api): add finite durable session history pages (#34097) 2026-06-26 14:49:47 -04:00
Brendan Allan af0b7ffae7 fix(app): animate prompt selectors after loading (#34101) 2026-06-27 02:36:01 +08:00
552 changed files with 19334 additions and 10127 deletions
+38
View File
@@ -34,10 +34,48 @@ jobs:
const now = Date.now(); const now = Date.now();
const twoHours = 2 * 60 * 60 * 1000; const twoHours = 2 * 60 * 60 * 1000;
const orgMemberAssociations = new Set(['OWNER', 'MEMBER']);
const agentLogin = 'opencode-agent[bot]';
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev',
});
const teamMembers = new Set(
Buffer.from(file.content, 'base64')
.toString()
.split('\n')
.map((line) => line.trim().toLowerCase())
.filter(Boolean)
);
function isExempt(item) {
const login = item.user?.login?.toLowerCase();
return (
login === agentLogin ||
orgMemberAssociations.has(item.author_association) ||
(login && teamMembers.has(login))
);
}
for (const item of items) { for (const item of items) {
const isPR = !!item.pull_request; const isPR = !!item.pull_request;
const kind = isPR ? 'PR' : 'issue'; const kind = isPR ? 'PR' : 'issue';
const login = item.user?.login;
if (isExempt(item)) {
core.info(`Skipping ${kind} #${item.number}; author ${login || 'unknown'} is exempt`);
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
name: 'needs:compliance',
});
} catch (e) {}
continue;
}
const { data: comments } = await github.rest.issues.listComments({ const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner, owner: context.repo.owner,
+45 -1
View File
@@ -17,12 +17,31 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Check exempt issue author
id: author
run: |
LOGIN="${{ github.event.issue.user.login }}"
ASSOCIATION="${{ github.event.issue.author_association }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
[ "$ASSOCIATION" = "OWNER" ] ||
[ "$ASSOCIATION" = "MEMBER" ] ||
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- uses: ./.github/actions/setup-bun - uses: ./.github/actions/setup-bun
if: steps.author.outputs.skip != 'true'
- name: Install opencode - name: Install opencode
if: steps.author.outputs.skip != 'true'
run: curl -fsSL https://opencode.ai/install | bash run: curl -fsSL https://opencode.ai/install | bash
- name: Check duplicates and compliance - name: Check duplicates and compliance
if: steps.author.outputs.skip != 'true'
env: env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -38,6 +57,7 @@ jobs:
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created: opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
Issue number: ${{ github.event.issue.number }} Issue number: ${{ github.event.issue.number }}
Issue author association: ${{ github.event.issue.author_association }}
Lookup this issue with gh issue view ${{ github.event.issue.number }}. Lookup this issue with gh issue view ${{ github.event.issue.number }}.
@@ -49,6 +69,8 @@ jobs:
Check whether the issue follows our contributing guidelines and issue templates. Check whether the issue follows our contributing guidelines and issue templates.
If the issue author association is OWNER or MEMBER, skip this compliance check. Do not add the needs:compliance label for organization-owned issues.
This project has three issue templates that every issue MUST use one of: This project has three issue templates that every issue MUST use one of:
1. Bug Report - requires a Description field with real content 1. Bug Report - requires a Description field with real content
@@ -83,7 +105,7 @@ jobs:
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows: Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
If the issue is NOT compliant, start the comment with: If the issue is NOT compliant and the author association is not OWNER or MEMBER, start the comment with:
<!-- issue-compliance --> <!-- issue-compliance -->
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
@@ -129,12 +151,31 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Check exempt issue author
id: author
run: |
LOGIN="${{ github.event.issue.user.login }}"
ASSOCIATION="${{ github.event.issue.author_association }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
[ "$ASSOCIATION" = "OWNER" ] ||
[ "$ASSOCIATION" = "MEMBER" ] ||
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- uses: ./.github/actions/setup-bun - uses: ./.github/actions/setup-bun
if: steps.author.outputs.skip != 'true'
- name: Install opencode - name: Install opencode
if: steps.author.outputs.skip != 'true'
run: curl -fsSL https://opencode.ai/install | bash run: curl -fsSL https://opencode.ai/install | bash
- name: Recheck compliance - name: Recheck compliance
if: steps.author.outputs.skip != 'true'
env: env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -148,9 +189,12 @@ jobs:
} }
run: | run: |
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited. opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
Issue author association: ${{ github.event.issue.author_association }}
Lookup this issue with gh issue view ${{ github.event.issue.number }}. Lookup this issue with gh issue view ${{ github.event.issue.number }}.
If the issue author association is OWNER or MEMBER, remove the needs:compliance label if present, delete the previous compliance comment if present, and do not post a new comment.
Re-check whether the issue now follows our contributing guidelines and issue templates. Re-check whether the issue now follows our contributing guidelines and issue templates.
This project has three issue templates that every issue MUST use one of: This project has three issue templates that every issue MUST use one of:
+19
View File
@@ -16,13 +16,32 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Check exempt issue author
id: author
run: |
LOGIN="${{ github.event.issue.user.login }}"
ASSOCIATION="${{ github.event.issue.author_association }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
[ "$ASSOCIATION" = "OWNER" ] ||
[ "$ASSOCIATION" = "MEMBER" ] ||
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun - name: Setup Bun
if: steps.author.outputs.skip != 'true'
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
- name: Install opencode - name: Install opencode
if: steps.author.outputs.skip != 'true'
run: curl -fsSL https://opencode.ai/install | bash run: curl -fsSL https://opencode.ai/install | bash
- name: Triage issue - name: Triage issue
if: steps.author.outputs.skip != 'true'
env: env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+15 -5
View File
@@ -83,6 +83,7 @@
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1", "vite-plugin-icons-spritesheet": "3.0.1",
@@ -316,6 +317,7 @@
"ai-gateway-provider": "3.1.2", "ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8", "bun-pty": "0.4.8",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
@@ -332,7 +334,7 @@
"npm-package-arg": "13.0.2", "npm-package-arg": "13.0.2",
"semver": "^7.6.3", "semver": "^7.6.3",
"turndown": "7.2.0", "turndown": "7.2.0",
"venice-ai-sdk-provider": "2.0.2", "venice-ai-sdk-provider": "2.1.1",
"which": "6.0.1", "which": "6.0.1",
"xdg-basedir": "5.1.0", "xdg-basedir": "5.1.0",
"zod": "catalog:", "zod": "catalog:",
@@ -635,7 +637,7 @@
"tree-sitter-powershell": "0.25.10", "tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0", "turndown": "7.2.0",
"ulid": "catalog:", "ulid": "catalog:",
"venice-ai-sdk-provider": "2.0.2", "venice-ai-sdk-provider": "2.1.1",
"vscode-jsonrpc": "8.2.1", "vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10", "web-tree-sitter": "0.25.10",
"ws": "8.21.0", "ws": "8.21.0",
@@ -990,6 +992,7 @@
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"solid-js": "catalog:", "solid-js": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1", "vite-plugin-icons-spritesheet": "3.0.1",
@@ -1048,6 +1051,7 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
@@ -5315,6 +5319,8 @@
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="], "turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="], "tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
@@ -5423,7 +5429,7 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.2", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.47", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.27" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-aoa05nI3BTK5aGbjBflq+Gfln2AHAkwNbWuGGvCzUIsOfp5Y3iPD4O4PUGDAEiWVJWbjpPn0KfDa0H/HebwsaA=="], "venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.1.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.51", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.30" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-w3OHkuzzKZ3r2TOxER6myBYzZJNoDqol+DUHu3NnfBN/GETnUVxecZJab0CHQQ8GZc0jjzpFymepjcLDPS4SQg=="],
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
@@ -6443,11 +6449,11 @@
"unzipper/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], "unzipper/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="], "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SoPSkrL5cbNQnAljRsJ7pOzJ2FmWgnhC0lfFOda873ycCdFJL1A+h3Ib7mX2spcv3XnNaO13y/45/0RyqNWlIQ=="],
"venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], "venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], "venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.32", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Kwj499fTcN9bP/AfGoPU7JWIXeP6VZqKI6omsH062c9E2G4gdjeJczkz4z/tYSkzYjLE2AI3DtZbMfs6D7vn2Q=="],
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], "verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
@@ -7067,6 +7073,10 @@
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=", "x86_64-linux": "sha256-An7vZmA+sVlxoSOMK4vQvkq3KJOF6Rm9IBU62LN3Hkg=",
"aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=", "aarch64-linux": "sha256-Hx4trrBLxRDyY4HQc96EQZUa8abIeetNvE8GtRFvrrc=",
"aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=", "aarch64-darwin": "sha256-/eXdkybUkDCrfQRCkZHsgCa3BM/OfaQzH/z7OxwlpVM=",
"x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg=" "x86_64-darwin": "sha256-flhsuFXikpI8tzK7nTwIVF/jzpmb5p+BWxGa/5vhN54="
} }
} }
+2 -1
View File
@@ -153,6 +153,7 @@
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch" "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
} }
} }
+1
View File
@@ -38,6 +38,7 @@
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1", "vite-plugin-icons-spritesheet": "3.0.1",
+48 -13
View File
@@ -28,7 +28,7 @@ import {
Show, Show,
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command" import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk" import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
@@ -38,8 +38,9 @@ import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout" import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models" import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification" import { NotificationProvider, useNotification } from "@/context/notification"
import { PermissionProvider } from "@/context/permission" import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt" import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider, useSettings } from "@/context/settings" import { SettingsProvider, useSettings } from "@/context/settings"
@@ -300,12 +301,36 @@ function SharedProviders(props: ParentProps) {
<> <>
<BodyDesignClass /> <BodyDesignClass />
<CommandProvider> <CommandProvider>
<DesktopCommands />
<HighlightsProvider>{props.children}</HighlightsProvider> <HighlightsProvider>{props.children}</HighlightsProvider>
</CommandProvider> </CommandProvider>
</> </>
) )
} }
function DesktopCommands() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
command.register("desktop", () => {
const commands: CommandOption[] = []
if (platform.platform === "desktop" && platform.exportDebugLogs) {
commands.push({
id: "logs.export",
title: "Export logs",
category: language.t("command.category.settings"),
onSelect: () => {
void platform.exportDebugLogs?.()
},
})
}
return commands
})
return null
}
// Server-scoped providers shared by the legacy shell and the top-level new shell. // Server-scoped providers shared by the legacy shell and the top-level new shell.
type ServerScopedShellProps = ParentProps<{ type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined directory?: () => string | undefined
@@ -316,9 +341,7 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<PermissionProvider directory={props.directory}> <PermissionProvider directory={props.directory}>
<LayoutProvider> <LayoutProvider>
<NotificationProvider directory={props.directory} sessionID={props.sessionID}> <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</NotificationProvider>
</LayoutProvider> </LayoutProvider>
</PermissionProvider> </PermissionProvider>
) )
@@ -345,13 +368,23 @@ function NewAppLayout(props: ParentProps) {
function TargetServerScopedProviders(props: ServerScopedShellProps) { function TargetServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<PermissionProvider directory={props.directory}> <PermissionProvider directory={props.directory}>
<NotificationProvider directory={props.directory} sessionID={props.sessionID}> <MarkSessionNotificationsViewed sessionID={props.sessionID} />
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider> <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</NotificationProvider>
</PermissionProvider> </PermissionProvider>
) )
} }
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
const notification = useNotification()
createEffect(() => {
const sessionID = props.sessionID?.()
if (!notification.ready() || !sessionID) return
if (notification.session.unseenCount(sessionID) === 0) return
notification.session.markViewed(sessionID)
})
return null
}
function SessionProviders(props: ParentProps) { function SessionProviders(props: ParentProps) {
return ( return (
<TerminalProvider> <TerminalProvider>
@@ -560,11 +593,13 @@ export function AppInterface(props: {
component={props.router ?? Router} component={props.router ?? Router}
root={(routerProps) => ( root={(routerProps) => (
<TabsProvider> <TabsProvider>
<ServerShell> <NotificationProvider>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}> <ServerShell>
<NewAppLayout>{routerProps.children}</NewAppLayout> <Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
</Show> <NewAppLayout>{routerProps.children}</NewAppLayout>
</ServerShell> </Show>
</ServerShell>
</NotificationProvider>
</TabsProvider> </TabsProvider>
)} )}
> >
+74 -19
View File
@@ -3,6 +3,7 @@ import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
type Mem = Performance & { type Mem = Performance & {
@@ -51,31 +52,62 @@ const bad = (n: number | undefined, limit: number, low = false) => {
const session = (path: string) => path.includes("/session") const session = (path: string) => path.includes("/session")
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) { function Cell(props: {
return ( bad?: boolean
<Tooltip value={props.tip} placement="top"> dim?: boolean
inline?: boolean
label: string
tip: string
value: string
wide?: boolean
}) {
const content = () => (
<div
classList={{
"flex min-w-0 items-center": true,
"min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
"justify-center text-center": !props.inline,
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
"col-span-2": !!props.wide && !props.inline,
}}
>
<div <div
classList={{ classList={{
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true, "text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
"col-span-2": !!props.wide,
}} }}
> >
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div> {props.label}
<div
classList={{
"text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div> </div>
<div
classList={{
"uppercase leading-none font-bold tabular-nums": true,
"text-[11px]": !!props.inline,
"text-[13px] sm:text-[14px]": !props.inline,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div>
)
if (props.inline) {
return (
<TooltipV2 value={props.tip} placement="top">
{content()}
</TooltipV2>
)
}
return (
<Tooltip value={props.tip} placement="top">
{content()}
</Tooltip> </Tooltip>
) )
} }
export function DebugBar() { export function DebugBar(props: { inline?: boolean } = {}) {
const language = useLanguage() const language = useLanguage()
const location = useLocation() const location = useLocation()
const routing = useIsRouting() const routing = useIsRouting()
@@ -101,7 +133,7 @@ export function DebugBar() {
}, },
}) })
const na = () => language.t("debugBar.na") const na = () => language.t("debugBar.na").toUpperCase()
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()
@@ -363,15 +395,30 @@ export function DebugBar() {
return ( return (
<aside <aside
aria-label={language.t("debugBar.ariaLabel")} aria-label={language.t("debugBar.ariaLabel")}
class="pointer-events-auto fixed bottom-3 right-3 z-50 hidden w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] md:block sm:bottom-4 sm:right-4 sm:w-[324px]" classList={{
"pointer-events-auto hidden overflow-hidden text-text-strong md:block": true,
"mt-[-6px] w-full shrink-0 px-3 py-1": !!props.inline,
"fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]":
!props.inline,
}}
> >
<div class="grid grid-cols-5 gap-px font-mono"> <div
classList={{
"font-mono": true,
"gap-[9px]": !!props.inline,
"gap-px": !props.inline,
"flex w-full flex-nowrap items-center justify-start": !!props.inline,
"grid-cols-5": !props.inline,
grid: !props.inline,
}}
>
<Cell <Cell
label={language.t("debugBar.nav.label")} label={language.t("debugBar.nav.label")}
tip={language.t("debugBar.nav.tip")} 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}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.fps.label")} label={language.t("debugBar.fps.label")}
@@ -379,6 +426,7 @@ export function DebugBar() {
value={state.fps === undefined ? na() : `${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}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.frame.label")} label={language.t("debugBar.frame.label")}
@@ -386,6 +434,7 @@ export function DebugBar() {
value={time(state.gap) ?? na()} value={time(state.gap) ?? na()}
bad={bad(state.gap, 50)} bad={bad(state.gap, 50)}
dim={state.gap === undefined} dim={state.gap === undefined}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.jank.label")} label={language.t("debugBar.jank.label")}
@@ -393,6 +442,7 @@ export function DebugBar() {
value={state.jank === undefined ? na() : `${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}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.long.label")} label={language.t("debugBar.long.label")}
@@ -400,6 +450,7 @@ export function DebugBar() {
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}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.delay.label")} label={language.t("debugBar.delay.label")}
@@ -407,6 +458,7 @@ export function DebugBar() {
value={time(state.delay) ?? na()} value={time(state.delay) ?? na()}
bad={bad(state.delay, 100)} bad={bad(state.delay, 100)}
dim={state.delay === undefined} dim={state.delay === undefined}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.inp.label")} label={language.t("debugBar.inp.label")}
@@ -414,6 +466,7 @@ export function DebugBar() {
value={time(state.inp) ?? na()} value={time(state.inp) ?? na()}
bad={bad(state.inp, 200)} bad={bad(state.inp, 200)}
dim={state.inp === undefined} dim={state.inp === undefined}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.cls.label")} label={language.t("debugBar.cls.label")}
@@ -421,6 +474,7 @@ export function DebugBar() {
value={state.cls === undefined ? na() : 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}
inline={props.inline}
/> />
<Cell <Cell
label={language.t("debugBar.mem.label")} label={language.t("debugBar.mem.label")}
@@ -435,6 +489,7 @@ export function DebugBar() {
value={heapv()} value={heapv()}
bad={bad(heap(), 0.8)} bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined} dim={state.heap.used === undefined}
inline={props.inline}
wide wide
/> />
</div> </div>
@@ -65,6 +65,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
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,
promptInputs: undefined as undefined | Record<string, string>,
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
@@ -73,6 +74,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
| { type: "method.select"; index: number } | { type: "method.select"; index: number }
| { type: "method.reset" } | { type: "method.reset" }
| { type: "auth.prompt" } | { type: "auth.prompt" }
| { type: "auth.inputs"; inputs: Record<string, string> }
| { 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 }
@@ -83,6 +85,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
if (action.type === "method.select") { if (action.type === "method.select") {
draft.methodIndex = action.index draft.methodIndex = action.index
draft.authorization = undefined draft.authorization = undefined
draft.promptInputs = undefined
draft.state = undefined draft.state = undefined
draft.error = undefined draft.error = undefined
return return
@@ -90,6 +93,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
if (action.type === "method.reset") { if (action.type === "method.reset") {
draft.methodIndex = undefined draft.methodIndex = undefined
draft.authorization = undefined draft.authorization = undefined
draft.promptInputs = undefined
draft.state = undefined draft.state = undefined
draft.error = undefined draft.error = undefined
return return
@@ -99,6 +103,12 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
draft.error = undefined draft.error = undefined
return return
} }
if (action.type === "auth.inputs") {
draft.promptInputs = action.inputs
draft.state = undefined
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
@@ -151,6 +161,15 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
const method = methods()[index] const method = methods()[index]
dispatch({ type: "method.select", index }) dispatch({ type: "method.select", index })
if (method.type === "api" && method.prompts?.length) {
if (!inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.inputs", inputs })
return
}
if (method.type === "oauth") { if (method.type === "oauth") {
if (method.prompts?.length && !inputs) { if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" }) dispatch({ type: "auth.prompt" })
@@ -190,7 +209,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
} }
} }
function OAuthPromptsView() { function AuthPromptsView() {
const [formStore, setFormStore] = createStore({ const [formStore, setFormStore] = createStore({
value: {} as Record<string, string>, value: {} as Record<string, string>,
index: 0, index: 0,
@@ -198,8 +217,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => { const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
const value = method() const value = method()
if (value?.type !== "oauth") return [] return value?.prompts ?? []
return value.prompts ?? []
}) })
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => { const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true if (!prompt.when) return true
@@ -230,6 +248,10 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
setFormStore("index", next) setFormStore("index", next)
return return
} }
if (method()?.type === "api") {
dispatch({ type: "auth.inputs", inputs: value })
return
}
await selectMethod(store.methodIndex, value) await selectMethod(store.methodIndex, value)
} }
@@ -414,6 +436,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
auth: { auth: {
type: "api", type: "api",
key: apiKey, key: apiKey,
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
}, },
}) })
await complete() await complete()
@@ -622,7 +645,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
</div> </div>
</Match> </Match>
<Match when={store.state === "prompt"}> <Match when={store.state === "prompt"}>
<OAuthPromptsView /> <AuthPromptsView />
</Match> </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">
@@ -59,6 +59,7 @@ const ModelList: Component<{
class="w-full" class="w-full"
placement="right-start" placement="right-start"
gutter={12} gutter={12}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />} value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
> >
{node} {node}
+204 -47
View File
@@ -35,6 +35,8 @@ import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -66,6 +68,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state" import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
export type PromptInputState = ReturnType<typeof usePrompt> export type PromptInputState = ReturnType<typeof usePrompt>
@@ -212,6 +215,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let fileInputRef: HTMLInputElement | undefined let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement
let restoreEndOnFocus = true
const mirror = { input: false } const mirror = { input: false }
const inset = 56 const inset = 56
@@ -591,6 +595,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
} }
const handleFocus = () => {
if (!restoreEndOnFocus) return
restoreEndOnFocus = false
requestAnimationFrame(() => {
if (document.activeElement !== editorRef) return
setCursorPosition(editorRef, prompt.cursor() ?? promptLength(prompt.current()))
queueScroll()
})
}
const renderEditorWithCursor = (parts: Prompt) => { const renderEditorWithCursor = (parts: Prompt) => {
const cursor = currentCursor() const cursor = currentCursor()
renderEditor(parts) renderEditor(parts)
@@ -627,24 +641,89 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
} }
const referenceDescription = (reference: ReferenceInfo) =>
reference.source.type === "git" ? reference.source.repository : reference.source.path
const referenceList = createMemo(() =>
sync()
.data.reference.filter((reference) => !reference.hidden)
.map(
(reference): AtOption => ({
type: "reference",
name: reference.name,
path: reference.path,
display: reference.name,
description: reference.description ?? referenceDescription(reference),
}),
),
)
const agentList = createMemo(() => const agentList = createMemo(() =>
props.controls.agents.available props.controls.agents.available
.filter((agent) => !agent.hidden && agent.mode !== "primary") .filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
) )
const mcpResourceList = createMemo(() =>
Object.values(sync().data.mcp_resource).map(
(resource): AtOption => ({
type: "resource",
name: resource.name,
uri: resource.uri,
client: resource.client,
display: resource.name,
description: resource.description,
mime: resource.mimeType,
}),
),
)
const handleAtSelect = (option: AtOption | undefined) => { const handleAtSelect = (option: AtOption | undefined) => {
if (!option) return if (!option) return
if (option.type === "agent") { if (option.type === "agent") {
addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 })
} else { return
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
} }
if (option.type === "reference") {
addPart({
type: "file",
path: option.path,
content: "@" + option.name,
start: 0,
end: 0,
mime: "application/x-directory",
filename: option.name,
})
return
}
if (option.type === "resource") {
addPart({
type: "file",
path: option.uri,
content: "@" + option.name,
start: 0,
end: 0,
mime: option.mime ?? "text/plain",
filename: option.name,
url: option.uri,
source: {
type: "resource",
text: { value: "@" + option.name, start: 0, end: 0 },
clientName: option.client,
uri: option.uri,
},
})
return
}
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
} }
const atKey = (x: AtOption | undefined) => { const atKey = (x: AtOption | undefined) => {
if (!x) return "" if (!x) return ""
return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}` if (x.type === "agent") return `agent:${x.name}`
if (x.type === "reference") return `reference:${x.name}`
if (x.type === "resource") return `resource:${x.client}:${x.uri}`
return `file:${x.path}`
} }
const { const {
@@ -655,30 +734,36 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onKeyDown: atOnKeyDown, onKeyDown: atOnKeyDown,
} = useFilteredList<AtOption>({ } = useFilteredList<AtOption>({
items: async (query) => { items: async (query) => {
const references = referenceList()
const agents = agentList() const agents = agentList()
const mcpResources = mcpResourceList()
const open = recent() const open = recent()
const seen = new Set(open) const seen = new Set(open)
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
if (!query.trim()) return [...agents, ...pinned] if (!query.trim()) return [...references, ...agents, ...mcpResources, ...pinned]
const paths = await files.searchFilesAndDirectories(query) const paths = await files.searchFilesAndDirectories(query)
const fileOptions: AtOption[] = paths const fileOptions: AtOption[] = paths
.filter((path) => !seen.has(path)) .filter((path) => !seen.has(path))
.map((path) => ({ type: "file", path, display: path })) .map((path) => ({ type: "file", path, display: path }))
return [...agents, ...pinned, ...fileOptions] return [...references, ...agents, ...mcpResources, ...pinned, ...fileOptions]
}, },
key: atKey, key: atKey,
filterKeys: ["display"], filterKeys: ["display"],
skipFilter: (item) => item.type === "file" && !item.recent, skipFilter: (item) => item.type === "file" && !item.recent,
groupBy: (item) => { groupBy: (item) => {
if (item.type === "reference") return "reference"
if (item.type === "agent") return "agent" if (item.type === "agent") return "agent"
if (item.type === "resource") return "resource"
if (item.recent) return "recent" if (item.recent) return "recent"
return "file" return "file"
}, },
sortGroupsBy: (a, b) => { sortGroupsBy: (a, b) => {
const rank = (category: string) => { const rank = (category: string) => {
if (category === "agent") return 0 if (category === "reference") return 0
if (category === "recent") return 1 if (category === "agent") return 1
return 2 if (category === "resource") return 2
if (category === "recent") return 3
return 4
} }
return rank(a.category) - rank(b.category) return rank(a.category) - rank(b.category)
}, },
@@ -744,7 +829,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const pill = document.createElement("span") const pill = document.createElement("span")
pill.textContent = part.content pill.textContent = part.content
pill.setAttribute("data-type", part.type) pill.setAttribute("data-type", part.type)
if (part.type === "file") pill.setAttribute("data-path", part.path) if (part.type === "file") {
pill.setAttribute("data-path", part.path)
if (part.mime) pill.setAttribute("data-mime", part.mime)
if (part.filename) pill.setAttribute("data-filename", part.filename)
if (part.url) pill.setAttribute("data-url", part.url)
if (part.source?.type === "resource") {
pill.setAttribute("data-source-type", part.source.type)
pill.setAttribute("data-source-client-name", part.source.clientName)
pill.setAttribute("data-source-uri", part.source.uri)
}
}
if (part.type === "agent") pill.setAttribute("data-name", part.name) if (part.type === "agent") pill.setAttribute("data-name", part.name)
pill.setAttribute("contenteditable", "false") pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text" pill.style.userSelect = "text"
@@ -789,8 +884,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
// Auto-scroll active command into view when navigating with keyboard const scrollSlashActiveIntoView = () => {
createEffect(() => {
const activeId = slashActive() const activeId = slashActive()
if (!activeId || !slashPopoverRef) return if (!activeId || !slashPopoverRef) return
@@ -798,7 +892,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`)
element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
}) })
}) }
const selectPopoverActive = () => { const selectPopoverActive = () => {
if (store.popover === "at") { if (store.popover === "at") {
const items = atFlat() const items = atFlat()
@@ -860,12 +954,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const pushFile = (file: HTMLElement) => { const pushFile = (file: HTMLElement) => {
const content = file.textContent ?? "" const content = file.textContent ?? ""
const source =
file.dataset.sourceType === "resource" && file.dataset.sourceClientName && file.dataset.sourceUri
? {
type: "resource" as const,
text: {
value: content,
start: position,
end: position + content.length,
},
clientName: file.dataset.sourceClientName,
uri: file.dataset.sourceUri,
}
: undefined
parts.push({ parts.push({
type: "file", type: "file",
path: file.dataset.path!, path: file.dataset.path!,
content, content,
start: position, start: position,
end: position + content.length, end: position + content.length,
...(file.dataset.mime ? { mime: file.dataset.mime } : {}),
...(file.dataset.filename ? { filename: file.dataset.filename } : {}),
...(file.dataset.url ? { url: file.dataset.url } : {}),
...(source ? { source } : {}),
}) })
position += content.length position += content.length
} }
@@ -1285,6 +1396,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
if (store.popover === "slash") { if (store.popover === "slash") {
slashOnKeyDown(event) slashOnKeyDown(event)
if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) {
scrollSlashActiveIntoView()
}
} }
event.preventDefault() event.preventDefault()
return return
@@ -1343,9 +1457,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const agentsLoading = () => props.controls.agents.loading const agentsLoading = () => props.controls.agents.loading
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
const providersLoading = () => props.controls.model.loading const providersLoading = () => props.controls.model.loading
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) const providersShouldFadeIn = createMemo<boolean>((prev) => prev ?? providersLoading())
const [promptReady] = createResource( const [promptReady] = createResource(
() => prompt.ready.promise, () => prompt.ready.promise,
@@ -1359,9 +1473,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const modelControlState = createMemo<ComposerModelControlState>(() => ({ const modelControlState = createMemo<ComposerModelControlState>(() => ({
loading: providersLoading(), loading: providersLoading(),
shouldAnimate: providersShouldFadeIn(),
paid: props.controls.model.paid, paid: props.controls.model.paid,
title: language.t("command.model.choose"), title: language.t("command.model.choose"),
keybind: command.keybind("model.choose"), keybind: command.keybindParts("model.choose"),
model: props.controls.model.selection, model: props.controls.model.selection,
providerID: props.controls.model.selection.current()?.provider?.id, providerID: props.controls.model.selection.current()?.provider?.id,
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"), modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
@@ -1375,10 +1490,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})) }))
const newSession = () => props.variant === "new-session" const newSession = () => props.variant === "new-session"
const bindEditorRef = (el: HTMLDivElement) => {
editorRef = el
restoreEndOnFocus = true
props.ref?.(el)
}
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0) const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
const agentControlState = createMemo<ComposerAgentControlState>(() => ({ const agentControlState = createMemo<ComposerAgentControlState>(() => ({
title: language.t("command.agent.cycle"), title: language.t("command.agent.cycle"),
keybind: command.keybind("agent.cycle"), keybind: command.keybindParts("agent.cycle"),
options: props.controls.agents.options, options: props.controls.agents.options,
current: props.controls.agents.current, current: props.controls.agents.current,
style: control(), style: control(),
@@ -1403,6 +1523,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setSlashActive={setSlashActive} setSlashActive={setSlashActive}
onSlashSelect={handleSlashSelect} onSlashSelect={handleSlashSelect}
commandKeybind={command.keybind} commandKeybind={command.keybind}
commandKeybindParts={command.keybindParts}
newLayoutDesigns={props.controls.newLayoutDesigns}
t={(key) => language.t(key as Parameters<typeof language.t>[0])} t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/> />
<Switch> <Switch>
@@ -1456,10 +1578,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}> <div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
<div <div
data-component="prompt-input" data-component="prompt-input"
ref={(el) => { ref={bindEditorRef}
editorRef = el
props.ref?.(el)
}}
role="textbox" role="textbox"
aria-multiline="true" aria-multiline="true"
aria-label={designPlaceholder()} aria-label={designPlaceholder()}
@@ -1474,6 +1593,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onPaste={handlePaste} onPaste={handlePaste}
onCompositionStart={handleCompositionStart} onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd} onCompositionEnd={handleCompositionEnd}
onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
classList={{ classList={{
@@ -1494,12 +1614,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div> </div>
</div> </div>
<div class="flex h-11 items-center px-2"> <div class="flex h-11 items-center px-2">
<div class="flex min-w-0 flex-1 items-center gap-0"> <div class="flex min-w-0 flex-1 items-center gap-1">
{fileAttachmentInput()} {fileAttachmentInput()}
<TooltipKeybind <TooltipV2
placement="top" placement="top"
title={language.t("prompt.action.attachFile")} value={
keybind={command.keybind("file.attach")} <>
{language.t("prompt.action.attachFile")}
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
</>
}
> >
<IconButton <IconButton
data-action="prompt-attach" data-action="prompt-attach"
@@ -1513,25 +1637,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
tabIndex={store.mode === "normal" ? undefined : -1} tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")} aria-label={language.t("prompt.action.attachFile")}
/> />
</TooltipKeybind> </TooltipV2>
<Show when={showAgentControl()}> <Show when={showAgentControl()}>
<ComposerAgentControl state={agentControlState()} /> <ComposerAgentControl state={agentControlState()} />
</Show> </Show>
{props.toolbar} {props.toolbar}
<ComposerModelControl state={modelControlState()} /> <ComposerModelControl state={modelControlState()} />
<Show when={store.mode !== "shell" && showVariantControl()}> <Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
<div <div
data-component="prompt-variant-control" data-component="prompt-variant-control"
classList={{ classList={{
"animate-in fade-in": providersShouldFadeIn(),
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block": "hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
!props.controls.model.selection.variant.current() && !store.variantOpen, !props.controls.model.selection.variant.current() && !store.variantOpen,
}} }}
> >
<TooltipKeybind <TooltipV2
placement="top" placement="top"
gutter={4} gutter={4}
title={language.t("command.model.variant.cycle")} value={
keybind={command.keybind("model.variant.cycle")} <>
{language.t("command.model.variant.cycle")}
<KeybindV2 keys={command.keybindParts("model.variant.cycle")} variant="neutral" />
</>
}
> >
<Select <Select
size="normal" size="normal"
@@ -1549,11 +1678,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
triggerProps={{ "data-action": "prompt-model-variant" }} triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost" variant="ghost"
/> />
</TooltipKeybind> </TooltipV2>
</div> </div>
</Show> </Show>
</div> </div>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}> <TooltipV2 placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton <IconButton
data-action="prompt-submit" data-action="prompt-submit"
type="submit" type="submit"
@@ -1568,7 +1697,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}} }}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/> />
</Tooltip> </TooltipV2>
</div> </div>
</DockShellForm> </DockShellForm>
</div> </div>
@@ -1628,10 +1757,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<div <div
data-component="prompt-input" data-component="prompt-input"
ref={(el) => { ref={bindEditorRef}
editorRef = el
props.ref?.(el)
}}
role="textbox" role="textbox"
aria-multiline="true" aria-multiline="true"
aria-label={placeholder()} aria-label={placeholder()}
@@ -1646,6 +1772,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onPaste={handlePaste} onPaste={handlePaste}
onCompositionStart={handleCompositionStart} onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd} onCompositionEnd={handleCompositionEnd}
onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
classList={{ classList={{
@@ -1765,7 +1892,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={!agentsLoading()}> <Show when={!agentsLoading()}>
<div <div
data-component="prompt-agent-control" data-component="prompt-agent-control"
style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined} classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
> >
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
@@ -1794,7 +1921,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={store.mode !== "shell"}> <Show when={store.mode !== "shell"}>
<div <div
data-component="prompt-model-control" data-component="prompt-model-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined} classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
> >
<Show <Show
when={props.controls.model.paid} when={props.controls.model.paid}
@@ -1873,7 +2000,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={showVariantControl()}> <Show when={showVariantControl()}>
<div <div
data-component="prompt-variant-control" data-component="prompt-variant-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined} classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
> >
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
@@ -1914,7 +2041,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type ComposerAgentControlState = { type ComposerAgentControlState = {
title: string title: string
keybind: string keybind: string[]
options: string[] options: string[]
current: string current: string
style: JSX.CSSProperties | undefined style: JSX.CSSProperties | undefined
@@ -1923,9 +2050,10 @@ type ComposerAgentControlState = {
type ComposerModelControlState = { type ComposerModelControlState = {
loading: boolean loading: boolean
shouldAnimate: boolean
paid: boolean paid: boolean
title: string title: string
keybind: string keybind: string[]
model: ReturnType<typeof useLocal>["model"] model: ReturnType<typeof useLocal>["model"]
providerID?: string providerID?: string
modelName: string modelName: string
@@ -1940,7 +2068,16 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted"> <div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
<Icon name="sliders" size="small" /> <Icon name="sliders" size="small" />
</div> </div>
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}> <TooltipV2
placement="top"
gutter={4}
value={
<>
{props.state.title}
<KeybindV2 keys={props.state.keybind} variant="neutral" />
</>
}
>
<Select <Select
size="normal" size="normal"
options={props.state.options} options={props.state.options}
@@ -1952,7 +2089,7 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
triggerProps={{ "data-action": "prompt-agent" }} triggerProps={{ "data-action": "prompt-agent" }}
variant="ghost" variant="ghost"
/> />
</TooltipKeybind> </TooltipV2>
</div> </div>
) )
} }
@@ -1963,13 +2100,23 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
<Show <Show
when={props.state.paid} when={props.state.paid}
fallback={ fallback={
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}> <TooltipV2
placement="top"
gutter={4}
value={
<>
{props.state.title}
<KeybindV2 keys={props.state.keybind} variant="neutral" />
</>
}
>
<Button <Button
data-action="prompt-model" data-action="prompt-model"
as="div" as="div"
variant="ghost" variant="ghost"
size="normal" size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group" class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
style={props.state.style} style={props.state.style}
onClick={props.state.onUnpaidClick} onClick={props.state.onUnpaidClick}
> >
@@ -1987,10 +2134,19 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" /> <Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span> </span>
</Button> </Button>
</TooltipKeybind> </TooltipV2>
} }
> >
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}> <TooltipV2
placement="top"
gutter={4}
value={
<>
{props.state.title}
<KeybindV2 keys={props.state.keybind} variant="neutral" />
</>
}
>
<ModelSelectorPopover <ModelSelectorPopover
model={props.state.model} model={props.state.model}
triggerAs={Button} triggerAs={Button}
@@ -2000,6 +2156,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
style: props.state.style, style: props.state.style,
class: class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group", "min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
classList: { "animate-in fade-in": props.state.shouldAnimate },
"data-action": "prompt-model", "data-action": "prompt-model",
}} }}
onClose={props.state.onClose} onClose={props.state.onClose}
@@ -2018,7 +2175,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" /> <Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span> </span>
</ModelSelectorPopover> </ModelSelectorPopover>
</TooltipKeybind> </TooltipV2>
</Show> </Show>
</Show> </Show>
) )
@@ -100,6 +100,41 @@ describe("buildRequestParts", () => {
) )
}) })
test("preserves reference aliases as directory file parts", () => {
const result = buildRequestParts({
prompt: [
{
type: "file",
path: "/repo/../docs",
content: "@docs",
start: 0,
end: 5,
mime: "application/x-directory",
filename: "docs",
},
],
context: [],
images: [],
text: "@docs",
messageID: "msg_reference",
sessionID: "ses_reference",
sessionDirectory: "/repo/app",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
expect(filePart.mime).toBe("application/x-directory")
expect(filePart.filename).toBe("docs")
expect(filePart.url).toBe("file:///repo/../docs")
expect(filePart.source?.type).toBe("file")
if (filePart.source?.type === "file") {
expect(filePart.source.path).toBe("/repo/../docs")
expect(filePart.source.text.value).toBe("@docs")
}
}
})
test("deduplicates context files when prompt already includes same path", () => { test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }] const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
@@ -99,21 +99,31 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
const files = input.prompt.filter(isFileAttachment).map((attachment) => { const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path) const path = absolute(input.sessionDirectory, attachment.path)
const source = attachment.source
? {
...attachment.source,
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
}
: {
type: "file" as const,
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
path,
}
return { return {
id: Identifier.ascending("part"), id: Identifier.ascending("part"),
type: "file", type: "file",
mime: "text/plain", mime: attachment.mime ?? "text/plain",
url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`, url: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
filename: getFilename(attachment.path), filename: attachment.filename ?? getFilename(attachment.path),
source: { source,
type: "file",
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
path,
},
} satisfies PromptRequestPart } satisfies PromptRequestPart
}) })
@@ -1,10 +1,22 @@
import { Component, For, Match, Show, Switch } from "solid-js" import { Component, For, Match, Show, Switch } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
export type AtOption = export type AtOption =
| { type: "agent"; name: string; display: string } | { type: "agent"; name: string; display: string }
| {
type: "resource"
name: string
uri: string
client: string
display: string
description?: string
mime?: string
}
| { type: "reference"; name: string; path: string; display: string; description: string }
| { type: "file"; path: string; display: string; recent?: boolean } | { type: "file"; path: string; display: string; recent?: boolean }
export interface SlashCommand { export interface SlashCommand {
@@ -30,6 +42,8 @@ type PromptPopoverProps = {
setSlashActive: (id: string) => void setSlashActive: (id: string) => void
onSlashSelect: (item: SlashCommand) => void onSlashSelect: (item: SlashCommand) => void
commandKeybind: (id: string) => string | undefined commandKeybind: (id: string) => string | undefined
commandKeybindParts: (id: string) => string[]
newLayoutDesigns: boolean
t: (key: string) => string t: (key: string) => string
} }
@@ -41,15 +55,29 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
if (props.popover === "slash") props.setSlashPopoverRef(el) if (props.popover === "slash") props.setSlashPopoverRef(el)
}} }}
class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10 class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px] overflow-auto no-scrollbar flex flex-col p-2"
bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]" classList={{
"z-[70] rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": props.newLayoutDesigns,
"rounded-[12px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]":
!props.newLayoutDesigns,
}}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
> >
<Switch> <Switch>
<Match when={props.popover === "at"}> <Match when={props.popover === "at"}>
<Show <Show
when={props.atFlat.length > 0} when={props.atFlat.length > 0}
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>} fallback={
<div
class="px-2 py-1"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{props.t("prompt.popover.emptyResults")}
</div>
}
> >
<For each={props.atFlat.slice(0, 10)}> <For each={props.atFlat.slice(0, 10)}>
{(item) => { {(item) => {
@@ -58,13 +86,117 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
if (item.type === "agent") { if (item.type === "agent") {
return ( return (
<button <button
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5" class="w-full flex items-center gap-x-2 px-2 py-0.5"
classList={{ "bg-surface-raised-base-hover": props.atActive === key }} classList={{
"rounded-[4px]": props.newLayoutDesigns,
"rounded-md": !props.newLayoutDesigns,
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
}}
onClick={() => props.onAtSelect(item)} onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(key)} onPointerMove={() => props.setAtActive(key)}
> >
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" /> <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span> <span
class="whitespace-nowrap"
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-v2-text-text-base": props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
"text-text-strong": !props.newLayoutDesigns,
}}
>
@{item.name}
</span>
</button>
)
}
if (item.type === "resource") {
return (
<button
class="w-full flex items-center gap-x-2 px-2 py-0.5"
classList={{
"rounded-[4px]": props.newLayoutDesigns,
"rounded-md": !props.newLayoutDesigns,
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
}}
onClick={() => props.onAtSelect(item)}
onPointerMove={() => props.setAtActive(key)}
>
<FileIcon node={{ path: item.uri, type: "file" }} class="shrink-0 size-4" />
<div
class="flex items-center min-w-0"
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
}}
>
<span
class="text-text-strong whitespace-nowrap"
classList={{ "text-v2-text-text-base": props.newLayoutDesigns }}
>
@{item.name}
</span>
<Show when={item.description}>
{(description) => (
<span
class="whitespace-nowrap truncate min-w-0 ml-2"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{description()}
</span>
)}
</Show>
</div>
</button>
)
}
if (item.type === "reference") {
return (
<button
class="w-full flex items-center gap-x-2 px-2 py-0.5"
classList={{
"rounded-[4px]": props.newLayoutDesigns,
"rounded-md": !props.newLayoutDesigns,
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
}}
onClick={() => props.onAtSelect(item)}
onPointerMove={() => props.setAtActive(key)}
>
<FileIcon node={{ path: item.path, type: "directory" }} class="shrink-0 size-4" />
<div
class="flex items-center min-w-0"
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
}}
>
<span
class="text-text-strong whitespace-nowrap"
classList={{ "text-v2-text-text-base": props.newLayoutDesigns }}
>
@{item.name}
</span>
<span
class="whitespace-nowrap truncate min-w-0 ml-2"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{item.description}
</span>
</div>
</button> </button>
) )
} }
@@ -75,16 +207,44 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
return ( return (
<button <button
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5" class="w-full flex items-center gap-x-2 px-2 py-0.5"
classList={{ "bg-surface-raised-base-hover": props.atActive === key }} classList={{
"rounded-[4px]": props.newLayoutDesigns,
"rounded-md": !props.newLayoutDesigns,
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
}}
onClick={() => props.onAtSelect(item)} onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(key)} onPointerMove={() => props.setAtActive(key)}
> >
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" /> <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0"> <div
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span> class="flex items-center min-w-0"
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
}}
>
<span
class="whitespace-nowrap truncate min-w-0"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{directory}
</span>
<Show when={!isDirectory}> <Show when={!isDirectory}>
<span class="text-text-strong whitespace-nowrap">{filename}</span> <span
class="whitespace-nowrap"
classList={{
"text-v2-text-text-base": props.newLayoutDesigns,
"text-text-strong": !props.newLayoutDesigns,
}}
>
{filename}
</span>
</Show> </Show>
</div> </div>
</button> </button>
@@ -96,41 +256,98 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
<Match when={props.popover === "slash"}> <Match when={props.popover === "slash"}>
<Show <Show
when={props.slashFlat.length > 0} when={props.slashFlat.length > 0}
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>} fallback={
<div
class="px-2 py-1"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{props.t("prompt.popover.emptyCommands")}
</div>
}
> >
<For each={props.slashFlat}> <For each={props.slashFlat}>
{(cmd) => ( {(cmd) => {
<button const keybind = () => props.commandKeybind(cmd.id)
data-slash-id={cmd.id} const keybindParts = () => props.commandKeybindParts(cmd.id)
classList={{ return (
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true, <button
"bg-surface-raised-base-hover": props.slashActive === cmd.id, data-slash-id={cmd.id}
}} classList={{
onClick={() => props.onSlashSelect(cmd)} "w-full flex items-center justify-between gap-4 px-2 py-1": true,
onMouseEnter={() => props.setSlashActive(cmd.id)} "rounded-[4px] scroll-my-2": props.newLayoutDesigns,
> "rounded-md": !props.newLayoutDesigns,
<div class="flex items-center gap-2 min-w-0"> "bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.slashActive === cmd.id,
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span> "bg-surface-raised-base-hover": !props.newLayoutDesigns && props.slashActive === cmd.id,
<Show when={cmd.description}> }}
<span class="text-14-regular text-text-weak truncate">{cmd.description}</span> onClick={() => props.onSlashSelect(cmd)}
</Show> onPointerMove={() => props.setSlashActive(cmd.id)}
</div> >
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 min-w-0">
<Show when={cmd.type === "custom" && cmd.source !== "command"}> <span
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded"> class="whitespace-nowrap"
{cmd.source === "skill" classList={{
? props.t("prompt.slash.badge.skill") "text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
: cmd.source === "mcp" props.newLayoutDesigns,
? props.t("prompt.slash.badge.mcp") "text-v2-text-text-base": props.newLayoutDesigns,
: props.t("prompt.slash.badge.custom")} "text-14-regular": !props.newLayoutDesigns,
"text-text-strong": !props.newLayoutDesigns,
}}
>
/{cmd.trigger}
</span> </span>
</Show> <Show when={cmd.description}>
<Show when={props.commandKeybind(cmd.id)}> <span
<span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span> class="truncate"
</Show> classList={{
</div> "text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
</button> props.newLayoutDesigns,
)} "text-v2-text-text-muted": props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{cmd.description}
</span>
</Show>
</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
<Show
when={props.newLayoutDesigns}
fallback={
<span class="text-11-regular px-1.5 py-0.5 rounded bg-surface-base text-text-subtle">
{cmd.source === "skill"
? props.t("prompt.slash.badge.skill")
: cmd.source === "mcp"
? props.t("prompt.slash.badge.mcp")
: props.t("prompt.slash.badge.custom")}
</span>
}
>
<Tag>
{cmd.source === "skill"
? props.t("prompt.slash.badge.skill")
: cmd.source === "mcp"
? props.t("prompt.slash.badge.mcp")
: props.t("prompt.slash.badge.custom")}
</Tag>
</Show>
</Show>
<Show when={props.newLayoutDesigns ? keybindParts().length > 0 : keybind()}>
<Show
when={props.newLayoutDesigns}
fallback={<span class="text-12-regular text-text-subtle">{keybind()}</span>}
>
<KeybindV2 keys={keybindParts()} variant="neutral" />
</Show>
</Show>
</div>
</button>
)
}}
</For> </For>
</Show> </Show>
</Match> </Match>
@@ -1,7 +1,9 @@
import { Match, Show, Switch, createMemo } from "solid-js" import { Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip" import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
@@ -9,12 +11,13 @@ import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { getSessionContextMetrics } from "@/components/session/session-context-metrics" import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
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"
interface SessionContextUsageProps { interface SessionContextUsageProps {
variant?: "button" | "indicator" variant?: "button" | "indicator"
buttonAppearance?: "default" | "v2"
placement?: TooltipProps["placement"] placement?: TooltipProps["placement"]
} }
@@ -23,7 +26,7 @@ function openSessionContext(args: {
layout: ReturnType<typeof useLayout> layout: ReturnType<typeof useLayout>
tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]> tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
}) { }) {
if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open() args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button")
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all") if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
void args.tabs.open("context") void args.tabs.open("context")
args.tabs.setActive("context") args.tabs.setActive("context")
@@ -39,12 +42,14 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const { params, tabs, view } = useSessionLayout() const { params, tabs, view } = useSessionLayout()
const variant = createMemo(() => props.variant ?? "button") const variant = createMemo(() => props.variant ?? "button")
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
const tabState = createSessionTabs({ const tabState = createSessionTabs({
tabs, tabs,
pathFromTab: file.pathFromTab, pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}) })
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
const usd = createMemo( const usd = createMemo(
() => () =>
@@ -54,21 +59,30 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}), }),
) )
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()])) const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const context = createMemo(() => metrics().context) const tokens = createMemo(() => info()?.tokens)
const cost = createMemo(() => { const cost = createMemo(() => {
return usd().format(metrics().totalCost) return usd().format(info()?.cost ?? 0)
}) })
const contextVisible = createMemo(() => view().reviewPanel.opened() && tabState.activeTab() === "context")
const hasOtherTabs = createMemo(() =>
tabs()
.all()
.some((tab) => tab !== "context" && tab !== "review"),
)
const openContext = () => { const openContext = () => {
if (!params.id) return if (!params.id) return
if (tabState.activeTab() === "context") { const sessionView = view()
if (contextVisible()) {
tabs().close("context") tabs().close("context")
if (sessionView.reviewPanel.source() === "context-button" && !hasOtherTabs()) sessionView.reviewPanel.close()
return return
} }
openSessionContext({ openSessionContext({
view: view(), view: sessionView,
layout, layout,
tabs: tabs(), tabs: tabs(),
}) })
@@ -76,24 +90,46 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const circle = () => ( const circle = () => (
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} /> <ProgressCircle
size={16}
strokeWidth={2}
percentage={context()?.usage ?? 0}
style={
variant() === "indicator"
? {
"--progress-circle-background": "var(--v2-background-bg-layer-04, var(--border-weak-base))",
"--progress-circle-background-overlay": "var(--v2-overlay-simple-overlay-pressed, transparent)",
"--progress-circle-progress": "var(--v2-icon-icon-base, var(--icon-base))",
}
: undefined
}
/>
</div>
)
const circleV2 = () => (
<div class="flex items-center justify-center">
<ProgressCircleV2 percentage={context()?.usage ?? 0} />
</div> </div>
) )
const tooltipValue = () => ( const tooltipValue = () => (
<div> <div>
<Show when={tokens()}>
{(value) => (
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">
{getSessionTokenTotal(value())?.toLocaleString(language.intl())}
</span>
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
</div>
)}
</Show>
<Show when={context()}> <Show when={context()}>
{(ctx) => ( {(ctx) => (
<> <div class="flex items-center gap-2">
<div class="flex items-center gap-2"> <span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span> <span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span> </div>
</div>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
</div>
</>
)} )}
</Show> </Show>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -105,10 +141,22 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
return ( return (
<Show when={params.id}> <Show when={params.id}>
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}> <Switch>
<Switch> <Match when={variant() === "indicator"}>{circle()}</Match>
<Match when={variant() === "indicator"}>{circle()}</Match> <Match when={buttonAppearance() === "v2"}>
<Match when={true}> <Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
icon={circleV2()}
onClick={openContext}
aria-label={language.t("context.usage.view")}
/>
</Tooltip>
</Match>
<Match when={true}>
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -118,9 +166,9 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
> >
{circle()} {circle()}
</Button> </Button>
</Match> </Tooltip>
</Switch> </Match>
</Tooltip> </Switch>
</Show> </Show>
) )
} }
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client" import type { Message } from "@opencode-ai/sdk/v2/client"
import { getSessionContextMetrics } from "./session-context-metrics" import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
const assistant = ( const assistant = (
id: string, id: string,
@@ -37,8 +37,8 @@ const user = (id: string) => {
} as unknown as Message } as unknown as Message
} }
describe("getSessionContextMetrics", () => { describe("getSessionContext", () => {
test("computes totals and usage from latest assistant with tokens", () => { test("computes usage from latest assistant with tokens", () => {
const messages = [ const messages = [
user("u1"), user("u1"),
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5), assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
@@ -57,45 +57,52 @@ describe("getSessionContextMetrics", () => {
}, },
] ]
const metrics = getSessionContextMetrics(messages, providers) const ctx = getSessionContext(messages, providers)
expect(metrics.totalCost).toBe(1.75) expect(ctx?.message.id).toBe("a2")
expect(metrics.context?.message.id).toBe("a2") expect(ctx?.usage).toBe(50)
expect(metrics.context?.total).toBe(500) expect(ctx?.providerLabel).toBe("OpenAI")
expect(metrics.context?.usage).toBe(50) expect(ctx?.modelLabel).toBe("GPT-4.1")
expect(metrics.context?.providerLabel).toBe("OpenAI")
expect(metrics.context?.modelLabel).toBe("GPT-4.1")
}) })
test("preserves fallback labels and null usage when model metadata is missing", () => { test("preserves fallback labels and null usage when model metadata is missing", () => {
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")] const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
const providers = [{ id: "p-1", models: {} }] const providers = [{ id: "p-1", models: {} }]
const metrics = getSessionContextMetrics(messages, providers) const ctx = getSessionContext(messages, providers)
expect(metrics.context?.providerLabel).toBe("p-1") expect(ctx?.providerLabel).toBe("p-1")
expect(metrics.context?.modelLabel).toBe("m-1") expect(ctx?.modelLabel).toBe("m-1")
expect(metrics.context?.limit).toBeUndefined() expect(ctx?.limit).toBeUndefined()
expect(metrics.context?.usage).toBeNull() expect(ctx?.usage).toBeNull()
}) })
test("recomputes when message array is mutated in place", () => { test("recomputes when message array is mutated in place", () => {
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)] const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
const providers = [{ id: "openai", models: {} }] const providers = [{ id: "openai", models: {} }]
const one = getSessionContextMetrics(messages, providers) const one = getSessionContext(messages, providers)
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75)) messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
const two = getSessionContextMetrics(messages, providers) const two = getSessionContext(messages, providers)
expect(one.context?.message.id).toBe("a1") expect(one?.message.id).toBe("a1")
expect(two.context?.message.id).toBe("a2") expect(two?.message.id).toBe("a2")
expect(two.totalCost).toBe(1)
}) })
test("returns empty metrics when inputs are undefined", () => { test("returns undefined when inputs are undefined", () => {
const metrics = getSessionContextMetrics(undefined, undefined) const ctx = getSessionContext(undefined, undefined)
expect(metrics.totalCost).toBe(0) expect(ctx).toBeUndefined()
expect(metrics.context).toBeUndefined() })
test("computes stored session token totals", () => {
expect(
getSessionTokenTotal({
input: 10,
output: 20,
reasoning: 30,
cache: { read: 40, write: 50 },
}),
).toBe(150)
}) })
}) })
@@ -1,4 +1,4 @@
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
type Provider = { type Provider = {
id: string id: string
@@ -21,19 +21,9 @@ type Context = {
modelLabel: string modelLabel: string
limit: number | undefined limit: number | undefined
input: number input: number
output: number
reasoning: number
cacheRead: number
cacheWrite: number
total: number
usage: number | null usage: number | null
} }
type Metrics = {
totalCost: number
context: Context | undefined
}
const tokenTotal = (msg: AssistantMessage) => { const tokenTotal = (msg: AssistantMessage) => {
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
} }
@@ -47,10 +37,9 @@ const lastAssistantWithTokens = (messages: Message[]) => {
} }
} }
const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => { const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
const message = lastAssistantWithTokens(messages) const message = lastAssistantWithTokens(messages)
if (!message) return { totalCost, context: undefined } if (!message) return undefined
const provider = providers.find((item) => item.id === message.providerID) const provider = providers.find((item) => item.id === message.providerID)
const model = provider?.models[message.modelID] const model = provider?.models[message.modelID]
@@ -58,25 +47,22 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics =>
const total = tokenTotal(message) const total = tokenTotal(message)
return { return {
totalCost, message,
context: { provider,
message, model,
provider, providerLabel: provider?.name ?? message.providerID,
model, modelLabel: model?.name ?? message.modelID,
providerLabel: provider?.name ?? message.providerID, limit,
modelLabel: model?.name ?? message.modelID, input: message.tokens.input,
limit, usage: limit ? Math.round((total / limit) * 100) : null,
input: message.tokens.input,
output: message.tokens.output,
reasoning: message.tokens.reasoning,
cacheRead: message.tokens.cache.read,
cacheWrite: message.tokens.cache.write,
total,
usage: limit ? Math.round((total / limit) * 100) : null,
},
} }
} }
export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) { export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers) return build(messages, providers)
} }
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
if (!tokens) return undefined
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContextMetrics } from "./session-context-metrics" import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format" import { createSessionContextFormatter } from "./session-context-format"
@@ -134,12 +134,12 @@ export function SessionContextTab() {
}), }),
) )
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()])) const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const ctx = createMemo(() => metrics().context) const tokens = createMemo(() => info()?.tokens)
const formatter = createMemo(() => createSessionContextFormatter(language.intl())) const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
const cost = createMemo(() => { const cost = createMemo(() => {
return usd().format(metrics().totalCost) return usd().format(info()?.cost ?? 0)
}) })
const counts = createMemo(() => { const counts = createMemo(() => {
@@ -204,14 +204,14 @@ export function SessionContextTab() {
{ label: "context.stats.provider", value: providerLabel }, { label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel }, { label: "context.stats.model", value: modelLabel },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, { label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, { label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) }, { label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) }, { label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
{ {
label: "context.stats.cacheTokens", label: "context.stats.cacheTokens",
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`, value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
}, },
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
@@ -21,7 +21,7 @@
position: relative; position: relative;
} }
[data-titlebar-tab-slot]:not(:first-child)::before { [data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before {
content: ""; content: "";
position: absolute; position: absolute;
top: 8px; top: 8px;
@@ -32,6 +32,10 @@
background: var(--v2-background-bg-layer-02); background: var(--v2-background-bg-layer-02);
} }
[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
display: none;
}
[data-titlebar-tab] [data-slot="tab-close"]::before { [data-titlebar-tab] [data-slot="tab-close"]::before {
content: ""; content: "";
position: absolute; position: absolute;
@@ -13,6 +13,9 @@ import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import "./titlebar-tab-nav.css" import "./titlebar-tab-nav.css"
// MouseEvent.button uses 1 for the middle/wheel button.
const MIDDLE_MOUSE_BUTTON = 1
export function TabNavItem(props: { export function TabNavItem(props: {
ref?: Ref<HTMLDivElement> ref?: Ref<HTMLDivElement>
href: string href: string
@@ -184,7 +187,12 @@ export function TabNavItem(props: {
data-dragging={props.dragging} data-dragging={props.dragging}
data-pressed={props.pressed} data-pressed={props.pressed}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== 1) return if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault()
event.stopPropagation()
}}
onAuxClick={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
closeTab(event) closeTab(event)
}} }}
> >
@@ -302,7 +310,12 @@ export function DraftTabItem(props: {
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
classList={{ invisible: props.hidden }} classList={{ invisible: props.hidden }}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== 1) return if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault()
event.stopPropagation()
}}
onAuxClick={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
closeTab(event) closeTab(event)
}} }}
> >
@@ -85,6 +85,7 @@ function SessionTabSlot(props: {
ref={sortable.ref} ref={sortable.ref}
data-titlebar-tab-slot data-titlebar-tab-slot
data-tab-key={props.id} data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink" class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ hidden: !session() }} classList={{ hidden: !session() }}
> >
@@ -140,6 +141,7 @@ function DraftTabSlot(props: {
ref={sortable.ref} ref={sortable.ref}
data-titlebar-tab-slot data-titlebar-tab-slot
data-tab-key={props.id} data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink" class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
> >
<DraftTabItem <DraftTabItem
+5 -2
View File
@@ -99,6 +99,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const path = () => `${location.pathname}${location.search}${location.hash}` const path = () => `${location.pathname}${location.search}${location.hash}`
const creating = createMemo(() => { const creating = createMemo(() => {
const route = layout.route()
if (route.type === "draft" || route.type === "dir-new-sesssion") return true
if (!params.dir) return false if (!params.dir) return false
if (params.id) return false if (params.id) return false
const parts = location.pathname.replace(/\/+$/, "").split("/") const parts = location.pathname.replace(/\/+$/, "").split("/")
@@ -227,7 +229,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}} }}
style={{ style={{
"min-height": minHeight(), "min-height": minHeight(),
"padding-left": mac() && !mobile() ? `${84 / zoom()}px` : 0, // Keep native macOS traffic lights clear even when the desktop window is narrow.
"padding-left": mac() ? `${84 / zoom()}px` : 0,
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined, width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": electronWindows() "max-width": electronWindows()
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
@@ -466,7 +469,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}} }}
onReorder={(keys) => tabsStoreActions.reorder(keys)} onReorder={(keys) => tabsStoreActions.reorder(keys)}
/> />
<Show when={!(creating() && params.dir)}> <Show when={!creating()}>
<TooltipV2 <TooltipV2
placement="bottom" placement="bottom"
value={ value={
@@ -16,6 +16,7 @@ describe("bootstrapDirectory", () => {
status: "loading", status: "loading",
agent: [], agent: [],
command: [], command: [],
reference: [],
project: "", project: "",
projectMeta: undefined, projectMeta: undefined,
icon: undefined, icon: undefined,
@@ -35,6 +36,7 @@ describe("bootstrapDirectory", () => {
question: {}, question: {},
mcp_ready: true, mcp_ready: true,
mcp: {}, mcp: {},
mcp_resource: {},
lsp_ready: true, lsp_ready: true,
lsp: [], lsp: [],
vcs: undefined, vcs: undefined,
@@ -67,6 +69,7 @@ describe("bootstrapDirectory", () => {
}, },
permission: { list: async () => ({ data: [] }) }, permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) }, question: { list: async () => ({ data: [] }) },
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
mcp: { mcp: {
status: async () => { status: async () => {
mcpReads.push("status") mcpReads.push("status")
@@ -6,6 +6,7 @@ import type {
Project, Project,
ProviderAuthResponse, ProviderAuthResponse,
QuestionRequest, QuestionRequest,
ReferenceInfo,
Session, Session,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
@@ -18,7 +19,7 @@ import type { ServerSession } from "../server-session"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query" import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery } from "../server-sync" import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope" import { ScopedKey, type ServerScope } from "@/utils/server-scope"
@@ -195,6 +196,13 @@ export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk:
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)), queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
}) })
export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions<ReferenceInfo[]>({
queryKey: [scope, directory, "references"] as const,
queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
placeholderData: [],
})
export async function bootstrapDirectory(input: { export async function bootstrapDirectory(input: {
directory: string directory: string
scope: ServerScope scope: ServerScope
@@ -277,6 +285,7 @@ export async function bootstrapDirectory(input: {
}), }),
), ),
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))), input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
() => () =>
retry(() => retry(() =>
input.sdk.permission.list().then((x) => { input.sdk.permission.list().then((x) => {
@@ -339,6 +348,7 @@ export async function bootstrapDirectory(input: {
), ),
() => Promise.resolve(input.loadSessions(input.directory)), () => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))), input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
() => () =>
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => { input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
const project = getFilename(input.directory) const project = getFilename(input.directory)
@@ -34,7 +34,9 @@ const queryOptionsApi = {
}), }),
agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }), agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }),
mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }), mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }),
mcpResources: (directory: string) => ({ queryKey: [directory, "mcpResources"], queryFn: async () => ({}) }),
lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }), lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }),
references: (directory: string) => ({ queryKey: [directory, "references"], queryFn: async () => [] }),
sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }), sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }),
} as unknown as QueryOptionsApi } as unknown as QueryOptionsApi
@@ -197,14 +199,18 @@ describe("createChildStoreManager", () => {
try { try {
if (!manager) throw new Error("manager required") if (!manager) throw new Error("manager required")
const [store, setStore] = manager.child("/project", { bootstrap: false }) const [store, setStore] = manager.child("/project", { bootstrap: false })
expect(querySingles.length - offset).toBe(4) expect(querySingles.length - offset).toBe(6)
const query = querySingles[offset + 1] const query = querySingles[offset + 1]
const resourceQuery = querySingles[offset + 2]
if (!query) throw new Error("query required") if (!query) throw new Error("query required")
if (!resourceQuery) throw new Error("resource query required")
expect(query().enabled).toBe(false) expect(query().enabled).toBe(false)
expect(resourceQuery().enabled).toBe(false)
setStore("status", "complete") setStore("status", "complete")
manager.child("/project", { bootstrap: false, mcp: true }) manager.child("/project", { bootstrap: false, mcp: true })
expect(query().enabled).toBe(true) expect(query().enabled).toBe(true)
expect(resourceQuery().enabled).toBe(true)
expect(store.mcp).toEqual({ demo: { status: "disabled" } }) expect(store.mcp).toEqual({ demo: { status: "disabled" } })
expect(mcpLoads).toEqual(["/project"]) expect(mcpLoads).toEqual(["/project"])
@@ -185,8 +185,10 @@ export function createChildStoreManager(input: {
const pathQuery = useQuery(() => input.queryOptions.path(key)) const pathQuery = useQuery(() => input.queryOptions.path(key))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() })) const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => input.queryOptions.lsp(key)) const lspQuery = useQuery(() => input.queryOptions.lsp(key))
const providerQuery = useQuery(() => input.queryOptions.providers(key)) const providerQuery = useQuery(() => input.queryOptions.providers(key))
const referenceQuery = useQuery(() => input.queryOptions.references(key))
const child = createStore<State>({ const child = createStore<State>({
project: "", project: "",
@@ -210,6 +212,9 @@ export function createChildStoreManager(input: {
status: "loading" as const, status: "loading" as const,
agent: [], agent: [],
command: [], command: [],
get reference() {
return referenceQuery.isLoading ? [] : (referenceQuery.data ?? [])
},
session: [], session: [],
sessionTotal: 0, sessionTotal: 0,
session_status: {}, session_status: {},
@@ -227,6 +232,9 @@ export function createChildStoreManager(input: {
get mcp() { get mcp() {
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {}) return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
}, },
get mcp_resource() {
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
},
get lsp_ready() { get lsp_ready() {
return !lspQuery.isLoading return !lspQuery.isLoading
}, },
@@ -112,6 +112,7 @@ export function applyDirectoryEvent(input: {
push: (directory: string) => void push: (directory: string) => void
directory: string directory: string
loadLsp: () => void loadLsp: () => void
loadReferences?: () => void
vcsCache?: VcsCache vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
retainedLimit?: number retainedLimit?: number
@@ -404,5 +405,9 @@ export function applyDirectoryEvent(input: {
input.loadLsp() input.loadLsp()
break break
} }
case "reference.updated": {
input.loadReferences?.()
break
}
} }
} }
@@ -3,12 +3,14 @@ import type {
Command, Command,
Config, Config,
LspStatus, LspStatus,
McpResource,
McpStatus, McpStatus,
Message, Message,
Part, Part,
Path, Path,
PermissionRequest, PermissionRequest,
QuestionRequest, QuestionRequest,
ReferenceInfo,
Session, Session,
SessionStatus, SessionStatus,
SnapshotFileDiff, SnapshotFileDiff,
@@ -34,6 +36,7 @@ export type State = {
status: "loading" | "partial" | "complete" status: "loading" | "partial" | "complete"
agent: Agent[] agent: Agent[]
command: Command[] command: Command[]
reference: ReferenceInfo[]
project: string project: string
projectMeta: ProjectMeta | undefined projectMeta: ProjectMeta | undefined
icon: string | undefined icon: string | undefined
@@ -63,6 +66,9 @@ export type State = {
mcp: { mcp: {
[name: string]: McpStatus [name: string]: McpStatus
} }
mcp_resource: {
[key: string]: McpResource
}
lsp_ready: boolean lsp_ready: boolean
lsp: LspStatus[] lsp: LspStatus[]
vcs: VcsInfo | undefined vcs: VcsInfo | undefined
+31 -13
View File
@@ -28,6 +28,7 @@ const DEFAULT_SIDEBAR_WIDTH = 344
const DEFAULT_FILE_TREE_WIDTH = 200 const DEFAULT_FILE_TREE_WIDTH = 200
const DEFAULT_SESSION_WIDTH = 600 const DEFAULT_SESSION_WIDTH = 600
const DEFAULT_TERMINAL_HEIGHT = 280 const DEFAULT_TERMINAL_HEIGHT = 280
const DEFAULT_REVIEW_PANEL_OPENED = false
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
export function getAvatarColors(key?: string) { export function getAvatarColors(key?: string) {
@@ -77,6 +78,7 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string } export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export type ReviewDiffStyle = "unified" | "split" export type ReviewDiffStyle = "unified" | "split"
export type ReviewPanelSource = "context-button" | "other"
export type LayoutRoute = export type LayoutRoute =
| { type: "home" } | { type: "home" }
@@ -210,7 +212,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (!isRecord(review)) return review if (!isRecord(review)) return review
if (typeof review.panelOpened === "boolean") return review if (typeof review.panelOpened === "boolean") return review
const opened = isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : true const opened =
isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : DEFAULT_REVIEW_PANEL_OPENED
return { return {
...review, ...review,
panelOpened: opened, panelOpened: opened,
@@ -279,7 +282,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
review: { review: {
diffStyle: "split" as ReviewDiffStyle, diffStyle: "split" as ReviewDiffStyle,
panelOpened: true, panelOpened: DEFAULT_REVIEW_PANEL_OPENED,
}, },
fileTree: { fileTree: {
opened: false, opened: false,
@@ -302,6 +305,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
}), }),
) )
const [ephemeral, setEphemeral] = createStore({
reviewPanelSource: "other" as ReviewPanelSource,
})
const MAX_SESSION_KEYS = 50 const MAX_SESSION_KEYS = 50
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000 const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
@@ -662,7 +668,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"), diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
setDiffStyle(diffStyle: ReviewDiffStyle) { setDiffStyle(diffStyle: ReviewDiffStyle) {
if (!store.review) { if (!store.review) {
setStore("review", { diffStyle, panelOpened: true }) setStore("review", { diffStyle, panelOpened: DEFAULT_REVIEW_PANEL_OPENED })
return return
} }
setStore("review", "diffStyle", diffStyle) setStore("review", "diffStyle", diffStyle)
@@ -777,7 +783,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const key = createSessionKeyReader(sessionKey, ensureKey) const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} }) const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const terminalOpened = createMemo(() => store.terminal?.opened ?? false) const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true) const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
function setTerminalOpened(next: boolean) { function setTerminalOpened(next: boolean) {
const current = store.terminal const current = store.terminal
@@ -791,16 +798,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("terminal", "opened", next) setStore("terminal", "opened", next)
} }
function setReviewPanelOpened(next: boolean) { function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) {
const nextSource = next ? source : "other"
const current = store.review const current = store.review
if (!current) { if (!current) {
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next }) batch(() => {
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
setEphemeral("reviewPanelSource", nextSource)
})
return return
} }
const value = current.panelOpened ?? true const value = current.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED
if (value === next) return if (value === next) {
setStore("review", "panelOpened", next) if (ephemeral.reviewPanelSource !== nextSource) setEphemeral("reviewPanelSource", nextSource)
return
}
batch(() => {
setStore("review", "panelOpened", next)
setEphemeral("reviewPanelSource", nextSource)
})
} }
return { return {
@@ -836,14 +853,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
reviewPanel: { reviewPanel: {
opened: reviewPanelOpened, opened: reviewPanelOpened,
open() { source: reviewPanelSource,
setReviewPanelOpened(true) open(source: ReviewPanelSource = "other") {
setReviewPanelOpened(true, source)
}, },
close() { close() {
setReviewPanelOpened(false) setReviewPanelOpened(false, "other")
}, },
toggle() { toggle() {
setReviewPanelOpened(!reviewPanelOpened()) setReviewPanelOpened(!reviewPanelOpened(), "other")
}, },
}, },
review: { review: {
+39 -32
View File
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo } from "solid-js" import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
@@ -80,6 +80,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
current?: string current?: string
draft?: State draft?: State
promoting?: State
last?: { last?: {
type: "agent" | "model" | "variant" type: "agent" | "model" | "variant"
agent?: string agent?: string
@@ -123,7 +124,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const scope = createMemo<State | undefined>(() => { const scope = createMemo<State | undefined>(() => {
const session = id() const session = id()
if (!session) return store.draft if (!session) return store.draft ?? store.promoting
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session)) return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
}) })
@@ -136,11 +137,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!next) return if (!next) return
if (saved.session[session] !== undefined) { if (saved.session[session] !== undefined) {
handoff.delete(key) handoff.delete(key)
setStore("promoting", undefined)
return return
} }
setSaved("session", session, clone(next)) setSaved("session", session, clone(next))
handoff.delete(key) handoff.delete(key)
setStore("promoting", undefined)
}) })
const configuredModel = () => { const configuredModel = () => {
@@ -294,19 +297,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model.set({ providerID: entry.provider.id, modelID: entry.id }) model.set({ providerID: entry.provider.id, modelID: entry.id })
}, },
set(item: ModelKey | undefined, options?: { recent?: boolean }) { set(item: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => { startTransition(() =>
setStore("last", { batch(() => {
type: "model", setStore("last", {
agent: agent.current()?.name, type: "model",
model: item ?? null, agent: agent.current()?.name,
variant: selected(), model: item ?? null,
}) variant: selected(),
write({ model: item }) })
if (!item) return write({ model: item })
models.setVisibility(item, true) if (!item) return
if (!options?.recent) return models.setVisibility(item, true)
models.recent.push(item) if (!options?.recent) return
}) models.recent.push(item)
}),
)
}, },
visible(item: ModelKey) { visible(item: ModelKey) {
return models.visible(item) return models.visible(item)
@@ -335,19 +340,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return Object.keys(item.variants) return Object.keys(item.variants)
}, },
set(value: string | undefined) { set(value: string | undefined) {
batch(() => { startTransition(() =>
const model = current() batch(() => {
setStore("last", { const model = current()
type: "variant", setStore("last", {
agent: agent.current()?.name, type: "variant",
model: model ? { providerID: model.provider.id, modelID: model.id } : null, agent: agent.current()?.name,
variant: value ?? null, model: model ? { providerID: model.provider.id, modelID: model.id } : null,
}) variant: value ?? null,
write({ variant: value ?? null }) })
if (model) { write({ variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined) if (model) {
} models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
}) }
}),
)
}, },
cycle() { cycle() {
const items = this.list() const items = this.list()
@@ -369,19 +376,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
agent, agent,
session: { session: {
reset() { reset() {
setStore("draft", undefined) setStore({ draft: undefined, promoting: undefined })
}, },
promote(dir: string, session: string) { promote(dir: string, session: string) {
const next = clone(snapshot()) const next = clone(snapshot())
if (!next) return if (!next) return
const key = handoffKey(serverSDK().scope, dir, session)
handoff.set(key, next)
if (dir === sdk().directory) { if (dir === sdk().directory) {
setSaved("session", session, next) setSaved("session", session, next)
setStore("draft", undefined)
return
} }
handoff.set(handoffKey(serverSDK().scope, dir, session), next) setStore("promoting", next)
setStore("draft", undefined) setStore("draft", undefined)
}, },
restore(msg: { sessionID: string; agent: string; model: ModelKey }) { restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
+343 -245
View File
@@ -1,9 +1,9 @@
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams, useSearchParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServerSDK } from "./server-sdk" import type { ServerSDK } from "./server-sdk"
import { useServerSync } from "./server-sync" import type { ServerSync } from "./server-sync"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
@@ -12,6 +12,11 @@ import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2" import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { playSoundById } from "@/utils/sound" import { playSoundById } from "@/utils/sound"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { type DraftTab, useTabs } from "./tabs"
import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
type NotificationBase = { type NotificationBase = {
directory?: string directory?: string
@@ -107,267 +112,360 @@ function buildNotificationIndex(list: Notification[]) {
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification", name: "Notification",
gate: false, gate: false,
init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => { init: () => {
const params = useParams() const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
const serverSDK = useServerSDK() const [search] = useSearchParams<{ draftId?: string }>()
const serverSync = useServerSync() const global = useGlobal()
const server = useServer()
const tabs = useTabs()
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings() const settings = useSettings()
const language = useLanguage() const language = useLanguage()
const owner = getOwner()
const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
const empty: Notification[] = [] const activeServer = createMemo(() => {
if (params.serverKey) return requireServerKey(params.serverKey)
const currentDirectory = createMemo(() => { if (search.draftId) {
return props.directory?.() ?? decode64(params.dir) const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
if (draft) return draft.server
}
return server.key
}) })
const activeDirectory = createMemo(() => decode64(params.dir))
const activeSession = createMemo(() => params.id)
const currentSession = createMemo(() => props.sessionID?.() ?? params.id) const ensure = (key: ServerConnection.Key) => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
const [store, setStore, _, ready] = persisted( if (!conn) throw new Error(`Notification server not found: ${key}`)
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]), const ctx = global.ensureServerCtx(conn)
createStore({ const existing = states.get(ctx.sdk.scope)
list: [] as Notification[], if (existing) return existing.state
}), const root = createRoot(
) (dispose) => ({
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list)) dispose,
state: createServerNotificationState({
const meta = { pruned: false, disposed: false } sdk: ctx.sdk,
sync: ctx.sync,
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => { active: () => server.scope(activeServer()) === ctx.sdk.scope,
setIndex(scope, "unseen", key, unseen) directory: activeDirectory,
setIndex(scope, "unseenCount", key, unseen.length) sessionID: activeSession,
setIndex( platform,
scope, settings,
"unseenHasError", language,
key, }),
unseen.some((notification) => notification.type === "error"), }),
owner ?? undefined,
) )
} states.set(ctx.sdk.scope, root)
return root.state
const appendToIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
}
}
}
const removeFromIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
updateUnseen("session", notification.session, unseen)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
updateUnseen("project", notification.directory, unseen)
}
}
} }
createEffect(() => { createEffect(() => {
if (!ready()) return global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
if (meta.pruned) return })
meta.pruned = true
const list = pruneNotifications(store.list) createEffect(() => {
batch(() => { const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn))))
setStore("list", list) states.forEach((value, scope) => {
setIndex(reconcile(buildNotificationIndex(list), { merge: false })) if (scopes.has(scope)) return
value.dispose()
states.delete(scope)
}) })
}) })
const append = (notification: Notification) => { onCleanup(() => states.forEach((value) => value.dispose()))
const list = pruneNotifications([...store.list, notification])
const keep = new Set(list)
const removed = store.list.filter((n) => !keep.has(n))
batch(() => { const selected = () => ensure(activeServer())
if (keep.has(notification)) appendToIndex(notification)
removed.forEach((n) => removeFromIndex(n))
setStore("list", list)
})
}
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const sync = serverSync().ensureDirSyncContext(directory)
const session = sync.session.get(sessionID)
if (session) return session
return sync.session
.sync(sessionID)
.then(() => sync.session.get(sessionID))
.catch(() => undefined)
}
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
const activeDirectory = currentDirectory()
const activeSession = currentSession()
if (!activeDirectory) return false
if (!activeSession) return false
if (!sessionID) return false
if (directory !== activeDirectory) return false
return sessionID === activeSession
}
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
if (session.parentID) return
if (settings.sounds.agentEnabled()) {
void playSoundById(settings.sounds.agent())
}
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "turn-complete",
session: sessionID,
})
const href = `/${base64Encode(directory)}/session/${sessionID}`
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
}
})
}
const handleSessionError = (
directory: string,
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
time: number,
) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
void playSoundById(settings.sounds.errors())
}
const error = "error" in event.properties ? event.properties.error : undefined
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "error",
session: sessionID ?? "global",
error,
})
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, href)
}
})
}
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name
const time = Date.now()
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
return
}
handleSessionError(directory, event, time)
})
onCleanup(() => {
meta.disposed = true
unsub()
})
return { return {
ready, ready: () => selected().ready(),
ensureServerState: ensure,
session: { session: {
all(session: string) { all: (session: string) => selected().session.all(session),
return index.session.all[session] ?? empty unseen: (session: string) => selected().session.unseen(session),
}, unseenCount: (session: string) => selected().session.unseenCount(session),
unseen(session: string) { unseenHasError: (session: string) => selected().session.unseenHasError(session),
return index.session.unseen[session] ?? empty markViewed: (session: string) => selected().session.markViewed(session),
},
unseenCount(session: string) {
return index.session.unseenCount[session] ?? 0
},
unseenHasError(session: string) {
return index.session.unseenHasError[session] ?? false
},
markViewed(session: string) {
const unseen = index.session.unseen[session] ?? empty
if (!unseen.length) return
const projects = [
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
]
batch(() => {
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
updateUnseen("session", session, [])
projects.forEach((directory) => {
const next = (index.project.unseen[directory] ?? empty).filter(
(notification) => notification.session !== session,
)
updateUnseen("project", directory, next)
})
})
},
}, },
project: { project: {
all(directory: string) { all: (directory: string) => selected().project.all(directory),
return index.project.all[directory] ?? empty unseen: (directory: string) => selected().project.unseen(directory),
}, unseenCount: (directory: string) => selected().project.unseenCount(directory),
unseen(directory: string) { unseenHasError: (directory: string) => selected().project.unseenHasError(directory),
return index.project.unseen[directory] ?? empty markViewed: (directory: string) => selected().project.markViewed(directory),
},
unseenCount(directory: string) {
return index.project.unseenCount[directory] ?? 0
},
unseenHasError(directory: string) {
return index.project.unseenHasError[directory] ?? false
},
markViewed(directory: string) {
const unseen = index.project.unseen[directory] ?? empty
if (!unseen.length) return
const sessions = [
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
]
batch(() => {
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
updateUnseen("project", directory, [])
sessions.forEach((session) => {
const next = (index.session.unseen[session] ?? empty).filter(
(notification) => notification.directory !== directory,
)
updateUnseen("session", session, next)
})
})
},
}, },
} }
}, },
}) })
type NotificationState = ReturnType<typeof createServerNotificationState>
function createServerNotificationState(input: {
sdk: ServerSDK
sync: ServerSync
active: Accessor<boolean>
directory: Accessor<string | undefined>
sessionID: Accessor<string | undefined>
platform: ReturnType<typeof usePlatform>
settings: ReturnType<typeof useSettings>
language: ReturnType<typeof useLanguage>
}) {
const serverSDK = () => input.sdk
const serverSync = () => input.sync
const platform = input.platform
const settings = input.settings
const language = input.language
const empty: Notification[] = []
const currentDirectory = input.directory
const currentSession = input.sessionID
const [store, setStore, _, ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
createStore({
list: [] as Notification[],
}),
)
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
const meta = { pruned: false, disposed: false }
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
setIndex(scope, "unseen", key, unseen)
setIndex(scope, "unseenCount", key, unseen.length)
setIndex(
scope,
"unseenHasError",
key,
unseen.some((notification) => notification.type === "error"),
)
}
const appendToIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
}
}
}
const removeFromIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
updateUnseen("session", notification.session, unseen)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
updateUnseen("project", notification.directory, unseen)
}
}
}
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
const list = pruneNotifications(store.list)
batch(() => {
setStore("list", list)
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
})
})
const append = (notification: Notification) => {
const list = pruneNotifications([...store.list, notification])
const keep = new Set(list)
const removed = store.list.filter((n) => !keep.has(n))
batch(() => {
if (keep.has(notification)) appendToIndex(notification)
removed.forEach((n) => removeFromIndex(n))
setStore("list", list)
})
}
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const sync = serverSync().ensureDirSyncContext(directory)
const session = sync.session.get(sessionID)
if (session) return session
return sync.session
.sync(sessionID)
.then(() => sync.session.get(sessionID))
.catch(() => undefined)
}
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
if (!input.active()) return false
const activeDirectory = currentDirectory()
const activeSession = currentSession()
if (!activeSession) return false
if (!sessionID) return false
if (activeDirectory && directory !== activeDirectory) return false
return sessionID === activeSession
}
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
if (session.parentID) return
if (settings.sounds.agentEnabled()) {
void playSoundById(settings.sounds.agent())
}
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "turn-complete",
session: sessionID,
})
const href = `/${base64Encode(directory)}/session/${sessionID}`
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
}
})
}
const handleSessionError = (
directory: string,
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
time: number,
) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
void playSoundById(settings.sounds.errors())
}
const error = "error" in event.properties ? event.properties.error : undefined
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "error",
session: sessionID ?? "global",
error,
})
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, href)
}
})
}
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name
const time = Date.now()
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
return
}
handleSessionError(directory, event, time)
})
onCleanup(() => {
meta.disposed = true
unsub()
})
return {
ready,
session: {
all(session: string) {
return index.session.all[session] ?? empty
},
unseen(session: string) {
return index.session.unseen[session] ?? empty
},
unseenCount(session: string) {
return index.session.unseenCount[session] ?? 0
},
unseenHasError(session: string) {
return index.session.unseenHasError[session] ?? false
},
markViewed(session: string) {
const unseen = index.session.unseen[session] ?? empty
if (!unseen.length) return
const projects = [
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
]
batch(() => {
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
updateUnseen("session", session, [])
projects.forEach((directory) => {
const next = (index.project.unseen[directory] ?? empty).filter(
(notification) => notification.session !== session,
)
updateUnseen("project", directory, next)
})
})
},
},
project: {
all(directory: string) {
return index.project.all[directory] ?? empty
},
unseen(directory: string) {
return index.project.unseen[directory] ?? empty
},
unseenCount(directory: string) {
return index.project.unseenCount[directory] ?? 0
},
unseenHasError(directory: string) {
return index.project.unseenHasError[directory] ?? false
},
markViewed(directory: string) {
const unseen = index.project.unseen[directory] ?? empty
if (!unseen.length) return
const sessions = [
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
]
batch(() => {
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
updateUnseen("project", directory, [])
sessions.forEach((session) => {
const next = (index.session.unseen[session] ?? empty).filter(
(notification) => notification.directory !== directory,
)
updateUnseen("session", session, next)
})
})
},
},
}
}
+12 -1
View File
@@ -12,6 +12,7 @@ import { useTabs, type Tab } from "./tabs"
import { ServerConnection } from "./server" import { ServerConnection } from "./server"
import { requireServerKey } from "@/utils/session-route" import { requireServerKey } from "@/utils/session-route"
import { useSettings } from "./settings" import { useSettings } from "./settings"
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
interface PartBase { interface PartBase {
content: string content: string
@@ -27,6 +28,10 @@ export interface FileAttachmentPart extends PartBase {
type: "file" type: "file"
path: string path: string
selection?: FileSelection selection?: FileSelection
mime?: string
filename?: string
url?: string
source?: FilePartSource
} }
export interface AgentPart extends PartBase { export interface AgentPart extends PartBase {
@@ -73,7 +78,13 @@ function isPartEqual(partA: ContentPart, partB: ContentPart) {
case "text": case "text":
return partB.type === "text" && partA.content === partB.content return partB.type === "text" && partA.content === partB.content
case "file": case "file":
return partB.type === "file" && partA.path === partB.path && isSelectionEqual(partA.selection, partB.selection) return (
partB.type === "file" &&
partA.path === partB.path &&
partA.mime === partB.mime &&
partA.filename === partB.filename &&
isSelectionEqual(partA.selection, partB.selection)
)
case "agent": case "agent":
return partB.type === "agent" && partA.name === partB.name return partB.type === "agent" && partA.name === partB.name
case "image": case "image":
+115 -8
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk" import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import type { Event } from "@opencode-ai/sdk/v2/client" import type { Event } from "@opencode-ai/sdk/v2/client"
describe("resumeStreamAfterPageShow", () => { describe("resumeStreamAfterPageShow", () => {
@@ -15,19 +15,23 @@ describe("resumeStreamAfterPageShow", () => {
}) })
describe("coalesceServerEvents", () => { describe("coalesceServerEvents", () => {
const delta = (value: string, field = "text") => ({ const delta = (value: string, field = "text", partID = "part") => ({
directory: "/repo", directory: "/repo",
payload: { payload: {
type: "message.part.delta", type: "message.part.delta",
properties: { messageID: "msg", partID: "part", field, delta: value }, properties: { messageID: "msg", partID, field, delta: value },
} as Event, } as Event,
}) })
test("merges adjacent deltas for the same field", () => { test("merges adjacent deltas for the same field", () => {
const result = coalesceServerEvents([delta("hello "), delta("world")]) const first = delta("hello ")
const second = delta("world")
first.payload.id = "first"
second.payload.id = "second"
const result = coalesceServerEvents([first, second])
expect(result).toHaveLength(1) expect(result).toHaveLength(1)
expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } }) expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
}) })
test("preserves event boundaries and distinct fields", () => { test("preserves event boundaries and distinct fields", () => {
@@ -45,9 +49,112 @@ describe("coalesceServerEvents", () => {
]) ])
}) })
test("drops stale deltas", () => { test("preserves event ID order across interleaved deltas", () => {
const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"])) const first = delta("a")
const other = delta("b", "text", "other")
const last = delta("c")
first.payload.id = "1"
other.payload.id = "2"
last.payload.id = "3"
expect(result).toEqual([]) const result = coalesceServerEvents([first, other, last])
expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"])
})
})
describe("enqueueServerEvent", () => {
const partUpdated = (text: string) =>
({
type: "message.part.updated",
properties: {
sessionID: "session",
part: { id: "part", sessionID: "session", messageID: "message", type: "text", text },
},
}) as Event
test("preserves part updates across message remove and re-add barriers", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old"))
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
enqueue({
type: "message.updated",
properties: {
sessionID: "session",
info: {
id: "message",
sessionID: "session",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "provider", modelID: "model" },
},
},
} as Event)
enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([
"message.part.updated",
"message.removed",
"message.updated",
"message.part.updated",
])
})
test("preserves deltas after a replacement snapshot", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("a"))
enqueue(partUpdated("ab"))
enqueue({
type: "message.part.delta",
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
} as Event)
const result = coalesceServerEvents(events)
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
})
test("preserves updates after session deletion", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old"))
enqueue({
type: "session.deleted",
properties: { sessionID: "session", info: { id: "session" } },
} as Event)
enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([
"message.part.updated",
"session.deleted",
"message.part.updated",
])
})
test("does not coalesce edge-triggered session statuses", () => {
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (status: "retry" | "busy") =>
enqueueServerEvent(events, {
directory: "/repo",
payload: {
type: "session.status",
properties: {
sessionID: "session",
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
},
} as Event,
})
enqueue("retry")
enqueue("busy")
expect(events).toHaveLength(2)
}) })
}) })
+41 -50
View File
@@ -17,34 +17,56 @@ const isAbortError = (error: unknown) =>
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
type QueuedServerEvent = { directory: string; payload: Event } type QueuedServerEvent = { directory: string; payload: Event }
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}` const coalescedKey = (event: QueuedServerEvent) => {
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
if (event.payload.type === "message.part.updated") {
const part = event.payload.properties.part
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
}
return undefined
}
export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set<string>) { export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
const key = coalescedKey(event)
const previous = queue[queue.length - 1]
if (key && previous && coalescedKey(previous) === key) {
queue[queue.length - 1] = event
return false
}
queue.push(event)
return true
}
export function coalesceServerEvents(events: QueuedServerEvent[]) {
const output: QueuedServerEvent[] = [] const output: QueuedServerEvent[] = []
const deltas = new Map<string, number>()
events.forEach((event) => { events.forEach((event) => {
if (stale && event.payload.type === "message.part.delta") {
const props = event.payload.properties
if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return
}
if (event.payload.type !== "message.part.delta") { if (event.payload.type !== "message.part.delta") {
deltas.clear()
output.push(event) output.push(event)
return return
} }
const props = event.payload.properties const props = event.payload.properties
const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}` const previous = output[output.length - 1]
const index = deltas.get(id) if (
const existing = index === undefined ? undefined : output[index] !previous ||
if (!existing || existing.payload.type !== "message.part.delta") { previous.payload.type !== "message.part.delta" ||
deltas.set(id, output.length) previous.directory !== event.directory ||
previous.payload.properties.messageID !== props.messageID ||
previous.payload.properties.partID !== props.partID ||
previous.payload.properties.field !== props.field
) {
output.push({ output.push({
directory: event.directory, directory: event.directory,
payload: { ...event.payload, properties: { ...props } }, payload: { ...event.payload, properties: { ...props } },
}) })
return return
} }
existing.payload.properties.delta += props.delta output[output.length - 1] = {
directory: event.directory,
payload: {
...event.payload,
properties: { ...props, delta: previous.payload.properties.delta + props.delta },
},
}
}) })
return output return output
} }
@@ -85,20 +107,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
let queue: Queued[] = [] let queue: Queued[] = []
let buffer: Queued[] = [] let buffer: Queued[] = []
const coalesced = new Map<string, number>()
const staleDeltas = new Set<string>()
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
let last = 0 let last = 0
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
if (payload.type === "message.part.updated") {
const part = payload.properties.part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
}
}
const flush = () => { const flush = () => {
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer)
timer = undefined timer = undefined
@@ -106,15 +117,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
if (queue.length === 0) return if (queue.length === 0) return
const events = queue const events = queue
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
queue = buffer queue = buffer
buffer = events buffer = events
queue.length = 0 queue.length = 0
coalesced.clear()
staleDeltas.clear()
last = Date.now() last = Date.now()
const output = coalesceServerEvents(events, skip) const output = coalesceServerEvents(events)
batch(() => { batch(() => {
output.forEach((event) => emitter.emit(event.directory, event.payload)) output.forEach((event) => emitter.emit(event.directory, event.payload))
}) })
@@ -184,29 +192,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
for await (const event of events.stream) { for await (const event of events.stream) {
resetHeartbeat() resetHeartbeat()
streamErrorLogged = false streamErrorLogged = false
const directory = event.directory ?? "global" if (event.payload.type !== "sync") {
if (event.payload.type === "sync") { const directory = event.directory ?? "global"
continue const payload = event.payload as Event
if (enqueueServerEvent(queue, { directory, payload })) schedule()
} }
const payload = event.payload as Event
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now() yielded = Date.now()
await wait(0) await wait(0)
File diff suppressed because it is too large Load Diff
+537 -66
View File
@@ -18,44 +18,67 @@ import { rootSession } from "@/utils/session-route"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 2 const initialMessagePageSize = 2
const historyMessagePageSize = 200 const historyMessagePageSize = 200
const sessionInfoLimit = 2_048 const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set()
type OptimisticItem = { type OptimisticItem = {
message: Message message: Message
parts: Part[] parts: Part[]
confirmedParts?: Part[]
confirmedMessage?: boolean
} }
const hasParts = (parts: Part[] | undefined, want: Part[]) => { type MessagePage = {
if (!parts) return want.length === 0 session: Message[]
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found) part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
} }
function mergeOptimisticPage( // Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
page: { session: Message[]; part: { id: string; part: Part[] }[]; cursor?: string; complete: boolean }, type MessageLoadState = {
items: OptimisticItem[], touchedMessages: Set<string>
) { removedMessages: Set<string>
if (items.length === 0) return { ...page, confirmed: [] as string[] } retainedMessages: Set<string>
touchedParts: Map<string, Set<string>>
deltaParts: Map<string, Set<string>>
carriedDeltaParts: Map<string, Set<string>>
removedParts: Map<string, Set<string>>
optimisticParts: Map<string, Set<string>>
orphanParents: Set<string>
clearedMessageParts: Set<string>
}
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
const session = [...page.session] const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, item.part])) const part = new Map(page.part.map((item) => [item.id, item.part]))
const confirmed: string[] = [] const observed: { messageID: string; parts: Part[] }[] = []
for (const item of items) { for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id) const result = Binary.search(session, item.message.id, (message) => message.id)
if (!result.found) session.splice(result.index, 0, item.message) if (!result.found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id) const current = part.get(item.message.id)
if (result.found && hasParts(current, item.parts)) { const confirmed = result.found
confirmed.push(item.message.id) ? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
continue : []
} if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
part.set(item.message.id, merge(current ?? [], item.parts)) part.set(
item.message.id,
merge(
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
item.parts.filter((part) => !confirmed.includes(part)),
),
)
} }
return { return {
...page, ...page,
session, session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })), part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
confirmed, observed,
} }
} }
@@ -75,7 +98,38 @@ function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
return [...items.values()].sort((x, y) => cmp(x.id, y.id)) return [...items.values()].sort((x, y) => cmp(x.id, y.id))
} }
export function createServerSession(client: OpencodeClient) { function reconcileFetched<T extends { id: string }>(
fetched: T[],
current: readonly T[],
options: {
touched?: ReadonlySet<string>
retained?: ReadonlySet<string>
preserveUnfetched?: boolean | ((item: T) => boolean)
} = {},
) {
const result = new Map(fetched.map((item) => [item.id, item]))
const live = new Map(current.map((item) => [item.id, item]))
if (options.preserveUnfetched) {
for (const item of current) {
if (!result.has(item.id) && (options.preserveUnfetched === true || options.preserveUnfetched(item)))
result.set(item.id, item)
}
}
for (const id of options.retained ?? emptyIDs) {
if (result.has(id)) continue
const item = live.get(id)
if (item) result.set(id, item)
}
// Events observed while the request is pending are the freshest client state for those identities.
for (const id of options.touched ?? emptyIDs) {
const item = live.get(id)
if (item) result.set(id, item)
if (!item) result.delete(id)
}
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
}
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
const [data, setData] = createStore({ const [data, setData] = createStore({
info: {} as Record<string, Session | undefined>, info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>, session_status: {} as Record<string, SessionStatus>,
@@ -95,10 +149,32 @@ export function createServerSession(client: OpencodeClient) {
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 optimistic = new Map<string, Map<string, OptimisticItem>>()
const messageLoads = new Map<string, MessageLoadState>()
const pendingParts = new Map<string, Map<string, Set<string>>>()
const orphanParts = new Map<string, Set<string>>()
const removedMessages = new Map<string, Set<string>>()
const deltaBases = new Map<string, { base: string; sessionID: string }>()
const deleteMessageParts = (
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
messageID: string,
) => {
for (const part of cache.part[messageID] ?? []) {
delete cache.part_text_accum_delta[part.id]
deltaBases.delete(part.id)
}
delete cache.part[messageID]
}
const seen = new Set<string>() const seen = new Set<string>()
const infoSeen = new Set<string>() const infoSeen = new Set<string>()
const pinned = new Map<string, number>() const pinned = new Map<string, number>()
const generations = new Map<string, number>() const generations = new Map<string, object>()
const generation = (sessionID: string) => {
const current = generations.get(sessionID)
if (current) return current
const created = {}
generations.set(sessionID, created)
return created
}
const [meta, setMeta] = createStore({ const [meta, setMeta] = createStore({
limit: {} as Record<string, number | undefined>, limit: {} as Record<string, number | undefined>,
cursor: {} as Record<string, string | undefined>, cursor: {} as Record<string, string | undefined>,
@@ -115,6 +191,11 @@ export function createServerSession(client: OpencodeClient) {
const preserve = new Set([ const preserve = new Set([
...pinned.keys(), ...pinned.keys(),
...requests.keys(), ...requests.keys(),
...inflight.keys(),
...inflightDiff.keys(),
...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(),
...Object.entries(data.permission) ...Object.entries(data.permission)
.filter(([, items]) => items.length > 0) .filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID), .map(([sessionID]) => sessionID),
@@ -138,6 +219,7 @@ export function createServerSession(client: OpencodeClient) {
if (!preserve.has(sessionID)) stale.push(sessionID) if (!preserve.has(sessionID)) stale.push(sessionID)
} }
stale.forEach((sessionID) => infoSeen.delete(sessionID)) stale.forEach((sessionID) => infoSeen.delete(sessionID))
stale.forEach((sessionID) => generations.delete(sessionID))
setData( setData(
"info", "info",
produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])), produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])),
@@ -151,21 +233,27 @@ export function createServerSession(client: OpencodeClient) {
if (cached && !options?.force) return Promise.resolve(cached) if (cached && !options?.force) return Promise.resolve(cached)
const pending = requests.get(sessionID) const pending = requests.get(sessionID)
if (pending) return pending if (pending) return pending
const generation = generations.get(sessionID) ?? 0 const active = generation(sessionID)
const request = client.session.get({ sessionID }).then((result) => { const request = client.session.get({ sessionID }).then((result) => {
if (!result.data) throw new Error(`Session not found: ${sessionID}`) if (!result.data) throw new Error(`Session not found: ${sessionID}`)
if ((generations.get(sessionID) ?? 0) !== generation) return result.data if (generations.get(sessionID) !== active) return result.data
return remember(result.data) return remember(result.data)
}) })
requests.set(sessionID, request) requests.set(sessionID, request)
void request.then( const cleanup = () => {
() => { if (requests.get(sessionID) === request) requests.delete(sessionID)
if (requests.get(sessionID) === request) requests.delete(sessionID) if (
}, generations.get(sessionID) === active &&
() => { !data.info[sessionID] &&
if (requests.get(sessionID) === request) requests.delete(sessionID) !requests.has(sessionID) &&
}, !messageLoads.has(sessionID) &&
) !inflight.has(sessionID) &&
!inflightDiff.has(sessionID) &&
!inflightTodo.has(sessionID)
)
generations.delete(sessionID)
}
void request.then(cleanup, cleanup)
return request return request
} }
@@ -195,15 +283,121 @@ export function createServerSession(client: OpencodeClient) {
if (items.size === 0) optimistic.delete(sessionID) if (items.size === 0) optimistic.delete(sessionID)
} }
const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const parts = item.parts.filter((part) => part.id !== partID)
const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID)
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
}
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const parts = item.parts.filter((value) => value.id !== part.id)
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, {
...item,
parts,
confirmedParts: merge(item.confirmedParts ?? [], [part]),
confirmedMessage: true,
})
}
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const confirmed = new Set(confirmedParts.map((part) => part.id))
const parts = item.parts.filter((part) => !confirmed.has(part.id))
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, {
...item,
parts,
confirmedParts: merge(item.confirmedParts ?? [], confirmedParts),
confirmedMessage: true,
})
}
const trackPartChange = (sessionID: string, messageID: string, partID: string) => {
const load = messageLoads.get(sessionID)
if (!load) return
// A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata.
const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
const parts = load.touchedParts.get(messageID)
if (parts) {
parts.add(partID)
return
}
load.touchedParts.set(messageID, new Set([partID]))
}
const resetMessageLoad = (sessionID: string, load: MessageLoadState) => {
load.touchedMessages.clear()
load.retainedMessages.clear()
load.touchedParts.clear()
load.carriedDeltaParts.clear()
load.clearedMessageParts.clear()
for (const messageID of load.removedMessages) {
load.touchedMessages.add(messageID)
load.clearedMessageParts.add(messageID)
}
for (const [messageID, parts] of load.deltaParts) {
load.touchedParts.set(messageID, new Set(parts))
load.carriedDeltaParts.set(messageID, new Set(parts))
const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
}
for (const [messageID, parts] of load.removedParts) {
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched)
const messages = data.message[sessionID]
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
}
for (const [messageID, parts] of load.optimisticParts) {
load.removedMessages.delete(messageID)
load.clearedMessageParts.add(messageID)
load.touchedMessages.add(messageID)
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched)
}
}
const evict = (sessionIDs: string[]) => { const evict = (sessionIDs: string[]) => {
if (sessionIDs.length === 0) return if (sessionIDs.length === 0) return
const evicted = new Set(sessionIDs)
for (const [partID, item] of deltaBases) {
if (evicted.has(item.sessionID)) deltaBases.delete(partID)
}
sessionIDs.forEach((sessionID) => { sessionIDs.forEach((sessionID) => {
generations.set(sessionID, (generations.get(sessionID) ?? 0) + 1) generations.delete(sessionID)
clearOptimistic(sessionID) clearOptimistic(sessionID)
requests.delete(sessionID) requests.delete(sessionID)
inflight.delete(sessionID) inflight.delete(sessionID)
inflightDiff.delete(sessionID) inflightDiff.delete(sessionID)
inflightTodo.delete(sessionID) inflightTodo.delete(sessionID)
messageLoads.delete(sessionID)
pendingParts.delete(sessionID)
orphanParts.delete(sessionID)
removedMessages.delete(sessionID)
}) })
setData( setData(
produce((draft) => { produce((draft) => {
@@ -230,6 +424,7 @@ export function createServerSession(client: OpencodeClient) {
...inflight.keys(), ...inflight.keys(),
...inflightDiff.keys(), ...inflightDiff.keys(),
...inflightTodo.keys(), ...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(), ...optimistic.keys(),
...Object.entries(data.permission) ...Object.entries(data.permission)
.filter(([, items]) => items.length > 0) .filter(([, items]) => items.length > 0)
@@ -247,8 +442,11 @@ export function createServerSession(client: OpencodeClient) {
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }), pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
) )
const fetchMessages = async (sessionID: string, limit: number, before?: string) => { const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
const response = await retry(() => client.session.messages({ sessionID, limit, before })) const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
return client.session.messages({ sessionID, limit, before })
})
const items = (response.data ?? []).filter((item) => !!item?.info?.id) const items = (response.data ?? []).filter((item) => !!item?.info?.id)
return { return {
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)), session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
@@ -261,30 +459,153 @@ export function createServerSession(client: OpencodeClient) {
} }
} }
const replaceMessages = (sessionID: string, messages: Message[]) => {
const messageIDs = new Set(messages.map((message) => message.id))
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
setData("message", sessionID, reconcile(messages, { key: "id" }))
setData(
produce((draft) => {
for (const message of dropped) deleteMessageParts(draft, message.id)
}),
)
return messageIDs
}
const replaceParts = (
sessionID: string,
items: MessagePage["part"],
messageIDs: Set<string>,
load?: MessageLoadState,
) => {
for (const item of items) {
if (!messageIDs.has(item.id)) continue
const fetched = load?.clearedMessageParts.has(item.id)
? []
: item.part.filter((part) => !SKIP_PARTS.has(part.type))
const fetchedIDs = new Set(fetched.map((part) => part.id))
const pending = pendingParts.get(sessionID)?.get(item.id)
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
for (const part of fetched) {
const accumulated = data.part_text_accum_delta[part.id]
const base = deltaBases.get(part.id)?.base
const preserveDelta =
base !== undefined &&
accumulated !== undefined &&
"text" in part &&
typeof part.text === "string" &&
part.text.startsWith(base) &&
accumulated.startsWith(part.text) &&
accumulated !== part.text
if (preserveDelta) touched.add(part.id)
if (load?.carriedDeltaParts.get(item.id)?.has(part.id) && !preserveDelta) touched.delete(part.id)
}
for (const partID of load?.carriedDeltaParts.get(item.id) ?? []) {
if (!fetchedIDs.has(partID)) touched.delete(partID)
}
const parts = reconcileFetched(fetched, data.part[item.id] ?? [], { touched })
if (!parts.length) {
orphanParts.get(sessionID)?.delete(item.id)
setData(produce((draft) => deleteMessageParts(draft, item.id)))
continue
}
const partIDs = new Set(parts.map((part) => part.id))
setData(
"part_text_accum_delta",
produce((draft) => {
for (const part of data.part[item.id] ?? []) {
if (!partIDs.has(part.id) || !touched.has(part.id)) {
delete draft[part.id]
deltaBases.delete(part.id)
}
}
}),
)
setData("part", item.id, reconcile(parts, { key: "id" }))
orphanParts.get(sessionID)?.delete(item.id)
}
}
const applyMessagePage = (
sessionID: string,
page: MessagePage,
load: MessageLoadState | undefined,
preserveUnfetched: boolean | ((message: Message) => boolean),
cleanupOrphans: boolean,
) => {
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
merged.observed.forEach((item) => {
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
})
const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])])
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
touched: touchedMessages,
retained: load?.retainedMessages,
preserveUnfetched,
})
batch(() => {
const messageIDs = replaceMessages(sessionID, messages)
replaceParts(sessionID, merged.part, messageIDs, load)
const orphans = orphanParts.get(sessionID)
if (cleanupOrphans && page.complete && orphans) {
for (const messageID of orphans) {
if (!messageIDs.has(messageID)) setData(produce((draft) => deleteMessageParts(draft, messageID)))
}
orphanParts.delete(sessionID)
}
setMeta("limit", sessionID, messages.length)
setMeta("cursor", sessionID, merged.cursor)
setMeta("complete", sessionID, merged.complete)
setMeta("at", sessionID, Date.now())
})
}
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => { const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
if (meta.loading[sessionID]) return if (meta.loading[sessionID]) return
const generation = generations.get(sessionID) ?? 0 const active = generation(sessionID)
const load: MessageLoadState = {
touchedMessages: new Set(),
removedMessages: new Set(),
retainedMessages: new Set(),
touchedParts: new Map(),
deltaParts: new Map(),
carriedDeltaParts: new Map(),
removedParts: new Map(),
optimisticParts: new Map(),
orphanParents: new Set(),
clearedMessageParts: new Set(),
}
messageLoads.set(sessionID, load)
setMeta("loading", sessionID, true) setMeta("loading", sessionID, true)
await fetchMessages(sessionID, limit, before) let applied = false
await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
.then((page) => { .then((page) => {
if ((generations.get(sessionID) ?? 0) !== generation) return if (generations.get(sessionID) !== active) return
const next = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])]) const first = page.session.reduce<Message | undefined>(
next.confirmed.forEach((messageID) => clearOptimistic(sessionID, messageID)) (oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
const messages = mode === "prepend" ? merge(data.message[sessionID] ?? [], next.session) : next.session undefined,
batch(() => { )
setData("message", sessionID, reconcile(messages, { key: "id" })) const preserveUnfetched =
for (const item of next.part) { mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
const parts = item.part.filter((part) => !SKIP_PARTS.has(part.type)) applyMessagePage(
if (parts.length) setData("part", item.id, reconcile(parts, { key: "id" })) sessionID,
} page,
setMeta("limit", sessionID, messages.length) messageLoads.get(sessionID) === load ? load : undefined,
setMeta("cursor", sessionID, next.cursor) preserveUnfetched,
setMeta("complete", sessionID, next.complete) mode !== "prepend",
setMeta("at", sessionID, Date.now()) )
}) applied = true
}) })
.finally(() => { .finally(() => {
if ((generations.get(sessionID) ?? 0) === generation) setMeta("loading", sessionID, false) if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
for (const messageID of load.orphanParents) {
if (!orphanParts.get(sessionID)?.has(messageID)) continue
setData(produce((draft) => deleteMessageParts(draft, messageID)))
orphanParts.get(sessionID)?.delete(messageID)
}
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
}
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
}) })
} }
@@ -339,7 +660,13 @@ export function createServerSession(client: OpencodeClient) {
const eventID = eventSessionID(event) const eventID = eventSessionID(event)
if (eventID) { if (eventID) {
touch(eventID) touch(eventID)
if (!data.info[eventID]) void resolve(eventID).catch(() => {}) if (
!data.info[eventID] &&
event.type !== "session.created" &&
event.type !== "session.updated" &&
event.type !== "session.deleted"
)
void resolve(eventID).catch(() => {})
} }
switch (event.type) { switch (event.type) {
case "session.created": case "session.created":
@@ -378,6 +705,21 @@ export function createServerSession(client: OpencodeClient) {
} }
case "message.updated": { case "message.updated": {
const info = cleanMessage((event.properties as { info: Message }).info) const info = cleanMessage((event.properties as { info: Message }).info)
const load = messageLoads.get(info.sessionID)
load?.touchedMessages.add(info.id)
load?.removedMessages.delete(info.id)
const items = optimistic.get(info.sessionID)
const item = items?.get(info.id)
if (items && item) {
if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id)
if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true })
}
const orphans = orphanParts.get(info.sessionID)
orphans?.delete(info.id)
if (orphans?.size === 0) orphanParts.delete(info.sessionID)
const removedMessagesForSession = removedMessages.get(info.sessionID)
removedMessagesForSession?.delete(info.id)
if (removedMessagesForSession?.size === 0) removedMessages.delete(info.sessionID)
const messages = data.message[info.sessionID] const messages = data.message[info.sessionID]
if (!messages) { if (!messages) {
setData("message", info.sessionID, [info]) setData("message", info.sessionID, [info])
@@ -395,6 +737,20 @@ export function createServerSession(client: OpencodeClient) {
} }
case "message.removed": { case "message.removed": {
const props = event.properties as { sessionID: string; messageID: string } const props = event.properties as { sessionID: string; messageID: string }
const load = messageLoads.get(props.sessionID)
load?.touchedMessages.add(props.messageID)
load?.removedMessages.add(props.messageID)
load?.clearedMessageParts.add(props.messageID)
load?.deltaParts.delete(props.messageID)
load?.carriedDeltaParts.delete(props.messageID)
load?.removedParts.delete(props.messageID)
load?.optimisticParts.delete(props.messageID)
pendingParts.get(props.sessionID)?.delete(props.messageID)
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
removedMessagesForSession.add(props.messageID)
removedMessages.set(props.sessionID, removedMessagesForSession)
clearOptimistic(props.sessionID, props.messageID)
setData( setData(
produce((draft) => { produce((draft) => {
const messages = draft.message[props.sessionID] const messages = draft.message[props.sessionID]
@@ -402,8 +758,7 @@ export function createServerSession(client: OpencodeClient) {
const result = Binary.search(messages, props.messageID, (message) => message.id) const result = Binary.search(messages, props.messageID, (message) => message.id)
if (result.found) messages.splice(result.index, 1) if (result.found) messages.splice(result.index, 1)
} }
for (const part of draft.part[props.messageID] ?? []) delete draft.part_text_accum_delta[part.id] deleteMessageParts(draft, props.messageID)
delete draft.part[props.messageID]
}), }),
) )
return return
@@ -411,6 +766,42 @@ export function createServerSession(client: OpencodeClient) {
case "message.part.updated": { case "message.part.updated": {
const part = (event.properties as { part: Part }).part const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) return if (SKIP_PARTS.has(part.type)) return
const messages = data.message[part.sessionID]
const load = messageLoads.get(part.sessionID)
const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found
// Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan.
if (
missing &&
(!load ||
load.clearedMessageParts.has(part.messageID) ||
removedMessages.get(part.sessionID)?.has(part.messageID))
)
return
if (missing) {
const orphans = orphanParts.get(part.sessionID) ?? new Set<string>()
orphans.add(part.messageID)
orphanParts.set(part.sessionID, orphans)
load?.orphanParents.add(part.messageID)
}
const deltas = load?.deltaParts.get(part.messageID)
deltas?.delete(part.id)
if (deltas?.size === 0) load?.deltaParts.delete(part.messageID)
const carried = load?.carriedDeltaParts.get(part.messageID)
carried?.delete(part.id)
if (carried?.size === 0) load?.carriedDeltaParts.delete(part.messageID)
const removed = load?.removedParts.get(part.messageID)
removed?.delete(part.id)
if (removed?.size === 0) load?.removedParts.delete(part.messageID)
const pending = pendingParts.get(part.sessionID)?.get(part.messageID)
pending?.delete(part.id)
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID)
const optimistic = load?.optimisticParts.get(part.messageID)
optimistic?.delete(part.id)
if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID)
deltaBases.delete(part.id)
trackPartChange(part.sessionID, part.messageID, part.id)
confirmOptimisticPart(part.sessionID, part.messageID, part)
setData( setData(
"part_text_accum_delta", "part_text_accum_delta",
produce((draft) => void delete draft[part.id]), produce((draft) => void delete draft[part.id]),
@@ -431,10 +822,34 @@ export function createServerSession(client: OpencodeClient) {
return return
} }
case "message.part.removed": { case "message.part.removed": {
const props = event.properties as { messageID: string; partID: string } const props = event.properties as { sessionID: string; messageID: string; partID: string }
// Part removal is event-only on the server, so its tombstone lasts until a later update or eviction.
const pending = pendingParts.get(props.sessionID) ?? new Map<string, Set<string>>()
const parts = pending.get(props.messageID) ?? new Set<string>()
parts.add(props.partID)
pending.set(props.messageID, parts)
pendingParts.set(props.sessionID, pending)
const deltas = messageLoads.get(props.sessionID)?.deltaParts.get(props.messageID)
deltas?.delete(props.partID)
if (deltas?.size === 0) messageLoads.get(props.sessionID)?.deltaParts.delete(props.messageID)
const load = messageLoads.get(props.sessionID)
const carried = load?.carriedDeltaParts.get(props.messageID)
carried?.delete(props.partID)
if (carried?.size === 0) load?.carriedDeltaParts.delete(props.messageID)
if (load) {
const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
parts.add(props.partID)
load.removedParts.set(props.messageID, parts)
const optimistic = load.optimisticParts.get(props.messageID)
optimistic?.delete(props.partID)
if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID)
}
trackPartChange(props.sessionID, props.messageID, props.partID)
clearOptimisticPart(props.sessionID, props.messageID, props.partID)
setData( setData(
produce((draft) => { produce((draft) => {
delete draft.part_text_accum_delta[props.partID] delete draft.part_text_accum_delta[props.partID]
deltaBases.delete(props.partID)
const parts = draft.part[props.messageID] const parts = draft.part[props.messageID]
if (!parts) return if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id) const result = Binary.search(parts, props.partID, (part) => part.id)
@@ -445,13 +860,31 @@ export function createServerSession(client: OpencodeClient) {
return return
} }
case "message.part.delta": { case "message.part.delta": {
const props = event.properties as { messageID: string; partID: string; field: string; delta: string } const props = event.properties as {
sessionID: string
messageID: string
partID: string
field: string
delta: string
}
const parts = data.part[props.messageID] const parts = data.part[props.messageID]
if (!parts) return if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id) const result = Binary.search(parts, props.partID, (part) => part.id)
if (!result.found) return if (!result.found) return
trackPartChange(props.sessionID, props.messageID, props.partID)
const load = messageLoads.get(props.sessionID)
if (load) {
const parts = load.deltaParts.get(props.messageID) ?? new Set<string>()
parts.add(props.partID)
load.deltaParts.set(props.messageID, parts)
const carried = load.carriedDeltaParts.get(props.messageID)
carried?.delete(props.partID)
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
}
const field = props.field as keyof (typeof parts)[number] const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field] const current = parts[result.index]?.[field]
if (!deltaBases.has(props.partID) && typeof current === "string")
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
setData( setData(
"part_text_accum_delta", "part_text_accum_delta",
props.partID, props.partID,
@@ -559,32 +992,70 @@ export function createServerSession(client: OpencodeClient) {
}, },
optimistic: { optimistic: {
add(input: { sessionID: string; message: Message; parts: Part[] }) { add(input: { sessionID: string; message: Message; parts: Part[] }) {
const parts = input.parts
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
.sort((a, b) => cmp(a.id, b.id))
const load = messageLoads.get(input.sessionID)
if (load?.clearedMessageParts.has(input.message.id)) {
const touched = load.touchedParts.get(input.message.id) ?? new Set<string>()
parts.forEach((part) => touched.add(part.id))
load.touchedParts.set(input.message.id, touched)
}
if (load) {
load.removedMessages.delete(input.message.id)
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id)))
}
const items = optimistic.get(input.sessionID) const items = optimistic.get(input.sessionID)
if (items) items.set(input.message.id, input) const removedMessagesForSession = removedMessages.get(input.sessionID)
if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, input]])) removedMessagesForSession?.delete(input.message.id)
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID)
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
if (!items)
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message])) setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
setData( setData(
"part", "part_text_accum_delta",
input.message.id, produce((draft) => {
input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), for (const part of [...(data.part[input.message.id] ?? []), ...parts]) {
delete draft[part.id]
deltaBases.delete(part.id)
}
}),
) )
setData("part", input.message.id, parts)
}, },
remove(input: { sessionID: string; messageID: string }) { remove(input: { sessionID: string; messageID: string }) {
const item = optimistic.get(input.sessionID)?.get(input.messageID)
if (!item) return
messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID)
clearOptimistic(input.sessionID, input.messageID) clearOptimistic(input.sessionID, input.messageID)
if (item.confirmedMessage) {
const partIDs = new Set(item.parts.map((part) => part.id))
setData(
produce((draft) => {
for (const part of item.parts) {
delete draft.part_text_accum_delta[part.id]
deltaBases.delete(part.id)
}
const parts = draft.part[input.messageID]
if (!parts) return
draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id))
if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID]
}),
)
return
}
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID)) setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
setData( setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
"part",
produce((draft) => void delete draft[input.messageID]),
)
}, },
}, },
diff(sessionID: string, options?: { force?: boolean }) { diff(sessionID: string, options?: { force?: boolean }) {
touch(sessionID) touch(sessionID)
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve() if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
return runInflight(inflightDiff, sessionID, () => { return runInflight(inflightDiff, sessionID, () => {
const generation = generations.get(sessionID) ?? 0 const active = generation(sessionID)
return retry(() => client.session.diff({ sessionID })).then((result) => { return retry(() => client.session.diff({ sessionID })).then((result) => {
if ((generations.get(sessionID) ?? 0) !== generation) return if (generations.get(sessionID) !== active) return
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" })) setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
}) })
}) })
@@ -593,9 +1064,9 @@ export function createServerSession(client: OpencodeClient) {
touch(sessionID) touch(sessionID)
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve() if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
return runInflight(inflightTodo, sessionID, () => { return runInflight(inflightTodo, sessionID, () => {
const generation = generations.get(sessionID) ?? 0 const active = generation(sessionID)
return retry(() => client.session.todo({ sessionID })).then((result) => { return retry(() => client.session.todo({ sessionID })).then((result) => {
if ((generations.get(sessionID) ?? 0) !== generation) return if (generations.get(sessionID) !== active) return
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" })) setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
}) })
}) })
+22 -1
View File
@@ -1,4 +1,11 @@
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse } from "@opencode-ai/sdk/v2/client" import type {
Config,
McpResource,
OpencodeClient,
Path,
Project,
ProviderAuthResponse,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
@@ -15,6 +22,7 @@ import {
loadPathQuery, loadPathQuery,
loadProjectsQuery, loadProjectsQuery,
loadProvidersQuery, loadProvidersQuery,
loadReferencesQuery,
} from "./global-sync/bootstrap" } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store" import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
@@ -56,6 +64,13 @@ export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: Opencod
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
}) })
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions<Record<string, McpResource>>({
queryKey: [scope, directory, "mcpResources"] as const,
queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
placeholderData: {},
})
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "lsp"] as const, queryKey: [scope, directory, "lsp"] as const,
@@ -75,7 +90,9 @@ function makeQueryOptionsApi(
path: (directory: PathKey | null) => path: (directory: PathKey | null) =>
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)), agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)), mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
} }
@@ -396,6 +413,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
loadLsp: () => { loadLsp: () => {
void queryClient.fetchQuery(queryOptionsApi.lsp(key)) void queryClient.fetchQuery(queryOptionsApi.lsp(key))
}, },
loadReferences: () => {
void queryClient.fetchQuery(queryOptionsApi.references(key))
},
}) })
}) })
@@ -484,6 +504,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}, },
refresh: async () => { refresh: async () => {
await queryClient.refetchQueries(queryOptionsApi.mcp(key)) await queryClient.refetchQueries(queryOptionsApi.mcp(key))
await queryClient.refetchQueries(queryOptionsApi.mcpResources(key))
}, },
}) })
}, },
+25 -21
View File
@@ -138,12 +138,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const next = { type: "session" as const, ...tab } const next = { type: "session" as const, ...tab }
const existing = store.find((item) => tabKey(item) === tabKey(next)) const existing = store.find((item) => tabKey(item) === tabKey(next))
if (existing) return existing if (existing) return existing
setStore( void startTransition(() => {
produce((tabs) => { setStore(
if (tabs.some((item) => tabKey(item) === tabKey(next))) return produce((tabs) => {
tabs.push(next) if (tabs.some((item) => tabKey(item) === tabKey(next))) return
}), tabs.push(next)
) }),
)
})
return next return next
}, },
reorder(keys: string[]) { reorder(keys: string[]) {
@@ -163,27 +165,29 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}, },
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) { newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
const draftID = uuid() const draftID = uuid()
setStore( void startTransition(() => {
produce((tabs) => { setStore(
tabs.push({ type: "draft", draftID, ...draft }) produce((tabs) => {
}), tabs.push({ type: "draft", draftID, ...draft })
) }),
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID)) )
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
})
}, },
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) { updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
setStore( void startTransition(() => {
(tab) => tab.type === "draft" && tab.draftID === draftID, setStore(
produce((tab) => Object.assign(tab, draft)), (tab) => tab.type === "draft" && tab.draftID === draftID,
) produce((tab) => Object.assign(tab, draft)),
)
})
}, },
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) { promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
// We're viewing this draft when /new-session?draftId=… points at it. Promoting // Keep the replacement and navigation atomic so /new-session never renders
// replaces the draft tab with a session tab, so the draft route would stop resolving // after its backing draft tab has been removed from the store.
// and fall back home. Navigate to the new session first so we leave /new-session
// before the draft is removed from the store.
const active = location.pathname === "/new-session" && location.query.draftId === draftID const active = location.pathname === "/new-session" && location.query.draftId === draftID
const next = { type: "session" as const, ...session } const next = { type: "session" as const, ...session }
startTransition(() => { void startTransition(() => {
setStore( setStore(
produce((tabs) => { produce((tabs) => {
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID) const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test"
import { DESKTOP_MENU } from "./desktop-menu"
describe("desktop menu", () => {
test("exports logs through the desktop command registry", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && item.label === "Export Logs...",
)
expect(items).toHaveLength(2)
expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true)
})
})
+96 -3
View File
@@ -1,6 +1,7 @@
@import "@opencode-ai/ui/styles/tailwind"; @import "@opencode-ai/ui/styles/tailwind";
@import "@opencode-ai/session-ui/styles"; @import "@opencode-ai/session-ui/styles";
@import "@opencode-ai/ui/v2/styles/tailwind.css"; @import "@opencode-ai/ui/v2/styles/tailwind.css";
@import "tw-animate-css";
@font-face { @font-face {
font-family: "JetBrainsMono Nerd Font Mono"; font-family: "JetBrainsMono Nerd Font Mono";
@@ -107,6 +108,45 @@
} }
} }
.home-session-group-header::before {
content: "";
position: absolute;
top: -12px;
left: 0;
width: 100%;
height: 12px;
background: var(--v2-background-bg-base);
}
.home-session-group-header::after {
content: "";
position: absolute;
top: 100%;
left: 0;
width: 100%;
height: 16px;
pointer-events: none;
background: linear-gradient(
180deg,
var(--v2-background-bg-base) 0%,
color-mix(in srgb, var(--v2-background-bg-base) 92.0456%, transparent) 7.93%,
color-mix(in srgb, var(--v2-background-bg-base) 84.9947%, transparent) 14.14%,
color-mix(in srgb, var(--v2-background-bg-base) 78.6813%, transparent) 19%,
color-mix(in srgb, var(--v2-background-bg-base) 72.9394%, transparent) 22.85%,
color-mix(in srgb, var(--v2-background-bg-base) 67.6028%, transparent) 26.05%,
color-mix(in srgb, var(--v2-background-bg-base) 62.5055%, transparent) 28.95%,
color-mix(in srgb, var(--v2-background-bg-base) 57.4815%, transparent) 31.91%,
color-mix(in srgb, var(--v2-background-bg-base) 52.3647%, transparent) 35.27%,
color-mix(in srgb, var(--v2-background-bg-base) 46.989%, transparent) 39.4%,
color-mix(in srgb, var(--v2-background-bg-base) 41.1884%, transparent) 44.65%,
color-mix(in srgb, var(--v2-background-bg-base) 34.7969%, transparent) 51.36%,
color-mix(in srgb, var(--v2-background-bg-base) 27.6484%, transparent) 59.9%,
color-mix(in srgb, var(--v2-background-bg-base) 19.5767%, transparent) 70.62%,
color-mix(in srgb, var(--v2-background-bg-base) 10.416%, transparent) 83.87%,
transparent 100%
);
}
[data-slot="titlebar-update-loader"] { [data-slot="titlebar-update-loader"] {
display: block; display: block;
flex-shrink: 0; flex-shrink: 0;
@@ -132,12 +172,65 @@
} }
} }
@keyframes fade-in { @keyframes home-projects-fade-top {
from { from {
opacity: 0; visibility: hidden;
} }
to { to {
opacity: 1; visibility: visible;
}
}
@keyframes home-projects-fade-bottom {
from {
visibility: visible;
}
to {
visibility: hidden;
}
}
[data-slot="home-projects-scroll"] {
timeline-scope: --home-projects-scroll;
}
[data-slot="home-projects-scroll"]::before,
[data-slot="home-projects-scroll"]::after {
content: "";
position: absolute;
left: 0;
right: 0;
z-index: 10;
height: 16px;
pointer-events: none;
visibility: hidden;
}
[data-slot="home-projects-scroll"]::before {
top: 0;
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
}
[data-slot="home-projects-scroll"]::after {
bottom: 0;
background: linear-gradient(to top, var(--v2-background-bg-base), transparent);
}
@supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) {
[data-slot="home-projects-scroll"] .scroll-view__viewport {
scroll-timeline: --home-projects-scroll y;
}
[data-slot="home-projects-scroll"]::before {
animation: home-projects-fade-top linear both;
animation-timeline: --home-projects-scroll;
animation-range: 0 0.1px;
}
[data-slot="home-projects-scroll"]::after {
animation: home-projects-fade-bottom linear both;
animation-timeline: --home-projects-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
} }
} }
} }
+4 -1
View File
@@ -276,7 +276,10 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
} }
return ( return (
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans"> <div
class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans"
data-tauri-drag-region
>
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8"> <div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
<Logo class="w-58.5 opacity-12 shrink-0" /> <Logo class="w-58.5 opacity-12 shrink-0" />
<div class="flex flex-col items-center gap-2 text-center"> <div class="flex flex-col items-center gap-2 text-center">
+266 -113
View File
@@ -1,5 +1,6 @@
import type { Session } from "@opencode-ai/sdk/v2/client" import type { Session } from "@opencode-ai/sdk/v2/client"
import { import {
type ComponentProps,
createEffect, createEffect,
createMemo, createMemo,
createResource, createResource,
@@ -68,6 +69,10 @@ import { archiveHomeSession } from "./home-session-archive"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
const HOME_SESSION_LIMIT = 64 const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_ROW_LAYOUT = const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none" "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0` const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
@@ -132,6 +137,107 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}` return `${pathKey(record.session.directory)}:${record.session.id}`
} }
function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
let viewport: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
let positionFrame: number | undefined
let resizeObserver: ResizeObserver | undefined
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
const [state, setState] = createStore({
titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
})
createEffect(() => {
const items = groups()
const ids = new Set(items.map((group) => group.id))
headerRefs.forEach((_, id) => {
if (!ids.has(id)) headerRefs.delete(id)
})
headerOffsets.forEach((_, id) => {
if (!ids.has(id)) headerOffsets.delete(id)
})
if (items.length === 0) {
content = undefined
bindResizeObserver()
}
queuePositionUpdate()
})
onCleanup(() => {
if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
resizeObserver?.disconnect()
})
function setViewport(el: HTMLDivElement) {
viewport = el
bindResizeObserver()
queuePositionUpdate()
}
function setContentRef(el: HTMLDivElement) {
content = el
bindResizeObserver()
queuePositionUpdate()
}
function setHeaderRef(id: HomeSessionGroup["id"], el: HTMLDivElement) {
headerRefs.set(id, el)
queuePositionUpdate()
}
function queuePositionUpdate() {
if (typeof requestAnimationFrame === "undefined") {
updatePositionCache()
return
}
if (positionFrame !== undefined) return
positionFrame = requestAnimationFrame(() => {
positionFrame = undefined
updatePositionCache()
})
}
function updatePositionCache() {
if (!viewport) return
groups().forEach((group) => {
const el = headerRefs.get(group.id)
if (!el) return
headerOffsets.set(group.id, el.offsetTop)
})
update(viewport.scrollTop)
}
function update(scrollTop: number) {
const items = groups()
items.forEach((group, index) => {
const nextOffset = items
.slice(index + 1)
.map((item) => headerOffsets.get(item.id))
.find((offset) => offset !== undefined)
const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
const opacity =
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
})
}
function titleOpacity(id: HomeSessionGroup["id"]) {
return state.titleOpacity[id] ?? 1
}
function bindResizeObserver() {
resizeObserver?.disconnect()
if (typeof ResizeObserver === "undefined") return
resizeObserver = new ResizeObserver(() => queuePositionUpdate())
if (viewport) resizeObserver.observe(viewport)
if (content) resizeObserver.observe(content)
}
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
}
export function NewHome() { export function NewHome() {
const sync = useServerSync() const sync = useServerSync()
const layout = useLayout() const layout = useLayout()
@@ -222,6 +328,7 @@ export function NewHome() {
}) })
const searchOpen = createMemo(() => state.searchFocused && search().length > 0) const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
const groups = createMemo(() => groupSessions(records(), language)) const groups = createMemo(() => groupSessions(records(), language))
const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups)
const prefetched = new Set<string>() const prefetched = new Set<string>()
createEffect(() => { createEffect(() => {
@@ -301,6 +408,7 @@ export function NewHome() {
function selectProject(conn: ServerConnection.Any, directory: string) { function selectProject(conn: ServerConnection.Any, directory: string) {
const key = ServerConnection.key(conn) const key = ServerConnection.key(conn)
if (global.servers.health[key]?.healthy === false) return
if ( if (
!global !global
.ensureServerCtx(conn) .ensureServerCtx(conn)
@@ -341,15 +449,15 @@ export function NewHome() {
} }
function unseenCount(conn: ServerConnection.Any, project: LocalProject) { function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
if (ServerConnection.key(conn) !== server.key) return 0 const state = notification.ensureServerState(ServerConnection.key(conn))
return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0) return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
} }
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) { function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
if (ServerConnection.key(conn) !== server.key) return const state = notification.ensureServerState(ServerConnection.key(conn))
directories(project) directories(project)
.filter((directory) => notification.project.unseenCount(directory) > 0) .filter((directory) => state.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory)) .forEach((directory) => state.project.markViewed(directory))
} }
function openSession(session: Session) { function openSession(session: Session) {
@@ -407,7 +515,7 @@ export function NewHome() {
return ( return (
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1"> <div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 pb-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 lg:pb-16"> <div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
<HomeProjectColumn <HomeProjectColumn
projects={projects()} projects={projects()}
selected={selection()} selected={selection()}
@@ -433,7 +541,7 @@ export function NewHome() {
/> />
<section <section
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12" class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative"
aria-label={language.t("sidebar.project.recentSessions")} aria-label={language.t("sidebar.project.recentSessions")}
> >
<HomeSessionSearch <HomeSessionSearch
@@ -454,7 +562,25 @@ export function NewHome() {
onClose={closeSearch} onClose={closeSearch}
onSelect={selectSearchSession} onSelect={selectSearchSession}
/> />
<ScrollView class="mt-3 min-h-0 flex-1"> <ScrollView
class="mt-3 -mr-3 min-h-0 flex-1 relative"
viewportRef={sessionHeaderOpacity.setViewport}
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
>
<Show when={groups().length > 0 && newSessionProject()}>
<div class="pointer-events-none absolute top-3 right-3 z-20 flex">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={openNewSession}
>
{language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
<Show <Show
when={!sessionLoad.isLoading} when={!sessionLoad.isLoading}
fallback={ fallback={
@@ -467,15 +593,19 @@ export function NewHome() {
when={groups().length > 0} when={groups().length > 0}
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />} fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
> >
<div class="pt-3 flex flex-col gap-6"> <div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
<For each={groups()}> <For each={groups()}>
{(group, index) => ( {(group, index) => (
<div class="flex min-w-0 flex-col gap-4"> <>
<HomeSessionGroupHeader <HomeSessionGroupHeader
title={group.title} title={group.title}
onNewSession={index() === 0 && newSessionProject() ? openNewSession : undefined} titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
elevated={index() === 0}
/> />
<div class="flex min-w-0 flex-col gap-px"> <div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}> <For each={group.sessions}>
{(record) => ( {(record) => (
<HomeSessionRow <HomeSessionRow
@@ -489,7 +619,7 @@ export function NewHome() {
)} )}
</For> </For>
</div> </div>
</div> </>
)} )}
</For> </For>
</div> </div>
@@ -538,10 +668,10 @@ function HomeProjectColumn(props: {
return ( return (
<aside <aside
class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]" class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]"
aria-label={props.language.t("home.projects")} aria-label={props.language.t("home.projects")}
> >
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5"> <div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div> <div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<Show when={global.servers.list().length === 1}> <Show when={global.servers.list().length === 1}>
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}> <TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
@@ -558,42 +688,51 @@ function HomeProjectColumn(props: {
</TooltipV2> </TooltipV2>
</Show> </Show>
</div> </div>
<Show <ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
when={global.servers.list().length > 1} <Show
fallback={<HomeProjectList {...props} server={global.servers.list()[0]!} />} when={global.servers.list().length > 1}
> fallback={
<For each={global.servers.list()}> <div class="pr-3">
{(item) => { <HomeProjectList {...props} server={global.servers.list()[0]!} />
const key = ServerConnection.key(item) </div>
const healthy = () => !!global.servers.health[key]?.healthy }
const serverCtx = global.ensureServerCtx(item) >
const collapsed = () => !!state().collapsed[key] <div class="flex min-w-0 flex-col gap-1 pr-3">
return ( <For each={global.servers.list()}>
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> {(item) => {
<HomeServerRow const key = ServerConnection.key(item)
server={item} const healthy = () => !!global.servers.health[key]?.healthy
selected={props.selected.server === key && !props.selected.directory} const serverCtx = global.ensureServerCtx(item)
healthy={healthy()} const projects = () => serverCtx.projects.list()
collapsed={collapsed()} const hasProjects = () => projects().length > 0
health={global.servers.health[key]} const collapsed = () => !!state().collapsed[key]
controller={controller} return (
focusServer={props.focusServer} <div class="flex min-w-0 flex-col gap-1">
chooseProject={props.chooseProject} <HomeServerRow
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)} server={item}
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])} selected={props.selected.server === key && !props.selected.directory}
language={props.language} collapsed={collapsed()}
/> health={global.servers.health[key]}
<Show when={healthy() && !collapsed()}> controller={controller}
<div class="mx-3 h-px bg-v2-border-border-base" /> focusServer={props.focusServer}
<HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} /> chooseProject={props.chooseProject}
</Show> openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
</div> toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
) language={props.language}
}} />
</For> <Show when={healthy() && hasProjects() && !collapsed()}>
</Show> <div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList {...props} server={item} projects={projects()} />
</Show>
</div>
)
}}
</For>
</div>
</Show>
</ScrollView>
<HomeUtilityNav <HomeUtilityNav
class="mt-4 hidden lg:flex" class="mb-8 mt-4 hidden shrink-0 lg:flex"
openSettings={props.openSettings} openSettings={props.openSettings}
openHelp={props.openHelp} openHelp={props.openHelp}
language={props.language} language={props.language}
@@ -633,7 +772,6 @@ function HomeUtilityNav(props: {
function HomeServerRow(props: { function HomeServerRow(props: {
server: ServerConnection.Any server: ServerConnection.Any
selected: boolean selected: boolean
healthy: boolean
collapsed: boolean collapsed: boolean
health: ServerHealth | undefined health: ServerHealth | undefined
controller: ReturnType<typeof useServerManagementController> controller: ReturnType<typeof useServerManagementController>
@@ -643,39 +781,46 @@ function HomeServerRow(props: {
toggleCollapsed: () => void toggleCollapsed: () => void
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}) { }) {
const global = useGlobal()
const [state, setState] = createStore({ menuOpen: false }) const [state, setState] = createStore({ menuOpen: false })
const healthy = () => !!props.health?.healthy
const canToggle = () => healthy() && global.ensureServerCtx(props.server).projects.list().length > 0
return ( return (
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]"> <div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
<button <button
type="button" type="button"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`} class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
data-selected={props.selected ? "" : undefined} data-selected={props.selected ? "" : undefined}
disabled={!props.healthy} disabled={!healthy()}
onClick={() => props.focusServer(props.server)} onClick={() => props.focusServer(props.server)}
> >
<Show when={props.healthy}> <span
<span data-action="home-server-collapse"
data-action="home-server-collapse" class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted"
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover" classList={{
aria-label={ "hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse") "cursor-default opacity-40": !canToggle(),
} }}
aria-expanded={!props.collapsed} aria-label={
onClick={(event) => { props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
event.preventDefault() }
event.stopPropagation() aria-disabled={!canToggle()}
props.toggleCollapsed() aria-expanded={canToggle() ? !props.collapsed : undefined}
}} onClick={(event) => {
onPointerDown={(event) => event.preventDefault()} event.preventDefault()
> event.stopPropagation()
<IconV2 if (!canToggle()) return
name="chevron-down" props.toggleCollapsed()
size="small" }}
class="transition-transform duration-150 ease-in-out" onPointerDown={(event) => event.preventDefault()}
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }} >
/> <IconV2
</span> name="chevron-down"
</Show> size="small"
class="transition-transform duration-150 ease-in-out"
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
/>
</span>
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5"> <div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
<ServerHealthIndicator health={props.health} /> <ServerHealthIndicator health={props.health} />
</div> </div>
@@ -766,15 +911,18 @@ function HomeProjectRow(props: {
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}) { }) {
const global = useGlobal()
const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
const [state, setState] = createStore({ menuOpen: false }) const [state, setState] = createStore({ menuOpen: false })
return ( return (
<div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"> <div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]">
<button <button
type="button" type="button"
data-component="home-project-row" data-component="home-project-row"
class={`${HOME_PROJECT_NAV_ROW} pr-16`} class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
data-selected={props.selected ? "" : undefined} data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined} aria-current={props.selected ? "page" : undefined}
disabled={serverUnreachable()}
onClick={() => props.selectProject(props.server, props.project.worktree)} onClick={() => props.selectProject(props.server, props.project.worktree)}
> >
<HomeProjectAvatar project={props.project} /> <HomeProjectAvatar project={props.project} />
@@ -849,6 +997,7 @@ function HomeSessionLeading(props: {
session: Session session: Session
server: ServerConnection.Key server: ServerConnection.Key
activeServer: boolean activeServer: boolean
revealProjectOnHover: boolean
}) { }) {
const tabs = useTabs() const tabs = useTabs()
const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session)) const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
@@ -866,6 +1015,7 @@ function HomeSessionLeading(props: {
directory={props.session.directory} directory={props.session.directory}
sessionId={props.session.id} sessionId={props.session.id}
activeServer={props.activeServer} activeServer={props.activeServer}
revealProjectOnHover={props.revealProjectOnHover}
/> />
</div> </div>
) )
@@ -956,7 +1106,7 @@ function HomeSessionSearch(props: {
return ( return (
<div class="w-full"> <div class="w-full">
<div ref={root} data-component="home-session-search" class="relative z-10 w-full"> <div ref={root} data-component="home-session-search" class="relative z-30 w-full">
<Show when={props.open}> <Show when={props.open}>
<div <div
data-component="home-session-search-panel" data-component="home-session-search-panel"
@@ -964,7 +1114,7 @@ function HomeSessionSearch(props: {
style={{ style={{
top: "-6px", top: "-6px",
left: "-6px", left: "-6px",
width: "calc(100% + 14px)", width: "calc(100% + 12px)",
}} }}
> >
<div class="flex flex-col pt-9"> <div class="flex flex-col pt-9">
@@ -1105,6 +1255,7 @@ function HomeSessionSearchResultRow(props: {
classList={{ classList={{
[HOME_SEARCH_RESULT_ROW]: true, [HOME_SEARCH_RESULT_ROW]: true,
"bg-v2-overlay-simple-overlay-hover": props.selected, "bg-v2-overlay-simple-overlay-hover": props.selected,
group: !!showProjectName(),
}} }}
onMouseEnter={() => props.onHighlight()} onMouseEnter={() => props.onHighlight()}
onClick={() => props.onSelect(props.record.session)} onClick={() => props.onSelect(props.record.session)}
@@ -1114,6 +1265,7 @@ function HomeSessionSearchResultRow(props: {
session={props.record.session} session={props.record.session}
server={props.server} server={props.server}
activeServer={props.activeServer} activeServer={props.activeServer}
revealProjectOnHover={!!showProjectName()}
/> />
<div class="flex min-w-0 flex-1 items-center gap-1.5"> <div class="flex min-w-0 flex-1 items-center gap-1.5">
<span <span
@@ -1129,25 +1281,20 @@ function HomeSessionSearchResultRow(props: {
) )
} }
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) { function HomeSessionGroupHeader(props: {
const language = useLanguage() title: string
titleOpacity: number
ref: ComponentProps<"div">["ref"]
elevated?: boolean
}) {
return ( return (
<div class="flex h-7 min-w-0 items-center justify-between pl-[18px]"> <div
<div class={HOME_SECTION_LABEL}>{props.title}</div> ref={props.ref}
<Show when={props.onNewSession}> class={`pointer-events-none sticky top-3 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
{(onNewSession) => ( >
<ButtonV2 <div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
data-action="home-new-session" {props.title}
variant="ghost-muted" </div>
size="normal"
icon="edit"
class="h-7 px-2 [font-weight:530]"
onClick={onNewSession()}
>
{language.t("command.session.new")}
</ButtonV2>
)}
</Show>
</div> </div>
) )
} }
@@ -1165,7 +1312,10 @@ function HomeSessionRow(props: {
const showProjectName = () => props.showProjectName && props.record.projectName const showProjectName = () => props.showProjectName && props.record.projectName
return ( return (
<div class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"> <div
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
classList={{ group: !!showProjectName() }}
>
<button <button
type="button" type="button"
data-component="home-session-row" data-component="home-session-row"
@@ -1177,6 +1327,7 @@ function HomeSessionRow(props: {
session={props.record.session} session={props.record.session}
server={props.server} server={props.server}
activeServer={props.activeServer} activeServer={props.activeServer}
revealProjectOnHover={!!showProjectName()}
/> />
<span <span
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`} class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
@@ -1189,22 +1340,24 @@ function HomeSessionRow(props: {
</span> </span>
</Show> </Show>
</button> </button>
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100"> <Show when={SHOW_HOME_SESSION_ARCHIVE}>
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}> <div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
<IconButtonV2 <TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
data-action="home-session-archive" <IconButtonV2
variant="ghost-muted" data-action="home-session-archive"
size="large" variant="ghost-muted"
icon={<IconV2 name="archive" />} size="large"
aria-label={language.t("common.archive")} icon={<IconV2 name="archive" />}
onClick={(event) => { aria-label={language.t("common.archive")}
event.preventDefault() onClick={(event) => {
event.stopPropagation() event.preventDefault()
void props.archiveSession(props.record.session) event.stopPropagation()
}} void props.archiveSession(props.record.session)
/> }}
</TooltipV2> />
</div> </TooltipV2>
</div>
</Show>
</div> </div>
) )
} }
+2 -10
View File
@@ -1,26 +1,18 @@
import { createEffect, Suspense, type ParentProps } from "solid-js" import { createEffect, Suspense, type ParentProps } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar" import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button" import { HelpButton } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useNotification } from "@/context/notification"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click" import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast" import { setV2Toast, ToastRegion } from "@/utils/toast"
export default function NewLayout(props: ParentProps) { export default function NewLayout(props: ParentProps) {
const platform = usePlatform() const platform = usePlatform()
const notification = useNotification()
const navigate = useNavigate() const navigate = useNavigate()
const params = useParams<{ id?: string }>()
setNavigate(navigate) setNavigate(navigate)
createEffect(() => setV2Toast(true)) createEffect(() => setV2Toast(true))
createEffect(() => {
if (!notification.ready() || !params.id) return
if (notification.session.unseenCount(params.id) === 0) return
notification.session.markViewed(params.id)
})
const update: TitlebarUpdate = { const update: TitlebarUpdate = {
version: () => { version: () => {
@@ -44,7 +36,7 @@ export default function NewLayout(props: ParentProps) {
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict"> <main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Suspense>{props.children}</Suspense> <Suspense>{props.children}</Suspense>
</main> </main>
{import.meta.env.DEV && <DebugBar />} {import.meta.env.DEV && <DebugBar inline />}
<HelpButton /> <HelpButton />
<ToastRegion v2 /> <ToastRegion v2 />
</div> </div>
-12
View File
@@ -943,18 +943,6 @@ export default function LegacyLayout(props: ParentProps) {
keybind: "mod+comma", keybind: "mod+comma",
onSelect: () => openSettings(), onSelect: () => openSettings(),
}, },
...(platform.platform === "desktop" && platform.exportDebugLogs
? [
{
id: "logs.export",
title: "Export logs",
category: language.t("command.category.settings"),
onSelect: () => {
void platform.exportDebugLogs?.()
},
},
]
: []),
{ {
id: "session.previous", id: "session.previous",
title: language.t("command.session.previous"), title: language.t("command.session.previous"),
@@ -11,32 +11,28 @@ export function SessionTabAvatar(props: {
directory: string directory: string
sessionId: string sessionId: string
activeServer: boolean activeServer: boolean
revealProjectOnHover?: boolean
}) { }) {
const directory = () => props.directory const directory = () => props.directory
const sessionId = () => props.sessionId const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer) const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
const projectAvatar = () => (
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
/>
)
return ( return (
<Show <Show when={state.loading()} fallback={projectAvatar()}>
when={state.loading()}
fallback={
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
/>
}
>
<span class="relative block size-4 shrink-0"> <span class="relative block size-4 shrink-0">
<SessionProgressIndicatorV2 class="absolute inset-0 group-hover:invisible" /> <SessionProgressIndicatorV2
<span class="invisible absolute inset-0 group-hover:visible"> class={`absolute inset-0 ${props.revealProjectOnHover === false ? "" : "group-hover:invisible"}`}
<ProjectAvatar />
fallback={displayName(props.project ?? { worktree: props.directory })} <Show when={props.revealProjectOnHover !== false}>
src={getProjectAvatarSource(props.project?.id, props.project?.icon)} <span class="invisible absolute inset-0 group-hover:visible">{projectAvatar()}</span>
variant={getProjectAvatarVariant(props.project?.icon?.color)} </Show>
unread={state.unread()}
/>
</span>
</span> </span>
</Show> </Show>
) )
@@ -125,7 +125,7 @@ export function SessionComposerRegion(props: {
</Show> </Show>
<div <div
classList={{ classList={{
"relative z-30": true, "relative z-[70]": true,
}} }}
style={{ style={{
"margin-top": `${-controller.lift()}px`, "margin-top": `${-controller.lift()}px`,
@@ -31,9 +31,14 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { InlineInput } from "@opencode-ai/ui/inline-input" import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SessionRetry } from "@opencode-ai/session-ui/session-retry" import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
@@ -671,6 +676,34 @@ export function MessageTimeline(props: {
if (!shareEnabled()) return if (!shareEnabled()) return
unshareMutation.mutate(id) unshareMutation.mutate(id)
} }
const copyShareUrl = () => {
const url = shareUrl()
if (!url) return
void navigator.clipboard
.writeText(url)
.then(() =>
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: url,
}),
)
.catch((err: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
}),
)
}
const selectShareUrlText: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {
const selection = window.getSelection()
if (!selection) return
const range = document.createRange()
range.selectNodeContents(event.currentTarget)
selection.removeAllRanges()
selection.addRange(range)
}
createEffect( createEffect(
on( on(
@@ -703,8 +736,9 @@ export function MessageTimeline(props: {
if (!sessionID() || parentID()) return if (!sessionID() || parentID()) return
setTitle({ editing: true, draft: titleLabel() ?? "" }) setTitle({ editing: true, draft: titleLabel() ?? "" })
requestAnimationFrame(() => { requestAnimationFrame(() => {
titleRef?.focus() if (!titleRef) return
titleRef?.select() titleRef.focus()
titleRef.select()
}) })
} }
@@ -856,6 +890,26 @@ export function MessageTimeline(props: {
dialog.close() dialog.close()
} }
if (settings.general.newLayoutDesigns())
return (
<DialogV2 fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", { name: name() })}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="danger" onClick={handleDelete}>
{language.t("session.delete.button")}
</ButtonV2>
</DialogFooter>
</DialogV2>
)
return ( return (
<Dialog title={language.t("session.delete.title")} fit> <Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3"> <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
@@ -960,6 +1014,7 @@ export function MessageTimeline(props: {
message={message()} message={message()}
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)} showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
turnDurationMs={turnDurationMs(row().userMessageID)} turnDurationMs={turnDurationMs(row().userMessageID)}
useV2Actions={settings.general.newLayoutDesigns()}
defaultOpen={defaultOpen()} defaultOpen={defaultOpen()}
toolOpen={toolOpen[part().id] ?? defaultOpen()} toolOpen={toolOpen[part().id] ?? defaultOpen()}
onToolOpenChange={(open) => setToolOpen(part().id, open)} onToolOpenChange={(open) => setToolOpen(part().id, open)}
@@ -1067,6 +1122,7 @@ export function MessageTimeline(props: {
message={message()} message={message()}
parts={getMsgParts(userMessageRow().userMessageID)} parts={getMsgParts(userMessageRow().userMessageID)}
actions={props.actions} actions={props.actions}
useV2Actions={settings.general.newLayoutDesigns()}
/> />
</div> </div>
</div> </div>
@@ -1302,14 +1358,19 @@ export function MessageTimeline(props: {
"w-full": true, "w-full": true,
"pb-4": true, "pb-4": true,
"pr-3": true, "pr-3": true,
"pl-4": settings.general.newLayoutDesigns(), "pl-2": settings.general.newLayoutDesigns(),
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(), "pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
}} }}
> >
<div class="h-12 w-full flex items-center justify-between gap-2"> <div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3"> <div
<div class="flex items-center min-w-0 grow-1"> classList={{
"flex items-center gap-1 min-w-0 flex-1": true,
"pr-3": !settings.general.newLayoutDesigns(),
}}
>
<div class="flex items-center min-w-0 flex-1 w-full">
<Show when={parentID()}> <Show when={parentID()}>
<button <button
type="button" type="button"
@@ -1333,8 +1394,13 @@ export function MessageTimeline(props: {
fallback={ fallback={
<h1 <h1
data-slot="session-title-child" data-slot="session-title-child"
class="text-14-medium text-text-strong truncate grow-1 min-w-0" classList={{
onDblClick={openTitleEditor} "text-14-medium text-text-strong truncate": true,
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
settings.general.newLayoutDesigns(),
"grow-1 min-w-0": !settings.general.newLayoutDesigns(),
}}
onClick={openTitleEditor}
> >
{childTitle()} {childTitle()}
</h1> </h1>
@@ -1347,8 +1413,17 @@ export function MessageTimeline(props: {
data-slot="session-title-child" data-slot="session-title-child"
value={title.draft} value={title.draft}
disabled={titleMutation.isPending} disabled={titleMutation.isPending}
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px] pl-1 -ml-1" classList={{
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} "text-14-medium text-text-strong block": true,
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !settings.general.newLayoutDesigns(),
"field-sizing-content self-start rounded-[6px] px-2 py-1 ":
settings.general.newLayoutDesigns(),
}}
style={{
"--inline-input-shadow": settings.general.newLayoutDesigns()
? "none"
: "var(--shadow-xs-border-select)",
}}
onInput={(event) => setTitle("draft", event.currentTarget.value)} onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
event.stopPropagation() event.stopPropagation()
@@ -1370,88 +1445,170 @@ export function MessageTimeline(props: {
</div> </div>
<Show when={sessionID()} keyed> <Show when={sessionID()} keyed>
{(id) => ( {(id) => (
<div class="shrink-0 flex items-center gap-3"> <div
<SessionContextUsage placement="bottom" /> classList={{
"shrink-0 flex items-center": true,
"gap-2": settings.general.newLayoutDesigns(),
"gap-3": !settings.general.newLayoutDesigns(),
}}
>
<SessionContextUsage
placement="bottom"
buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"}
/>
<Show when={!parentID()}> <Show when={!parentID()}>
<DropdownMenu <Show
gutter={4} when={settings.general.newLayoutDesigns()}
placement="bottom-end" fallback={
open={title.menuOpen} <DropdownMenu
onOpenChange={(open) => { gutter={4}
setTitle("menuOpen", open) placement="bottom-end"
if (open) return open={title.menuOpen}
}} onOpenChange={(open) => {
> setTitle("menuOpen", open)
<DropdownMenu.Trigger if (open) return
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"bg-surface-base-active": share.open || title.pendingShare,
}}
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen || share.open || title.pendingShare}
ref={(el: HTMLButtonElement) => {
more = el
}}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}} }}
> >
<DropdownMenu.Item <DropdownMenu.Trigger
onSelect={() => { as={IconButton}
setTitle("pendingRename", true) icon="dot-grid"
setTitle("menuOpen", false) variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"bg-surface-base-active": share.open || title.pendingShare,
}} }}
> aria-label={language.t("common.moreOptions")}
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel> aria-expanded={title.menuOpen || share.open || title.pendingShare}
</DropdownMenu.Item> ref={(el: HTMLButtonElement) => {
<Show when={shareEnabled()}> more = el
<DropdownMenu.Item }}
onSelect={() => { />
setTitle({ pendingShare: true, menuOpen: false }) <DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}} }}
> >
<DropdownMenu.ItemLabel> <DropdownMenu.Item
{language.t("session.share.action.share")} onSelect={() => {
</DropdownMenu.ItemLabel> setTitle("pendingRename", true)
</DropdownMenu.Item> setTitle("menuOpen", false)
</Show> }}
<DropdownMenu.Item onSelect={() => void archiveSession(id)}> >
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Separator /> <Show when={shareEnabled()}>
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)} onSelect={() => {
setTitle({ pendingShare: true, menuOpen: false })
}}
>
<DropdownMenu.ItemLabel>
{language.t("session.share.action.share")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
}
>
<MenuV2
gutter={6}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => {
setTitle("menuOpen", open)
if (open) return
}}
>
<MenuV2.Trigger
as={IconButtonV2}
icon={<IconV2 name="outline-dots" />}
variant="ghost-muted"
size="large"
state={share.open || title.pendingShare ? "pressed" : undefined}
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen || share.open || title.pendingShare}
ref={(el: HTMLButtonElement) => {
more = el
}}
/>
<MenuV2.Portal>
<MenuV2.Content
style={{ width: "120px", "min-width": "120px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}}
> >
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel> <MenuV2.Item
</DropdownMenu.Item> onSelect={() => {
</DropdownMenu.Content> setTitle("pendingRename", true)
</DropdownMenu.Portal> setTitle("menuOpen", false)
</DropdownMenu> }}
>
{language.t("common.rename")}
</MenuV2.Item>
<Show when={shareEnabled()}>
<MenuV2.Item
onSelect={() => {
setTitle({ pendingShare: true, menuOpen: false })
}}
>
{language.t("session.share.action.share")}...
</MenuV2.Item>
</Show>
<MenuV2.Item onSelect={() => void archiveSession(id)}>
{language.t("common.archive")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
{language.t("common.delete")}...
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Show>
<KobaltePopover <KobaltePopover
open={share.open} open={share.open}
anchorRef={() => more} anchorRef={() => more}
placement="bottom-end" placement="bottom-end"
gutter={4} gutter={settings.general.newLayoutDesigns() ? 6 : 4}
modal={false} modal={false}
onOpenChange={(open) => { onOpenChange={(open) => {
if (open) setShare("dismiss", null) if (open) setShare("dismiss", null)
@@ -1461,6 +1618,10 @@ export function MessageTimeline(props: {
<KobaltePopover.Portal> <KobaltePopover.Portal>
<KobaltePopover.Content <KobaltePopover.Content
data-component="popover-content" data-component="popover-content"
classList={{
"flex w-80 max-w-none flex-col items-start gap-3 rounded-[10px] border-0 bg-v2-background-bg-layer-01 p-3 shadow-[var(--v2-elevation-floating)]":
settings.general.newLayoutDesigns(),
}}
style={{ "min-width": "320px" }} style={{ "min-width": "320px" }}
onEscapeKeyDown={(event) => { onEscapeKeyDown={(event) => {
setShare({ dismiss: "escape", open: false }) setShare({ dismiss: "escape", open: false })
@@ -1478,24 +1639,90 @@ export function MessageTimeline(props: {
setShare("dismiss", null) setShare("dismiss", null)
}} }}
> >
<div class="flex flex-col p-3"> <Show
<div class="flex flex-col gap-1"> when={settings.general.newLayoutDesigns()}
<div class="text-13-medium text-text-strong"> fallback={
<div class="flex flex-col p-3">
<div class="flex flex-col gap-1">
<div class="text-13-medium text-text-strong">
{language.t("session.share.popover.title")}
</div>
<div class="text-12-regular text-text-weak">
{shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")}
</div>
</div>
<div class="mt-3 flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<Button
size="large"
variant="primary"
class="w-full"
onClick={shareSession}
disabled={shareMutation.isPending}
>
{shareMutation.isPending
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
}
>
<div class="flex flex-col gap-2">
<TextField
value={shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
tabIndex={-1}
class="w-full"
/>
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={unshareMutation.isPending}
>
{unshareMutation.isPending
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={unshareMutation.isPending}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div>
</Show>
</div>
</div>
}
>
<div class="flex w-full flex-col gap-1.5 px-0.5 pt-0.5">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base [font-variation-settings:'slnt'_0]">
{language.t("session.share.popover.title")} {language.t("session.share.popover.title")}
</div> </div>
<div class="text-12-regular text-text-weak"> <div class="select-none text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-variation-settings:'slnt'_0]">
{shareUrl() {shareUrl()
? language.t("session.share.popover.description.shared") ? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")} : language.t("session.share.popover.description.unshared")}
</div> </div>
</div> </div>
<div class="mt-3 flex flex-col gap-2"> <div class="flex w-full flex-col gap-2">
<Show <Show
when={shareUrl()} when={shareUrl()}
fallback={ fallback={
<Button <ButtonV2
size="large" variant="contrast"
variant="primary"
class="w-full" class="w-full"
onClick={shareSession} onClick={shareSession}
disabled={shareMutation.isPending} disabled={shareMutation.isPending}
@@ -1503,48 +1730,57 @@ export function MessageTimeline(props: {
{shareMutation.isPending {shareMutation.isPending
? language.t("session.share.action.publishing") ? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")} : language.t("session.share.action.publish")}
</Button> </ButtonV2>
} }
> >
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<TextField <div
value={shareUrl() ?? ""} class="flex h-8 w-full items-center gap-1.5 rounded-[6px] py-1 pl-2.5 pr-1.5 shadow-[var(--v2-elevation-button-neutral)]"
readOnly style={{
copyable background:
copyKind="link" "linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-button-neutral)",
tabIndex={-1} }}
class="w-full" >
/> <div
<div class="grid grid-cols-2 gap-2"> class="min-w-0 flex-1 truncate select-text cursor-text text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-variation-settings:'slnt'_0]"
<Button onClick={selectShareUrlText}
size="large" >
variant="secondary" {shareUrl()}
class={ </div>
settings.general.newLayoutDesigns() <IconButtonV2
? "w-full shadow-none border-[0.5px] border-border-weak-base" type="button"
: "w-full shadow-none border border-border-weak-base" size="small"
} variant="ghost-muted"
icon={<IconV2 name="outline-copy" />}
aria-label={language.t("session.share.copy.copyLink")}
onClick={copyShareUrl}
/>
<IconButtonV2
type="button"
size="small"
variant="ghost-muted"
icon={<IconV2 name="outline-square-arrow" />}
aria-label={language.t("session.share.action.view")}
onClick={viewShare}
disabled={unshareMutation.isPending}
/>
</div>
<div class="flex w-full">
<ButtonV2
variant="outline"
class="w-full"
onClick={unshareSession} onClick={unshareSession}
disabled={unshareMutation.isPending} disabled={unshareMutation.isPending}
> >
{unshareMutation.isPending {unshareMutation.isPending
? language.t("session.share.action.unpublishing") ? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")} : language.t("session.share.action.unpublish")}
</Button> </ButtonV2>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={unshareMutation.isPending}
>
{language.t("session.share.action.view")}
</Button>
</div> </div>
</div> </div>
</Show> </Show>
</div> </div>
</div> </Show>
</KobaltePopover.Content> </KobaltePopover.Content>
</KobaltePopover.Portal> </KobaltePopover.Portal>
</KobaltePopover> </KobaltePopover>
+1 -1
View File
@@ -7,7 +7,7 @@ Private generation target for clients derived directly from OpenCode's authorita
- `@opencode-ai/client`: zero-Effect Promise client using `fetch`. - `@opencode-ai/client`: zero-Effect Promise client using `fetch`.
- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`. - `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
The generated surface starts with the Session group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift. The generated surface includes every standard HTTP group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Custom transports such as the PTY WebSocket connection remain outside the generic HTTP client. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift.
The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API. The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API.
+14 -7
View File
@@ -1,20 +1,27 @@
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen" import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
import { Api } from "@opencode-ai/server/api" import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpApi } from "effect/unstable/httpapi"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
const contract = compile(HttpApi.make("opencode-client").add(Api.groups["server.session"]), { const contract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints })
groupNames: { "server.session": "sessions" },
})
await Effect.runPromise( await Effect.runPromise(
Effect.all( Effect.all(
[ [
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
write( write(
emitEffectImported(contract, { module: "../contract", group: "SessionGroup" }), emitPromise(contract, {
outputTypes: {
"events.subscribe": {
name: "OpenCodeEventEncoded",
import: 'import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"',
},
},
}),
fileURLToPath(new URL("../src/generated", import.meta.url)),
),
write(
emitEffectImported(contract, { module: "../contract", api: "ClientApi" }),
fileURLToPath(new URL("../src/generated-effect", import.meta.url)), fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
), ),
], ],
+36 -2
View File
@@ -11,9 +11,43 @@ class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocatio
{ error: [InvalidRequestError, SessionNotFoundError] }, { error: [InvalidRequestError, SessionNotFoundError] },
) {} ) {}
const Api = makeDefaultApi({ export const ClientApi = makeDefaultApi({
locationMiddleware: LocationMiddleware, locationMiddleware: LocationMiddleware,
sessionLocationMiddleware: SessionLocationMiddleware, sessionLocationMiddleware: SessionLocationMiddleware,
}) })
export const SessionGroup = Api.groups["server.session"] export const groupNames = {
"server.health": "health",
"server.location": "location",
"server.agent": "agents",
"server.session": "sessions",
"server.message": "messages",
"server.model": "models",
"server.provider": "providers",
"server.integration": "integrations",
"server.credential": "credentials",
"server.permission": "permissions",
"server.fs": "files",
"server.command": "commands",
"server.skill": "skills",
"server.event": "events",
"server.pty": "ptys",
"server.question": "questions",
"server.reference": "references",
"server.projectCopy": "projectCopies",
} as const
export const endpointNames = {
"session.messages": "list",
"integration.connect.key": "connectKey",
"integration.connect.oauth": "connectOauth",
"integration.attempt.status": "attemptStatus",
"integration.attempt.complete": "attemptComplete",
"integration.attempt.cancel": "attemptCancel",
"permission.request.list": "listRequests",
"permission.saved.list": "listSaved",
"permission.saved.remove": "removeSaved",
"question.request.list": "listRequests",
} as const
export const omitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"])
+13
View File
@@ -2,11 +2,24 @@
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations. // Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
export * from "./generated-effect/index" export * from "./generated-effect/index"
export { Agent } from "@opencode-ai/schema/agent" export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
export { Credential } from "@opencode-ai/schema/credential"
export { FileSystem } from "@opencode-ai/schema/filesystem"
export { Integration } from "@opencode-ai/schema/integration"
export { Location } from "@opencode-ai/schema/location" export { Location } from "@opencode-ai/schema/location"
export { Model } from "@opencode-ai/schema/model" export { Model } from "@opencode-ai/schema/model"
export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export { Project } from "@opencode-ai/schema/project"
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
export { Provider } from "@opencode-ai/schema/provider" export { Provider } from "@opencode-ai/schema/provider"
export { Pty } from "@opencode-ai/schema/pty"
export { Question } from "@opencode-ai/schema/question"
export { Reference } from "@opencode-ai/schema/reference"
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema" export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
export { Session } from "@opencode-ai/schema/session" export { Session } from "@opencode-ai/schema/session"
export { SessionInput } from "@opencode-ai/schema/session-input" export { SessionInput } from "@opencode-ai/schema/session-input"
export { SessionMessage } from "@opencode-ai/schema/session-message" export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Skill } from "@opencode-ai/schema/skill"
export { Prompt } from "@opencode-ai/schema/prompt" export { Prompt } from "@opencode-ai/schema/prompt"
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
+625 -122
View File
@@ -2,202 +2,705 @@
import { Effect, Stream, Schema } from "effect" import { Effect, Stream, Schema } from "effect"
import { Sse } from "effect/unstable/encoding" import { Sse } from "effect/unstable/encoding"
import { HttpClientError } from "effect/unstable/http" import { HttpClientError } from "effect/unstable/http"
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" import { HttpApiClient } from "effect/unstable/httpapi"
import { SessionGroup } from "../contract" import { ClientApi } from "../contract"
import { ClientError } from "./client-error" import { ClientError } from "./client-error"
const Api = HttpApi.make("generated").add(SessionGroup) type RawClient = HttpApiClient.ForApi<typeof ClientApi>
type RawClient = HttpApiClient.ForApi<typeof Api>
const mapClientError = <E>(error: E) => const mapClientError = <E>(error: E) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error }) ? new ClientError({ cause: error })
: error : error
type Endpoint0_0Request = Parameters<RawClient["server.session"]["session.list"]>[0] const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
type Endpoint0_0Input = { raw["health.get"]({}).pipe(Effect.mapError(mapClientError))
readonly workspace?: Endpoint0_0Request["query"]["workspace"]
readonly limit?: Endpoint0_0Request["query"]["limit"] const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) })
readonly order?: Endpoint0_0Request["query"]["order"]
readonly search?: Endpoint0_0Request["query"]["search"] type Endpoint1_0Request = Parameters<RawClient["server.location"]["location.get"]>[0]
readonly directory?: Endpoint0_0Request["query"]["directory"] type Endpoint1_0Input = { readonly location?: Endpoint1_0Request["query"]["location"] }
readonly project?: Endpoint0_0Request["query"]["project"] const Endpoint1_0 = (raw: RawClient["server.location"]) => (input?: Endpoint1_0Input) =>
readonly subpath?: Endpoint0_0Request["query"]["subpath"] raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
readonly cursor?: Endpoint0_0Request["query"]["cursor"]
const adaptGroup1 = (raw: RawClient["server.location"]) => ({ get: Endpoint1_0(raw) })
type Endpoint2_0Request = Parameters<RawClient["server.agent"]["agent.list"]>[0]
type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["location"] }
const Endpoint2_0 = (raw: RawClient["server.agent"]) => (input?: Endpoint2_0Input) =>
raw["agent.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup2 = (raw: RawClient["server.agent"]) => ({ list: Endpoint2_0(raw) })
type Endpoint3_0Request = Parameters<RawClient["server.session"]["session.list"]>[0]
type Endpoint3_0Input = {
readonly workspace?: Endpoint3_0Request["query"]["workspace"]
readonly limit?: Endpoint3_0Request["query"]["limit"]
readonly order?: Endpoint3_0Request["query"]["order"]
readonly search?: Endpoint3_0Request["query"]["search"]
readonly directory?: Endpoint3_0Request["query"]["directory"]
readonly project?: Endpoint3_0Request["query"]["project"]
readonly subpath?: Endpoint3_0Request["query"]["subpath"]
readonly cursor?: Endpoint3_0Request["query"]["cursor"]
} }
const Endpoint0_0 = (raw: RawClient["server.session"]) => (input?: Endpoint0_0Input) => const Endpoint3_0 = (raw: RawClient["server.session"]) => (input?: Endpoint3_0Input) =>
raw["session.list"]({ raw["session.list"]({
query: { query: {
workspace: input?.workspace, workspace: input?.["workspace"],
limit: input?.limit, limit: input?.["limit"],
order: input?.order, order: input?.["order"],
search: input?.search, search: input?.["search"],
directory: input?.directory, directory: input?.["directory"],
project: input?.project, project: input?.["project"],
subpath: input?.subpath, subpath: input?.["subpath"],
cursor: input?.cursor, cursor: input?.["cursor"],
}, },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint0_1Request = Parameters<RawClient["server.session"]["session.create"]>[0] type Endpoint3_1Request = Parameters<RawClient["server.session"]["session.create"]>[0]
type Endpoint0_1Input = { type Endpoint3_1Input = {
readonly id?: Endpoint0_1Request["payload"]["id"] readonly id?: Endpoint3_1Request["payload"]["id"]
readonly agent?: Endpoint0_1Request["payload"]["agent"] readonly agent?: Endpoint3_1Request["payload"]["agent"]
readonly model?: Endpoint0_1Request["payload"]["model"] readonly model?: Endpoint3_1Request["payload"]["model"]
readonly location?: Endpoint0_1Request["payload"]["location"] readonly location?: Endpoint3_1Request["payload"]["location"]
} }
const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1Input) => const Endpoint3_1 = (raw: RawClient["server.session"]) => (input?: Endpoint3_1Input) =>
raw["session.create"]({ raw["session.create"]({
payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location }, payload: { id: input?.["id"], agent: input?.["agent"], model: input?.["model"], location: input?.["location"] },
}).pipe( }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
const Endpoint0_2 = (raw: RawClient["server.session"]) => () => const Endpoint3_2 = (raw: RawClient["server.session"]) => () =>
raw["session.active"]({}).pipe( raw["session.active"]({}).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.get"]>[0] type Endpoint3_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
type Endpoint0_3Input = { readonly sessionID: Endpoint0_3Request["params"]["sessionID"] } type Endpoint3_3Input = { readonly sessionID: Endpoint3_3Request["params"]["sessionID"] }
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) => const Endpoint3_3 = (raw: RawClient["server.session"]) => (input: Endpoint3_3Input) =>
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe( raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0] type Endpoint3_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
type Endpoint0_4Input = { type Endpoint3_4Input = {
readonly sessionID: Endpoint0_4Request["params"]["sessionID"] readonly sessionID: Endpoint3_4Request["params"]["sessionID"]
readonly agent: Endpoint0_4Request["payload"]["agent"] readonly agent: Endpoint3_4Request["payload"]["agent"]
} }
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) => const Endpoint3_4 = (raw: RawClient["server.session"]) => (input: Endpoint3_4Input) =>
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe( raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0] type Endpoint3_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
type Endpoint0_5Input = { type Endpoint3_5Input = {
readonly sessionID: Endpoint0_5Request["params"]["sessionID"] readonly sessionID: Endpoint3_5Request["params"]["sessionID"]
readonly model: Endpoint0_5Request["payload"]["model"] readonly model: Endpoint3_5Request["payload"]["model"]
} }
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) => const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Input) =>
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe( raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0] type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint0_6Input = { type Endpoint3_6Input = {
readonly sessionID: Endpoint0_6Request["params"]["sessionID"] readonly sessionID: Endpoint3_6Request["params"]["sessionID"]
readonly id?: Endpoint0_6Request["payload"]["id"] readonly id?: Endpoint3_6Request["payload"]["id"]
readonly prompt: Endpoint0_6Request["payload"]["prompt"] readonly prompt: Endpoint3_6Request["payload"]["prompt"]
readonly delivery?: Endpoint0_6Request["payload"]["delivery"] readonly delivery?: Endpoint3_6Request["payload"]["delivery"]
readonly resume?: Endpoint0_6Request["payload"]["resume"] readonly resume?: Endpoint3_6Request["payload"]["resume"]
} }
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) => const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) =>
raw["session.prompt"]({ raw["session.prompt"]({
params: { sessionID: input.sessionID }, params: { sessionID: input["sessionID"] },
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume }, payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
}).pipe( }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0] type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] } type Endpoint3_7Input = { readonly sessionID: Endpoint3_7Request["params"]["sessionID"] }
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) => const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) =>
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0] type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] } type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] }
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) => const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) =>
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0] type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint0_9Input = { type Endpoint3_9Input = {
readonly sessionID: Endpoint0_9Request["params"]["sessionID"] readonly sessionID: Endpoint3_9Request["params"]["sessionID"]
readonly messageID: Endpoint0_9Request["payload"]["messageID"] readonly messageID: Endpoint3_9Request["payload"]["messageID"]
readonly files?: Endpoint0_9Request["payload"]["files"] readonly files?: Endpoint3_9Request["payload"]["files"]
} }
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) => const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
raw["session.revert.stage"]({ raw["session.revert.stage"]({
params: { sessionID: input.sessionID }, params: { sessionID: input["sessionID"] },
payload: { messageID: input.messageID, files: input.files }, payload: { messageID: input["messageID"], files: input["files"] },
}).pipe( }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0] type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] } type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] }
const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) => const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0] type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] } type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] }
const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) => const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.context"]>[0] type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint0_12Input = { readonly sessionID: Endpoint0_12Request["params"]["sessionID"] } type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] }
const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) => const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) =>
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe( raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0] type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.history"]>[0]
type Endpoint0_13Input = { type Endpoint3_13Input = {
readonly sessionID: Endpoint0_13Request["params"]["sessionID"] readonly sessionID: Endpoint3_13Request["params"]["sessionID"]
readonly after?: Endpoint0_13Request["query"]["after"] readonly limit?: Endpoint3_13Request["query"]["limit"]
readonly after?: Endpoint3_13Request["query"]["after"]
} }
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) => const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
raw["session.history"]({
params: { sessionID: input["sessionID"] },
query: { limit: input["limit"], after: input["after"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint3_14Input = {
readonly sessionID: Endpoint3_14Request["params"]["sessionID"]
readonly after?: Endpoint3_14Request["query"]["after"]
}
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
Stream.unwrap( Stream.unwrap(
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe( raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))), Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
), ),
) )
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0] type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] } type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] }
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) => const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0] type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint0_15Input = { type Endpoint3_16Input = {
readonly sessionID: Endpoint0_15Request["params"]["sessionID"] readonly sessionID: Endpoint3_16Request["params"]["sessionID"]
readonly messageID: Endpoint0_15Request["params"]["messageID"] readonly messageID: Endpoint3_16Request["params"]["messageID"]
} }
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) => const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe( raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
const adaptGroup0 = (raw: RawClient["server.session"]) => ({ const adaptGroup3 = (raw: RawClient["server.session"]) => ({
list: Endpoint0_0(raw), list: Endpoint3_0(raw),
create: Endpoint0_1(raw), create: Endpoint3_1(raw),
active: Endpoint0_2(raw), active: Endpoint3_2(raw),
get: Endpoint0_3(raw), get: Endpoint3_3(raw),
switchAgent: Endpoint0_4(raw), switchAgent: Endpoint3_4(raw),
switchModel: Endpoint0_5(raw), switchModel: Endpoint3_5(raw),
prompt: Endpoint0_6(raw), prompt: Endpoint3_6(raw),
compact: Endpoint0_7(raw), compact: Endpoint3_7(raw),
wait: Endpoint0_8(raw), wait: Endpoint3_8(raw),
stage: Endpoint0_9(raw), stage: Endpoint3_9(raw),
clear: Endpoint0_10(raw), clear: Endpoint3_10(raw),
commit: Endpoint0_11(raw), commit: Endpoint3_11(raw),
context: Endpoint0_12(raw), context: Endpoint3_12(raw),
events: Endpoint0_13(raw), history: Endpoint3_13(raw),
interrupt: Endpoint0_14(raw), events: Endpoint3_14(raw),
message: Endpoint0_15(raw), interrupt: Endpoint3_15(raw),
message: Endpoint3_16(raw),
}) })
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) }) type Endpoint4_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
type Endpoint4_0Input = {
readonly sessionID: Endpoint4_0Request["params"]["sessionID"]
readonly limit?: Endpoint4_0Request["query"]["limit"]
readonly order?: Endpoint4_0Request["query"]["order"]
readonly cursor?: Endpoint4_0Request["query"]["cursor"]
}
const Endpoint4_0 = (raw: RawClient["server.message"]) => (input: Endpoint4_0Input) =>
raw["session.messages"]({
params: { sessionID: input["sessionID"] },
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup4 = (raw: RawClient["server.message"]) => ({ list: Endpoint4_0(raw) })
type Endpoint5_0Request = Parameters<RawClient["server.model"]["model.list"]>[0]
type Endpoint5_0Input = { readonly location?: Endpoint5_0Request["query"]["location"] }
const Endpoint5_0 = (raw: RawClient["server.model"]) => (input?: Endpoint5_0Input) =>
raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup5 = (raw: RawClient["server.model"]) => ({ list: Endpoint5_0(raw) })
type Endpoint6_0Request = Parameters<RawClient["server.provider"]["provider.list"]>[0]
type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["location"] }
const Endpoint6_0 = (raw: RawClient["server.provider"]) => (input?: Endpoint6_0Input) =>
raw["provider.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint6_1Request = Parameters<RawClient["server.provider"]["provider.get"]>[0]
type Endpoint6_1Input = {
readonly providerID: Endpoint6_1Request["params"]["providerID"]
readonly location?: Endpoint6_1Request["query"]["location"]
}
const Endpoint6_1 = (raw: RawClient["server.provider"]) => (input: Endpoint6_1Input) =>
raw["provider.get"]({ params: { providerID: input["providerID"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup6 = (raw: RawClient["server.provider"]) => ({ list: Endpoint6_0(raw), get: Endpoint6_1(raw) })
type Endpoint7_0Request = Parameters<RawClient["server.integration"]["integration.list"]>[0]
type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] }
const Endpoint7_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint7_0Input) =>
raw["integration.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint7_1Request = Parameters<RawClient["server.integration"]["integration.get"]>[0]
type Endpoint7_1Input = {
readonly integrationID: Endpoint7_1Request["params"]["integrationID"]
readonly location?: Endpoint7_1Request["query"]["location"]
}
const Endpoint7_1 = (raw: RawClient["server.integration"]) => (input: Endpoint7_1Input) =>
raw["integration.get"]({
params: { integrationID: input["integrationID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
type Endpoint7_2Input = {
readonly integrationID: Endpoint7_2Request["params"]["integrationID"]
readonly location?: Endpoint7_2Request["query"]["location"]
readonly key: Endpoint7_2Request["payload"]["key"]
readonly label?: Endpoint7_2Request["payload"]["label"]
}
const Endpoint7_2 = (raw: RawClient["server.integration"]) => (input: Endpoint7_2Input) =>
raw["integration.connect.key"]({
params: { integrationID: input["integrationID"] },
query: { location: input["location"] },
payload: { key: input["key"], label: input["label"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
type Endpoint7_3Input = {
readonly integrationID: Endpoint7_3Request["params"]["integrationID"]
readonly location?: Endpoint7_3Request["query"]["location"]
readonly methodID: Endpoint7_3Request["payload"]["methodID"]
readonly inputs: Endpoint7_3Request["payload"]["inputs"]
readonly label?: Endpoint7_3Request["payload"]["label"]
}
const Endpoint7_3 = (raw: RawClient["server.integration"]) => (input: Endpoint7_3Input) =>
raw["integration.connect.oauth"]({
params: { integrationID: input["integrationID"] },
query: { location: input["location"] },
payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
type Endpoint7_4Input = {
readonly attemptID: Endpoint7_4Request["params"]["attemptID"]
readonly location?: Endpoint7_4Request["query"]["location"]
}
const Endpoint7_4 = (raw: RawClient["server.integration"]) => (input: Endpoint7_4Input) =>
raw["integration.attempt.status"]({
params: { attemptID: input["attemptID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
type Endpoint7_5Input = {
readonly attemptID: Endpoint7_5Request["params"]["attemptID"]
readonly location?: Endpoint7_5Request["query"]["location"]
readonly code?: Endpoint7_5Request["payload"]["code"]
}
const Endpoint7_5 = (raw: RawClient["server.integration"]) => (input: Endpoint7_5Input) =>
raw["integration.attempt.complete"]({
params: { attemptID: input["attemptID"] },
query: { location: input["location"] },
payload: { code: input["code"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
type Endpoint7_6Input = {
readonly attemptID: Endpoint7_6Request["params"]["attemptID"]
readonly location?: Endpoint7_6Request["query"]["location"]
}
const Endpoint7_6 = (raw: RawClient["server.integration"]) => (input: Endpoint7_6Input) =>
raw["integration.attempt.cancel"]({
params: { attemptID: input["attemptID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup7 = (raw: RawClient["server.integration"]) => ({
list: Endpoint7_0(raw),
get: Endpoint7_1(raw),
connectKey: Endpoint7_2(raw),
connectOauth: Endpoint7_3(raw),
attemptStatus: Endpoint7_4(raw),
attemptComplete: Endpoint7_5(raw),
attemptCancel: Endpoint7_6(raw),
})
type Endpoint8_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
type Endpoint8_0Input = {
readonly credentialID: Endpoint8_0Request["params"]["credentialID"]
readonly location?: Endpoint8_0Request["query"]["location"]
readonly label: Endpoint8_0Request["payload"]["label"]
}
const Endpoint8_0 = (raw: RawClient["server.credential"]) => (input: Endpoint8_0Input) =>
raw["credential.update"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
payload: { label: input["label"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint8_1Request = Parameters<RawClient["server.credential"]["credential.remove"]>[0]
type Endpoint8_1Input = {
readonly credentialID: Endpoint8_1Request["params"]["credentialID"]
readonly location?: Endpoint8_1Request["query"]["location"]
}
const Endpoint8_1 = (raw: RawClient["server.credential"]) => (input: Endpoint8_1Input) =>
raw["credential.remove"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup8 = (raw: RawClient["server.credential"]) => ({ update: Endpoint8_0(raw), remove: Endpoint8_1(raw) })
type Endpoint9_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] }
const Endpoint9_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_0Input) =>
raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint9_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
type Endpoint9_1Input = { readonly projectID?: Endpoint9_1Request["query"]["projectID"] }
const Endpoint9_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_1Input) =>
raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
type Endpoint9_2Input = { readonly id: Endpoint9_2Request["params"]["id"] }
const Endpoint9_2 = (raw: RawClient["server.permission"]) => (input: Endpoint9_2Input) =>
raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint9_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
type Endpoint9_3Input = {
readonly sessionID: Endpoint9_3Request["params"]["sessionID"]
readonly id?: Endpoint9_3Request["payload"]["id"]
readonly action: Endpoint9_3Request["payload"]["action"]
readonly resources: Endpoint9_3Request["payload"]["resources"]
readonly save?: Endpoint9_3Request["payload"]["save"]
readonly metadata?: Endpoint9_3Request["payload"]["metadata"]
readonly source?: Endpoint9_3Request["payload"]["source"]
readonly agent?: Endpoint9_3Request["payload"]["agent"]
}
const Endpoint9_3 = (raw: RawClient["server.permission"]) => (input: Endpoint9_3Input) =>
raw["session.permission.create"]({
params: { sessionID: input["sessionID"] },
payload: {
id: input["id"],
action: input["action"],
resources: input["resources"],
save: input["save"],
metadata: input["metadata"],
source: input["source"],
agent: input["agent"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
type Endpoint9_4Input = { readonly sessionID: Endpoint9_4Request["params"]["sessionID"] }
const Endpoint9_4 = (raw: RawClient["server.permission"]) => (input: Endpoint9_4Input) =>
raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
type Endpoint9_5Input = {
readonly sessionID: Endpoint9_5Request["params"]["sessionID"]
readonly requestID: Endpoint9_5Request["params"]["requestID"]
}
const Endpoint9_5 = (raw: RawClient["server.permission"]) => (input: Endpoint9_5Input) =>
raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
type Endpoint9_6Input = {
readonly sessionID: Endpoint9_6Request["params"]["sessionID"]
readonly requestID: Endpoint9_6Request["params"]["requestID"]
readonly reply: Endpoint9_6Request["payload"]["reply"]
readonly message?: Endpoint9_6Request["payload"]["message"]
}
const Endpoint9_6 = (raw: RawClient["server.permission"]) => (input: Endpoint9_6Input) =>
raw["session.permission.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { reply: input["reply"], message: input["message"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup9 = (raw: RawClient["server.permission"]) => ({
listRequests: Endpoint9_0(raw),
listSaved: Endpoint9_1(raw),
removeSaved: Endpoint9_2(raw),
create: Endpoint9_3(raw),
list: Endpoint9_4(raw),
get: Endpoint9_5(raw),
reply: Endpoint9_6(raw),
})
type Endpoint10_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
type Endpoint10_0Input = {
readonly location?: Endpoint10_0Request["query"]["location"]
readonly path?: Endpoint10_0Request["query"]["path"]
}
const Endpoint10_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint10_0Input) =>
raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint10_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
type Endpoint10_1Input = {
readonly location?: Endpoint10_1Request["query"]["location"]
readonly query: Endpoint10_1Request["query"]["query"]
readonly type?: Endpoint10_1Request["query"]["type"]
readonly limit?: Endpoint10_1Request["query"]["limit"]
}
const Endpoint10_1 = (raw: RawClient["server.fs"]) => (input: Endpoint10_1Input) =>
raw["fs.find"]({
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup10 = (raw: RawClient["server.fs"]) => ({ list: Endpoint10_0(raw), find: Endpoint10_1(raw) })
type Endpoint11_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
const Endpoint11_0 = (raw: RawClient["server.command"]) => (input?: Endpoint11_0Input) =>
raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup11 = (raw: RawClient["server.command"]) => ({ list: Endpoint11_0(raw) })
type Endpoint12_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] }
const Endpoint12_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint12_0Input) =>
raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup12 = (raw: RawClient["server.skill"]) => ({ list: Endpoint12_0(raw) })
const Endpoint13_0 = (raw: RawClient["server.event"]) => () =>
Stream.unwrap(
raw["event.subscribe"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
),
)
const adaptGroup13 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint13_0(raw) })
type Endpoint14_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
const Endpoint14_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_0Input) =>
raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint14_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
type Endpoint14_1Input = {
readonly location?: Endpoint14_1Request["query"]["location"]
readonly command?: Endpoint14_1Request["payload"]["command"]
readonly args?: Endpoint14_1Request["payload"]["args"]
readonly cwd?: Endpoint14_1Request["payload"]["cwd"]
readonly title?: Endpoint14_1Request["payload"]["title"]
readonly env?: Endpoint14_1Request["payload"]["env"]
}
const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Input) =>
raw["pty.create"]({
query: { location: input?.["location"] },
payload: {
command: input?.["command"],
args: input?.["args"],
cwd: input?.["cwd"],
title: input?.["title"],
env: input?.["env"],
},
}).pipe(Effect.mapError(mapClientError))
type Endpoint14_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
type Endpoint14_2Input = {
readonly ptyID: Endpoint14_2Request["params"]["ptyID"]
readonly location?: Endpoint14_2Request["query"]["location"]
}
const Endpoint14_2 = (raw: RawClient["server.pty"]) => (input: Endpoint14_2Input) =>
raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint14_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
type Endpoint14_3Input = {
readonly ptyID: Endpoint14_3Request["params"]["ptyID"]
readonly location?: Endpoint14_3Request["query"]["location"]
readonly title?: Endpoint14_3Request["payload"]["title"]
readonly size?: Endpoint14_3Request["payload"]["size"]
}
const Endpoint14_3 = (raw: RawClient["server.pty"]) => (input: Endpoint14_3Input) =>
raw["pty.update"]({
params: { ptyID: input["ptyID"] },
query: { location: input["location"] },
payload: { title: input["title"], size: input["size"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint14_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
type Endpoint14_4Input = {
readonly ptyID: Endpoint14_4Request["params"]["ptyID"]
readonly location?: Endpoint14_4Request["query"]["location"]
}
const Endpoint14_4 = (raw: RawClient["server.pty"]) => (input: Endpoint14_4Input) =>
raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup14 = (raw: RawClient["server.pty"]) => ({
list: Endpoint14_0(raw),
create: Endpoint14_1(raw),
get: Endpoint14_2(raw),
update: Endpoint14_3(raw),
remove: Endpoint14_4(raw),
})
type Endpoint15_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] }
const Endpoint15_0 = (raw: RawClient["server.question"]) => (input?: Endpoint15_0Input) =>
raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint15_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
type Endpoint15_1Input = { readonly sessionID: Endpoint15_1Request["params"]["sessionID"] }
const Endpoint15_1 = (raw: RawClient["server.question"]) => (input: Endpoint15_1Input) =>
raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint15_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
type Endpoint15_2Input = {
readonly sessionID: Endpoint15_2Request["params"]["sessionID"]
readonly requestID: Endpoint15_2Request["params"]["requestID"]
readonly answers: Endpoint15_2Request["payload"]["answers"]
}
const Endpoint15_2 = (raw: RawClient["server.question"]) => (input: Endpoint15_2Input) =>
raw["session.question.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { answers: input["answers"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint15_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
type Endpoint15_3Input = {
readonly sessionID: Endpoint15_3Request["params"]["sessionID"]
readonly requestID: Endpoint15_3Request["params"]["requestID"]
}
const Endpoint15_3 = (raw: RawClient["server.question"]) => (input: Endpoint15_3Input) =>
raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup15 = (raw: RawClient["server.question"]) => ({
listRequests: Endpoint15_0(raw),
list: Endpoint15_1(raw),
reply: Endpoint15_2(raw),
reject: Endpoint15_3(raw),
})
type Endpoint16_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
const Endpoint16_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint16_0Input) =>
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup16 = (raw: RawClient["server.reference"]) => ({ list: Endpoint16_0(raw) })
type Endpoint17_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0]
type Endpoint17_0Input = {
readonly projectID: Endpoint17_0Request["params"]["projectID"]
readonly location?: Endpoint17_0Request["query"]["location"]
readonly strategy: Endpoint17_0Request["payload"]["strategy"]
readonly directory: Endpoint17_0Request["payload"]["directory"]
readonly name?: Endpoint17_0Request["payload"]["name"]
}
const Endpoint17_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_0Input) =>
raw["projectCopy.create"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint17_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0]
type Endpoint17_1Input = {
readonly projectID: Endpoint17_1Request["params"]["projectID"]
readonly location?: Endpoint17_1Request["query"]["location"]
readonly directory: Endpoint17_1Request["payload"]["directory"]
readonly force: Endpoint17_1Request["payload"]["force"]
}
const Endpoint17_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_1Input) =>
raw["projectCopy.remove"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint17_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0]
type Endpoint17_2Input = {
readonly projectID: Endpoint17_2Request["params"]["projectID"]
readonly location?: Endpoint17_2Request["query"]["location"]
}
const Endpoint17_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_2Input) =>
raw["projectCopy.refresh"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup17 = (raw: RawClient["server.projectCopy"]) => ({
create: Endpoint17_0(raw),
remove: Endpoint17_1(raw),
refresh: Endpoint17_2(raw),
})
const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]),
location: adaptGroup1(raw["server.location"]),
agents: adaptGroup2(raw["server.agent"]),
sessions: adaptGroup3(raw["server.session"]),
messages: adaptGroup4(raw["server.message"]),
models: adaptGroup5(raw["server.model"]),
providers: adaptGroup6(raw["server.provider"]),
integrations: adaptGroup7(raw["server.integration"]),
credentials: adaptGroup8(raw["server.credential"]),
permissions: adaptGroup9(raw["server.permission"]),
files: adaptGroup10(raw["server.fs"]),
commands: adaptGroup11(raw["server.command"]),
skills: adaptGroup12(raw["server.skill"]),
events: adaptGroup13(raw["server.event"]),
ptys: adaptGroup14(raw["server.pty"]),
questions: adaptGroup15(raw["server.question"]),
references: adaptGroup16(raw["server.reference"]),
projectCopies: adaptGroup17(raw["server.projectCopy"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) => export const make = (options?: { readonly baseUrl?: URL | string }) =>
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient)) HttpApiClient.make(ClientApi, options).pipe(Effect.map(adaptClient))
+642 -14
View File
@@ -1,4 +1,9 @@
import type { import type {
HealthGetOutput,
LocationGetInput,
LocationGetOutput,
AgentsListInput,
AgentsListOutput,
SessionsListInput, SessionsListInput,
SessionsListOutput, SessionsListOutput,
SessionsCreateInput, SessionsCreateInput,
@@ -24,12 +29,89 @@ import type {
SessionsCommitOutput, SessionsCommitOutput,
SessionsContextInput, SessionsContextInput,
SessionsContextOutput, SessionsContextOutput,
SessionsHistoryInput,
SessionsHistoryOutput,
SessionsEventsInput, SessionsEventsInput,
SessionsEventsOutput, SessionsEventsOutput,
SessionsInterruptInput, SessionsInterruptInput,
SessionsInterruptOutput, SessionsInterruptOutput,
SessionsMessageInput, SessionsMessageInput,
SessionsMessageOutput, SessionsMessageOutput,
MessagesListInput,
MessagesListOutput,
ModelsListInput,
ModelsListOutput,
ProvidersListInput,
ProvidersListOutput,
ProvidersGetInput,
ProvidersGetOutput,
IntegrationsListInput,
IntegrationsListOutput,
IntegrationsGetInput,
IntegrationsGetOutput,
IntegrationsConnectKeyInput,
IntegrationsConnectKeyOutput,
IntegrationsConnectOauthInput,
IntegrationsConnectOauthOutput,
IntegrationsAttemptStatusInput,
IntegrationsAttemptStatusOutput,
IntegrationsAttemptCompleteInput,
IntegrationsAttemptCompleteOutput,
IntegrationsAttemptCancelInput,
IntegrationsAttemptCancelOutput,
CredentialsUpdateInput,
CredentialsUpdateOutput,
CredentialsRemoveInput,
CredentialsRemoveOutput,
PermissionsListRequestsInput,
PermissionsListRequestsOutput,
PermissionsListSavedInput,
PermissionsListSavedOutput,
PermissionsRemoveSavedInput,
PermissionsRemoveSavedOutput,
PermissionsCreateInput,
PermissionsCreateOutput,
PermissionsListInput,
PermissionsListOutput,
PermissionsGetInput,
PermissionsGetOutput,
PermissionsReplyInput,
PermissionsReplyOutput,
FilesListInput,
FilesListOutput,
FilesFindInput,
FilesFindOutput,
CommandsListInput,
CommandsListOutput,
SkillsListInput,
SkillsListOutput,
EventsSubscribeOutput,
PtysListInput,
PtysListOutput,
PtysCreateInput,
PtysCreateOutput,
PtysGetInput,
PtysGetOutput,
PtysUpdateInput,
PtysUpdateOutput,
PtysRemoveInput,
PtysRemoveOutput,
QuestionsListRequestsInput,
QuestionsListRequestsOutput,
QuestionsListInput,
QuestionsListOutput,
QuestionsReplyInput,
QuestionsReplyOutput,
QuestionsRejectInput,
QuestionsRejectOutput,
ReferencesListInput,
ReferencesListOutput,
ProjectCopiesCreateInput,
ProjectCopiesCreateOutput,
ProjectCopiesRemoveInput,
ProjectCopiesRemoveOutput,
ProjectCopiesRefreshInput,
ProjectCopiesRefreshOutput,
} from "./types" } from "./types"
import { ClientError } from "./client-error" import { ClientError } from "./client-error"
@@ -165,6 +247,41 @@ export function make(options: ClientOptions) {
}) })
return { return {
health: {
get: (requestOptions?: RequestOptions) =>
request<HealthGetOutput>(
{ method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
},
location: {
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
request<LocationGetOutput>(
{
method: "GET",
path: `/api/location`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
agents: {
list: (input?: AgentsListInput, requestOptions?: RequestOptions) =>
request<AgentsListOutput>(
{
method: "GET",
path: `/api/agent`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
sessions: { sessions: {
list: (input?: SessionsListInput, requestOptions?: RequestOptions) => list: (input?: SessionsListInput, requestOptions?: RequestOptions) =>
request<SessionsListOutput>( request<SessionsListOutput>(
@@ -172,14 +289,14 @@ export function make(options: ClientOptions) {
method: "GET", method: "GET",
path: `/api/session`, path: `/api/session`,
query: { query: {
workspace: input?.workspace, workspace: input?.["workspace"],
limit: input?.limit, limit: input?.["limit"],
order: input?.order, order: input?.["order"],
search: input?.search, search: input?.["search"],
directory: input?.directory, directory: input?.["directory"],
project: input?.project, project: input?.["project"],
subpath: input?.subpath, subpath: input?.["subpath"],
cursor: input?.cursor, cursor: input?.["cursor"],
}, },
successStatus: 200, successStatus: 200,
declaredStatuses: [400, 401], declaredStatuses: [400, 401],
@@ -192,7 +309,12 @@ export function make(options: ClientOptions) {
{ {
method: "POST", method: "POST",
path: `/api/session`, path: `/api/session`,
body: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location }, body: {
id: input?.["id"],
agent: input?.["agent"],
model: input?.["model"],
location: input?.["location"],
},
successStatus: 200, successStatus: 200,
declaredStatuses: [401, 400], declaredStatuses: [401, 400],
empty: false, empty: false,
@@ -226,7 +348,7 @@ export function make(options: ClientOptions) {
{ {
method: "POST", method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`, path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`,
body: { agent: input.agent }, body: { agent: input["agent"] },
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: true, empty: true,
@@ -238,7 +360,7 @@ export function make(options: ClientOptions) {
{ {
method: "POST", method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/model`, path: `/api/session/${encodeURIComponent(input.sessionID)}/model`,
body: { model: input.model }, body: { model: input["model"] },
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: true, empty: true,
@@ -250,7 +372,7 @@ export function make(options: ClientOptions) {
{ {
method: "POST", method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`, path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
body: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume }, body: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
successStatus: 200, successStatus: 200,
declaredStatuses: [409, 404, 400, 401], declaredStatuses: [409, 404, 400, 401],
empty: false, empty: false,
@@ -284,7 +406,7 @@ export function make(options: ClientOptions) {
{ {
method: "POST", method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
body: { messageID: input.messageID, files: input.files }, body: { messageID: input["messageID"], files: input["files"] },
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 500, 400, 401], declaredStatuses: [404, 500, 400, 401],
empty: false, empty: false,
@@ -324,12 +446,24 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
history: (input: SessionsHistoryInput, requestOptions?: RequestOptions) =>
request<SessionsHistoryOutput>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/history`,
query: { limit: input["limit"], after: input["after"] },
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
),
events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> => events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> =>
sse<SessionsEventsOutput>( sse<SessionsEventsOutput>(
{ {
method: "GET", method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/event`, path: `/api/session/${encodeURIComponent(input.sessionID)}/event`,
query: { after: input.after }, query: { after: input["after"] },
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: false, empty: false,
@@ -359,6 +493,500 @@ export function make(options: ClientOptions) {
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
}, },
messages: {
list: (input: MessagesListInput, requestOptions?: RequestOptions) =>
request<MessagesListOutput>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/message`,
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
successStatus: 200,
declaredStatuses: [400, 404, 500, 401],
empty: false,
},
requestOptions,
),
},
models: {
list: (input?: ModelsListInput, requestOptions?: RequestOptions) =>
request<ModelsListOutput>(
{
method: "GET",
path: `/api/model`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
},
providers: {
list: (input?: ProvidersListInput, requestOptions?: RequestOptions) =>
request<ProvidersListOutput>(
{
method: "GET",
path: `/api/provider`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
get: (input: ProvidersGetInput, requestOptions?: RequestOptions) =>
request<ProvidersGetOutput>(
{
method: "GET",
path: `/api/provider/${encodeURIComponent(input.providerID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
),
},
integrations: {
list: (input?: IntegrationsListInput, requestOptions?: RequestOptions) =>
request<IntegrationsListOutput>(
{
method: "GET",
path: `/api/integration`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
get: (input: IntegrationsGetInput, requestOptions?: RequestOptions) =>
request<IntegrationsGetOutput>(
{
method: "GET",
path: `/api/integration/${encodeURIComponent(input.integrationID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
connectKey: (input: IntegrationsConnectKeyInput, requestOptions?: RequestOptions) =>
request<IntegrationsConnectKeyOutput>(
{
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
query: { location: input["location"] },
body: { key: input["key"], label: input["label"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
),
connectOauth: (input: IntegrationsConnectOauthInput, requestOptions?: RequestOptions) =>
request<IntegrationsConnectOauthOutput>(
{
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`,
query: { location: input["location"] },
body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
attemptStatus: (input: IntegrationsAttemptStatusInput, requestOptions?: RequestOptions) =>
request<IntegrationsAttemptStatusOutput>(
{
method: "GET",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
attemptComplete: (input: IntegrationsAttemptCompleteInput, requestOptions?: RequestOptions) =>
request<IntegrationsAttemptCompleteOutput>(
{
method: "POST",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`,
query: { location: input["location"] },
body: { code: input["code"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
),
attemptCancel: (input: IntegrationsAttemptCancelInput, requestOptions?: RequestOptions) =>
request<IntegrationsAttemptCancelOutput>(
{
method: "DELETE",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
},
credentials: {
update: (input: CredentialsUpdateInput, requestOptions?: RequestOptions) =>
request<CredentialsUpdateOutput>(
{
method: "PATCH",
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
query: { location: input["location"] },
body: { label: input["label"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
remove: (input: CredentialsRemoveInput, requestOptions?: RequestOptions) =>
request<CredentialsRemoveOutput>(
{
method: "DELETE",
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
},
permissions: {
listRequests: (input?: PermissionsListRequestsInput, requestOptions?: RequestOptions) =>
request<PermissionsListRequestsOutput>(
{
method: "GET",
path: `/api/permission/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
listSaved: (input?: PermissionsListSavedInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsListSavedOutput }>(
{
method: "GET",
path: `/api/permission/saved`,
query: { projectID: input?.["projectID"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
removeSaved: (input: PermissionsRemoveSavedInput, requestOptions?: RequestOptions) =>
request<PermissionsRemoveSavedOutput>(
{
method: "DELETE",
path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
create: (input: PermissionsCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsCreateOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
body: {
id: input["id"],
action: input["action"],
resources: input["resources"],
save: input["save"],
metadata: input["metadata"],
source: input["source"],
agent: input["agent"],
},
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
list: (input: PermissionsListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: PermissionsGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: PermissionsReplyInput, requestOptions?: RequestOptions) =>
request<PermissionsReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`,
body: { reply: input["reply"], message: input["message"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
},
files: {
list: (input?: FilesListInput, requestOptions?: RequestOptions) =>
request<FilesListOutput>(
{
method: "GET",
path: `/api/fs/list`,
query: { location: input?.["location"], path: input?.["path"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
find: (input: FilesFindInput, requestOptions?: RequestOptions) =>
request<FilesFindOutput>(
{
method: "GET",
path: `/api/fs/find`,
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
commands: {
list: (input?: CommandsListInput, requestOptions?: RequestOptions) =>
request<CommandsListOutput>(
{
method: "GET",
path: `/api/command`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
skills: {
list: (input?: SkillsListInput, requestOptions?: RequestOptions) =>
request<SkillsListOutput>(
{
method: "GET",
path: `/api/skill`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
events: {
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventsSubscribeOutput> =>
sse<EventsSubscribeOutput>(
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
},
ptys: {
list: (input?: PtysListInput, requestOptions?: RequestOptions) =>
request<PtysListOutput>(
{
method: "GET",
path: `/api/pty`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
create: (input?: PtysCreateInput, requestOptions?: RequestOptions) =>
request<PtysCreateOutput>(
{
method: "POST",
path: `/api/pty`,
query: { location: input?.["location"] },
body: {
command: input?.["command"],
args: input?.["args"],
cwd: input?.["cwd"],
title: input?.["title"],
env: input?.["env"],
},
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
get: (input: PtysGetInput, requestOptions?: RequestOptions) =>
request<PtysGetOutput>(
{
method: "GET",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
),
update: (input: PtysUpdateInput, requestOptions?: RequestOptions) =>
request<PtysUpdateOutput>(
{
method: "PUT",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input["location"] },
body: { title: input["title"], size: input["size"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
),
remove: (input: PtysRemoveInput, requestOptions?: RequestOptions) =>
request<PtysRemoveOutput>(
{
method: "DELETE",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
},
requestOptions,
),
},
questions: {
listRequests: (input?: QuestionsListRequestsInput, requestOptions?: RequestOptions) =>
request<QuestionsListRequestsOutput>(
{
method: "GET",
path: `/api/question/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
list: (input: QuestionsListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: QuestionsListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: QuestionsReplyInput, requestOptions?: RequestOptions) =>
request<QuestionsReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`,
body: { answers: input["answers"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
reject: (input: QuestionsRejectInput, requestOptions?: RequestOptions) =>
request<QuestionsRejectOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
},
references: {
list: (input?: ReferencesListInput, requestOptions?: RequestOptions) =>
request<ReferencesListOutput>(
{
method: "GET",
path: `/api/reference`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
projectCopies: {
create: (input: ProjectCopiesCreateInput, requestOptions?: RequestOptions) =>
request<ProjectCopiesCreateOutput>(
{
method: "POST",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
remove: (input: ProjectCopiesRemoveInput, requestOptions?: RequestOptions) =>
request<ProjectCopiesRemoveOutput>(
{
method: "DELETE",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
body: { directory: input["directory"], force: input["force"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
),
refresh: (input: ProjectCopiesRefreshInput, requestOptions?: RequestOptions) =>
request<ProjectCopiesRefreshOutput>(
{
method: "POST",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
),
},
} }
} }
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1 +1,2 @@
export * from "./generated/index" export * from "./generated/index"
export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"
@@ -19,8 +19,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace" import { Workspace } from "@opencode-ai/schema/workspace"
import { Api } from "@opencode-ai/server/api" import { Api } from "@opencode-ai/server/api"
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen" import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
import { HttpApi } from "effect/unstable/httpapi" import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract"
import { SessionGroup } from "../src/contract"
test("Core and Server reuse the authoritative Schema and Protocol values", () => { test("Core and Server reuse the authoritative Schema and Protocol values", () => {
expect(AgentV2.ID).toBe(Agent.ID) expect(AgentV2.ID).toBe(Agent.ID)
@@ -31,17 +30,16 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message) expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
expect(CorePrompt).toBe(Prompt) expect(CorePrompt).toBe(Prompt)
expect(Api.groups["server.session"].identifier).toBe("server.session") expect(Api.groups["server.session"].identifier).toBe("server.session")
expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier) expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
expect(Session.ID.create()).toStartWith("ses_") expect(Session.ID.create()).toStartWith("ses_")
expect(Project.ID.global).toBe("global") expect(Project.ID.global).toBe("global")
expect(Provider.ID.anthropic).toBe("anthropic") expect(Provider.ID.anthropic).toBe("anthropic")
expect(Workspace.ID.create()).toStartWith("wrk_") expect(Workspace.ID.create()).toStartWith("wrk_")
}) })
test("client and Server Session contracts generate identically", () => { test("client and Server contracts generate identically", () => {
const options = { groupNames: { "server.session": "sessions" } } const server = compile(Api, { groupNames, endpointNames, omitEndpoints })
const server = compile(HttpApi.make("server").add(Api.groups["server.session"]), options) const client = compile(ClientApi, { groupNames, endpointNames, omitEndpoints })
const client = compile(HttpApi.make("client").add(SessionGroup), options)
expect(emitPromise(client)).toEqual(emitPromise(server)) expect(emitPromise(client)).toEqual(emitPromise(server))
}) })
+100 -1
View File
@@ -15,7 +15,53 @@ test("sessions.get returns the decoded Effect projection", async () => {
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
}) })
test("events.subscribe exposes and decodes the native Effect event stream", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
),
),
)
const events = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.events.subscribe().pipe(Stream.runCollect)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"])
const durable = events[1]
if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event")
expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000)
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
})
test("events.subscribe terminates on Effect protocol decode failures", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(`data: {"type":"server.connected"}\n\n`, {
headers: { "content-type": "text/event-stream" },
}),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error._tag).toBe("ClientError")
})
test("session methods retain decoded Effect inputs and outputs", async () => { test("session methods retain decoded Effect inputs and outputs", async () => {
const historyQueries: Array<Record<string, string>> = []
let historyPage = 0
const httpClient = HttpClient.make((request) => { const httpClient = HttpClient.make((request) => {
const url = request.url const url = request.url
if (url.includes("/event")) { if (url.includes("/event")) {
@@ -28,6 +74,18 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
), ),
) )
} }
if (url.includes("/history")) {
historyPage++
historyQueries.push(Object.fromEntries(request.urlParams.params))
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
),
),
)
}
if (url.includes("/prompt")) { if (url.includes("/prompt")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission))) return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
} }
@@ -72,6 +130,18 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") }) yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") }) yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") }) const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
const history = yield* client.sessions.history({
sessionID: Session.ID.make("ses_test"),
after: 0,
limit: 1,
})
const historyNext = history.hasMore
? yield* client.sessions.history({
sessionID: Session.ID.make("ses_test"),
after: history.data.at(-1)?.durable?.seq,
limit: 2,
})
: undefined
const events = yield* client.sessions const events = yield* client.sessions
.events({ sessionID: Session.ID.make("ses_test"), after: 0 }) .events({ sessionID: Session.ID.make("ses_test"), after: 0 })
.pipe(Stream.runCollect) .pipe(Stream.runCollect)
@@ -80,7 +150,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
sessionID: Session.ID.make("ses_test"), sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_model"), messageID: SessionMessage.ID.make("msg_model"),
}) })
return { page, active, created, admitted, context, events, message } return { page, active, created, admitted, context, history, historyNext, events, message }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
@@ -92,10 +162,39 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype) expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
expect(result.context).toEqual([]) expect(result.context).toEqual([])
expect(DateTime.toEpochMillis(result.history.data[0].data.timestamp)).toBe(1_717_171_717_000)
expect(result.history).toEqual(expect.objectContaining({ hasMore: true }))
expect(result.historyNext).toEqual({ data: [], hasMore: false })
expect(historyQueries[0]).toEqual({ limit: "1", after: "0" })
expect(historyQueries[1]).toEqual({ limit: "2", after: "1" })
expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" })) expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
}) })
test("sessions.history retains the typed SessionNotFoundError", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
{ status: 404 },
),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.sessions
.history({
sessionID: Session.ID.make("ses_missing"),
})
.pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error._tag).toBe("SessionNotFoundError")
})
const session = { const session = {
data: { data: {
id: "ses_test", id: "ses_test",
+103 -3
View File
@@ -1,5 +1,42 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { isUnauthorizedError, OpenCode } from "../src" import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src"
test("exposes every standard HTTP API group", () => {
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
expect(Object.keys(client)).toEqual([
"health",
"location",
"agents",
"sessions",
"messages",
"models",
"providers",
"integrations",
"credentials",
"permissions",
"files",
"commands",
"skills",
"events",
"ptys",
"questions",
"references",
"projectCopies",
])
expect(Object.keys(client.messages)).toEqual(["list"])
expect(Object.keys(client.integrations)).toEqual([
"list",
"get",
"connectKey",
"connectOauth",
"attemptStatus",
"attemptComplete",
"attemptCancel",
])
expect(Object.keys(client.files)).toEqual(["list", "find"])
expect(Object.keys(client.ptys)).toEqual(["list", "create", "get", "update", "remove"])
})
test("sessions.get returns the wire projection", async () => { test("sessions.get returns the wire projection", async () => {
const client = OpenCode.make({ const client = OpenCode.make({
@@ -17,8 +54,38 @@ test("sessions.get returns the wire projection", async () => {
expect(result.time.created).toBe(1_717_171_717_000) expect(result.time.created).toBe(1_717_171_717_000)
}) })
test("events.subscribe exposes the Promise event stream wire projection", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(
`: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
})
const events = []
for await (const event of client.events.subscribe()) events.push(event)
expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent])
expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000)
})
test("events.subscribe terminates on malformed Promise SSE data", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }),
})
await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
name: "ClientError",
reason: "MalformedResponse",
})
})
test("session methods use the public HTTP contract", async () => { test("session methods use the public HTTP contract", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [] const requests: Array<{ url: string; init?: RequestInit }> = []
let historyPage = 0
const client = OpenCode.make({ const client = OpenCode.make({
baseUrl: "http://localhost:3000", baseUrl: "http://localhost:3000",
fetch: async (input, init) => { fetch: async (input, init) => {
@@ -29,6 +96,12 @@ test("session methods use the public HTTP contract", async () => {
headers: { "content-type": "text/event-stream" }, headers: { "content-type": "text/event-stream" },
}) })
} }
if (url.includes("/history")) {
historyPage++
return Response.json(
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
)
}
if (url.includes("/prompt")) return Response.json(admission) if (url.includes("/prompt")) return Response.json(admission)
if (url.includes("/context")) return Response.json({ data: [] }) if (url.includes("/context")) return Response.json({ data: [] })
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage }) if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
@@ -39,7 +112,7 @@ test("session methods use the public HTTP contract", async () => {
}, },
}) })
const page = await client.sessions.list({ limit: "10", order: "desc" }) const page = await client.sessions.list({ limit: 10, order: "desc" })
const active = await client.sessions.active() const active = await client.sessions.active()
const created = await client.sessions.create({ location: { directory: "/tmp/project" } }) const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" }) await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
@@ -55,8 +128,13 @@ test("session methods use the public HTTP contract", async () => {
await client.sessions.compact({ sessionID: "ses_test" }) await client.sessions.compact({ sessionID: "ses_test" })
await client.sessions.wait({ sessionID: "ses_test" }) await client.sessions.wait({ sessionID: "ses_test" })
const context = await client.sessions.context({ sessionID: "ses_test" }) const context = await client.sessions.context({ sessionID: "ses_test" })
const history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 })
const historyAfter = history.data.at(-1)?.durable?.seq
const historyNext = history.hasMore
? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
: undefined
const events = [] const events = []
for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event) for await (const event of client.sessions.events({ sessionID: "ses_test", after: 0 })) events.push(event)
await client.sessions.interrupt({ sessionID: "ses_test" }) await client.sessions.interrupt({ sessionID: "ses_test" })
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" }) const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
@@ -65,6 +143,8 @@ test("session methods use the public HTTP contract", async () => {
expect(created.id).toBe("ses_test") expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test") expect(admitted.id).toBe("msg_test")
expect(context).toEqual([]) expect(context).toEqual([])
expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
expect(historyNext).toEqual({ data: [], hasMore: false })
expect(events).toEqual([modelSwitchedEvent]) expect(events).toEqual([modelSwitchedEvent])
expect(message).toEqual(modelSwitchedMessage) expect(message).toEqual(modelSwitchedMessage)
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([ expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
@@ -77,6 +157,8 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/session/ses_test/compact"], ["POST", "http://localhost:3000/api/session/ses_test/compact"],
["POST", "http://localhost:3000/api/session/ses_test/wait"], ["POST", "http://localhost:3000/api/session/ses_test/wait"],
["GET", "http://localhost:3000/api/session/ses_test/context"], ["GET", "http://localhost:3000/api/session/ses_test/context"],
["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"],
["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"],
["GET", "http://localhost:3000/api/session/ses_test/event?after=0"], ["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
["POST", "http://localhost:3000/api/session/ses_test/interrupt"], ["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"], ["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
@@ -104,6 +186,24 @@ test("middleware errors remain declared client errors", async () => {
} }
}) })
test("sessions.history decodes SessionNotFoundError", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
Response.json(
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
{ status: 404 },
),
})
try {
await client.sessions.history({ sessionID: "ses_missing" })
throw new Error("Expected request to fail")
} catch (error) {
expect(isSessionNotFoundError(error)).toBe(true)
}
})
const session = { const session = {
data: { data: {
id: "ses_test", id: "ses_test",
+5
View File
@@ -369,6 +369,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}", "لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}",
"zen.api.error.modelDisabled": "النموذج معطل", "zen.api.error.modelDisabled": "النموذج معطل",
"zen.api.error.regionNotAllowed":
"هذا النموذج مستضاف في الصين. إذا كنت ترغب في استخدام هذا النموذج، فعّله في إعداداتك: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"انتهى العرض المجاني لـ {{model}}. يمكنك مواصلة استخدام النموذج بالاشتراك في OpenCode Go - {{link}}", "انتهى العرض المجاني لـ {{model}}. يمكنك مواصلة استخدام النموذج بالاشتراك في OpenCode Go - {{link}}",
@@ -646,6 +648,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "استخدم رصيدك المتوفر بعد الوصول إلى حدود الاستخدام", "workspace.lite.subscription.useBalance": "استخدم رصيدك المتوفر بعد الوصول إلى حدود الاستخدام",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'اختر "OpenCode Go" كمزود في إعدادات opencode الخاصة بك لاستخدام نماذج Go.', 'اختر "OpenCode Go" كمزود في إعدادات opencode الخاصة بك لاستخدام نماذج Go.',
"workspace.lite.providers.title": "المزودون",
"workspace.lite.providers.description": "تحكم في المزودين المستخدمين للتوجيه.",
"workspace.lite.providers.useChina": "تفعيل النماذج المستضافة في الصين",
"workspace.lite.black.message": "workspace.lite.black.message":
"أنت مشترك حاليًا في OpenCode Black أو في قائمة الانتظار. يرجى إلغاء الاشتراك أولاً إذا كنت ترغب في التبديل إلى Go.", "أنت مشترك حاليًا في OpenCode Black أو في قائمة الانتظار. يرجى إلغاء الاشتراك أولاً إذا كنت ترغب في التبديل إلى Go.",
"workspace.lite.other.message": "workspace.lite.other.message":
+5
View File
@@ -377,6 +377,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}", "Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}",
"zen.api.error.modelDisabled": "O modelo está desabilitado", "zen.api.error.modelDisabled": "O modelo está desabilitado",
"zen.api.error.regionNotAllowed":
"Este modelo está hospedado na China. Se você quiser usar este modelo, ative-o nas suas configurações: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"A promoção gratuita do {{model}} terminou. Você pode continuar usando o modelo assinando o OpenCode Go - {{link}}", "A promoção gratuita do {{model}} terminou. Você pode continuar usando o modelo assinando o OpenCode Go - {{link}}",
@@ -656,6 +658,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Use seu saldo disponível após atingir os limites de uso", "workspace.lite.subscription.useBalance": "Use seu saldo disponível após atingir os limites de uso",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Selecione "OpenCode Go" como provedor na sua configuração do opencode para usar os modelos Go.', 'Selecione "OpenCode Go" como provedor na sua configuração do opencode para usar os modelos Go.',
"workspace.lite.providers.title": "Provedores",
"workspace.lite.providers.description": "Controle quais provedores são usados para roteamento.",
"workspace.lite.providers.useChina": "Ativar modelos hospedados na China",
"workspace.lite.black.message": "workspace.lite.black.message":
"Você está atualmente inscrito no OpenCode Black ou na lista de espera. Por favor, cancele a assinatura primeiro se desejar mudar para o Go.", "Você está atualmente inscrito no OpenCode Black ou na lista de espera. Por favor, cancele a assinatura primeiro se desejar mudar para o Go.",
"workspace.lite.other.message": "workspace.lite.other.message":
+5
View File
@@ -373,6 +373,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}", "Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modellen er deaktiveret", "zen.api.error.modelDisabled": "Modellen er deaktiveret",
"zen.api.error.regionNotAllowed":
"Denne model hostes i Kina. Hvis du vil bruge denne model, skal du aktivere den i dine indstillinger: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"Den gratis kampagne for {{model}} er afsluttet. Du kan fortsætte med at bruge modellen ved at abonnere på OpenCode Go - {{link}}", "Den gratis kampagne for {{model}} er afsluttet. Du kan fortsætte med at bruge modellen ved at abonnere på OpenCode Go - {{link}}",
@@ -652,6 +654,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Brug din tilgængelige saldo, når du har nået forbrugsgrænserne", "workspace.lite.subscription.useBalance": "Brug din tilgængelige saldo, når du har nået forbrugsgrænserne",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Vælg "OpenCode Go" som udbyder i din opencode-konfiguration for at bruge Go-modeller.', 'Vælg "OpenCode Go" som udbyder i din opencode-konfiguration for at bruge Go-modeller.',
"workspace.lite.providers.title": "Udbydere",
"workspace.lite.providers.description": "Styr, hvilke udbydere der bruges til routing.",
"workspace.lite.providers.useChina": "Aktivér modeller hostet i Kina",
"workspace.lite.black.message": "workspace.lite.black.message":
"Du abonnerer i øjeblikket på OpenCode Black eller er på venteliste. Afmeld venligst først, hvis du vil skifte til Go.", "Du abonnerer i øjeblikket på OpenCode Black eller er på venteliste. Afmeld venligst først, hvis du vil skifte til Go.",
"workspace.lite.other.message": "workspace.lite.other.message":
+5
View File
@@ -376,6 +376,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}", "Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modell ist deaktiviert", "zen.api.error.modelDisabled": "Modell ist deaktiviert",
"zen.api.error.regionNotAllowed":
"Dieses Modell wird in China gehostet. Wenn du dieses Modell verwenden möchtest, aktiviere es in deinen Einstellungen: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"Die kostenlose Aktion für {{model}} ist beendet. Du kannst das Modell weiterhin nutzen, indem du OpenCode Go abonnierst - {{link}}", "Die kostenlose Aktion für {{model}} ist beendet. Du kannst das Modell weiterhin nutzen, indem du OpenCode Go abonnierst - {{link}}",
@@ -655,6 +657,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Nutze dein verfügbares Guthaben, nachdem die Nutzungslimits erreicht sind", "workspace.lite.subscription.useBalance": "Nutze dein verfügbares Guthaben, nachdem die Nutzungslimits erreicht sind",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Wähle "OpenCode Go" als Anbieter in deiner opencode-Konfiguration, um Go-Modelle zu verwenden.', 'Wähle "OpenCode Go" als Anbieter in deiner opencode-Konfiguration, um Go-Modelle zu verwenden.',
"workspace.lite.providers.title": "Anbieter",
"workspace.lite.providers.description": "Steuere, welche Anbieter für das Routing verwendet werden.",
"workspace.lite.providers.useChina": "In China gehostete Modelle aktivieren",
"workspace.lite.black.message": "workspace.lite.black.message":
"Du hast derzeit OpenCode Black abonniert oder stehst auf der Warteliste. Bitte kündige zuerst, wenn du zu Go wechseln möchtest.", "Du hast derzeit OpenCode Black abonniert oder stehst auf der Warteliste. Bitte kündige zuerst, wenn du zu Go wechseln möchtest.",
"workspace.lite.other.message": "workspace.lite.other.message":
+5
View File
@@ -370,6 +370,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}", "You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}",
"zen.api.error.modelDisabled": "Model is disabled", "zen.api.error.modelDisabled": "Model is disabled",
"zen.api.error.regionNotAllowed":
"This model is hosted in China. If you would like to use this model, enable it in your settings: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"Free promotion has ended for {{model}}. You can continue using the model by subscribing to OpenCode Go - {{link}}", "Free promotion has ended for {{model}}. You can continue using the model by subscribing to OpenCode Go - {{link}}",
@@ -649,6 +651,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Use your available balance after reaching the usage limits", "workspace.lite.subscription.useBalance": "Use your available balance after reaching the usage limits",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Select "OpenCode Go" as the provider in your opencode configuration to use Go models.', 'Select "OpenCode Go" as the provider in your opencode configuration to use Go models.',
"workspace.lite.providers.title": "Providers",
"workspace.lite.providers.description": "Control which providers are used for routing.",
"workspace.lite.providers.useChina": "Enable models hosted in China",
"workspace.lite.black.message": "workspace.lite.black.message":
"You're currently subscribed to OpenCode Black or on the waitlist. Please unsubscribe first if you'd like to switch to Go.", "You're currently subscribed to OpenCode Black or on the waitlist. Please unsubscribe first if you'd like to switch to Go.",
"workspace.lite.other.message": "workspace.lite.other.message":
+5
View File
@@ -377,6 +377,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}", "Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}",
"zen.api.error.modelDisabled": "El modelo está deshabilitado", "zen.api.error.modelDisabled": "El modelo está deshabilitado",
"zen.api.error.regionNotAllowed":
"Este modelo está alojado en China. Si quieres usar este modelo, actívalo en tu configuración: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"La promoción gratuita de {{model}} ha finalizado. Puedes seguir usando el modelo suscribiéndote a OpenCode Go - {{link}}", "La promoción gratuita de {{model}} ha finalizado. Puedes seguir usando el modelo suscribiéndote a OpenCode Go - {{link}}",
@@ -656,6 +658,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Usa tu saldo disponible después de alcanzar los límites de uso", "workspace.lite.subscription.useBalance": "Usa tu saldo disponible después de alcanzar los límites de uso",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Selecciona "OpenCode Go" como proveedor en tu configuración de opencode para usar los modelos Go.', 'Selecciona "OpenCode Go" como proveedor en tu configuración de opencode para usar los modelos Go.',
"workspace.lite.providers.title": "Proveedores",
"workspace.lite.providers.description": "Controla qué proveedores se usan para el enrutamiento.",
"workspace.lite.providers.useChina": "Activar modelos alojados en China",
"workspace.lite.black.message": "workspace.lite.black.message":
"Actualmente estás suscrito a OpenCode Black o estás en la lista de espera. Por favor, cancela la suscripción primero si deseas cambiar a Go.", "Actualmente estás suscrito a OpenCode Black o estás en la lista de espera. Por favor, cancela la suscripción primero si deseas cambiar a Go.",
"workspace.lite.other.message": "workspace.lite.other.message":
+5
View File
@@ -377,6 +377,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Vous avez atteint votre limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{membersUrl}}", "Vous avez atteint votre limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{membersUrl}}",
"zen.api.error.modelDisabled": "Le modèle est désactivé", "zen.api.error.modelDisabled": "Le modèle est désactivé",
"zen.api.error.regionNotAllowed":
"Ce modèle est hébergé en Chine. Si vous souhaitez utiliser ce modèle, activez-le dans vos paramètres : {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"La promotion gratuite de {{model}} est terminée. Vous pouvez continuer à utiliser le modèle en vous abonnant à OpenCode Go - {{link}}", "La promotion gratuite de {{model}} est terminée. Vous pouvez continuer à utiliser le modèle en vous abonnant à OpenCode Go - {{link}}",
@@ -662,6 +664,9 @@ export const dict = {
"Utilisez votre solde disponible après avoir atteint les limites d'utilisation", "Utilisez votre solde disponible après avoir atteint les limites d'utilisation",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Sélectionnez "OpenCode Go" comme fournisseur dans votre configuration opencode pour utiliser les modèles Go.', 'Sélectionnez "OpenCode Go" comme fournisseur dans votre configuration opencode pour utiliser les modèles Go.',
"workspace.lite.providers.title": "Fournisseurs",
"workspace.lite.providers.description": "Contrôlez les fournisseurs utilisés pour le routage.",
"workspace.lite.providers.useChina": "Activer les modèles hébergés en Chine",
"workspace.lite.black.message": "workspace.lite.black.message":
"Vous êtes actuellement abonné à OpenCode Black ou sur liste d'attente. Veuillez d'abord vous désabonner si vous souhaitez passer à Go.", "Vous êtes actuellement abonné à OpenCode Black ou sur liste d'attente. Veuillez d'abord vous désabonner si vous souhaitez passer à Go.",
"workspace.lite.other.message": "workspace.lite.other.message":
+5
View File
@@ -373,6 +373,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Hai raggiunto il tuo limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{membersUrl}}", "Hai raggiunto il tuo limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{membersUrl}}",
"zen.api.error.modelDisabled": "Il modello è disabilitato", "zen.api.error.modelDisabled": "Il modello è disabilitato",
"zen.api.error.regionNotAllowed":
"Questo modello è ospitato in Cina. Se vuoi usare questo modello, abilitalo nelle tue impostazioni: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"La promozione gratuita di {{model}} è terminata. Puoi continuare a usare il modello abbonandoti a OpenCode Go - {{link}}", "La promozione gratuita di {{model}} è terminata. Puoi continuare a usare il modello abbonandoti a OpenCode Go - {{link}}",
@@ -654,6 +656,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Usa il tuo saldo disponibile dopo aver raggiunto i limiti di utilizzo", "workspace.lite.subscription.useBalance": "Usa il tuo saldo disponibile dopo aver raggiunto i limiti di utilizzo",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Seleziona "OpenCode Go" come provider nella tua configurazione opencode per utilizzare i modelli Go.', 'Seleziona "OpenCode Go" come provider nella tua configurazione opencode per utilizzare i modelli Go.',
"workspace.lite.providers.title": "Provider",
"workspace.lite.providers.description": "Controlla quali provider vengono usati per il routing.",
"workspace.lite.providers.useChina": "Abilita modelli ospitati in Cina",
"workspace.lite.black.message": "workspace.lite.black.message":
"Attualmente sei abbonato a OpenCode Black o sei in lista d'attesa. Annulla l'iscrizione prima se desideri passare a Go.", "Attualmente sei abbonato a OpenCode Black o sei in lista d'attesa. Annulla l'iscrizione prima se desideri passare a Go.",
"workspace.lite.other.message": "workspace.lite.other.message":
+5
View File
@@ -374,6 +374,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"月額の利用上限 ${{amount}} に達しました。こちらから上限を管理してください: {{membersUrl}}", "月額の利用上限 ${{amount}} に達しました。こちらから上限を管理してください: {{membersUrl}}",
"zen.api.error.modelDisabled": "モデルが無効です", "zen.api.error.modelDisabled": "モデルが無効です",
"zen.api.error.regionNotAllowed":
"このモデルは中国でホストされています。このモデルを使用したい場合は、設定で有効にしてください: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"{{model}} の無料プロモーションは終了しました。OpenCode Go を購読するとモデルを引き続き使用できます - {{link}}", "{{model}} の無料プロモーションは終了しました。OpenCode Go を購読するとモデルを引き続き使用できます - {{link}}",
@@ -654,6 +656,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "利用限度額に達したら利用可能な残高を使用する", "workspace.lite.subscription.useBalance": "利用限度額に達したら利用可能な残高を使用する",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
"Go モデルを使用するには、opencode の設定で「OpenCode Go」をプロバイダーとして選択してください。", "Go モデルを使用するには、opencode の設定で「OpenCode Go」をプロバイダーとして選択してください。",
"workspace.lite.providers.title": "プロバイダー",
"workspace.lite.providers.description": "ルーティングに使用するプロバイダーを管理します。",
"workspace.lite.providers.useChina": "中国でホストされているモデルを有効にする",
"workspace.lite.black.message": "workspace.lite.black.message":
"現在 OpenCode Black を購読中、またはウェイティングリストに登録されています。Go に切り替える場合は、先に登録を解除してください。", "現在 OpenCode Black を購読中、またはウェイティングリストに登録されています。Go に切り替える場合は、先に登録を解除してください。",
"workspace.lite.other.message": "workspace.lite.other.message":
+5
View File
@@ -368,6 +368,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"월간 지출 한도인 ${{amount}}에 도달했습니다. 한도 관리를 여기서 하세요: {{membersUrl}}", "월간 지출 한도인 ${{amount}}에 도달했습니다. 한도 관리를 여기서 하세요: {{membersUrl}}",
"zen.api.error.modelDisabled": "모델이 비활성화되었습니다", "zen.api.error.modelDisabled": "모델이 비활성화되었습니다",
"zen.api.error.regionNotAllowed":
"이 모델은 중국에서 호스팅됩니다. 이 모델을 사용하려면 설정에서 활성화하세요: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"{{model}}의 무료 프로모션이 종료되었습니다. OpenCode Go를 구독하면 모델을 계속 사용할 수 있습니다 - {{link}}", "{{model}}의 무료 프로모션이 종료되었습니다. OpenCode Go를 구독하면 모델을 계속 사용할 수 있습니다 - {{link}}",
@@ -646,6 +648,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "사용 한도 도달 후에는 보유 잔액 사용", "workspace.lite.subscription.useBalance": "사용 한도 도달 후에는 보유 잔액 사용",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Go 모델을 사용하려면 opencode 설정에서 "OpenCode Go"를 공급자로 선택하세요.', 'Go 모델을 사용하려면 opencode 설정에서 "OpenCode Go"를 공급자로 선택하세요.',
"workspace.lite.providers.title": "공급자",
"workspace.lite.providers.description": "라우팅에 사용할 공급자를 제어합니다.",
"workspace.lite.providers.useChina": "중국에서 호스팅되는 모델 활성화",
"workspace.lite.black.message": "workspace.lite.black.message":
"현재 OpenCode Black을 구독 중이거나 대기 명단에 등록되어 있습니다. Go로 전환하려면 먼저 구독을 취소해 주세요.", "현재 OpenCode Black을 구독 중이거나 대기 명단에 등록되어 있습니다. Go로 전환하려면 먼저 구독을 취소해 주세요.",
"workspace.lite.other.message": "workspace.lite.other.message":
+5
View File
@@ -374,6 +374,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Du har nådd din månedlige utgiftsgrense på ${{amount}}. Administrer grensene dine her: {{membersUrl}}", "Du har nådd din månedlige utgiftsgrense på ${{amount}}. Administrer grensene dine her: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modellen er deaktivert", "zen.api.error.modelDisabled": "Modellen er deaktivert",
"zen.api.error.regionNotAllowed":
"Denne modellen hostes i Kina. Hvis du vil bruke denne modellen, aktiver den i innstillingene dine: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"Den gratis kampanjen for {{model}} er avsluttet. Du kan fortsette å bruke modellen ved å abonnere på OpenCode Go - {{link}}", "Den gratis kampanjen for {{model}} er avsluttet. Du kan fortsette å bruke modellen ved å abonnere på OpenCode Go - {{link}}",
@@ -653,6 +655,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Bruk din tilgjengelige saldo etter å ha nådd bruksgrensene", "workspace.lite.subscription.useBalance": "Bruk din tilgjengelige saldo etter å ha nådd bruksgrensene",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Velg "OpenCode Go" som leverandør i opencode-konfigurasjonen din for å bruke Go-modeller.', 'Velg "OpenCode Go" som leverandør i opencode-konfigurasjonen din for å bruke Go-modeller.',
"workspace.lite.providers.title": "Leverandører",
"workspace.lite.providers.description": "Kontroller hvilke leverandører som brukes til ruting.",
"workspace.lite.providers.useChina": "Aktiver modeller hostet i Kina",
"workspace.lite.black.message": "workspace.lite.black.message":
"Du abonnerer for øyeblikket på OpenCode Black eller står på venteliste. Vennligst avslutt abonnementet først hvis du vil bytte til Go.", "Du abonnerer for øyeblikket på OpenCode Black eller står på venteliste. Vennligst avslutt abonnementet først hvis du vil bytte til Go.",
"workspace.lite.other.message": "workspace.lite.other.message":
+5
View File
@@ -375,6 +375,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Osiągnąłeś swój miesięczny limit wydatków w wysokości ${{amount}}. Zarządzaj swoimi limitami tutaj: {{membersUrl}}", "Osiągnąłeś swój miesięczny limit wydatków w wysokości ${{amount}}. Zarządzaj swoimi limitami tutaj: {{membersUrl}}",
"zen.api.error.modelDisabled": "Model jest wyłączony", "zen.api.error.modelDisabled": "Model jest wyłączony",
"zen.api.error.regionNotAllowed":
"Ten model jest hostowany w Chinach. Jeśli chcesz korzystać z tego modelu, włącz go w swoich ustawieniach: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"Bezpłatna promocja {{model}} dobiegła końca. Możesz dalej korzystać z modelu, subskrybując OpenCode Go - {{link}}", "Bezpłatna promocja {{model}} dobiegła końca. Możesz dalej korzystać z modelu, subskrybując OpenCode Go - {{link}}",
@@ -654,6 +656,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Użyj dostępnego salda po osiągnięciu limitów użycia", "workspace.lite.subscription.useBalance": "Użyj dostępnego salda po osiągnięciu limitów użycia",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Wybierz "OpenCode Go" jako dostawcę w konfiguracji opencode, aby używać modeli Go.', 'Wybierz "OpenCode Go" jako dostawcę w konfiguracji opencode, aby używać modeli Go.',
"workspace.lite.providers.title": "Dostawcy",
"workspace.lite.providers.description": "Kontroluj, którzy dostawcy są używani do routingu.",
"workspace.lite.providers.useChina": "Włącz modele hostowane w Chinach",
"workspace.lite.black.message": "workspace.lite.black.message":
"Obecnie subskrybujesz OpenCode Black lub jesteś na liście oczekujących. Jeśli chcesz przejść na Go, najpierw anuluj subskrypcję.", "Obecnie subskrybujesz OpenCode Black lub jesteś na liście oczekujących. Jeśli chcesz przejść na Go, najpierw anuluj subskrypcję.",
"workspace.lite.other.message": "workspace.lite.other.message":
+5
View File
@@ -379,6 +379,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Вы достигли ежемесячного лимита расходов в ${{amount}}. Управляйте лимитами здесь: {{membersUrl}}", "Вы достигли ежемесячного лимита расходов в ${{amount}}. Управляйте лимитами здесь: {{membersUrl}}",
"zen.api.error.modelDisabled": "Модель отключена", "zen.api.error.modelDisabled": "Модель отключена",
"zen.api.error.regionNotAllowed":
"Эта модель размещена в Китае. Если вы хотите использовать эту модель, включите её в настройках: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"Бесплатная акция для {{model}} завершена. Вы можете продолжить использование модели, подписавшись на OpenCode Go - {{link}}", "Бесплатная акция для {{model}} завершена. Вы можете продолжить использование модели, подписавшись на OpenCode Go - {{link}}",
@@ -660,6 +662,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Использовать доступный баланс после достижения лимитов", "workspace.lite.subscription.useBalance": "Использовать доступный баланс после достижения лимитов",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Выберите "OpenCode Go" в качестве провайдера в настройках opencode для использования моделей Go.', 'Выберите "OpenCode Go" в качестве провайдера в настройках opencode для использования моделей Go.',
"workspace.lite.providers.title": "Провайдеры",
"workspace.lite.providers.description": "Управляйте провайдерами, используемыми для маршрутизации.",
"workspace.lite.providers.useChina": "Включить модели, размещенные в Китае",
"workspace.lite.black.message": "workspace.lite.black.message":
"Вы подписаны на OpenCode Black или находитесь в списке ожидания. Пожалуйста, сначала отмените подписку, если хотите перейти на Go.", "Вы подписаны на OpenCode Black или находитесь в списке ожидания. Пожалуйста, сначала отмените подписку, если хотите перейти на Go.",
"workspace.lite.other.message": "workspace.lite.other.message":
+5
View File
@@ -370,6 +370,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"คุณถึงขีดจำกัดการใช้จ่ายรายเดือนที่ ${{amount}} แล้ว จัดการขีดจำกัดของคุณที่นี่: {{membersUrl}}", "คุณถึงขีดจำกัดการใช้จ่ายรายเดือนที่ ${{amount}} แล้ว จัดการขีดจำกัดของคุณที่นี่: {{membersUrl}}",
"zen.api.error.modelDisabled": "โมเดลถูกปิดใช้งาน", "zen.api.error.modelDisabled": "โมเดลถูกปิดใช้งาน",
"zen.api.error.regionNotAllowed":
"โมเดลนี้โฮสต์อยู่ในประเทศจีน หากคุณต้องการใช้โมเดลนี้ ให้เปิดใช้งานในการตั้งค่าของคุณ: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"โปรโมชันฟรีสำหรับ {{model}} สิ้นสุดแล้ว คุณสามารถใช้โมเดลต่อได้โดยสมัครสมาชิก OpenCode Go - {{link}}", "โปรโมชันฟรีสำหรับ {{model}} สิ้นสุดแล้ว คุณสามารถใช้โมเดลต่อได้โดยสมัครสมาชิก OpenCode Go - {{link}}",
@@ -649,6 +651,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "ใช้ยอดคงเหลือของคุณหลังจากถึงขีดจำกัดการใช้งาน", "workspace.lite.subscription.useBalance": "ใช้ยอดคงเหลือของคุณหลังจากถึงขีดจำกัดการใช้งาน",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'เลือก "OpenCode Go" เป็นผู้ให้บริการในการตั้งค่า opencode ของคุณเพื่อใช้โมเดล Go', 'เลือก "OpenCode Go" เป็นผู้ให้บริการในการตั้งค่า opencode ของคุณเพื่อใช้โมเดล Go',
"workspace.lite.providers.title": "ผู้ให้บริการ",
"workspace.lite.providers.description": "ควบคุมผู้ให้บริการที่ใช้สำหรับการกำหนดเส้นทาง",
"workspace.lite.providers.useChina": "เปิดใช้โมเดลที่โฮสต์ในจีน",
"workspace.lite.black.message": "workspace.lite.black.message":
"ขณะนี้คุณสมัครสมาชิก OpenCode Black หรืออยู่ในรายการรอ โปรดยกเลิกการสมัครก่อนหากต้องการเปลี่ยนไปใช้ Go", "ขณะนี้คุณสมัครสมาชิก OpenCode Black หรืออยู่ในรายการรอ โปรดยกเลิกการสมัครก่อนหากต้องการเปลี่ยนไปใช้ Go",
"workspace.lite.other.message": "workspace.lite.other.message":
+5
View File
@@ -377,6 +377,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Aylık ${{amount}} harcama limitinize ulaştınız. Limitlerinizi buradan yönetin: {{membersUrl}}", "Aylık ${{amount}} harcama limitinize ulaştınız. Limitlerinizi buradan yönetin: {{membersUrl}}",
"zen.api.error.modelDisabled": "Model devre dışı", "zen.api.error.modelDisabled": "Model devre dışı",
"zen.api.error.regionNotAllowed":
"Bu model Çin'de barındırılıyor. Bu modeli kullanmak istiyorsanız ayarlarınızdan etkinleştirin: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"{{model}} için ücretsiz promosyon sona erdi. OpenCode Go'ya abone olarak modeli kullanmaya devam edebilirsiniz - {{link}}", "{{model}} için ücretsiz promosyon sona erdi. OpenCode Go'ya abone olarak modeli kullanmaya devam edebilirsiniz - {{link}}",
@@ -656,6 +658,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Kullanım limitlerine ulaştıktan sonra mevcut bakiyenizi kullanın", "workspace.lite.subscription.useBalance": "Kullanım limitlerine ulaştıktan sonra mevcut bakiyenizi kullanın",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
'Go modellerini kullanmak için opencode yapılandırmanızda "OpenCode Go"\'yu sağlayıcı olarak seçin.', 'Go modellerini kullanmak için opencode yapılandırmanızda "OpenCode Go"\'yu sağlayıcı olarak seçin.',
"workspace.lite.providers.title": "Sağlayıcılar",
"workspace.lite.providers.description": "Yönlendirme için hangi sağlayıcıların kullanılacağını kontrol edin.",
"workspace.lite.providers.useChina": "Çin'de barındırılan modelleri etkinleştir",
"workspace.lite.black.message": "workspace.lite.black.message":
"Şu anda OpenCode Black abonesisiniz veya bekleme listesindesiniz. Go'ya geçmek istiyorsanız lütfen önce aboneliğinizi iptal edin.", "Şu anda OpenCode Black abonesisiniz veya bekleme listesindesiniz. Go'ya geçmek istiyorsanız lütfen önce aboneliğinizi iptal edin.",
"workspace.lite.other.message": "workspace.lite.other.message":
+5
View File
@@ -374,6 +374,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Ви досягли місячного ліміту витрат ${{amount}}. Керуйте лімітами: {{membersUrl}}", "Ви досягли місячного ліміту витрат ${{amount}}. Керуйте лімітами: {{membersUrl}}",
"zen.api.error.modelDisabled": "Модель вимкнено", "zen.api.error.modelDisabled": "Модель вимкнено",
"zen.api.error.regionNotAllowed":
"Ця модель розміщена в Китаї. Якщо ви хочете використовувати цю модель, увімкніть її в налаштуваннях: {{consoleGoUrl}}",
"zen.api.error.trialEnded": "zen.api.error.trialEnded":
"Безкоштовна акція для {{model}} закінчилася. Ви можете продовжити використання, підписавшись на OpenCode Go — {{link}}", "Безкоштовна акція для {{model}} закінчилася. Ви можете продовжити використання, підписавшись на OpenCode Go — {{link}}",
@@ -652,6 +654,9 @@ export const dict = {
"workspace.lite.subscription.resetsIn": "Скидається через", "workspace.lite.subscription.resetsIn": "Скидається через",
"workspace.lite.subscription.useBalance": "Використовуйте доступний баланс після досягнення лімітів", "workspace.lite.subscription.useBalance": "Використовуйте доступний баланс після досягнення лімітів",
"workspace.lite.subscription.selectProvider": 'Виберіть "OpenCode Go" як провайдера в конфігурації opencode.', "workspace.lite.subscription.selectProvider": 'Виберіть "OpenCode Go" як провайдера в конфігурації opencode.',
"workspace.lite.providers.title": "Провайдери",
"workspace.lite.providers.description": "Керуйте провайдерами, які використовуються для маршрутизації.",
"workspace.lite.providers.useChina": "Увімкнути моделі, розміщені в Китаї",
"workspace.lite.black.message": "workspace.lite.black.message":
"Ви вже підписані на OpenCode Black або в списку очікування. Спочатку скасуйте підписку, якщо хочете перейти на Go.", "Ви вже підписані на OpenCode Black або в списку очікування. Спочатку скасуйте підписку, якщо хочете перейти на Go.",
"workspace.lite.other.message": "Інший учасник цього робочого простору вже підписаний на OpenCode Go.", "workspace.lite.other.message": "Інший учасник цього робочого простору вже підписаний на OpenCode Go.",
+4
View File
@@ -356,6 +356,7 @@ export const dict = {
"您的工作区已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{billingUrl}}", "您的工作区已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{billingUrl}}",
"zen.api.error.userMonthlyLimitReached": "您已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{membersUrl}}", "zen.api.error.userMonthlyLimitReached": "您已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{membersUrl}}",
"zen.api.error.modelDisabled": "模型已禁用", "zen.api.error.modelDisabled": "模型已禁用",
"zen.api.error.regionNotAllowed": "该模型部署在中国。如果你想使用该模型,请在设置中启用它:{{consoleGoUrl}}",
"zen.api.error.trialEnded": "{{model}} 的限免活动已结束。您可以订阅 OpenCode Go 继续使用该模型 - {{link}}", "zen.api.error.trialEnded": "{{model}} 的限免活动已结束。您可以订阅 OpenCode Go 继续使用该模型 - {{link}}",
"black.meta.title": "OpenCode Black | 访问全球顶尖编程模型", "black.meta.title": "OpenCode Black | 访问全球顶尖编程模型",
@@ -631,6 +632,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "达到使用限额后使用您的可用余额", "workspace.lite.subscription.useBalance": "达到使用限额后使用您的可用余额",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
"在你的 opencode 配置中选择「OpenCode Go」作为提供商,即可使用 Go 模型。", "在你的 opencode 配置中选择「OpenCode Go」作为提供商,即可使用 Go 模型。",
"workspace.lite.providers.title": "提供商",
"workspace.lite.providers.description": "控制用于路由的提供商。",
"workspace.lite.providers.useChina": "启用部署在中国的模型",
"workspace.lite.black.message": "您当前已订阅 OpenCode Black 或在候补名单中。如需切换到 Go,请先取消订阅。", "workspace.lite.black.message": "您当前已订阅 OpenCode Black 或在候补名单中。如需切换到 Go,请先取消订阅。",
"workspace.lite.other.message": "此工作区中的另一位成员已经订阅了 OpenCode Go。每个工作区只有一名成员可以订阅。", "workspace.lite.other.message": "此工作区中的另一位成员已经订阅了 OpenCode Go。每个工作区只有一名成员可以订阅。",
"workspace.lite.promo.description": "workspace.lite.promo.description":
+4
View File
@@ -356,6 +356,7 @@ export const dict = {
"你的工作區已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{billingUrl}}", "你的工作區已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{billingUrl}}",
"zen.api.error.userMonthlyLimitReached": "你已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{membersUrl}}", "zen.api.error.userMonthlyLimitReached": "你已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{membersUrl}}",
"zen.api.error.modelDisabled": "模型已停用", "zen.api.error.modelDisabled": "模型已停用",
"zen.api.error.regionNotAllowed": "此模型部署於中國。如果你想使用此模型,請在設定中啟用它:{{consoleGoUrl}}",
"zen.api.error.trialEnded": "{{model}} 的限免活动已結束。您可以訂閱 OpenCode Go 繼續使用該模型 - {{link}}", "zen.api.error.trialEnded": "{{model}} 的限免活动已結束。您可以訂閱 OpenCode Go 繼續使用該模型 - {{link}}",
"black.meta.title": "OpenCode Black | 存取全球最佳編碼模型", "black.meta.title": "OpenCode Black | 存取全球最佳編碼模型",
@@ -631,6 +632,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "達到使用限制後使用您的可用餘額", "workspace.lite.subscription.useBalance": "達到使用限制後使用您的可用餘額",
"workspace.lite.subscription.selectProvider": "workspace.lite.subscription.selectProvider":
"在您的 opencode 設定中選擇「OpenCode Go」作為提供商,即可使用 Go 模型。", "在您的 opencode 設定中選擇「OpenCode Go」作為提供商,即可使用 Go 模型。",
"workspace.lite.providers.title": "提供商",
"workspace.lite.providers.description": "控制用於路由的提供商。",
"workspace.lite.providers.useChina": "啟用部署在中國的模型",
"workspace.lite.black.message": "您目前已訂閱 OpenCode Black 或在候補名單中。若要切換至 Go,請先取消訂閱。", "workspace.lite.black.message": "您目前已訂閱 OpenCode Black 或在候補名單中。若要切換至 Go,請先取消訂閱。",
"workspace.lite.other.message": "此工作區中的另一位成員已訂閱 OpenCode Go。每個工作區只能有一位成員訂閱。", "workspace.lite.other.message": "此工作區中的另一位成員已訂閱 OpenCode Go。每個工作區只能有一位成員訂閱。",
"workspace.lite.promo.description": "workspace.lite.promo.description":
@@ -0,0 +1,5 @@
export function countryFromRequest(request: Request | undefined) {
if (!request) return undefined
const cloudflareRequest = request as Request & { cf?: { country?: string } }
return cloudflareRequest.cf?.country ?? request.headers.get("cf-ipcountry") ?? undefined
}
@@ -3,7 +3,7 @@ import { z } from "zod"
import { Resource } from "@opencode-ai/console-resource" import { Resource } from "@opencode-ai/console-resource"
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js" import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
const DISCORD_ALERT_ROLE_ID = "1511795723262365887" const DISCORD_ALERT_ROLE_ID = "1520924666359713863"
const basePayload = z.object({ const basePayload = z.object({
name: z.string().optional(), name: z.string().optional(),
@@ -75,6 +75,40 @@
} }
} }
[data-slot="providers-section"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-6);
padding-top: var(--space-6);
border-top: 1px solid var(--color-border-muted);
[data-slot="providers-header"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
h3 {
color: var(--color-text);
font-size: var(--font-size-lg);
font-weight: 600;
line-height: 1.2;
margin: 0;
}
p {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
line-height: 1.5;
margin: 0;
}
}
[data-slot="setting-row"] {
margin-top: 0;
}
}
[data-slot="toggle-label"] { [data-slot="toggle-label"] {
position: relative; position: relative;
display: inline-block; display: inline-block;
@@ -5,7 +5,9 @@ import { Modal } from "~/component/modal"
import { Billing } from "@opencode-ai/console-core/billing.js" import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js" import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js" import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { Actor } from "@opencode-ai/console-core/actor.js" import { Actor } from "@opencode-ai/console-core/actor.js"
import { Workspace } from "@opencode-ai/console-core/workspace.js"
import { Subscription } from "@opencode-ai/console-core/subscription.js" import { Subscription } from "@opencode-ai/console-core/subscription.js"
import { LiteData } from "@opencode-ai/console-core/lite.js" import { LiteData } from "@opencode-ai/console-core/lite.js"
import { withActor } from "~/context/auth.withActor" import { withActor } from "~/context/auth.withActor"
@@ -16,6 +18,8 @@ import { useLanguage } from "~/context/language"
import { formError } from "~/lib/form-error" import { formError } from "~/lib/form-error"
import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time" import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time"
import { createReferralFromCookie } from "~/lib/referral-invite" import { createReferralFromCookie } from "~/lib/referral-invite"
import { getRequestEvent } from "solid-js/web"
import { countryFromRequest } from "~/lib/request-country"
import { IconAlipay, IconUpi } from "~/component/icon" import { IconAlipay, IconUpi } from "~/component/icon"
@@ -34,9 +38,11 @@ export const queryLiteSubscription = query(async (workspaceID: string) => {
timeMonthlyUpdated: LiteTable.timeMonthlyUpdated, timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
timeCreated: LiteTable.timeCreated, timeCreated: LiteTable.timeCreated,
lite: BillingTable.lite, lite: BillingTable.lite,
region: WorkspaceTable.region,
}) })
.from(BillingTable) .from(BillingTable)
.innerJoin(LiteTable, eq(LiteTable.workspaceID, BillingTable.workspaceID)) .innerJoin(LiteTable, eq(LiteTable.workspaceID, BillingTable.workspaceID))
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, BillingTable.workspaceID))
.where(and(eq(LiteTable.workspaceID, Actor.workspace()), isNull(LiteTable.timeDeleted))) .where(and(eq(LiteTable.workspaceID, Actor.workspace()), isNull(LiteTable.timeDeleted)))
.then((r) => r[0]), .then((r) => r[0]),
) )
@@ -48,6 +54,8 @@ export const queryLiteSubscription = query(async (workspaceID: string) => {
return { return {
mine, mine,
useBalance: row.lite?.useBalance ?? false, useBalance: row.lite?.useBalance ?? false,
region:
row.region ?? (await Workspace.setDefaultRegion({ country: countryFromRequest(getRequestEvent()?.request) })),
rollingUsage: Subscription.analyzeRollingUsage({ rollingUsage: Subscription.analyzeRollingUsage({
limit: limits.rollingLimit, limit: limits.rollingLimit,
window: limits.rollingWindow, window: limits.rollingWindow,
@@ -128,6 +136,24 @@ const setLiteUseBalance = action(async (form: FormData) => {
) )
}, "setLiteUseBalance") }, "setLiteUseBalance")
const setGoProviderRouting = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const useChinaProviders = (form.get("useChinaProviders") as string | null) === "true"
return json(
await withActor(
() =>
Workspace.update({ region: useChinaProviders ? ["us", "eu", "sg"] : ["us", "eu", "sg", "cn"] })
.then(() => ({ error: undefined }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: queryLiteSubscription.key },
)
}, "go.providerRouting.set")
function LiteUsageItem(props: { label: string; usage: { usagePercent: number; resetInSec: number } }) { function LiteUsageItem(props: { label: string; usage: { usagePercent: number; resetInSec: number } }) {
const i18n = useI18n() const i18n = useI18n()
@@ -159,6 +185,7 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {
const checkoutAction = useAction(createLiteCheckoutUrl) const checkoutAction = useAction(createLiteCheckoutUrl)
const checkoutSubmission = useSubmission(createLiteCheckoutUrl) const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
const useBalanceSubmission = useSubmission(setLiteUseBalance) const useBalanceSubmission = useSubmission(setLiteUseBalance)
const providerRoutingSubmission = useSubmission(setGoProviderRouting)
const [store, setStore] = createStore({ const [store, setStore] = createStore({
loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi", loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi",
showModal: false, showModal: false,
@@ -232,6 +259,28 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {
<span></span> <span></span>
</label> </label>
</form> </form>
{/*
<div data-slot="providers-section">
<div data-slot="providers-header">
<h3>{i18n.t("workspace.lite.providers.title")}</h3>
<p>{i18n.t("workspace.lite.providers.description")}</p>
</div>
<form action={setGoProviderRouting} method="post" data-slot="setting-row">
<p>{i18n.t("workspace.lite.providers.useChina")}</p>
<input type="hidden" name="workspaceID" value={params.id} />
<input type="hidden" name="useChinaProviders" value={sub().region.includes("cn") ? "true" : "false"} />
<label data-slot="toggle-label">
<input
type="checkbox"
checked={sub().region.includes("cn")}
disabled={providerRoutingSubmission.pending}
onChange={(e) => e.currentTarget.form?.requestSubmit()}
/>
<span></span>
</label>
</form>
</div>
*/}
</section> </section>
)} )}
</Show> </Show>
@@ -43,6 +43,7 @@ const updateWorkspace = action(async (form: FormData) => {
.catch((e) => ({ error: e.message as string })), .catch((e) => ({ error: e.message as string })),
workspaceID, workspaceID,
), ),
{ revalidate: getWorkspaceInfo.key },
) )
}, "workspace.update") }, "workspace.update")
@@ -3,6 +3,7 @@ export class CreditsError extends Error {}
export class MonthlyLimitError extends Error {} export class MonthlyLimitError extends Error {}
export class UserLimitError extends Error {} export class UserLimitError extends Error {}
export class ModelError extends Error {} export class ModelError extends Error {}
export class RegionError extends Error {}
class LimitError extends Error { class LimitError extends Error {
retryAfter?: number retryAfter?: number
@@ -21,6 +21,7 @@ import {
MonthlyLimitError, MonthlyLimitError,
UserLimitError, UserLimitError,
ModelError, ModelError,
RegionError,
RateLimitError, RateLimitError,
FreeUsageLimitError, FreeUsageLimitError,
GoUsageLimitError, GoUsageLimitError,
@@ -49,6 +50,8 @@ import { createModelTpmLimiter } from "./modelTpmLimiter"
import { createModelTpsLimiter } from "./modelTpsLimiter" import { createModelTpsLimiter } from "./modelTpsLimiter"
import { createProviderBudgetTracker } from "./providerBudgetTracker" import { createProviderBudgetTracker } from "./providerBudgetTracker"
import { accumulateUsage, HOT_WORKSPACES } from "./usageBatcher" import { accumulateUsage, HOT_WORKSPACES } from "./usageBatcher"
import { Workspace } from "@opencode-ai/console-core/workspace.js"
import { countryFromRequest } from "~/lib/request-country"
type ZenData = Awaited<ReturnType<typeof ZenData.list>> type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type RetryOptions = { type RetryOptions = {
@@ -125,6 +128,24 @@ export async function handler(
: createKeyRateLimiter(modelInfo.id, modelInfo.rateLimit, zenApiKey, input.request) : createKeyRateLimiter(modelInfo.id, modelInfo.rateLimit, zenApiKey, input.request)
await rateLimiter?.check() await rateLimiter?.check()
const authInfo = await authenticate(modelInfo, zenApiKey) const authInfo = await authenticate(modelInfo, zenApiKey)
const allowedRegions = authInfo?.region
? authInfo.region
: await (async () => {
if (!authInfo) return
return Actor.provide("system", { workspaceID: authInfo.workspaceID }, () =>
Workspace.setDefaultRegion({ country: countryFromRequest(input.request) }),
)
})()
/*
if (true) {
if (!allowedRegions?.includes("unavailable"))
throw new RegionError(
t("zen.api.error.regionNotAllowed", {
consoleGoUrl: `https://opencode.ai/workspace/${authInfo.workspaceID}/go`,
}),
)
}
*/
const stickyId = sessionId ? sessionId : (authInfo?.workspaceID ?? ip) const stickyId = sessionId ? sessionId : (authInfo?.workspaceID ?? ip)
const stickyTracker = createStickyTracker(modelInfo.id, modelInfo.stickyProvider, stickyId) const stickyTracker = createStickyTracker(modelInfo.id, modelInfo.stickyProvider, stickyId)
const stickyProvider = await stickyTracker?.get() const stickyProvider = await stickyTracker?.get()
@@ -137,7 +158,7 @@ export async function handler(
const providerBudgetTracker = createProviderBudgetTracker( const providerBudgetTracker = createProviderBudgetTracker(
modelInfo.providers.map((provider) => ({ ...zenData.providers[provider.id], ...provider })), modelInfo.providers.map((provider) => ({ ...zenData.providers[provider.id], ...provider })),
) )
const providerBudgetUsage = await providerBudgetTracker?.check() const providerBudget = await providerBudgetTracker?.check()
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => { const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const providerInfo = selectProvider( const providerInfo = selectProvider(
@@ -151,7 +172,7 @@ export async function handler(
stickyProvider, stickyProvider,
modelTpmLimits, modelTpmLimits,
modelTpsLimits, modelTpsLimits,
providerBudgetUsage, providerBudget,
) )
validateModelSettings(billingSource, authInfo) validateModelSettings(billingSource, authInfo)
updateProviderKey(authInfo, providerInfo) updateProviderKey(authInfo, providerInfo)
@@ -201,7 +222,10 @@ export async function handler(
if (v === "$model") return headers.set(k, model) if (v === "$model") return headers.set(k, model)
if (v === "$request") return headers.set(k, requestId) if (v === "$request") return headers.set(k, requestId)
if (v === "$project") return headers.set(k, projectId) if (v === "$project") return headers.set(k, projectId)
if (v === "$workspace" && authInfo?.workspaceID) return headers.set(k, authInfo.workspaceID) if (v === "$workspace") {
if (authInfo?.workspaceID) headers.set(k, authInfo.workspaceID)
return
}
headers.set(k, v) headers.set(k, v)
}) })
headers.delete("host") headers.delete("host")
@@ -213,8 +237,21 @@ export async function handler(
return headers return headers
})(), })(),
body: reqBody, body: reqBody,
// Propagate caller disconnects to the upstream provider request so
// abandoned Console requests do not leave orphaned inference work open.
signal: input.request.signal,
}) })
if (providerInfo.id.startsWith("console.")) {
const resEndpointId = res.headers.get("x-opencode-endpoint-id")
const resEndpointModelId = res.headers.get("x-opencode-upstream-model-id")
if (resEndpointId && resEndpointModelId)
logger.metric({
provider: resEndpointId,
"provider.model": resEndpointModelId,
})
}
if (res.status !== 200) { if (res.status !== 200) {
logger.metric({ logger.metric({
"llm.error.code": res.status, "llm.error.code": res.status,
@@ -271,7 +308,7 @@ export async function handler(
const costInfo = calculateCost(modelInfo, usageInfo) const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo) await trialLimiter?.track(usageInfo)
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo) await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent) await providerBudgetTracker?.track(providerInfo.id, providerInfo.budgetPriority, costInfo.totalCostInCent)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo) await reload(billingSource, authInfo, costInfo)
json.cost = calculateOccurredCost(billingSource, costInfo) json.cost = calculateOccurredCost(billingSource, costInfo)
@@ -298,9 +335,10 @@ export async function handler(
const streamConverter = createStreamPartConverter(providerInfo.format, opts.format) const streamConverter = createStreamPartConverter(providerInfo.format, opts.format)
const usageParser = providerInfo.createUsageParser() const usageParser = providerInfo.createUsageParser()
const binaryDecoder = providerInfo.createBinaryStreamDecoder() const binaryDecoder = providerInfo.createBinaryStreamDecoder()
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
const stream = new ReadableStream({ const stream = new ReadableStream({
start(c) { start(c) {
const reader = res.body?.getReader() reader = res.body?.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
const encoder = new TextEncoder() const encoder = new TextEncoder()
@@ -332,7 +370,11 @@ export async function handler(
timestampLastByte, timestampLastByte,
usageInfo, usageInfo,
) )
await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent) await providerBudgetTracker?.track(
providerInfo.id,
providerInfo.budgetPriority,
costInfo.totalCostInCent,
)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo) await reload(billingSource, authInfo, costInfo)
const cost = calculateOccurredCost(billingSource, costInfo) const cost = calculateOccurredCost(billingSource, costInfo)
@@ -382,6 +424,11 @@ export async function handler(
return pump() return pump()
}, },
cancel() {
// When the downstream caller stops reading, release the upstream
// response body instead of keeping the provider/inference stream alive.
return reader?.cancel()
},
}) })
return new Response(stream, { return new Response(stream, {
status: resStatus, status: resStatus,
@@ -389,6 +436,15 @@ export async function handler(
headers: resHeaders, headers: resHeaders,
}) })
} catch (error: any) { } catch (error: any) {
// The caller disconnected before we finished. Because the outbound provider
// request shares input.request.signal, an aborted caller surfaces here as an
// AbortError. There is no client left to receive a body, so skip the error
// metric and 500 and return a quiet client-closed response.
if (input.request.signal.aborted || error?.name === "AbortError") {
logger.debug("REQUEST ABORTED BY CALLER")
return new Response(null, { status: 499 })
}
logger.metric({ logger.metric({
"error.type": error.constructor.name, "error.type": error.constructor.name,
"error.message": error.message, "error.message": error.message,
@@ -402,6 +458,15 @@ export async function handler(
} catch {} } catch {}
} }
if (error instanceof RegionError)
return new Response(
JSON.stringify({
type: "error",
error: { type: error.constructor.name, message: error.message },
}),
{ status: 403 },
)
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message. // Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
if ( if (
error instanceof AuthError || error instanceof AuthError ||
@@ -499,7 +564,12 @@ export async function handler(
stickyProviderId: string | undefined, stickyProviderId: string | undefined,
modelTpmLimits: Record<string, number> | undefined, modelTpmLimits: Record<string, number> | undefined,
modelTpsLimits: Record<string, { qualify: number; unqualify: number }> | undefined, modelTpsLimits: Record<string, { qualify: number; unqualify: number }> | undefined,
providerBudgetUsage: Record<string, number> | undefined, providerBudget:
| {
qualify: (providerId: string, priority: number) => boolean
prefer: (providerId: string, priority: number) => boolean
}
| undefined,
) { ) {
const modelProvider = (() => { const modelProvider = (() => {
// Byok is top priority b/c if user set their own API key, we should use it // Byok is top priority b/c if user set their own API key, we should use it
@@ -517,67 +587,69 @@ export async function handler(
})) }))
} }
if (retry.retryCount !== MAX_FAILOVER_RETRIES) { // Use fallback provider if max retries reached
let topPriority = Infinity const fallbackProvider = allProviders.find((provider) => provider.id === modelInfo.fallbackProvider)
const providers = allProviders if (retry.retryCount === MAX_FAILOVER_RETRIES) return fallbackProvider
.filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id))
.filter((provider) => {
if (provider.budgetMode !== "fill") return true
const budget = zenData.providers[provider.id]?.budget
if (budget === undefined) return false
return (providerBudgetUsage?.[provider.id] ?? 0) < centsToMicroCents(budget * 100)
})
.filter((provider) => {
if (!provider.tpmLimit) return true
const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0
return usage < provider.tpmLimit * 1_000_000
})
.filter((provider) => {
if (!provider.tpsGoal) return true
const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? {
qualify: 0,
unqualify: 0,
}
const isLowTps = tps.qualify + tps.unqualify > 10 && tps.qualify < tps.unqualify
return !isLowTps
})
.map((provider) => {
topPriority = Math.min(topPriority, provider.priority)
return provider
})
.filter((p) => p.priority <= topPriority)
.flatMap((provider) => Array<typeof provider>(provider.weight).fill(provider))
// Use the last 4 characters of session ID to select a provider let topPriority = Infinity
let h = 0 const providers = allProviders
const l = stickyId.length .filter((provider) => provider.weight !== 0)
for (let i = l - 4; i < l; i++) { .filter((provider) => !retry.excludeProviders.includes(provider.id))
h = (h * 31 + stickyId.charCodeAt(i)) | 0 // 32-bit int .filter((provider) => {
} if (provider.budgetPriority === undefined) return true
const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1 if (!providerBudget) return true
const provider = providers[index || 0] return providerBudget.qualify(provider.id, provider.budgetPriority)
})
.filter((provider) => {
if (!provider.tpmLimit) return true
const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0
return usage < provider.tpmLimit * 1_000_000
})
.filter((provider) => {
if (!provider.tpsGoal) return true
const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? {
qualify: 0,
unqualify: 0,
}
const isLowTps = tps.qualify + tps.unqualify > 10 && tps.qualify < tps.unqualify
return !isLowTps
})
.map((provider) => {
topPriority = Math.min(topPriority, provider.priority)
return provider
})
.filter((p) => p.priority <= topPriority)
.flatMap((provider) => Array<typeof provider>(provider.weight).fill(provider))
// sticky provider does not exist => use selected provider // Use the last 4 characters of session ID to select a provider
if (!stickyProviderId) return provider let h = 0
const stickProvider = allProviders.find((provider) => provider.id === stickyProviderId) const l = stickyId.length
if (!stickProvider) return provider for (let i = l - 4; i < l; i++) {
h = (h * 31 + stickyId.charCodeAt(i)) | 0 // 32-bit int
}
const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1
const provider = providers[index || 0] ?? fallbackProvider
// stick provider exists + selected provider is API type => use sticky provider // sticky provider does not exist => use selected provider
if (!provider.tpsGoal) return stickProvider if (!stickyProviderId) return provider
const stickProvider = allProviders.find((provider) => provider.id === stickyProviderId)
if (!stickProvider) return provider
// stick provier exists + selected provider is GPU type + GPU not idle => use selected provider const preferBudgetProvider =
provider.budgetPriority !== undefined && providerBudget?.prefer(provider.id, provider.budgetPriority)
const preferTpsProvider = (() => {
if (!provider.tpsGoal) return false
const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? { const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? {
qualify: 0, qualify: 0,
unqualify: 0, unqualify: 0,
} }
if (tps.qualify <= tps.unqualify * 3) return stickProvider return tps.qualify > tps.unqualify * 3
})()
return provider if (!preferBudgetProvider && !preferTpsProvider) return stickProvider
}
// fallback provider return provider
return allProviders.find((provider) => provider.id === modelInfo.fallbackProvider)
})() })()
if (!modelProvider) throw new ModelError(t("zen.api.error.noProviderAvailable")) if (!modelProvider) throw new ModelError(t("zen.api.error.noProviderAvailable"))
@@ -614,7 +686,10 @@ export async function handler(
tx tx
.select({ .select({
apiKey: KeyTable.id, apiKey: KeyTable.id,
workspaceID: KeyTable.workspaceID, workspace: {
id: WorkspaceTable.id,
region: WorkspaceTable.region,
},
billing: { billing: {
balance: BillingTable.balance, balance: BillingTable.balance,
paymentMethodID: BillingTable.paymentMethodID, paymentMethodID: BillingTable.paymentMethodID,
@@ -692,13 +767,13 @@ export async function handler(
if ( if (
modelInfo.id.startsWith("alpha-") && modelInfo.id.startsWith("alpha-") &&
Resource.App.stage === "production" && Resource.App.stage === "production" &&
!ADMIN_WORKSPACES.includes(data.workspaceID) !ADMIN_WORKSPACES.includes(data.workspace.id)
) )
throw new AuthError(t("zen.api.error.modelNotSupported", { model: modelInfo.id })) throw new AuthError(t("zen.api.error.modelNotSupported", { model: modelInfo.id }))
logger.metric({ logger.metric({
api_key: data.apiKey, api_key: data.apiKey,
workspace: data.workspaceID, workspace: data.workspace.id,
user_id: data.user.id, user_id: data.user.id,
...(() => { ...(() => {
if (data.billing.subscription) if (data.billing.subscription)
@@ -715,13 +790,14 @@ export async function handler(
return { return {
apiKeyId: data.apiKey, apiKeyId: data.apiKey,
workspaceID: data.workspaceID, workspaceID: data.workspace.id,
region: data.workspace.region,
billing: data.billing, billing: data.billing,
user: data.user, user: data.user,
black: data.black, black: data.black,
lite: data.lite, lite: data.lite,
provider: data.provider, provider: data.provider,
isFree: ADMIN_WORKSPACES.includes(data.workspaceID), isFree: ADMIN_WORKSPACES.includes(data.workspace.id),
isDisabled: !!data.timeDisabled, isDisabled: !!data.timeDisabled,
} }
} }
@@ -20,7 +20,7 @@ export function createModelTpsLimiter(providers: { id: string; model: string; tp
) )
const now = Date.now() const now = Date.now()
const currInterval = toInterval(new Date(now)) const currInterval = toInterval(new Date(now))
const prevInterval = toInterval(new Date(now - 60 * 1000)) const prevInterval = toInterval(new Date(now - 60_000))
return { return {
check: async () => { check: async () => {
@@ -2,50 +2,148 @@ import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { buildRateLimitKey, getRedis } from "./redis" import { buildRateLimitKey, getRedis } from "./redis"
import { logger } from "./logger" import { logger } from "./logger"
// Per-provider, per-minute budget with priorities. The budget belongs to a
// provider and is shared across every model that routes to it. Each model's
// provider entry carries a `budgetPriority`: priority 1 ("always") routes
// unconditionally, while higher priorities ("fill") only route while the provider's
// current-minute spend through that priority is still under budget.
//
// Spend is tracked per (provider, priority, minute) so a fill priority can yield its
// leftover headroom to the next priority down. The previous minute is also read so
// higher priorities can reserve the next minute's budget first.
export function createProviderBudgetTracker( export function createProviderBudgetTracker(
providers: { providers: {
id: string id: string
budget?: number budget?: number
budgetContribution?: number budgetContribution?: number
budgetMode?: "always" | "fill" budgetPriority?: number
}[], }[],
) { ) {
const tracked = providers.filter( const tracked = providers.filter(
(provider) => provider.budget !== undefined && provider.budgetContribution !== undefined, (provider) =>
provider.budget !== undefined &&
provider.budgetContribution !== undefined &&
provider.budgetPriority !== undefined,
) )
if (tracked.length === 0) return undefined if (tracked.length === 0) return undefined
const interval = new Date() const intervalAt = (date: Date) =>
.toISOString() date
.replace(/[^0-9]/g, "") .toISOString()
.substring(0, 12) .replace(/[^0-9]/g, "")
.substring(0, 12)
const now = new Date()
const currInterval = intervalAt(now)
const prevInterval = intervalAt(new Date(now.getTime() - 60_000))
const redis = getRedis() const redis = getRedis()
const keys = Object.fromEntries( const key = (providerId: string, priority: number, withInterval: string) =>
tracked.map((provider) => [provider.id, buildRateLimitKey("provider-budget", provider.id, interval)]), buildRateLimitKey("provider-budget", `${providerId}:${priority}`, withInterval)
)
let budgetUsage: Record<string, number> = {} const budgetByProvider = tracked.reduce<Record<string, number>>((acc, provider) => {
acc[provider.id] = provider.budget!
return acc
}, {})
const maxPriorityByProvider = tracked.reduce<Record<string, number>>((acc, provider) => {
acc[provider.id] = Math.max(acc[provider.id] ?? 0, provider.budgetPriority!)
return acc
}, {})
// Effective budget in micro-cents per provider/priority, computed in check()
// from the configured budget minus previous-minute usage from higher priorities.
let effectiveBudget: Record<string, Record<number, number>> = {}
// Cumulative current-minute spend through each priority, per provider.
let spentThroughPriority: Record<string, Record<number, number>> = {}
let previousSpentThroughPriority: Record<string, Record<number, number>> = {}
return { return {
// Returns whether a provider at a given priority still has budget headroom.
// Priority 1 always qualifies; higher priorities qualify only while everything through
// the current priority hasn't already filled the previous-minute adjusted
// budget.
check: async () => { check: async () => {
const ids = tracked.map((provider) => provider.id) const reads = Object.entries(maxPriorityByProvider).flatMap(([providerId, maxPriority]) =>
if (ids.length === 0) return {} Array.from({ length: maxPriority }, (_, index) => index + 1).flatMap((priority) => [
const values = await redis.mget<(string | number | null)[]>(ids.map((id) => keys[id])) { providerId, priority, interval: currInterval, prev: false },
budgetUsage = Object.fromEntries(ids.map((id, index) => [id, Number(values[index] ?? 0)])) { providerId, priority, interval: prevInterval, prev: true },
return budgetUsage ]),
)
const values = await redis.mget<(string | number | null)[]>(
reads.map((r) => key(r.providerId, r.priority, r.interval)),
)
const current: Record<string, Record<number, number>> = {}
const previous: Record<string, Record<number, number>> = {}
reads.forEach((r, index) => {
const amount = Number(values[index] ?? 0)
if (r.prev) {
previous[r.providerId] ??= {}
previous[r.providerId][r.priority] = amount
return
}
current[r.providerId] ??= {}
current[r.providerId][r.priority] = amount
})
effectiveBudget = {}
spentThroughPriority = {}
previousSpentThroughPriority = {}
Object.entries(maxPriorityByProvider).forEach(([providerId, maxPriority]) => {
const providerBudget = budgetByProvider[providerId]
if (providerBudget === undefined) return
const budget = centsToMicroCents(providerBudget * 100)
let currentRunning = 0
let previousRunning = 0
effectiveBudget[providerId] = {}
spentThroughPriority[providerId] = {}
previousSpentThroughPriority[providerId] = {}
Array.from({ length: maxPriority }, (_, index) => index + 1).forEach((priority) => {
currentRunning += current[providerId]?.[priority] ?? 0
effectiveBudget[providerId][priority] = Math.max(0, budget - previousRunning)
previousRunning += previous[providerId]?.[priority] ?? 0
spentThroughPriority[providerId][priority] = currentRunning
previousSpentThroughPriority[providerId][priority] = previousRunning
})
})
return {
// Priority 1 is unconditional. Higher priorities gate on the spend through
// the current priority against the effective budget.
qualify: (providerId: string, priority: number) => {
if (priority <= 1) return true
const budget = effectiveBudget[providerId]?.[priority]
if (budget === undefined) return false
const spentThroughCurrentPriority = spentThroughPriority[providerId]?.[priority] ?? 0
return spentThroughCurrentPriority < budget
},
prefer: (providerId: string, priority: number) => {
const providerBudget = budgetByProvider[providerId]
if (providerBudget === undefined) return false
const budget = centsToMicroCents(providerBudget * 100)
const previousUsage = previousSpentThroughPriority[providerId]?.[priority]
if (previousUsage === undefined) return false
return previousUsage < budget * 0.8
},
}
}, },
track: async (provider: string, costInCent: number) => { track: async (provider: string, priority: number | undefined, costInCent: number) => {
const config = tracked.find((item) => item.id === provider) if (priority === undefined) return
const config = tracked.find((item) => item.id === provider && item.budgetPriority === priority)
if (!config) return if (!config) return
if (config.budgetContribution === undefined) return if (config.budgetContribution === undefined) return
const cost = centsToMicroCents(costInCent * config.budgetContribution) const cost = centsToMicroCents(costInCent * config.budgetContribution)
if (cost <= 0) return if (cost <= 0) return
const redisKey = key(provider, priority, currInterval)
const pipeline = redis.pipeline() const pipeline = redis.pipeline()
pipeline.incrby(keys[provider], cost) pipeline.incrby(redisKey, cost)
pipeline.expire(keys[provider], 120) // Keep two minutes so the previous interval is readable for budget adjustment.
pipeline.expire(redisKey, 120)
await pipeline.exec() await pipeline.exec()
logger.metric({ logger.metric({
"provider.budget_usage": budgetUsage[provider] + cost, "provider.budget_usage": cost,
"model.budget_usage": cost, "provider.budget_priority": priority,
}) })
}, },
} }
@@ -0,0 +1 @@
ALTER TABLE `workspace` ADD `region` json;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -37,7 +37,7 @@ export namespace ZenData {
priority: z.number().optional(), priority: z.number().optional(),
tpmLimit: z.number().optional(), tpmLimit: z.number().optional(),
tpsGoal: z.number().optional(), tpsGoal: z.number().optional(),
budgetMode: z.enum(["always", "fill"]).optional(), budgetPriority: z.number().optional(),
budgetContribution: z.number().optional(), budgetContribution: z.number().optional(),
weight: z.number().optional(), weight: z.number().optional(),
disabled: z.boolean().optional(), disabled: z.boolean().optional(),
@@ -1,4 +1,4 @@
import { primaryKey, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core" import { json, primaryKey, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { timestamps, ulid } from "../drizzle/types" import { timestamps, ulid } from "../drizzle/types"
export const WorkspaceTable = mysqlTable( export const WorkspaceTable = mysqlTable(
@@ -7,6 +7,7 @@ export const WorkspaceTable = mysqlTable(
id: ulid("id").notNull().primaryKey(), id: ulid("id").notNull().primaryKey(),
slug: varchar("slug", { length: 255 }), slug: varchar("slug", { length: 255 }),
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
region: json("region").$type<("us" | "eu" | "sg" | "cn")[]>(),
...timestamps, ...timestamps,
}, },
(table) => [uniqueIndex("slug").on(table.slug)], (table) => [uniqueIndex("slug").on(table.slug)],
+25 -3
View File
@@ -11,6 +11,9 @@ import { Key } from "./key"
import { and, eq, isNull, sql } from "drizzle-orm" import { and, eq, isNull, sql } from "drizzle-orm"
export namespace Workspace { export namespace Workspace {
export const Region = z.enum(["us", "eu", "sg", "cn"])
export type Region = z.infer<typeof Region>
export const create = fn( export const create = fn(
z.object({ z.object({
name: z.string().min(1), name: z.string().min(1),
@@ -57,22 +60,41 @@ export namespace Workspace {
export const update = fn( export const update = fn(
z.object({ z.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255).optional(),
region: z.array(Region).min(1).optional(),
}), }),
async ({ name }) => { async (input) => {
Actor.assertAdmin() Actor.assertAdmin()
const workspaceID = Actor.workspace() const workspaceID = Actor.workspace()
return await Database.use((tx) => return await Database.use((tx) =>
tx tx
.update(WorkspaceTable) .update(WorkspaceTable)
.set({ .set({
name, ...("name" in input ? { name: input.name } : {}),
...("region" in input ? { region: input.region } : {}),
}) })
.where(eq(WorkspaceTable.id, workspaceID)), .where(eq(WorkspaceTable.id, workspaceID)),
) )
}, },
) )
export const setDefaultRegion = fn(
z.object({
country: z.string().optional(),
}),
async (input) => {
const region: Workspace.Region[] =
input.country?.toUpperCase() === "CN" ? ["us", "eu", "sg", "cn"] : ["us", "eu", "sg"]
await Database.use((tx) =>
tx
.update(WorkspaceTable)
.set({ region })
.where(and(eq(WorkspaceTable.id, Actor.workspace()), isNull(WorkspaceTable.region))),
)
return region
},
)
export const remove = fn(z.void(), async () => { export const remove = fn(z.void(), async () => {
await Database.use((tx) => await Database.use((tx) =>
tx tx
+4 -1
View File
@@ -16,6 +16,8 @@
"opencode": "./bin/opencode" "opencode": "./bin/opencode"
}, },
"exports": { "exports": {
"./effect/layer-node": "./src/effect/layer-node.ts",
"./effect/app-node": "./src/effect/app-node.ts",
"./session/runner": "./src/session/runner/index.ts", "./session/runner": "./src/session/runner/index.ts",
"./system-context": "./src/system-context/index.ts", "./system-context": "./src/system-context/index.ts",
"./*": "./src/*.ts" "./*": "./src/*.ts"
@@ -102,6 +104,7 @@
"ai-gateway-provider": "3.1.2", "ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8", "bun-pty": "0.4.8",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
@@ -118,7 +121,7 @@
"npm-package-arg": "13.0.2", "npm-package-arg": "13.0.2",
"semver": "^7.6.3", "semver": "^7.6.3",
"turndown": "7.2.0", "turndown": "7.2.0",
"venice-ai-sdk-provider": "2.0.2", "venice-ai-sdk-provider": "2.1.1",
"which": "6.0.1", "which": "6.0.1",
"xdg-basedir": "5.1.0", "xdg-basedir": "5.1.0",
"zod": "catalog:" "zod": "catalog:"
+3
View File
@@ -1,5 +1,6 @@
export * as AgentV2 from "./agent" export * as AgentV2 from "./agent"
import { makeLocationNode } from "./effect/app-node"
import { Array, Context, Effect, Layer, Types } from "effect" import { Array, Context, Effect, Layer, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent" import { Agent } from "@opencode-ai/schema/agent"
import { State } from "./state" import { State } from "./state"
@@ -106,3 +107,5 @@ export const layer = Layer.effect(
) )
export const locationLayer = layer export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [] })
+3
View File
@@ -1,5 +1,6 @@
export * as AISDK from "./aisdk" export * as AISDK from "./aisdk"
import { makeLocationNode } from "./effect/app-node"
import type { LanguageModelV3 } from "@ai-sdk/provider" import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect" import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
@@ -231,4 +232,6 @@ export const locationLayer = Layer.effect(
}), }),
) )
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
export const defaultLayer = locationLayer export const defaultLayer = locationLayer
+3
View File
@@ -2,6 +2,7 @@ export * as BackgroundJob from "./background-job"
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect" import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
import { Identifier } from "./id/id" import { Identifier } from "./id/id"
import { makeGlobalNode } from "./effect/app-node"
export type Status = "running" | "completed" | "error" | "cancelled" export type Status = "running" | "completed" | "error" | "cancelled"
@@ -362,3 +363,5 @@ export const make = Effect.gen(function* () {
export const layer = Layer.effect(Service, make) export const layer = Layer.effect(Service, make)
export const defaultLayer = layer export const defaultLayer = layer
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
+3
View File
@@ -1,5 +1,6 @@
export * as Catalog from "./catalog" export * as Catalog from "./catalog"
import { makeLocationNode } from "./effect/app-node"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect" import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
import { Catalog } from "@opencode-ai/schema/catalog" import { Catalog } from "@opencode-ai/schema/catalog"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
@@ -296,3 +297,5 @@ export const locationLayer = layer.pipe(
Layer.provideMerge(Integration.locationLayer), Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(Policy.locationLayer), Layer.provideMerge(Policy.locationLayer),
) )
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] })
+3
View File
@@ -1,5 +1,6 @@
export * as CommandV2 from "./command" export * as CommandV2 from "./command"
import { makeLocationNode } from "./effect/app-node"
import { Context, Effect, Layer, Types } from "effect" import { Context, Effect, Layer, Types } from "effect"
import { Command } from "@opencode-ai/schema/command" import { Command } from "@opencode-ai/schema/command"
import { State } from "./state" import { State } from "./state"
@@ -59,3 +60,5 @@ export const layer = Layer.effect(
) )
export const locationLayer = layer export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [] })
+7
View File
@@ -1,5 +1,6 @@
export * as Config from "./config" export * as Config from "./config"
import { makeLocationNode } from "./effect/app-node"
import path from "path" import path from "path"
import { type ParseError, parse } from "jsonc-parser" import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, Schema } from "effect" import { Context, Effect, Layer, Option, Schema } from "effect"
@@ -218,3 +219,9 @@ export const layer = Layer.effect(
) )
export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer)) export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer))
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Global.node, Location.node, Policy.node],
})
@@ -1,6 +1,7 @@
export * as MoveSession from "./move-session" export * as MoveSession from "./move-session"
import { Context, DateTime, Effect, Layer, Schema } from "effect" import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "../effect/app-node"
import { EventV2 } from "../event" import { EventV2 } from "../event"
import { Git } from "../git" import { Git } from "../git"
import { Location } from "../location" import { Location } from "../location"
@@ -146,3 +147,9 @@ export const defaultLayer = layer.pipe(
Layer.provide(ProjectV2.defaultLayer), Layer.provide(ProjectV2.defaultLayer),
Layer.provide(SessionStore.defaultLayer), Layer.provide(SessionStore.defaultLayer),
) )
export const node = makeGlobalNode({
service: Service,
layer,
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node],
})
+3
View File
@@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential" import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration" import { Integration } from "@opencode-ai/schema/integration"
import { Database } from "./database/database" import { Database } from "./database/database"
import { makeGlobalNode } from "./effect/app-node"
import { CredentialTable } from "./credential/sql" import { CredentialTable } from "./credential/sql"
export const ID = Credential.ID export const ID = Credential.ID
@@ -135,3 +136,5 @@ export const layer = Layer.effect(
) )
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })

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