Compare commits

...

139 Commits

Author SHA1 Message Date
opencode-agent[bot]
d0757bf271 Apply PR #16230: feat(tui): add initial support for workspaces into the tui 2026-03-05 22:20:51 +00:00
opencode-agent[bot]
592b2c4778 Apply PR #16221: Fix review/filetree empty states 2026-03-05 22:20:51 +00:00
opencode-agent[bot]
75e879b723 Apply PR #15951: fix(tui): use WinRT clipboard API on Windows 2026-03-05 22:19:31 +00:00
opencode-agent[bot]
ed12093474 Apply PR #15863: ANIMATION RETRIBUTION II: PROLAPSED OUBLIETTE 2026-03-05 22:19:31 +00:00
opencode-agent[bot]
febe424d50 Apply PR #15698: tweak(ui): add sidebar fade mask under new buttons 2026-03-05 22:19:08 +00:00
opencode-agent[bot]
e49129599e Apply PR #15697: tweak(ui): make questions popup collapsible 2026-03-05 22:18:05 +00:00
opencode-agent[bot]
442dd578a2 Apply PR #15487: core: make account login upgrades safe while adding multi-account workspace auth 2026-03-05 22:18:05 +00:00
opencode-agent[bot]
afa00009ee Apply PR #15269: feat(ui): make edited file references clickable in desktop session UI 2026-03-05 22:17:42 +00:00
opencode-agent[bot]
6e44beabc6 Apply PR #15266: feat(app): changelog with PR links 2026-03-05 22:17:42 +00:00
opencode-agent[bot]
5cf80c1042 Apply PR #15250: feat(app): view archived sessions & unarchive 2026-03-05 22:17:22 +00:00
opencode-agent[bot]
9af18f0b0f Apply PR #15013: refactor: replace Bun.sleep with node timers 2026-03-05 22:17:22 +00:00
opencode-agent[bot]
732ccbe8c1 Apply PR #15012: refactor(opencode): replace Bun.which with npm which 2026-03-05 22:17:21 +00:00
opencode-agent[bot]
ea22e8ceb1 Apply PR #14677: feat: add experimental hashline edit mode with dual-schema support 2026-03-05 22:14:11 +00:00
opencode-agent[bot]
b17a5c4a48 Apply PR #14471: [DO NOT MERGE]: beta badge for desktop app 2026-03-05 22:14:11 +00:00
opencode-agent[bot]
8024f55791 Apply PR #14307: fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering 2026-03-05 22:14:10 +00:00
opencode-agent[bot]
d127e80d3c Apply PR #12633: feat(tui): add auto-accept mode for permission requests 2026-03-05 22:14:10 +00:00
opencode-agent[bot]
ee2c3066e2 Apply PR #12022: feat: update tui model dialog to utilize model family to reduce noise in list 2026-03-05 22:14:10 +00:00
Kit Langton
fec8d5bcf1 core: prevent share auth headers from being sent cross-origin 2026-03-05 16:56:36 -05:00
anduimagui
c664b4061d fix(app): apply file-link line targets in in-app fallback
When external open is unavailable or fails, opening from clickable file refs now preserves optional line numbers by selecting the target line in the review file tab.
2026-03-05 21:36:46 +00:00
anduimagui
984ef4711e feat(ui): open clicked file refs in desktop default apps
Route markdown and tool file-link clicks to the desktop openPath integration when available, with in-app review fallback if external open fails. Also harden file-ref parsing for wrapped paths and ensure in-app fallback activates the opened tab.
2026-03-05 21:36:46 +00:00
anduimagui
c77bd228f7 fix(ui): make tool file links keyboard-accessible
Render clickable tool filenames as native buttons with focus-visible styles so edit/write/apply_patch file links are accessible and keep existing click-to-open behavior.
2026-03-05 21:35:58 +00:00
anduimagui
89b05eb80f refactor(ui): extract markdown file reference parser
Move inline file-path parsing into a shared helper and add focused tests, including Windows file URL handling, so clickable markdown file links stay stable as parsing rules evolve.
2026-03-05 21:35:58 +00:00
anduimagui
de2def16ee fix(ui): restore singular project path helper name 2026-03-05 21:35:58 +00:00
anduimagui
0a5853496b feat(ui): make file references clickable in session output
Add clickable file links for inline markdown path references and edit/write/apply_patch file entries so desktop and web users can open changed files directly from the timeline.
2026-03-05 21:35:57 +00:00
James Long
56a4328aa6 feat(tui): add initial support for workspaces into the tui 2026-03-05 16:15:17 -05:00
Kit Langton
e923047219 core: route session sharing through org-scoped control APIs 2026-03-05 16:04:43 -05:00
Kit Langton
b19dc933a4 core: resolve account config env templates after loading control token 2026-03-05 16:03:10 -05:00
Kit Langton
902268e0d1 core: rename workspace scope to org across account and session surfaces 2026-03-05 16:02:17 -05:00
Kit Langton
95c7facdfc fix(ui): hold pending state on tool panels to match context tool behavior
Add 1s hold to ToolCallPanel pending state so the expand/collapse arrow
stays hidden until the rolling results fade out. Previously bash tool
panels became expandable immediately on completion while context tools
stayed locked for the hold duration, creating inconsistent behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:58:34 -05:00
Kit Langton
b8c9810b1f refactor(ui): extract rolling results components from message-part.tsx
Extract context-tool and shell rolling result clusters into dedicated
files, and shared reactive primitives into tool-utils.ts. Unify hold
duration to 1s for both rolling results, gate the context tool header
on the held pending signal so users can't expand until the rolling
animation collapses, and add a soft bottom-edge fade to the rolling
results viewport during resize animations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:56:30 -05:00
Kit Langton
66d487a348 feat(ui): enhance timeline simulator with streaming bash, webfetch, and layout fixes
- Bash tool now streams output chunk-by-chunk on repeated 'b' presses
  with 4 random command variants (test run, install, tsc, git log)
- Add WebFetch tool ('w' key) cycling through realistic URLs
- Fix background color to match real session (--background-stronger)
- Add column-reverse scroll to pin content to bottom like real app
- Add max-width container constraints matching real session layout
- Fix user message not appearing instantly by applying turn setup
  events synchronously instead of through the advance queue
- Fix appendAndPlay race condition with advance timer
- Fix completeTurn not clearing currentTurn
- Include filediff metadata in edit tool running state
- Use realistic varied user prompts instead of generic text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:53:31 -05:00
Frank
a6978167ae ci: fix 2026-03-05 15:46:17 -05:00
Frank
80c36c788c zen: gpt5.3 codex spark 2026-03-05 15:40:51 -05:00
Jun
76cdc668e8 fix(console): follow-up for #13108 docs/en routing and locale cookie sync (#13608) 2026-03-05 14:31:38 -06:00
Kit Langton
fd7494c080 feat(ui): add anchored shell rolling output
Keep the shell command fixed at the top while live output rolls beneath it, with a short linger after completion and dimmed prompt styling for readability. Extend rolling-results with optional fixed rows and update simulator/auto-scroll behavior used while testing this flow.
2026-03-05 15:25:22 -05:00
David Hill
7271885260 tui: add top padding to file tree panels to improve visual separation from headers 2026-03-05 20:02:04 +00:00
David Hill
38b5626095 Revert "tui: add top margin spacing to file tree for better visual separation"
This reverts commit fcea784a7d.
2026-03-05 19:57:47 +00:00
David Hill
fcea784a7d tui: add top margin spacing to file tree for better visual separation 2026-03-05 19:56:02 +00:00
Adam
2ba1ecabc9 fix(app): load tab on open file 2026-03-05 13:40:25 -06:00
David Hill
d28d4b2aad fix(app): always show filetree tabs divider
Removes scroll-based border toggling and keeps the bottom divider under filetree tabs visible at all times.
2026-03-05 19:38:19 +00:00
David Hill
e319d8de40 fix(app): polish review and filetree empty states
Removes branding/title in review empty states, hides the changes selector when there are no diffs, aligns filetree empty messaging, and updates the no-git copy.
2026-03-05 19:34:46 +00:00
opencode-agent[bot]
e3b6d84b57 docs(i18n): sync locale docs from english changes 2026-03-05 19:01:10 +00:00
Frank
0638e49b02 zen: gpt5.4 2026-03-05 19:01:10 +00:00
opencode
2c58964a6b release: v1.2.18 2026-03-05 19:01:03 +00:00
opencode-agent[bot]
37b0a189a4 docs(i18n): sync locale docs from english changes 2026-03-05 18:41:35 +00:00
Kit Langton
e11dcf7240 fix(ui): align context tool row font size
Use the base tool font size for rolling and expanded context rows so nested read/search lines match the rest of the tool UI.
2026-03-05 13:36:00 -05:00
Kit Langton
02c6630589 revert(opencode): roll back session guard changes
Temporarily remove the recent  defensive session and read-tool metadata updates so we can validate behavior without them and reintroduce selectively later.
2026-03-05 13:33:38 -05:00
Frank
9bf4bf209f zen: gpt5.4 2026-03-05 13:27:12 -05:00
Kit Langton
37da6c84a0 fix(ui): align Home/End scroll behavior with timeline spring
Use the same spring-based, cancellable scroll path for Home/End that the jump-to-bottom control uses so keyboard and button scrolling feel consistent.
2026-03-05 13:24:47 -05:00
Kit Langton
49aedd506a fix(app): stabilize session message navigation
Stop refreshed hash state from re-pinning message navigation, avoid double anchor-preserve starts, and keep manual scrolling from fighting the preserved anchor after layout changes.
2026-03-05 13:22:01 -05:00
Kit Langton
2378ea8e16 fix(app): reserve space for session timeline header
Add top inset for the extracted session header so its sticky row and gradient no longer overlap the first timeline turn.
2026-03-05 13:00:16 -05:00
Kit Langton
ee63b49c95 fix(ui): honor reduced motion across timeline motion
Make spring-driven layout changes snap when reduced motion is enabled and disable the remaining timeline and explore animations that were still easing despite the system preference.
2026-03-05 11:01:00 -05:00
Kit Langton
dfd515b4b9 tweak(ui): polish timeline motion details
Align the extracted timeline header with the message gutter, flip TextReveal to a bottom-to-top wipe, and shorten the explore rolling list so the motion reads more cleanly.
2026-03-05 10:41:36 -05:00
Kit Langton
2e4a146d27 feat(ui): consolidate timeline motion and composer island updates
Bundle the timeline animation, tool-state, and composer refinements into one commit so the branch lands cleanly on the current dev base without replaying dozens of intermediate checkpoints.
2026-03-05 10:10:49 -05:00
opencode-agent[bot]
9507b0eace chore: update nix node_modules hashes 2026-03-05 15:09:14 +00:00
Dax
4da199697b feat(tui): add onClick handler to InlineTool and Task components (#16187) 2026-03-05 15:02:30 +00:00
Adam
9cccaa693a chore(app): ghostty-web fork 2026-03-05 08:58:11 -06:00
Dax Raad
bb37e908ad ci: remove unused publishConfig that was breaking npm publishing 2026-03-05 09:45:30 -05:00
Dax Raad
d802e28381 update sdk package.json 2026-03-05 09:45:30 -05:00
Adam
7665b8e30d fix(app): stale keyed show errors 2026-03-05 08:42:50 -06:00
Adam
a3d4ea0de1 fix(app): locale error 2026-03-05 08:10:36 -06:00
Adam
152df2428d fix(app): stale read error 2026-03-05 08:09:29 -06:00
Adam
1a420a1a71 fix(app): websearch and codesearch tool rendering 2026-03-05 08:00:42 -06:00
Adam
4c185c70f2 fix(app): provider settings consistency 2026-03-05 08:00:42 -06:00
Adam
6f9e5335dc fix(app): file icon stability 2026-03-05 08:00:42 -06:00
Adam
6c9ae5ce9f fix(app): file path truncation in session turn 2026-03-05 08:00:42 -06:00
Adam
8cbe7b4a01 fix(app): file icon stability 2026-03-05 08:00:41 -06:00
LukeParkerDev
695a26a168 fix(tui): use WinRT clipboard API on Windows 2026-03-04 13:26:03 +10:00
Alex Yaroshuk
92aab78442 Merge branch 'dev' into feat/changelog 2026-03-04 03:29:28 +08:00
Alex Yaroshuk
1bbd3a71fb fix: wrap DialogChangelog with DataProvider 2026-03-04 03:26:57 +08:00
Alex Yaroshuk
bb2e9fffb1 Merge branch 'dev' into feat/changelog 2026-03-04 01:09:06 +08:00
MakonnenMak
e993acec31 Merge remote-tracking branch 'upstream/dev' into fix/clock-skew-prompt-loop-exit
# Conflicts:
#	packages/ui/src/components/session-turn.tsx
2026-03-02 13:30:01 -05:00
David Hill
611e616010 tui: add right margin to question progress indicator so it doesn't touch the container edge 2026-03-02 14:27:43 +00:00
David Hill
b286c0ae3f tweak(ui): restore questions progress indicator 2026-03-02 12:08:37 +00:00
David Hill
81a61f8dbd tweak(ui): improve collapse area 2026-03-02 11:14:01 +00:00
David Hill
752e449e38 tweak(ui): improve collpase area 2026-03-02 11:11:52 +00:00
Dax Raad
a44f78c34a core: maintain backward compatibility with existing account data by restoring legacy ControlAccountTable alongside new AccountTable structure 2026-03-01 14:21:55 -05:00
Dax Raad
a5d727e7f9 core: enable workspace-aware configuration and account management commands
Switch from boolean active flag to workspace_id tracking so users can select which workspace context to operate in. Login now automatically selects the first available workspace and stores it on the account record.

Logout command now actually removes account records and supports targeting specific accounts by email. Switch command provides an interactive picker to change active workspace. Workspaces command lists all available workspaces across accounts.

Configuration now loads workspace-specific settings from the server when an active workspace is selected, enabling per-workspace customization of opencode behavior.
2026-03-01 14:21:55 -05:00
Dax Raad
7b5b665b4a core: support managing multiple authenticated accounts with individual workspace access
Enable users to authenticate with multiple accounts and switch between
them, accessing workspaces from each account separately.
2026-03-01 14:21:55 -05:00
Dax Raad
b5515dd2f7 core: add device flow authentication commands
Allow users to authenticate via browser-based OAuth device flow
with opencode login command. Includes login, logout, switch account,
and workspaces list commands for managing multiple accounts.
2026-03-01 14:21:55 -05:00
Dax Raad
d16e5b98dc core: rename control module to account for clearer authentication management
Refactor internal authentication system by renaming the control module to account,
making it easier to understand that this handles user account credentials and
tokens. Simplify database schema management by removing the centralized schema
exports and letting each module manage its own tables directly.
2026-03-01 14:21:55 -05:00
Dax Raad
9dbf3a2042 core: rename auth command to providers for clearer credential management
The auth command has been renamed to providers to better reflect its purpose of managing AI provider credentials. This makes it easier for users to discover and use the credential management features when configuring different AI providers.
2026-03-01 14:21:55 -05:00
Alex Yaroshuk
fe0f298293 fix i18n 2026-03-01 06:00:24 +08:00
Alex Yaroshuk
29d90056e9 Merge branch 'dev' into feat/changelog 2026-03-01 05:48:41 +08:00
Alex Yaroshuk
276d60e82a fix ar.ts, sync dialog-select-file.tsx to fix typecheck 2026-03-01 05:45:07 +08:00
Alex Yaroshuk
9ea36ccd9d sync time.ts specific code to fix typecheck 2026-03-01 05:29:04 +08:00
Alex Yaroshuk
b9ca79f3b6 refactor: use createResource + Suspense instead of manual signals, remove unused imports 2026-03-01 03:11:15 +08:00
Alex Yaroshuk
323e7a36da (sync) update getRealtiveTime call to use the new language arg 2026-02-28 10:30:46 +08:00
Alex Yaroshuk
9faaa6130d Merge branch 'dev' into feat/unarchive 2026-02-28 09:45:55 +08:00
David Hill
5d419a0211 tweak(ui): expand question dock toggle area 2026-02-27 21:30:49 +00:00
David Hill
8b168981aa tweak(ui): active state on type your own answer 2026-02-27 18:50:50 +00:00
David Hill
724dd665ec tweak(ui): collapse questions 2026-02-27 18:47:53 +00:00
David Hill
d20698401b tweak(ui): sidebar fade scroll behind button 2026-02-27 17:37:19 +00:00
Shoubhit Dash
ab44597018 Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-02-27 11:52:55 +05:30
Shoubhit Dash
aec95c4d10 stabilize hashline routing and anchors 2026-02-27 09:45:06 +05:30
Shoubhit Dash
b2c82cb897 Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-02-27 08:55:43 +05:30
Alex Yaroshuk
20905212f9 Merge branch 'clean-dev' into feat/changelog 2026-02-27 02:07:31 +08:00
Alex Yaroshuk
76cda30896 use early return instead of 'else' in index.ts 2026-02-27 00:36:50 +08:00
Alex Yaroshuk
4f740306f0 fix layout 2026-02-26 23:47:23 +08:00
Alex Yaroshuk
c7e9851826 wip: unarchive 2026-02-26 03:26:47 +08:00
Dax Raad
bf53e1c24b test(opencode): tolerate Windows PATH extension casing 2026-02-25 00:00:27 -05:00
Dax Raad
50004d1f94 refactor: replace Bun.sleep with node timers 2026-02-24 23:54:49 -05:00
Dax Raad
acd7c5ad55 core: add tests for command resolution to ensure reliable tool discovery across platforms 2026-02-24 23:50:11 -05:00
Dax Raad
cf54b544e3 refactor(opencode): replace Bun.which with npm which 2026-02-24 23:42:25 -05:00
Shoubhit Dash
52b42258fa Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-02-23 15:16:20 +05:30
Shoubhit Dash
3026a005b6 test: reorder hashline config test for beta merge 2026-02-23 10:05:12 +05:30
Shoubhit Dash
a6f802d7fe fix: align codex prompt with edit routing 2026-02-22 22:39:03 +05:30
Shoubhit Dash
9ef803be82 feat: enable hashline by default 2026-02-22 22:31:33 +05:30
Shoubhit Dash
ce5c827a6e chore: remove local opencode config flags 2026-02-22 19:46:59 +05:30
Shoubhit Dash
56decd79db feat: add experimental hashline edit mode 2026-02-22 19:40:34 +05:30
MakonnenMak
fc258ea74f fix: remove as any type cast in processor exit logic 2026-02-20 13:20:10 -05:00
Makonnen
abd9e195ac fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering
When the client clock is ahead of the server, user message IDs (generated
client-side) sort after assistant message IDs (generated server-side).
This broke the prompt loop exit check and the UI message pairing logic.

- Extract shouldExitLoop() into a pure function that uses parentID matching
  instead of relying on ID ordering
- Extract findAssistantMessages() with forward+backward scan to handle
  messages sorted out of expected order due to clock skew
- Remove debug console.log statements added during investigation
- Add tests for both extracted functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:20:10 -05:00
Adam
9d78b69cd3 wip(app): beta badge 2026-02-20 10:59:59 -06:00
Dax
e31f00ad22 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-16 21:50:34 -05:00
Alex Yaroshuk
70b555472e refactor, add caching 2026-02-14 01:13:28 +08:00
Alex Yaroshuk
e514919cc4 changelog refactor 2026-02-11 22:23:39 +08:00
LukeParkerDev
a90e8de050 add missing return 2026-02-11 13:24:17 +10:00
Alex Yaroshuk
ba5121ce0b Merge remote-tracking branch 'upstream/dev' into feat/changelog 2026-02-11 05:06:54 +08:00
Aiden Cline
eabf770053 Merge branch 'dev' into utilize-family-in-dialog 2026-02-10 14:43:15 -06:00
Dax
86d7bdc542 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-09 10:55:01 -05:00
Dax
d3ab78bba0 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-09 10:04:40 -05:00
Dax Raad
a531f3f36d core: run command build agent now auto-accepts file edits to reduce workflow interruptions while still requiring confirmation for bash commands 2026-02-07 20:00:09 -05:00
Dax Raad
bb3382311d tui: standardize autoedit indicator text styling to match other status labels 2026-02-07 19:57:45 -05:00
Dax Raad
ad545d0cc9 tui: allow auto-accepting only edit permissions instead of all permissions 2026-02-07 19:52:53 -05:00
Dax Raad
ac244b1458 tui: add searchable 'toggle' keywords to command palette and show current state in toggle titles 2026-02-07 17:03:34 -05:00
Dax Raad
f202536b65 tui: show enable/disable state in permission toggle and make it searchable by 'toggle permissions' 2026-02-07 16:57:48 -05:00
Dax Raad
405cc3f610 tui: streamline permission toggle command naming and add keyboard shortcut support
Rename 'Toggle autoaccept permissions' to 'Toggle permissions' for clarity
and move the command to the Agent category for better discoverability.
Add permission_auto_accept_toggle keybind to enable keyboard shortcut
toggling of auto-accept mode for permission requests.
2026-02-07 16:51:55 -05:00
Dax Raad
878c1b8c2d feat(tui): add auto-accept mode for permission requests
Add a toggleable auto-accept mode that automatically accepts all incoming
permission requests with a 'once' reply. This is useful for users who want
to streamline their workflow when they trust the agent's actions.

Changes:
- Add permission_auto_accept keybind (default: shift+tab) to config
- Remove default for agent_cycle_reverse (was shift+tab)
- Add auto-accept logic in sync.tsx to auto-reply when enabled
- Add command bar action to toggle auto-accept mode (copy: "Toggle autoaccept permissions")
- Add visual indicator showing 'auto-accept' when active
- Store auto-accept state in KV for persistence across sessions
2026-02-07 16:44:39 -05:00
Alex Yaroshuk
d8bcfd90d3 trnslations, couple more fixes 2026-02-04 21:06:27 +08:00
Alex Yaroshuk
954d31903f fix ui 2026-02-04 20:50:55 +08:00
Alex Yaroshuk
1587d93b29 fixes 2026-02-04 20:35:06 +08:00
Alex Yaroshuk
d364c43916 style fixes 2026-02-04 20:15:02 +08:00
Alex Yaroshuk
72eec20437 add links 2026-02-04 17:12:41 +08:00
Alex Yaroshuk
4503bde1cc fix styling, add scrollbar 2026-02-04 16:59:00 +08:00
Alex Yaroshuk
1abc228e95 add timetsamps 2026-02-04 16:52:19 +08:00
Alex Yaroshuk
991e823039 style fixes 2026-02-04 16:39:01 +08:00
Alex Yaroshuk
62fa5c1314 fix styles 2026-02-04 16:28:09 +08:00
Alex Yaroshuk
93b9e47c05 changelog v1 2026-02-04 15:54:47 +08:00
Aiden Cline
bb4d978684 feat: update tui model dialog to utilize model family to reduce noise in list 2026-02-03 15:48:40 -06:00
255 changed files with 15820 additions and 4757 deletions

View File

@@ -122,3 +122,7 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
## Type Checking
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.

View File

@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.17",
"version": "1.2.18",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -47,7 +47,7 @@
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "0.4.0",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",
@@ -76,7 +76,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.2.17",
"version": "1.2.18",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -110,7 +110,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.2.17",
"version": "1.2.18",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -137,7 +137,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.2.17",
"version": "1.2.18",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -161,7 +161,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.2.17",
"version": "1.2.18",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -185,7 +185,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.2.17",
"version": "1.2.18",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -218,7 +218,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.2.17",
"version": "1.2.18",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -248,7 +248,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.2.17",
"version": "1.2.18",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -277,7 +277,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.2.17",
"version": "1.2.18",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -293,7 +293,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.2.17",
"version": "1.2.18",
"bin": {
"opencode": "./bin/opencode",
},
@@ -373,6 +373,7 @@
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
@@ -395,6 +396,7 @@
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
@@ -407,7 +409,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.17",
"version": "1.2.18",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -427,7 +429,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.2.17",
"version": "1.2.18",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -438,7 +440,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.2.17",
"version": "1.2.18",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -473,7 +475,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.2.17",
"version": "1.2.18",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -519,7 +521,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.17",
"version": "1.2.18",
"dependencies": {
"zod": "catalog:",
},
@@ -530,7 +532,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.17",
"version": "1.2.18",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -2120,6 +2122,8 @@
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="],
@@ -2958,7 +2962,7 @@
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
"ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="],
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
@@ -3236,7 +3240,7 @@
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
@@ -4586,7 +4590,7 @@
"when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -5273,7 +5277,6 @@
"dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"dmg-license/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
@@ -5963,7 +5966,6 @@
"dir-compare/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"editorconfig/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"electron-builder/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],

View File

@@ -8,6 +8,7 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { spawn } from "node:child_process"
import { setTimeout as sleep } from "node:timers/promises"
type GitHubAuthor = {
login: string
@@ -281,7 +282,7 @@ async function assertOpencodeConnected() {
connected = true
break
} catch (e) {}
await Bun.sleep(300)
await sleep(300)
} while (retry++ < 30)
if (!connected) {

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-ZmxeRNy2chc9py4m1iW6B+c/NSccMnVZ0lfni/EMdHw=",
"aarch64-linux": "sha256-R+1mxsmAQicerN8ixVy0ff6V8bZ4GH18MHpihvWnaTg=",
"aarch64-darwin": "sha256-m+QT20ohlqo9e86qXu67eKthZm6VDRLwlqJ9CNlEV+0=",
"x86_64-darwin": "sha256-4GeNPyTT2Hq4rxHGSON23ul5Ud3yFGE0QUVsB03Gidc="
"x86_64-linux": "sha256-v83hWzYVg/g4zJiBpGsQ71wTdndPk3BQVZ2mjMApUIQ=",
"aarch64-linux": "sha256-inpMwkQqwBFP2wL8w/pTOP7q3fg1aOqvE0wgzVd3/B8=",
"aarch64-darwin": "sha256-r42LGrQWqDyIy62mBSU5Nf3M22dJ3NNo7mjN/1h8d8Y=",
"x86_64-darwin": "sha256-J6XrrdK5qBK3sQBQOO/B3ZluOnsAf5f65l4q/K1nDTI="
}
}

View File

@@ -8,6 +8,7 @@ import {
sessionItemSelector,
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
sessionHeaderSelector,
projectMenuTriggerSelector,
projectWorkspacesToggleSelector,
titlebarRightSelector,
@@ -225,9 +226,9 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
export async function openSessionMoreMenu(page: Page, sessionID: string) {
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
const scroller = page.locator(".scroll-view__viewport").first()
await expect(scroller).toBeVisible()
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
const header = page.locator(sessionHeaderSelector).first()
await expect(header).toBeVisible()
await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
const menu = page
.locator(dropdownMenuContentSelector)
@@ -243,7 +244,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
if (opened) return menu
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click()

View File

@@ -53,6 +53,8 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte
export const inlineInputSelector = '[data-component="inline-input"]'
export const sessionHeaderSelector = "[data-session-title]"
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
export const workspaceItemSelector = (slug: string) =>

View File

@@ -7,7 +7,7 @@ import {
openSharePopover,
withSession,
} from "../actions"
import { sessionItemSelector, inlineInputSelector } from "../selectors"
import { sessionHeaderSelector, sessionItemSelector, inlineInputSelector } from "../selectors"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
@@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
const input = page.locator(sessionHeaderSelector).locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(renamedTitle)

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.17",
"version": "1.2.18",
"description": "",
"type": "module",
"exports": {
@@ -57,7 +57,7 @@
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "0.4.0",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",

View File

@@ -0,0 +1,50 @@
import type { Platform } from "@/context/platform"
const REPO = "anomalyco/opencode"
const GITHUB_API_URL = `https://api.github.com/repos/${REPO}/releases`
const PER_PAGE = 30
const CACHE_TTL = 1000 * 60 * 30
const CACHE_KEY = "opencode.releases"
type Release = {
tag: string
body: string
date: string
}
function loadCache() {
const raw = localStorage.getItem(CACHE_KEY)
return raw ? JSON.parse(raw) : null
}
function saveCache(data: { releases: Release[]; timestamp: number }) {
localStorage.setItem(CACHE_KEY, JSON.stringify(data))
}
export async function fetchReleases(platform: Platform): Promise<{ releases: Release[] }> {
const now = Date.now()
const cached = loadCache()
if (cached && now - cached.timestamp < CACHE_TTL) {
return { releases: cached.releases }
}
const fetcher = platform.fetch ?? fetch
const res = await fetcher(`${GITHUB_API_URL}?per_page=${PER_PAGE}`, {
headers: { Accept: "application/vnd.github.v3+json" },
}).then((r) => (r.ok ? r.json() : Promise.reject(new Error("Failed to load"))))
const releases = (Array.isArray(res) ? res : []).map((r) => ({
tag: r.tag_name ?? "Unknown",
body: (r.body ?? "")
.replace(/#(\d+)/g, (_: string, id: string) => `[#${id}](https://github.com/anomalyco/opencode/pull/${id})`)
.replace(/@([a-zA-Z0-9_-]+)/g, (_: string, u: string) => `[@${u}](https://github.com/${u})`),
date: r.published_at ?? "",
}))
saveCache({ releases, timestamp: now })
return { releases }
}
export type { Release }

View File

@@ -0,0 +1,150 @@
.dialog-changelog {
min-height: 500px;
display: flex;
flex-direction: column;
}
.dialog-changelog [data-slot="dialog-body"] {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
.dialog-changelog-list {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
.dialog-changelog-list [data-slot="list-scroll"] {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-weak-base) transparent;
}
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar-track {
background: transparent;
border-radius: 5px;
}
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar-thumb {
background: var(--border-weak-base);
border-radius: 5px;
border: 3px solid transparent;
background-clip: padding-box;
}
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar-thumb:hover {
background: var(--border-weak-base);
}
.dialog-changelog-header {
padding: 8px 12px 8px 8px;
display: flex;
align-items: baseline;
gap: 8px;
position: sticky;
top: 0;
z-index: 10;
background: var(--surface-raised-stronger-non-alpha);
}
.dialog-changelog-header::after {
content: "";
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 16px;
background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.dialog-changelog-header[data-stuck="true"]::after {
opacity: 1;
}
.dialog-changelog-version {
font-size: 20px;
font-weight: 600;
}
.dialog-changelog-date {
font-size: 12px;
font-weight: 400;
color: var(--text-weak);
}
.dialog-changelog-list [data-slot="list-item"] {
margin-bottom: 32px;
padding: 0;
border: none;
background: transparent;
cursor: default;
display: block;
text-align: left;
}
.dialog-changelog-list [data-slot="list-item"]:hover {
background: transparent;
}
.dialog-changelog-list [data-slot="list-item"]:focus {
outline: none;
}
.dialog-changelog-list [data-slot="list-item"]:focus-visible {
outline: 2px solid var(--focus-base);
outline-offset: 2px;
}
.dialog-changelog-content {
padding: 0 8px 24px;
}
.dialog-changelog-markdown h2 {
border-bottom: 1px solid var(--border-weak-base);
padding-bottom: 4px;
margin: 32px 0 12px 0;
font-size: 14px;
font-weight: 500;
text-transform: capitalize;
}
.dialog-changelog-markdown h2:first-child {
margin-top: 16px;
}
.dialog-changelog-markdown a.external-link {
color: var(--text-interactive-base);
font-weight: 500;
}
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/pull/"],
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/issues/"],
.dialog-changelog-markdown a.external-link[href^="https://github.com/"]
{
border-radius: 3px;
padding: 0 2px;
}
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/pull/"]:hover,
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/issues/"]:hover,
.dialog-changelog-markdown a.external-link[href^="https://github.com/"]:hover
{
background: var(--surface-weak-base);
}

View File

@@ -0,0 +1,43 @@
import { createResource, Suspense, ErrorBoundary, Show } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { DataProvider } from "@opencode-ai/ui/context"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { fetchReleases } from "@/api/releases"
import { ReleaseList } from "@/components/release-list"
export function DialogChangelog() {
const language = useLanguage()
const platform = usePlatform()
const [data] = createResource(() => fetchReleases(platform))
return (
<Dialog size="x-large" transition title="Changelog">
<DataProvider data={{ session: [], session_status: {}, session_diff: {}, message: {}, part: {} }} directory="">
<div class="flex-1 min-h-0 flex flex-col">
<ErrorBoundary
fallback={(e) => (
<p class="text-text-weak p-6">
{e instanceof Error ? e.message : "Failed to load changelog"}
</p>
)}
>
<Suspense fallback={<p class="text-text-weak p-6">{language.t("common.loading")}...</p>}>
<Show
when={(data()?.releases.length ?? 0) > 0}
fallback={<p class="text-text-weak p-6">{language.t("common.noReleasesFound")}</p>}
>
<ReleaseList
releases={data()!.releases}
hasMore={false}
loadingMore={false}
onLoadMore={() => {}}
/>
</Show>
</Suspense>
</ErrorBoundary>
</div>
</DataProvider>
</Dialog>
)
}

View File

@@ -459,4 +459,4 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</List>
</Dialog>
)
}
}

View File

@@ -2,16 +2,25 @@ import { Component } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogChangelog } from "@/components/dialog-changelog"
import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
import { SettingsArchive } from "./settings-archive"
export const DialogSettings: Component = () => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
function handleShowChangelog() {
dialog.show(() => <DialogChangelog />)
}
return (
<Dialog size="x-large" transition>
@@ -47,11 +56,27 @@ export const DialogSettings: Component = () => {
</Tabs.Trigger>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Tabs.SectionTitle>{language.t("settings.section.data")}</Tabs.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<Tabs.Trigger value="archive">
<Icon name="archive" />
{language.t("settings.archive.title")}
</Tabs.Trigger>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
<span>{language.t("app.name.desktop")}</span>
<span class="text-11-regular">v{platform.version}</span>
<button
class="text-11-regular text-text-weak hover:text-text-base self-start"
onClick={handleShowChangelog}
>
Changelog
</button>
</div>
</div>
</Tabs.List>
@@ -67,6 +92,9 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="models" class="no-scrollbar">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="archive" class="no-scrollbar">
<SettingsArchive />
</Tabs.Content>
</Tabs>
</Dialog>
)

View File

@@ -256,7 +256,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
pendingAutoAccept: false,
})
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const buttonsSpring = useSpring(
() => (store.mode === "normal" ? 1 : 0),
{ visualDuration: 0.2, bounce: 0 },
)
const springFade = (t: number): Record<string, string> => ({
opacity: `${t}`,
transform: `scale(${0.95 + t * 0.05})`,
filter: `blur(${(1 - t) * 2}px)`,
"pointer-events": t > 0.5 ? "auto" : "none",
})
const commentCount = createMemo(() => {
if (store.mode === "shell") return 0
@@ -1254,9 +1264,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div
aria-hidden={store.mode !== "normal"}
class="flex items-center gap-1"
style={{
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
style={{ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none" }}
>
<TooltipKeybind
placement="top"
@@ -1268,11 +1276,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button"
variant="ghost"
class="size-8 p-0"
style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
style={springFade(buttonsSpring())}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1310,11 +1314,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
style={springFade(buttonsSpring())}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
@@ -1370,13 +1370,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
<div
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
style={{
padding: "0 4px 0 8px",
opacity: 1 - buttonsSpring(),
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
filter: `blur(${buttonsSpring() * 2}px)`,
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
}}
style={{ padding: "0 4px 0 8px", ...springFade(1 - buttonsSpring()) }}
>
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
<div class="size-4 shrink-0" />
@@ -1395,13 +1389,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={local.agent.set}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
variant="ghost"
/>
</TooltipKeybind>
@@ -1419,13 +1407,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular group"
style={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
style={{ height: "28px", ...springFade(buttonsSpring()) }}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}>
@@ -1454,13 +1436,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
triggerProps={{
variant: "ghost",
size: "normal",
style: {
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
},
style: { height: "28px", ...springFade(buttonsSpring()) },
class: "min-w-0 max-w-[320px] text-13-regular group",
}}
>
@@ -1492,13 +1468,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
variant="ghost"
/>
</TooltipKeybind>

View File

@@ -1,8 +1,9 @@
import type { Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { errorMessage } from "@/pages/layout/helpers"
import { useNavigate, useParams } from "@solidjs/router"
import type { Accessor } from "solid-js"
import { batch, type Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
@@ -65,14 +66,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const language = useLanguage()
const params = useParams()
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
const abort = async () => {
const sessionID = params.id
@@ -158,7 +152,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err),
description: toastError(err),
})
return undefined
})
@@ -197,7 +191,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
description: errorMessage(err),
description: toastError(err),
})
return undefined
})
@@ -255,7 +249,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => {
showToast({
title: language.t("prompt.toast.shellSendFailed.title"),
description: errorMessage(err),
description: toastError(err),
})
restoreInput()
})
@@ -333,9 +327,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
messageID,
})
removeCommentItems(commentItems)
clearInput()
addOptimisticMessage()
batch(() => {
removeCommentItems(commentItems)
clearInput()
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "busy" })
}
addOptimisticMessage()
})
const waitForWorktree = async () => {
const worktree = WorktreeState.get(sessionDirectory)
@@ -412,7 +411,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err),
description: toastError(err),
})
removeOptimisticMessage()
restoreCommentItems(commentItems)

View File

@@ -0,0 +1,61 @@
import { Component } from "solid-js"
import { List } from "@opencode-ai/ui/list"
import { Markdown } from "@opencode-ai/ui/markdown"
import { Button } from "@opencode-ai/ui/button"
import { Tag } from "@opencode-ai/ui/tag"
import { useLanguage } from "@/context/language"
import { getRelativeTime } from "@/utils/time"
type Release = {
tag: string
body: string
date: string
}
interface ReleaseListProps {
releases: Release[]
hasMore: boolean
loadingMore: boolean
onLoadMore: () => void
}
export const ReleaseList: Component<ReleaseListProps> = (props) => {
const language = useLanguage()
return (
<List
items={props.releases}
key={(x) => x.tag}
search={false}
emptyMessage="No releases found"
loadingMessage={language.t("common.loading")}
class="flex-1 min-h-0 overflow-hidden flex flex-col [&_[data-slot=list-scroll]]:session-scroller [&_[data-slot=list-item]]:block [&_[data-slot=list-item]]:p-0 [&_[data-slot=list-item]]:border-0 [&_[data-slot=list-item]]:bg-transparent [&_[data-slot=list-item]]:text-left [&_[data-slot=list-item]]:cursor-default [&_[data-slot=list-item]]:hover:bg-transparent [&_[data-slot=list-item]]:focus:outline-none"
add={{
render: () =>
props.hasMore ? (
<div class="p-4 flex justify-center">
<Button variant="secondary" size="small" onClick={props.onLoadMore} loading={props.loadingMore}>
{language.t("common.loadMore")}
</Button>
</div>
) : null,
}}
>
{(item) => (
<div class="mb-8">
<div class="py-2 pr-3 pl-2 flex items-baseline gap-2 sticky top-0 z-10 bg-surface-raised-stronger-non-alpha">
<span class="text-[20px] font-semibold">{item.tag}</span>
<span class="text-xs text-text-weak">{item.date ? getRelativeTime(item.date, language.t) : ""}</span>
{item.tag === props.releases[0]?.tag && <Tag>{language.t("changelog.tag.latest")}</Tag>}
</div>
<div class="px-2 pb-2">
<Markdown
text={item.body}
class="prose prose-sm max-w-none text-text-base [&_h2]:border-b [&_h2]:border-border-weak-base [&_h2]:pb-1 [&_h2]:mt-8 [&_h2]:mb-3 [&_h2]:text-sm [&_h2]:font-medium [&_h2]:capitalize [&_h2:first-child]:mt-4 [&_a.external-link]:text-text-interactive-base [&_a.external-link]:font-medium"
/>
</div>
</div>
)}
</List>
)
}

View File

@@ -39,7 +39,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const usd = createMemo(
() =>
new Intl.NumberFormat(language.locale(), {
new Intl.NumberFormat(language.intl(), {
style: "currency",
currency: "USD",
}),
@@ -77,7 +77,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
{(ctx) => (
<>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.locale())}</span>
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span>
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
</div>
<div class="flex items-center gap-2">

View File

@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
function user(id: string): Message {
return {
id,
role: "user",
sessionID: "session-1",
time: { created: 1 },
} as unknown as Message
}
function assistant(id: string, parentID: string): Message {
return {
id,
role: "assistant",
sessionID: "session-1",
parentID,
time: { created: 1 },
} as unknown as Message
}
describe("findAssistantMessages", () => {
test("normal ordering: assistant after user in array → found via forward scan", () => {
const messages = [user("u1"), assistant("a1", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("clock skew: assistant before user in array → found via backward scan", () => {
// When client clock is ahead, user ID sorts after assistant ID,
// so assistant appears earlier in the ID-sorted message array
const messages = [assistant("a1", "u1"), user("u1")]
const result = findAssistantMessages(messages, 1, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("no assistant messages → returns empty array", () => {
const messages = [user("u1"), user("u2")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(0)
})
test("multiple assistant messages with matching parentID → all found", () => {
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(2)
expect(result[0].id).toBe("a1")
expect(result[1].id).toBe("a2")
})
test("does not return assistant messages with different parentID", () => {
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("stops forward scan at next user message", () => {
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("stops backward scan at previous user message", () => {
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
const result = findAssistantMessages(messages, 3, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("invalid index returns empty array", () => {
const messages = [user("u1")]
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
})
})

View File

@@ -4,8 +4,7 @@ import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { same } from "@/utils/same"
import { findLast, same } from "@opencode-ai/util/array"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
@@ -128,7 +127,7 @@ export function SessionContextTab() {
const usd = createMemo(
() =>
new Intl.NumberFormat(language.locale(), {
new Intl.NumberFormat(language.intl(), {
style: "currency",
currency: "USD",
}),
@@ -136,7 +135,7 @@ export function SessionContextTab() {
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
const cost = createMemo(() => {
return usd().format(metrics().totalCost)
@@ -200,7 +199,7 @@ export function SessionContextTab() {
const stats = [
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) },
{ label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
@@ -213,8 +212,8 @@ export function SessionContextTab() {
label: "context.stats.cacheTokens",
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
},
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
{ label: "context.stats.totalCost", value: cost },
{ label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
@@ -307,7 +306,7 @@ export function SessionContextTab() {
<div class="flex items-center gap-1 text-11-regular text-text-weak">
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
<div>{breakdownLabel(segment.key)}</div>
<div class="text-text-weaker">{segment.percent.toLocaleString(language.locale())}%</div>
<div class="text-text-weaker">{segment.percent.toLocaleString(language.intl())}%</div>
</div>
)}
</For>

View File

@@ -70,7 +70,7 @@ export function NewSessionView(props: NewSessionViewProps) {
{language.t("session.new.lastModified")}&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(project().time.updated ?? project().time.created)
.setLocale(language.locale())
.setLocale(language.intl())
.toRelative()}
</span>
</div>

View File

@@ -0,0 +1,188 @@
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { RadioGroup } from "@opencode-ai/ui/radio-group"
import { getFilename } from "@opencode-ai/util/path"
import { Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
import { useParams } from "@solidjs/router"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { getRelativeTime } from "@/utils/time"
import { decode64 } from "@/utils/base64"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { SessionSkeleton } from "@/pages/layout/sidebar-items"
type FilterScope = "all" | "current"
type ScopeOption = { value: FilterScope; label: "settings.archive.scope.all" | "settings.archive.scope.current" }
const scopeOptions: ScopeOption[] = [
{ value: "all", label: "settings.archive.scope.all" },
{ value: "current", label: "settings.archive.scope.current" },
]
export const SettingsArchive: Component = () => {
const language = useLanguage()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const layout = useLayout()
const params = useParams()
const [removedIds, setRemovedIds] = createSignal<Set<string>>(new Set())
const projects = createMemo(() => globalSync.data.project)
const layoutProjects = createMemo(() => layout.projects.list())
const hasMultipleProjects = createMemo(() => projects().length > 1)
const homedir = createMemo(() => globalSync.data.path.home)
const defaultScope = () => (hasMultipleProjects() ? "current" : "all")
const [filterScope, setFilterScope] = createSignal<FilterScope>(defaultScope())
const currentDirectory = createMemo(() => decode64(params.dir) ?? "")
const currentProject = createMemo(() => {
const dir = currentDirectory()
if (!dir) return null
return layoutProjects().find((p) => p.worktree === dir || p.sandboxes?.includes(dir)) ?? null
})
const filteredProjects = createMemo(() => {
if (filterScope() === "current" && currentProject()) {
return [currentProject()!]
}
return layoutProjects()
})
const getSessionLabel = (session: Session) => {
const directory = session.directory
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
if (filterScope() === "current" && currentProject()) {
const current = currentProject()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = globalSync.child(directory, { bootstrap: false })
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
}
return path
}
const [archivedSessions] = createResource(
() => ({ scope: filterScope(), projects: filteredProjects() }),
async ({ projects }) => {
const allSessions: Session[] = []
for (const project of projects) {
const directories = [project.worktree, ...(project.sandboxes ?? [])]
for (const directory of directories) {
const result = await globalSDK.client.experimental.session.list({ directory, archived: true })
const sessions = result.data ?? []
for (const session of sessions) {
allSessions.push(session)
}
}
}
return allSessions.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
},
{ initialValue: [] },
)
const displayedSessions = () => {
const sessions = archivedSessions() ?? []
const removed = removedIds()
return sessions.filter((s) => !removed.has(s.id))
}
const currentScopeOption = () => scopeOptions.find((o) => o.value === filterScope())
const unarchiveSession = async (session: Session) => {
setRemovedIds((prev) => new Set(prev).add(session.id))
await globalSDK.client.session.update({
directory: session.directory,
sessionID: session.id,
time: { archived: null as any },
})
}
const handleScopeChange = (option: ScopeOption | undefined) => {
if (!option) return
setRemovedIds(new Set<string>())
setFilterScope(option.value)
}
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.archive.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.archive.description")}</p>
</div>
</div>
<div class="flex flex-col gap-4 max-w-[720px]">
<Show when={hasMultipleProjects()}>
<RadioGroup
options={scopeOptions}
current={currentScopeOption() ?? undefined}
value={(o) => o.value}
size="small"
label={(o) => language.t(o.label)}
onSelect={handleScopeChange}
/>
</Show>
<Show
when={!archivedSessions.loading}
fallback={
<div class="min-h-[700px]">
<SessionSkeleton count={4} />
</div>
}
>
<Show
when={displayedSessions().length}
fallback={
<div class="min-h-[700px]">
<div class="text-14-regular text-text-weak">{language.t("settings.archive.none")}</div>
</div>
}
>
<div class="min-h-[700px] flex flex-col gap-2">
<For each={displayedSessions()}>
{(session) => (
<div class="flex items-center justify-between gap-4 px-3 py-1 rounded-md hover:bg-surface-raised-base-hover">
<div class="flex items-center gap-x-3 grow min-w-0">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong truncate">{session.title}</span>
<span class="text-14-regular text-text-weak truncate">{getSessionLabel(session)}</span>
</div>
</div>
<div class="flex items-center gap-4 shrink-0">
<Show when={session.time?.updated}>
{(updated) => (
<span class="text-12-regular text-text-weak whitespace-nowrap">
{getRelativeTime(new Date(updated()).toISOString(), language.t)}
</span>
)}
</Show>
<Button
size="normal"
variant="secondary"
onClick={() => unarchiveSession(session)}
>
{language.t("common.unarchive")}
</Button>
</div>
</div>
)}
</For>
</div>
</Show>
</Show>
</div>
</div>
)
}

View File

@@ -17,6 +17,7 @@ type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[num
const PROVIDER_NOTES = [
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
{ match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" },
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
@@ -181,21 +182,11 @@ export const SettingsProviders: Component = () => {
<div class="flex items-center gap-x-3">
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{item.name}</span>
<Show when={item.id === "opencode"}>
<span class="text-14-regular text-text-weak">
{language.t("dialog.provider.opencode.tagline")}
</span>
</Show>
<Show when={item.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={item.id === "opencode-go"}>
<>
<span class="text-14-regular text-text-weak">
{language.t("dialog.provider.opencodeGo.tagline")}
</span>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
</div>
<Show when={note(item.id)}>

View File

@@ -266,6 +266,9 @@ export function Titlebar() {
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
BETA
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">

View File

@@ -4,6 +4,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { isEditableTarget } from "@/utils/dom"
import { Persist, persisted } from "@/utils/persist"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
@@ -177,14 +178,6 @@ export function formatKeybind(config: string): string {
return IS_MAC ? parts.join("") : parts.join("+")
}
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
if (target.closest("[contenteditable='true']")) return true
if (target.closest("input, textarea, select")) return true
return false
}
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command",
init: () => {

View File

@@ -353,7 +353,6 @@ function createGlobalSync() {
.update({ config })
.then(bootstrap)
.then(() => {
queue.refresh()
setGlobalStore("reload", undefined)
queue.refresh()
})

View File

@@ -84,6 +84,26 @@ const LOCALES: readonly Locale[] = [
"tr",
]
const INTL: Record<Locale, string> = {
en: "en",
zh: "zh-Hans",
zht: "zh-Hant",
ko: "ko",
de: "de",
es: "es",
fr: "fr",
da: "da",
ja: "ja",
pl: "pl",
ru: "ru",
ar: "ar",
no: "nb-NO",
br: "pt-BR",
th: "th",
bs: "bs",
tr: "tr",
}
const LABEL_KEY: Record<Locale, keyof Dictionary> = {
en: "language.en",
zh: "language.zh",
@@ -197,6 +217,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
)
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
const intl = createMemo(() => INTL[locale()])
const dict = createMemo<Dictionary>(() => DICT[locale()])
@@ -213,6 +234,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
return {
ready,
locale,
intl,
locales: LOCALES,
label,
t,

View File

@@ -8,7 +8,7 @@ import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { decode64 } from "@/utils/base64"
import { same } from "@/utils/same"
import { same } from "@opencode-ai/util/array"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
import { createPathHelpers } from "./file/path"

View File

@@ -506,6 +506,10 @@ export const dict = {
"common.close": "إغلاق",
"common.edit": "تحرير",
"common.loadMore": "تحميل المزيد",
"common.changelog": "التغييرات",
"common.noReleasesFound": "لم يتم العثور على إصدارات",
"changelog.tag.latest": "الأحدث",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "تبديل القائمة",
"sidebar.nav.projectsAndSessions": "المشاريع والجلسات",
@@ -734,6 +738,11 @@ export const dict = {
"workspace.reset.archived.one": "ستتم أرشفة جلسة واحدة.",
"workspace.reset.archived.many": "ستتم أرشفة {{count}} جلسات.",
"workspace.reset.note": "سيؤدي هذا إلى إعادة تعيين مساحة العمل لتتطابق مع الفرع الافتراضي.",
"settings.archive.title": "الجلسات المؤرشفة",
"settings.archive.description": "استعادة الجلسات المؤرشفة لجعلها مرئية في الشريط الجانبي.",
"settings.archive.none": "لا توجد جلسات مؤرشفة.",
"settings.archive.scope.all": "جميع المشاريع",
"settings.archive.scope.current": "المشروع الحالي",
"common.open": "فتح",
"dialog.releaseNotes.action.getStarted": "البدء",
"dialog.releaseNotes.action.next": "التالي",
@@ -748,4 +757,4 @@ export const dict = {
"common.time.daysAgo.short": "قبل {{count}} ي",
"settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك",
"settings.providers.custom.description": "أضف مزود متوافق مع OpenAI بواسطة عنوان URL الأساسي.",
}
}

View File

@@ -512,6 +512,9 @@ export const dict = {
"common.close": "Fechar",
"common.edit": "Editar",
"common.loadMore": "Carregar mais",
"common.changelog": "Novidades",
"common.noReleasesFound": "Nenhuma release encontrada",
"changelog.tag.latest": "Mais recente",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Alternar menu",
"sidebar.nav.projectsAndSessions": "Projetos e sessões",
@@ -742,6 +745,11 @@ export const dict = {
"workspace.reset.archived.one": "1 sessão será arquivada.",
"workspace.reset.archived.many": "{{count}} sessões serão arquivadas.",
"workspace.reset.note": "Isso redefinirá o espaço de trabalho para corresponder ao branch padrão.",
"settings.archive.title": "Sessões arquivadas",
"settings.archive.description": "Restaure sessões arquivadas para torná-las visíveis na barra lateral.",
"settings.archive.none": "Nenhuma sessão arquivada.",
"settings.archive.scope.all": "Todos os projetos",
"settings.archive.scope.current": "Projeto atual",
"common.open": "Abrir",
"dialog.releaseNotes.action.getStarted": "Começar",
"dialog.releaseNotes.action.next": "Próximo",

View File

@@ -572,6 +572,9 @@ export const dict = {
"common.close": "Zatvori",
"common.edit": "Uredi",
"common.loadMore": "Učitaj još",
"common.changelog": "Novosti",
"common.noReleasesFound": "Nema pronađenih verzija",
"changelog.tag.latest": "Najnovije",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Prikaži/sakrij meni",
@@ -819,6 +822,11 @@ export const dict = {
"workspace.reset.archived.one": "1 sesija će biti arhivirana.",
"workspace.reset.archived.many": "Biće arhivirano {{count}} sesija.",
"workspace.reset.note": "Ovo će resetovati radni prostor da odgovara podrazumijevanoj grani.",
"settings.archive.title": "Arhivirane sesije",
"settings.archive.description": "Vrati arhivirane sesije da bi bile vidljive u bočnoj traci.",
"settings.archive.none": "Nema arhiviranih sesija.",
"settings.archive.scope.all": "Svi projekti",
"settings.archive.scope.current": "Trenutni projekt",
"common.open": "Otvori",
"dialog.releaseNotes.action.getStarted": "Započni",
"dialog.releaseNotes.action.next": "Sljedeće",

View File

@@ -568,6 +568,9 @@ export const dict = {
"common.close": "Luk",
"common.edit": "Rediger",
"common.loadMore": "Indlæs flere",
"common.changelog": "Nyheder",
"common.noReleasesFound": "Ingen versioner fundet",
"changelog.tag.latest": "Seneste",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Skift menu",
@@ -813,6 +816,11 @@ export const dict = {
"workspace.reset.archived.one": "1 session vil blive arkiveret.",
"workspace.reset.archived.many": "{{count}} sessioner vil blive arkiveret.",
"workspace.reset.note": "Dette vil nulstille arbejdsområdet til at matche hovedgrenen.",
"settings.archive.title": "Arkiverede sessioner",
"settings.archive.description": "Gendan arkiverede sessioner for at gøre dem synlige i sidebjælken.",
"settings.archive.none": "Ingen arkiverede sessioner.",
"settings.archive.scope.all": "Alle projekter",
"settings.archive.scope.current": "Nuværende projekt",
"common.open": "Åbn",
"dialog.releaseNotes.action.getStarted": "Kom i gang",
"dialog.releaseNotes.action.next": "Næste",

View File

@@ -520,6 +520,10 @@ export const dict = {
"common.close": "Schließen",
"common.edit": "Bearbeiten",
"common.loadMore": "Mehr laden",
"common.changelog": "Neuerungen",
"common.noReleasesFound": "Keine Versionen gefunden",
"changelog.tag.latest": "Neueste",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Menü umschalten",
"sidebar.nav.projectsAndSessions": "Projekte und Sitzungen",
@@ -751,6 +755,12 @@ export const dict = {
"workspace.reset.archived.one": "1 Sitzung wird archiviert.",
"workspace.reset.archived.many": "{{count}} Sitzungen werden archiviert.",
"workspace.reset.note": "Dadurch wird der Arbeitsbereich auf den Standard-Branch zurückgesetzt.",
"settings.archive.title": "Archivierte Sitzungen",
"settings.archive.description": "Archivierte Sitzungen wiederherstellen, um sie in der Seitenleiste anzuzeigen.",
"settings.archive.none": "Keine archivierten Sitzungen.",
"settings.archive.scope.all": "Alle Projekte",
"settings.archive.scope.current": "Aktuelles Projekt",
"common.open": "Öffnen",
"dialog.releaseNotes.action.getStarted": "Loslegen",
"dialog.releaseNotes.action.next": "Weiter",

View File

@@ -511,11 +511,12 @@ export const dict = {
"session.review.change.other": "Changes",
"session.review.loadingChanges": "Loading changes...",
"session.review.empty": "No changes in this session yet",
"session.review.noVcs": "No git VCS detected, so session changes will not be detected",
"session.review.noVcs": "No Git Version Control System detected, changes not displayed",
"session.review.noChanges": "No changes",
"session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files",
"session.files.empty": "No files",
"session.files.binaryContent": "Binary file (content cannot be displayed)",
"session.messages.renderEarlier": "Render earlier messages",
@@ -585,16 +586,19 @@ export const dict = {
"common.rename": "Rename",
"common.reset": "Reset",
"common.archive": "Archive",
"common.unarchive": "Unarchive",
"common.delete": "Delete",
"common.close": "Close",
"common.edit": "Edit",
"common.loadMore": "Load more",
"common.key.esc": "ESC",
"common.changelog": "Changelog",
"common.noReleasesFound": "No releases found",
"common.time.justNow": "Just now",
"common.time.minutesAgo.short": "{{count}}m ago",
"common.time.hoursAgo.short": "{{count}}h ago",
"common.time.daysAgo.short": "{{count}}d ago",
"changelog.tag.latest": "Latest",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Toggle menu",
"sidebar.nav.projectsAndSessions": "Projects and sessions",
@@ -613,6 +617,7 @@ export const dict = {
"settings.section.desktop": "Desktop",
"settings.section.server": "Server",
"settings.section.data": "Data",
"settings.tab.general": "General",
"settings.tab.shortcuts": "Shortcuts",
"settings.desktop.section.wsl": "WSL",
@@ -844,4 +849,10 @@ export const dict = {
"workspace.reset.archived.one": "1 session will be archived.",
"workspace.reset.archived.many": "{{count}} sessions will be archived.",
"workspace.reset.note": "This will reset the workspace to match the default branch.",
"settings.archive.title": "Archived Sessions",
"settings.archive.description": "Restore archived sessions to make them visible in the sidebar.",
"settings.archive.none": "No archived sessions.",
"settings.archive.scope.all": "All projects",
"settings.archive.scope.current": "Current project",
}

View File

@@ -575,6 +575,10 @@ export const dict = {
"common.close": "Cerrar",
"common.edit": "Editar",
"common.loadMore": "Cargar más",
"common.changelog": "Novedades",
"common.noReleasesFound": "No se encontraron versiones",
"changelog.tag.latest": "Último",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Alternar menú",
@@ -825,6 +829,12 @@ export const dict = {
"workspace.reset.archived.one": "1 sesión será archivada.",
"workspace.reset.archived.many": "{{count}} sesiones serán archivadas.",
"workspace.reset.note": "Esto restablecerá el espacio de trabajo para coincidir con la rama predeterminada.",
"settings.archive.title": "Sesiones archivadas",
"settings.archive.description": "Restaura las sesiones archivadas para hacerlas visibles en la barra lateral.",
"settings.archive.none": "No hay sesiones archivadas.",
"settings.archive.scope.all": "Todos los proyectos",
"settings.archive.scope.current": "Proyecto actual",
"common.open": "Abrir",
"dialog.releaseNotes.action.getStarted": "Comenzar",
"dialog.releaseNotes.action.next": "Siguiente",

View File

@@ -516,6 +516,10 @@ export const dict = {
"common.close": "Fermer",
"common.edit": "Modifier",
"common.loadMore": "Charger plus",
"common.changelog": "Nouveautés",
"common.noReleasesFound": "Aucune version trouvée",
"changelog.tag.latest": "Dernier",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Basculer le menu",
"sidebar.nav.projectsAndSessions": "Projets et sessions",
@@ -749,6 +753,11 @@ export const dict = {
"workspace.reset.archived.one": "1 session sera archivée.",
"workspace.reset.archived.many": "{{count}} sessions seront archivées.",
"workspace.reset.note": "Cela réinitialisera l'espace de travail pour correspondre à la branche par défaut.",
"settings.archive.title": "Sessions archivées",
"settings.archive.description": "Restaurez les sessions archivées pour les rendre visibles dans la barre latérale.",
"settings.archive.none": "Aucune session archivée.",
"settings.archive.scope.all": "Tous les Projets",
"settings.archive.scope.current": "Projet actuel",
"common.open": "Ouvrir",
"dialog.releaseNotes.action.getStarted": "Commencer",
"dialog.releaseNotes.action.next": "Suivant",

View File

@@ -510,6 +510,10 @@ export const dict = {
"common.close": "閉じる",
"common.edit": "編集",
"common.loadMore": "さらに読み込む",
"common.changelog": "更新履歴",
"common.noReleasesFound": "バージョンが見つかりません",
"changelog.tag.latest": "最新",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "メニューを切り替え",
"sidebar.nav.projectsAndSessions": "プロジェクトとセッション",
@@ -738,6 +742,12 @@ export const dict = {
"workspace.reset.archived.one": "1つのセッションがアーカイブされます。",
"workspace.reset.archived.many": "{{count}}個のセッションがアーカイブされます。",
"workspace.reset.note": "これにより、ワークスペースはデフォルトブランチと一致するようにリセットされます。",
"settings.archive.title": "アーカイブされたセッション",
"settings.archive.description": "アーカイブされたセッションを復元してサイドバーに表示します。",
"settings.archive.none": "アーカイブされたセッションはありません。",
"settings.archive.scope.all": "すべてのプロジェクト",
"settings.archive.scope.current": "現在のプロジェクト",
"common.open": "開く",
"dialog.releaseNotes.action.getStarted": "始める",
"dialog.releaseNotes.action.next": "次へ",

View File

@@ -511,6 +511,10 @@ export const dict = {
"common.close": "닫기",
"common.edit": "편집",
"common.loadMore": "더 불러오기",
"common.changelog": "새로운 기능",
"common.noReleasesFound": "버전을 찾을 수 없음",
"changelog.tag.latest": "최신",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "메뉴 토글",
"sidebar.nav.projectsAndSessions": "프로젝트 및 세션",
@@ -738,6 +742,12 @@ export const dict = {
"workspace.reset.archived.one": "1개의 세션이 보관됩니다.",
"workspace.reset.archived.many": "{{count}}개의 세션이 보관됩니다.",
"workspace.reset.note": "이 작업은 작업 공간을 기본 브랜치와 일치하도록 재설정합니다.",
"settings.archive.title": "보관된 세션",
"settings.archive.description": "보관된 세션을 복원하여 사이드바에 표시합니다.",
"settings.archive.none": "보관된 세션이 없습니다.",
"settings.archive.scope.all": "모든 프로젝트",
"settings.archive.scope.current": "현재 프로젝트",
"common.open": "열기",
"dialog.releaseNotes.action.getStarted": "시작하기",
"dialog.releaseNotes.action.next": "다음",

View File

@@ -575,6 +575,9 @@ export const dict = {
"common.close": "Lukk",
"common.edit": "Rediger",
"common.loadMore": "Last flere",
"common.changelog": "Nyheter",
"common.noReleasesFound": "Ingen versjoner funnet",
"changelog.tag.latest": "Siste",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Veksle meny",
@@ -821,6 +824,12 @@ export const dict = {
"workspace.reset.archived.one": "1 sesjon vil bli arkivert.",
"workspace.reset.archived.many": "{{count}} sesjoner vil bli arkivert.",
"workspace.reset.note": "Dette vil tilbakestille arbeidsområdet til å samsvare med standardgrenen.",
"settings.archive.title": "Arkiverte økter",
"settings.archive.description": "Gjenopprett arkiverte økter for å gjøre dem synlige i sidefeltet.",
"settings.archive.none": "Ingen arkiverte økter.",
"settings.archive.scope.all": "Alle prosjekter",
"settings.archive.scope.current": "Nåværende prosjekt",
"common.open": "Åpne",
"dialog.releaseNotes.action.getStarted": "Kom i gang",
"dialog.releaseNotes.action.next": "Neste",

View File

@@ -511,6 +511,9 @@ export const dict = {
"common.close": "Zamknij",
"common.edit": "Edytuj",
"common.loadMore": "Załaduj więcej",
"common.changelog": "Nowości",
"common.noReleasesFound": "Nie znaleziono wersji",
"changelog.tag.latest": "Najnowszy",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Przełącz menu",
"sidebar.nav.projectsAndSessions": "Projekty i sesje",
@@ -740,6 +743,11 @@ export const dict = {
"workspace.reset.archived.one": "1 sesja zostanie zarchiwizowana.",
"workspace.reset.archived.many": "{{count}} sesji zostanie zarchiwizowanych.",
"workspace.reset.note": "To zresetuje przestrzeń roboczą, aby odpowiadała domyślnej gałęzi.",
"settings.archive.title": "Zarchiwizowane sesje",
"settings.archive.description": "Przywróć zarchiwizowane sesje, aby były widoczne na pasku bocznym.",
"settings.archive.none": "Brak zarchiwizowanych sesji.",
"settings.archive.scope.all": "Wszystkie projekty",
"settings.archive.scope.current": "Bieżący projekt",
"common.open": "Otwórz",
"dialog.releaseNotes.action.getStarted": "Rozpocznij",
"dialog.releaseNotes.action.next": "Dalej",

View File

@@ -573,6 +573,9 @@ export const dict = {
"common.close": "Закрыть",
"common.edit": "Редактировать",
"common.loadMore": "Загрузить ещё",
"common.changelog": "Что нового",
"common.noReleasesFound": "Версии не найдены",
"changelog.tag.latest": "Последний",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Переключить меню",
@@ -821,6 +824,11 @@ export const dict = {
"workspace.reset.archived.one": "1 сессия будет архивирована.",
"workspace.reset.archived.many": "{{count}} сессий будет архивировано.",
"workspace.reset.note": "Рабочее пространство будет сброшено в соответствие с веткой по умолчанию.",
"settings.archive.title": "Архивированные сессии",
"settings.archive.description": "Восстановите архивированные сессии, чтобы они отображались на боковой панели.",
"settings.archive.none": "Нет архивированных сессий.",
"settings.archive.scope.all": "Все проекты",
"settings.archive.scope.current": "Текущий проект",
"common.open": "Открыть",
"dialog.releaseNotes.action.getStarted": "Начать",
"dialog.releaseNotes.action.next": "Далее",

View File

@@ -567,6 +567,9 @@ export const dict = {
"common.close": "ปิด",
"common.edit": "แก้ไข",
"common.loadMore": "โหลดเพิ่มเติม",
"common.changelog": "อัปเดต",
"common.noReleasesFound": "ไม่พบเวอร์ชัน",
"changelog.tag.latest": "ล่าสุด",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "สลับเมนู",
@@ -811,6 +814,12 @@ export const dict = {
"workspace.reset.archived.one": "1 เซสชันจะถูกจัดเก็บ",
"workspace.reset.archived.many": "{{count}} เซสชันจะถูกจัดเก็บ",
"workspace.reset.note": "สิ่งนี้จะรีเซ็ตพื้นที่ทำงานให้ตรงกับสาขาเริ่มต้น",
"settings.archive.title": "เซสชันที่จัดเก็บ",
"settings.archive.description": "กู้คืนเซสชันที่จัดเก็บเพื่อให้แสดงในแถบด้านข้าง",
"settings.archive.none": "ไม่มีเซสชันที่จัดเก็บ",
"settings.archive.scope.all": "โปรเจกต์ทั้งหมด",
"settings.archive.scope.current": "โปรเจกต์ปัจจุบัน",
"common.open": "เปิด",
"dialog.releaseNotes.action.getStarted": "เริ่มต้น",
"dialog.releaseNotes.action.next": "ถัดไป",

View File

@@ -566,6 +566,10 @@ export const dict = {
"common.close": "关闭",
"common.edit": "编辑",
"common.loadMore": "加载更多",
"common.changelog": "更新日志",
"common.noReleasesFound": "未找到版本",
"changelog.tag.latest": "最新",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "切换菜单",
@@ -809,6 +813,12 @@ export const dict = {
"workspace.reset.archived.one": "将归档 1 个会话。",
"workspace.reset.archived.many": "将归档 {{count}} 个会话。",
"workspace.reset.note": "这将把工作区重置为与默认分支一致。",
"settings.archive.title": "归档会话",
"settings.archive.description": "恢复归档会话以使其在侧边栏中可见。",
"settings.archive.none": "没有归档会话。",
"settings.archive.scope.all": "所有项目",
"settings.archive.scope.current": "当前项目",
"common.open": "打开",
"dialog.releaseNotes.action.getStarted": "开始",
"dialog.releaseNotes.action.next": "下一步",

View File

@@ -563,6 +563,9 @@ export const dict = {
"common.close": "關閉",
"common.edit": "編輯",
"common.loadMore": "載入更多",
"common.changelog": "更新日誌",
"common.noReleasesFound": "未找到版本",
"changelog.tag.latest": "最新",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "切換選單",
@@ -804,6 +807,12 @@ export const dict = {
"workspace.reset.archived.one": "將封存 1 個工作階段。",
"workspace.reset.archived.many": "將封存 {{count}} 個工作階段。",
"workspace.reset.note": "這將把工作區重設為與預設分支一致。",
"settings.archive.title": "封存工作階段",
"settings.archive.description": "恢復封存的工作階段以使其在側邊欄中可見。",
"settings.archive.none": "沒有封存的工作階段。",
"settings.archive.scope.all": "所有專案",
"settings.archive.scope.current": "目前專案",
"common.open": "打開",
"dialog.releaseNotes.action.getStarted": "開始",
"dialog.releaseNotes.action.next": "下一步",

View File

@@ -9,11 +9,13 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const params = useParams()
const navigate = useNavigate()
const sync = useSync()
const platform = usePlatform()
return (
<DataProvider
@@ -21,6 +23,34 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
directory={props.directory}
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
onOpenFilePath={async (input) => {
const file = input.path.replace(/^[\\/]+/, "")
const separator = props.directory.includes("\\") ? "\\" : "/"
const path = props.directory.endsWith(separator) ? props.directory + file : props.directory + separator + file
if (platform.platform === "desktop" && platform.openPath) {
await platform.openPath(path).catch((error) => {
const description = error instanceof Error ? error.message : String(error)
showToast({
variant: "error",
title: "Open failed",
description,
})
window.dispatchEvent(
new CustomEvent("opencode:open-file-path", {
detail: input,
}),
)
})
return
}
window.dispatchEvent(
new CustomEvent("opencode:open-file-path", {
detail: input,
}),
)
}}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>

View File

@@ -1846,7 +1846,7 @@ export default function Layout(props: ParentProps) {
}}
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
>
<Show when={panelProps.project}>
<Show when={panelProps.project} keyed>
{(p) => (
<>
<div class="shrink-0 px-2 py-1">
@@ -1855,7 +1855,7 @@ export default function Layout(props: ParentProps) {
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => renameProject(p(), next)}
onSave={(next) => renameProject(p, next)}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
@@ -1864,7 +1864,7 @@ export default function Layout(props: ParentProps) {
<Tooltip
placement="bottom"
gutter={2}
value={p().worktree}
value={p.worktree}
class="shrink-0"
contentStyle={{
"max-width": "640px",
@@ -1872,7 +1872,7 @@ export default function Layout(props: ParentProps) {
}}
>
<span class="text-12-regular text-text-base truncate select-text">
{p().worktree.replace(homedir(), "~")}
{p.worktree.replace(homedir(), "~")}
</span>
</Tooltip>
</div>
@@ -1883,7 +1883,7 @@ export default function Layout(props: ParentProps) {
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={base64Encode(p().worktree)}
data-project={base64Encode(p.worktree)}
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
@@ -1892,24 +1892,24 @@ export default function Layout(props: ParentProps) {
/>
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p)}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(p().worktree)}
disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
onSelect={() => toggleProjectWorkspaces(p())}
data-project={base64Encode(p.worktree)}
disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
onSelect={() => toggleProjectWorkspaces(p)}
>
<DropdownMenu.ItemLabel>
{layout.sidebar.workspaces(p().worktree)()
{layout.sidebar.workspaces(p.worktree)()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={base64Encode(p().worktree)}
data-project={base64Encode(p.worktree)}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
@@ -1920,8 +1920,8 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={base64Encode(p().worktree)}
onSelect={() => closeProject(p().worktree)}
data-project={base64Encode(p.worktree)}
onSelect={() => closeProject(p.worktree)}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
@@ -1936,45 +1936,34 @@ export default function Layout(props: ParentProps) {
when={workspacesEnabled()}
fallback={
<>
<div class="shrink-0 py-4 px-3">
<TooltipKeybind
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
placement="top"
>
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
>
{language.t("command.session.new")}
</Button>
</TooltipKeybind>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace
ctx={workspaceSidebarCtx}
project={p()}
project={p}
sortNow={sortNow}
mobile={panelProps.mobile}
header={
<TooltipKeybind
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
placement="top"
>
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
>
{language.t("command.session.new")}
</Button>
</TooltipKeybind>
}
/>
</div>
</>
}
>
<>
<div class="shrink-0 py-4 px-3">
<TooltipKeybind
title={language.t("workspace.new")}
keybind={command.keybind("workspace.new")}
placement="top"
>
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
{language.t("workspace.new")}
</Button>
</TooltipKeybind>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
@@ -1988,21 +1977,41 @@ export default function Layout(props: ParentProps) {
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
class="size-full flex flex-col overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={p()}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
)}
</For>
</SortableProvider>
<div class="sticky top-0 z-20 pointer-events-none bg-[linear-gradient(to_bottom,var(--background-stronger)_calc(100%_-_24px),transparent)] pt-4 pb-6 px-3">
<div class="pointer-events-auto">
<TooltipKeybind
title={language.t("workspace.new")}
keybind={command.keybind("workspace.new")}
placement="top"
>
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => createWorkspace(p)}
>
{language.t("workspace.new")}
</Button>
</TooltipKeybind>
</div>
</div>
<div class="flex flex-col gap-4 py-2">
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={p}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
)}
</For>
</SortableProvider>
</div>
</div>
<DragOverlay>
<WorkspaceDragOverlay

View File

@@ -277,7 +277,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
return (
<div
data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 scroll-mt-24
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
>
<Show

View File

@@ -467,6 +467,7 @@ export const LocalWorkspace = (props: {
project: LocalProject
sortNow: Accessor<number>
mobile?: boolean
header?: JSX.Element
}): JSX.Element => {
const globalSync = useGlobalSync()
const language = useLanguage()
@@ -488,9 +489,14 @@ export const LocalWorkspace = (props: {
return (
<div
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
class="size-full flex flex-col overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<nav class="flex flex-col gap-1 px-2">
<Show when={props.header}>
<div class="sticky top-0 z-20 pointer-events-none bg-[linear-gradient(to_bottom,var(--background-stronger)_calc(100%_-_24px),transparent)] pt-4 pb-6 px-3">
<div class="pointer-events-auto">{props.header}</div>
</div>
</Show>
<nav class="flex flex-col gap-1 px-2 pb-2" classList={{ "pt-2": !props.header }}>
<Show when={loading()}>
<SessionSkeleton />
</Show>

View File

@@ -18,9 +18,9 @@ import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { Select } from "@opencode-ai/ui/select"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { Mark } from "@opencode-ai/ui/logo"
import { base64Encode, checksum } from "@opencode-ai/util/encode"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
@@ -31,16 +31,17 @@ import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { same } from "@opencode-ai/util/array"
import { isEditableTarget } from "@/utils/dom"
import { createOpenReviewFile } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { createScrollSpy } from "@/pages/session/scroll-spy"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { same } from "@/utils/same"
const emptyUserMessages: UserMessage[] = []
@@ -118,13 +119,13 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
return
}
const beforeTop = el.scrollTop
const beforeHeight = el.scrollHeight
fn()
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (!delta) return
el.scrollTop = beforeTop + delta
})
// SolidJS updates the DOM synchronously. Force reflow so the browser
// processes the new layout, then restore scrollTop before paint.
// With column-reverse + overflow-anchor:none the same scrollTop value
// keeps the same distance from the bottom — no delta math needed.
void el.scrollHeight
el.scrollTop = beforeTop
}
const backfillTurns = () => {
@@ -207,7 +208,8 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
if (el.scrollTop >= turnScrollThreshold) return
// With column-reverse, distance from top = scrollHeight - clientHeight + scrollTop
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
const start = turnStart()
if (start > 0) {
@@ -285,7 +287,6 @@ export default function Page() {
bottom: true,
},
})
const composer = createSessionComposerState()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
@@ -430,20 +431,8 @@ export default function Page() {
mobileTab: "session" as "session" | "changes",
changes: "session" as "session" | "turn",
newSessionWorktree: "main",
deferRender: false,
})
createComputed((prev) => {
const key = sessionKey()
if (key !== prev) {
setStore("deferRender", true)
requestAnimationFrame(() => {
setTimeout(() => setStore("deferRender", false), 0)
})
}
return key
}, sessionKey())
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
@@ -454,11 +443,6 @@ export default function Page() {
return "main"
})
const activeMessage = createMemo(() => {
if (!store.messageId) return lastUserMessage()
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
return found ?? lastUserMessage()
})
const setActiveMessage = (message: UserMessage | undefined) => {
setStore("messageId", message?.id)
}
@@ -620,11 +604,6 @@ export default function Page() {
saveLabel: language.t("common.save"),
}))
const isEditableTarget = (target: EventTarget | null | undefined) => {
if (!(target instanceof HTMLElement)) return false
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
}
const deepActiveElement = () => {
let current: Element | null = document.activeElement
while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
@@ -718,31 +697,51 @@ export default function Page() {
const openReviewFile = createOpenReviewFile({
showAllFiles,
openReviewPanel,
tabForPath: file.tab,
openTab: tabs().open,
setActive: tabs().setActive,
setSelectedLines: file.setSelectedLines,
loadFile: file.load,
})
onMount(() => {
const open = (event: Event) => {
const detail = (event as CustomEvent<{ path?: string; line?: number }>).detail
const path = detail?.path
if (!path) return
openReviewFile(path, detail?.line)
}
window.addEventListener("opencode:open-file-path", open)
onCleanup(() => window.removeEventListener("opencode:open-file-path", open))
})
const changesOptions = ["session", "turn"] as const
const changesOptionsList = [...changesOptions]
const changesTitle = () => (
<Select
options={changesOptionsList}
current={store.changes}
label={(option) =>
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
}
onSelect={(option) => option && setStore("changes", option)}
variant="ghost"
size="small"
valueClass="text-14-medium"
/>
)
const changesTitle = () => {
if (!hasReview()) {
return null
}
return (
<Select
options={changesOptionsList}
current={store.changes}
label={(option) =>
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
}
onSelect={(option) => option && setStore("changes", option)}
variant="ghost"
size="small"
valueClass="text-14-medium"
/>
)
}
const emptyTurn = () => (
<div class="h-full pb-30 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
</div>
)
@@ -754,12 +753,35 @@ export default function Page() {
loadingClass: string
emptyClass: string
}) => (
<Show when={!store.deferRender}>
<Switch>
<Match when={store.changes === "turn" && !!params.id}>
<Switch>
<Match when={store.changes === "turn" && !!params.id}>
<SessionReviewTab
title={changesTitle()}
empty={emptyTurn()}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Match>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
>
<SessionReviewTab
title={changesTitle()}
empty={emptyTurn()}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
@@ -776,64 +798,39 @@ export default function Page() {
onViewFile={openReviewFile}
classes={input.classes}
/>
</Match>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
>
<SessionReviewTab
title={changesTitle()}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Show>
</Match>
<Match when={true}>
<SessionReviewTab
title={changesTitle()}
empty={
store.changes === "turn" ? (
emptyTurn()
) : (
<div class={input.emptyClass}>
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
</div>
)
}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Match>
</Switch>
</Show>
</Show>
</Match>
<Match when={true}>
<SessionReviewTab
title={changesTitle()}
empty={
store.changes === "turn" ? (
emptyTurn()
) : (
<div class={input.emptyClass}>
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
</div>
)
}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Match>
</Switch>
)
const reviewPanel = () => (
@@ -1044,7 +1041,10 @@ export default function Page() {
const updateScrollState = (el: HTMLDivElement) => {
const max = el.scrollHeight - el.clientHeight
const overflow = max > 1
const bottom = !overflow || el.scrollTop >= max - 2
// If auto-scroll is tracking the bottom, always report bottom: true
// to prevent the scroll-down arrow from flashing during height animations
// With column-reverse, scrollTop=0 is at the bottom
const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
setUi("scroll", { overflow, bottom })
@@ -1067,7 +1067,7 @@ export default function Page() {
const resumeScroll = () => {
setStore("messageId", undefined)
autoScroll.forceScrollToBottom()
autoScroll.smoothScrollToBottom()
clearMessageHash()
const el = scroller
@@ -1135,9 +1135,8 @@ export default function Page() {
const el = scroller
const delta = next - dockHeight
const stick = el
? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
: false
// With column-reverse, near bottom = scrollTop near 0
const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
dockHeight = next
@@ -1203,50 +1202,49 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id}>
<Show when={activeMessage()}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
})}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
isDesktop={isDesktop()}
onScrollSpyScroll={scrollSpy.onScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
})}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
isDesktop={isDesktop()}
onScrollSpyScroll={scrollSpy.onScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
onPreserveScrollAnchor={autoScroll.preserve}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
const root = scroller
if (root) scheduleScrollState(root)
}}
turnStart={historyWindow.turnStart()}
historyMore={historyMore()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
void historyWindow.loadAndReveal()
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
anchor={anchor}
onRegisterMessage={scrollSpy.register}
onUnregisterMessage={scrollSpy.unregister}
/>
</Show>
const root = scroller
if (root) scheduleScrollState(root)
}}
turnStart={historyWindow.turnStart()}
historyMore={historyMore()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
void historyWindow.loadAndReveal()
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
anchor={anchor}
onRegisterMessage={scrollSpy.register}
onUnregisterMessage={scrollSpy.unregister}
/>
</Match>
<Match when={true}>
<NewSessionView
@@ -1272,7 +1270,6 @@ export default function Page() {
<SessionComposerRegion
state={composer}
ready={!store.deferRender && messagesReady()}
centered={centered()}
inputRef={(el) => {
inputRef = el

View File

@@ -1,7 +1,7 @@
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Show, createMemo, createSignal, createEffect } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { useElementHeight } from "@opencode-ai/ui/hooks"
import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
@@ -9,11 +9,12 @@ import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import { SessionTodoDock, COLLAPSED_HEIGHT } from "@/pages/session/composer/session-todo-dock"
const DOCK_SPRING = { visualDuration: 0.3, bounce: 0 }
export function SessionComposerRegion(props: {
state: SessionComposerState
ready: boolean
centered: boolean
inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string
@@ -21,23 +22,6 @@ export function SessionComposerRegion(props: {
onSubmit: () => void
onResponseSubmit: () => void
setPromptDockRef: (el: HTMLDivElement) => void
visualDuration?: number
bounce?: number
dockOpenVisualDuration?: number
dockOpenBounce?: number
dockCloseVisualDuration?: number
dockCloseBounce?: number
drawerExpandVisualDuration?: number
drawerExpandBounce?: number
drawerCollapseVisualDuration?: number
drawerCollapseBounce?: number
subtitleDuration?: number
subtitleTravel?: number
subtitleEdge?: number
countDuration?: number
countMask?: number
countMaskHeight?: number
countWidthDuration?: number
}) {
const params = useParams()
const prompt = usePrompt()
@@ -63,73 +47,15 @@ export function SessionComposerRegion(props: {
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
const [gate, setGate] = createStore({
ready: false,
})
let timer: number | undefined
let frame: number | undefined
const clear = () => {
if (timer !== undefined) {
window.clearTimeout(timer)
timer = undefined
}
if (frame !== undefined) {
cancelAnimationFrame(frame)
frame = undefined
}
}
createEffect(() => {
sessionKey()
const ready = props.ready
const delay = 140
clear()
setGate("ready", false)
if (!ready) return
frame = requestAnimationFrame(() => {
frame = undefined
timer = window.setTimeout(() => {
setGate("ready", true)
timer = undefined
}, delay)
})
})
onCleanup(clear)
const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing())
const config = createMemo(() =>
open()
? {
visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.dockOpenBounce ?? props.bounce ?? 0,
}
: {
visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.dockCloseBounce ?? props.bounce ?? 0,
},
const open = createMemo(() => props.state.dock() && !props.state.closing())
const progress = useSpring(
() => (open() ? 1 : 0),
DOCK_SPRING,
)
const progress = useSpring(() => (open() ? 1 : 0), config)
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
const [height, setHeight] = createSignal(320)
const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001)
const full = createMemo(() => Math.max(78, height()))
const dock = createMemo(() => props.state.dock() || progress() > 0.001)
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
createEffect(() => {
const el = contentRef()
if (!el) return
const update = () => {
setHeight(el.getBoundingClientRect().height)
}
update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
})
const height = useElementHeight(contentRef, 320)
const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
return (
<div
@@ -179,10 +105,10 @@ export function SessionComposerRegion(props: {
<div
classList={{
"overflow-hidden": true,
"pointer-events-none": value() < 0.98,
"pointer-events-none": progress() < 0.98,
}}
style={{
"max-height": `${full() * value()}px`,
"max-height": `${full() * progress()}px`,
}}
>
<div ref={setContentRef}>
@@ -191,20 +117,7 @@ export function SessionComposerRegion(props: {
title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
dockProgress={value()}
visualDuration={props.visualDuration}
bounce={props.bounce}
expandVisualDuration={props.drawerExpandVisualDuration}
expandBounce={props.drawerExpandBounce}
collapseVisualDuration={props.drawerCollapseVisualDuration}
collapseBounce={props.drawerCollapseBounce}
subtitleDuration={props.subtitleDuration}
subtitleTravel={props.subtitleTravel}
subtitleEdge={props.subtitleEdge}
countDuration={props.countDuration}
countMask={props.countMask}
countMaskHeight={props.countMaskHeight}
countWidthDuration={props.countWidthDuration}
dockProgress={progress()}
/>
</div>
</div>
@@ -214,7 +127,7 @@ export function SessionComposerRegion(props: {
"relative z-10": true,
}}
style={{
"margin-top": `${-36 * value()}px`,
"margin-top": `${-36 * progress()}px`,
}}
>
<PromptInput

View File

@@ -29,7 +29,11 @@ export function createSessionComposerBlocked() {
})
}
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
export function createSessionComposerState(
options?: {
closeMs?: number | (() => number)
},
) {
const params = useParams()
const sdk = useSDK()
const sync = useSync()

View File

@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
@@ -25,6 +26,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
customOn: cached?.customOn ?? ([] as boolean[]),
editing: false,
sending: false,
collapsed: false,
})
let root: HTMLDivElement | undefined
@@ -35,6 +37,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const input = createMemo(() => store.custom[store.tab] ?? "")
const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true)
const picked = createMemo(() => store.answers[store.tab]?.length ?? 0)
const summary = createMemo(() => {
const n = Math.min(store.tab + 1, total())
@@ -43,6 +46,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const last = createMemo(() => store.tab >= total() - 1)
const fold = () => setStore("collapsed", (value) => !value)
const customUpdate = (value: string, selected: boolean = on()) => {
const prev = input().trim()
const next = value.trim()
@@ -257,9 +262,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
kind="question"
ref={(el) => (root = el)}
header={
<>
<div
data-action="session-question-toggle"
class="flex flex-1 min-w-0 items-center gap-2 cursor-default select-none"
role="button"
tabIndex={0}
style={{ margin: "0 -10px", padding: "0 0 0 10px" }}
onClick={fold}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
fold()
}}
>
<div data-slot="question-header-title">{summary()}</div>
<div data-slot="question-progress">
<div data-slot="question-progress" class="ml-auto mr-1">
<For each={questions()}>
{(_, i) => (
<button
@@ -271,13 +288,38 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
disabled={store.sending}
onClick={() => jump(i())}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
jump(i())
}}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/>
)}
</For>
</div>
</>
<div>
<IconButton
data-action="session-question-toggle-button"
icon="chevron-down"
size="normal"
variant="ghost"
classList={{ "rotate-180": store.collapsed }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
fold()
}}
aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
/>
</div>
</div>
}
footer={
<>
@@ -297,56 +339,121 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text">{question()?.question}</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
<div
data-slot="question-text"
class="cursor-default"
classList={{
"mb-6": store.collapsed && picked() === 0,
}}
role={store.collapsed ? "button" : undefined}
tabIndex={store.collapsed ? 0 : undefined}
onClick={fold}
onKeyDown={(event) => {
if (!store.collapsed) return
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
fold()
}}
>
{question()?.question}
</div>
<Show when={store.collapsed && picked() > 0}>
<div data-slot="question-hint" class="cursor-default mb-6">
{picked()} answer{picked() === 1 ? "" : "s"} selected
</div>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<div data-slot="question-answers" hidden={store.collapsed} aria-hidden={store.collapsed}>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<button
data-slot="question-option"
data-picked={picked()}
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
aria-checked={on()}
disabled={store.sending}
onClick={() => selectOption(i())}
onClick={customOpen}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<button
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={store.sending}
onClick={customOpen}
onMouseDown={(e) => {
if (store.sending) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<span
data-slot="question-option-check"
@@ -365,80 +472,39 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
</span>
</button>
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
onMouseDown={(e) => {
if (store.sending) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</form>
</Show>
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</form>
</Show>
</div>
</div>
</DockPrompt>
)

View File

@@ -6,9 +6,15 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
import { useElementHeight } from "@opencode-ai/ui/hooks"
import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
const COLLAPSE_SPRING = { visualDuration: 0.3, bounce: 0 }
export const COLLAPSED_HEIGHT = 78
const SUBTITLE = { duration: 600, travel: 25, edge: 17 }
const COUNT = { duration: 600, mask: 18, maskHeight: 0, widthDuration: 560 }
function dot(status: Todo["status"]) {
if (status !== "in_progress") return undefined
return (
@@ -40,19 +46,6 @@ export function SessionTodoDock(props: {
collapseLabel: string
expandLabel: string
dockProgress?: number
visualDuration?: number
bounce?: number
expandVisualDuration?: number
expandBounce?: number
collapseVisualDuration?: number
collapseBounce?: number
subtitleDuration?: number
subtitleTravel?: number
subtitleEdge?: number
countDuration?: number
countMask?: number
countMaskHeight?: number
countWidthDuration?: number
}) {
const [store, setStore] = createStore({
collapsed: false,
@@ -73,39 +66,12 @@ export function SessionTodoDock(props: {
)
const preview = createMemo(() => active()?.content ?? "")
const config = createMemo(() =>
store.collapsed
? {
visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.collapseBounce ?? props.bounce ?? 0,
}
: {
visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.expandBounce ?? props.bounce ?? 0,
},
)
const collapse = useSpring(() => (store.collapsed ? 1 : 0), config)
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
const shut = createMemo(() => 1 - dock())
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
const hide = createMemo(() => Math.max(value(), shut()))
const off = createMemo(() => hide() > 0.98)
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
const [height, setHeight] = createSignal(320)
const full = createMemo(() => Math.max(78, height()))
const collapse = useSpring(() => (store.collapsed ? 1 : 0), COLLAPSE_SPRING)
const shut = createMemo(() => 1 - (props.dockProgress ?? 1))
const hide = createMemo(() => Math.max(collapse(), shut()))
let contentRef: HTMLDivElement | undefined
createEffect(() => {
const el = contentRef
if (!el) return
const update = () => {
setHeight(el.getBoundingClientRect().height)
}
update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
})
const height = useElementHeight(() => contentRef, 320)
const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
return (
<DockTray
@@ -113,7 +79,7 @@ export function SessionTodoDock(props: {
style={{
"overflow-x": "visible",
"overflow-y": "hidden",
"max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
"max-height": `${Math.max(COLLAPSED_HEIGHT, full() - collapse() * (full() - COLLAPSED_HEIGHT))}px`,
}}
>
<div ref={contentRef}>
@@ -133,12 +99,12 @@ export function SessionTodoDock(props: {
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
aria-label={label()}
style={{
"--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`,
"--tool-motion-mask": `${props.countMask ?? 18}%`,
"--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
"--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
"--tool-motion-odometer-ms": `${COUNT.duration}ms`,
"--tool-motion-mask": `${COUNT.mask}%`,
"--tool-motion-mask-height": `${COUNT.maskHeight}px`,
"--tool-motion-spring-ms": `${COUNT.widthDuration}ms`,
opacity: `${1 - shut()}`,
filter: shut() > 0.01 ? `blur(${shut() * 2}px)` : "none",
}}
>
<AnimatedNumber value={done()} />
@@ -157,9 +123,9 @@ export function SessionTodoDock(props: {
<TextReveal
class="text-14-regular text-text-base cursor-default"
text={store.collapsed ? preview() : undefined}
duration={props.subtitleDuration ?? 600}
travel={props.subtitleTravel ?? 25}
edge={props.subtitleEdge ?? 17}
duration={SUBTITLE.duration}
travel={SUBTITLE.travel}
edge={SUBTITLE.edge}
spring="cubic-bezier(0.34, 1, 0.64, 1)"
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
growOnly
@@ -173,7 +139,7 @@ export function SessionTodoDock(props: {
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${turn() * 180}deg)` }}
style={{ transform: `rotate(${collapse() * 180}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
@@ -189,14 +155,15 @@ export function SessionTodoDock(props: {
<div
data-slot="session-todo-list"
aria-hidden={store.collapsed || off()}
class="pb-2"
aria-hidden={store.collapsed}
classList={{
"pointer-events-none": hide() > 0.1,
}}
style={{
visibility: off() ? "hidden" : "visible",
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
opacity: `${1 - hide()}`,
filter: hide() > 0.01 ? `blur(${hide() * 2}px)` : "none",
visibility: hide() > 0.98 ? "hidden" : "visible",
}}
>
<TodoList todos={props.todos} open={!store.collapsed} />
@@ -282,7 +249,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
"--checkbox-align": "flex-start",
"--checkbox-offset": "1px",
transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
opacity: todo().status === "pending" ? "0.94" : "1",
opacity: todo().status === "pending" ? "0.5" : "1",
}}
>
<TextStrikethrough
@@ -292,12 +259,11 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
style={{
"line-height": "var(--line-height-normal)",
transition:
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
color:
todo().status === "completed" || todo().status === "cancelled"
? "var(--text-weak)"
: "var(--text-strong)",
opacity: todo().status === "pending" ? "0.92" : "1",
}}
/>
</Checkbox>

View File

@@ -6,17 +6,45 @@ describe("createOpenReviewFile", () => {
const calls: string[] = []
const openReviewFile = createOpenReviewFile({
showAllFiles: () => calls.push("show"),
openReviewPanel: () => calls.push("review"),
tabForPath: (path) => {
calls.push(`tab:${path}`)
return `file://${path}`
},
openTab: (tab) => calls.push(`open:${tab}`),
setActive: (tab) => calls.push(`active:${tab}`),
setSelectedLines: (path, range) => calls.push(`select:${path}:${range ? `${range.start}-${range.end}` : "none"}`),
loadFile: (path) => calls.push(`load:${path}`),
})
openReviewFile("src/a.ts")
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts"])
expect(calls).toEqual([
"tab:src/a.ts",
"show",
"review",
"load:src/a.ts",
"open:file://src/a.ts",
"active:file://src/a.ts",
"select:src/a.ts:none",
])
})
test("selects the requested line when provided", () => {
const calls: string[] = []
const openReviewFile = createOpenReviewFile({
showAllFiles: () => calls.push("show"),
openReviewPanel: () => calls.push("review"),
tabForPath: (path) => `file://${path}`,
openTab: () => calls.push("open"),
setActive: () => calls.push("active"),
setSelectedLines: (_path, range) => calls.push(`select:${range?.start}-${range?.end}`),
loadFile: () => calls.push("load"),
})
openReviewFile("src/a.ts", 12)
expect(calls).toContain("select:12-12")
})
})

View File

@@ -24,13 +24,22 @@ export const createOpenReviewFile = (input: {
showAllFiles: () => void
tabForPath: (path: string) => string
openTab: (tab: string) => void
setActive: (tab: string) => void
openReviewPanel: () => void
setSelectedLines: (path: string, range: { start: number; end: number } | null) => void
loadFile: (path: string) => any | Promise<void>
}) => {
return (path: string) => {
return (path: string, line?: number) => {
const tab = input.tabForPath(path)
batch(() => {
input.showAllFiles()
input.openReviewPanel()
const maybePromise = input.loadFile(path)
const openTab = () => input.openTab(input.tabForPath(path))
const openTab = () => {
input.openTab(tab)
input.setActive(tab)
input.setSelectedLines(path, line ? { start: line, end: line } : null)
}
if (maybePromise instanceof Promise) maybePromise.then(openTab)
else openTab()
})

View File

@@ -49,11 +49,11 @@ describe("shouldMarkBoundaryGesture", () => {
).toBe(true)
})
test("does not mark when nested scroller can consume movement", () => {
test("does not mark when scroller can consume movement", () => {
expect(
shouldMarkBoundaryGesture({
delta: 20,
scrollTop: 200,
scrollTop: 300,
scrollHeight: 1000,
clientHeight: 400,
}),

View File

@@ -14,8 +14,8 @@ export const shouldMarkBoundaryGesture = (input: {
if (max <= 1) return true
if (!input.delta) return false
if (input.delta < 0) return input.scrollTop + input.delta <= 0
const remaining = max - input.scrollTop
return input.delta > remaining
const top = Math.max(0, Math.min(max, input.scrollTop))
if (input.delta < 0) return -input.delta > top
const bottom = max - top
return input.delta > bottom
}

View File

@@ -1,27 +1,31 @@
import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, Index, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import {
For,
Index,
createEffect,
createMemo,
createSignal,
on,
onCleanup,
Show,
startTransition,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { SessionTimelineHeader } from "@/pages/session/session-timeline-header"
type MessageComment = {
path: string
@@ -33,7 +37,9 @@ type MessageComment = {
}
const emptyMessages: MessageType[] = []
const idle = { type: "idle" as const }
const isDefaultSessionTitle = (title?: string) =>
!!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
const messageComments = (parts: Part[]): MessageComment[] =>
parts.flatMap((part) => {
@@ -110,6 +116,8 @@ function createTimelineStaging(input: TimelineStageInput) {
completedSession: "",
count: 0,
})
const [readySession, setReadySession] = createSignal("")
let active = ""
const stagedCount = createMemo(() => {
const total = input.messages().length
@@ -134,23 +142,46 @@ function createTimelineStaging(input: TimelineStageInput) {
cancelAnimationFrame(frame)
frame = undefined
}
const scheduleReady = (sessionKey: string) => {
if (input.sessionKey() !== sessionKey) return
if (readySession() === sessionKey) return
setReadySession(sessionKey)
}
createEffect(
on(
() => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
([sessionKey, isWindowed, total]) => {
const switched = active !== sessionKey
if (switched) {
active = sessionKey
setReadySession("")
}
const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey
const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey
if (staging && !switched && shouldStage && frame !== undefined) return
cancel()
const shouldStage =
isWindowed &&
total > input.config.init &&
state.completedSession !== sessionKey &&
state.activeSession !== sessionKey
if (shouldStage) setReadySession("")
if (!shouldStage) {
setState({ activeSession: "", count: total })
setState({
activeSession: "",
completedSession: isWindowed ? sessionKey : state.completedSession,
count: total,
})
if (total <= 0) {
setReadySession("")
return
}
if (readySession() !== sessionKey) scheduleReady(sessionKey)
return
}
let count = Math.min(total, input.config.init)
if (staging) count = Math.min(total, Math.max(count, state.count))
setState({ activeSession: sessionKey, count })
const step = () => {
@@ -164,6 +195,7 @@ function createTimelineStaging(input: TimelineStageInput) {
if (count >= currentTotal) {
setState({ completedSession: sessionKey, activeSession: "" })
frame = undefined
scheduleReady(sessionKey)
return
}
frame = requestAnimationFrame(step)
@@ -177,9 +209,12 @@ function createTimelineStaging(input: TimelineStageInput) {
const key = input.sessionKey()
return state.activeSession === key && state.completedSession !== key
})
const ready = createMemo(() => readySession() === input.sessionKey())
onCleanup(cancel)
return { messages: stagedUserMessages, isStaging }
onCleanup(() => {
cancel()
})
return { messages: stagedUserMessages, isStaging, ready }
}
export function MessageTimeline(props: {
@@ -196,6 +231,7 @@ export function MessageTimeline(props: {
onScrollSpyScroll: () => void
onTurnBackfillScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
onPreserveScrollAnchor: (target: HTMLElement) => void
centered: boolean
setContentRef: (el: HTMLDivElement) => void
turnStart: number
@@ -210,14 +246,19 @@ export function MessageTimeline(props: {
let touchGesture: number | undefined
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const dialog = useDialog()
const language = useLanguage()
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
const trigger = (target: EventTarget | null) => {
const next =
target instanceof Element
? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"]')
: undefined
if (!(next instanceof HTMLElement)) return
return next
}
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const sessionID = createMemo(() => params.id)
const sessionMessages = createMemo(() => {
@@ -230,28 +271,20 @@ export function MessageTimeline(props: {
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
),
)
const sessionStatus = createMemo(() => {
const id = sessionID()
if (!id) return idle
return sync.data.session_status[id] ?? idle
})
const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle")
const activeMessageID = createMemo(() => {
const parentID = pending()?.parentID
if (parentID) {
const messages = sessionMessages()
const result = Binary.search(messages, parentID, (message) => message.id)
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
if (message && message.role === "user") return message.id
const messages = sessionMessages()
const message = pending()
if (message?.parentID) {
const result = Binary.search(messages, message.parentID, (item) => item.id)
const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID)
if (parent?.role === "user") return parent.id
}
const status = sessionStatus()
if (status.type !== "idle") {
const messages = sessionMessages()
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") return messages[i].id
}
if (sessionStatus() === "idle") return undefined
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") return messages[i].id
}
return undefined
})
const info = createMemo(() => {
@@ -259,9 +292,19 @@ export function MessageTimeline(props: {
if (!id) return
return sync.session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const titleValue = createMemo(() => {
const title = info()?.title
if (!title) return
if (isDefaultSessionTitle(title)) return language.t("command.session.new")
return title
})
const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title))
const headerTitle = createMemo(
() => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined),
)
const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0))
const parentID = createMemo(() => info()?.parentID)
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const showHeader = createMemo(() => !!(headerTitle() || parentID()))
const stageCfg = { init: 1, batch: 3 }
const staging = createTimelineStaging({
sessionKey,
@@ -269,212 +312,7 @@ export function MessageTimeline(props: {
messages: () => props.renderedUserMessages,
config: stageCfg,
})
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
let titleRef: HTMLInputElement | undefined
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
createEffect(
on(
sessionKey,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!sessionID()) return
setTitle({ editing: true, draft: titleValue() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const id = sessionID()
if (!id) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (titleValue() ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID: id, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === id)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
return
}
navigate(`/${params.dir}/session`)
}
const archiveSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const deleteSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
return true
}
const navigateParent = () => {
const id = parentID()
if (!id) return
navigate(`/${params.dir}/session/${id}`)
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
return (
<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-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
const rendered = createMemo(() => staging.messages().map((message) => message.id))
return (
<Show
@@ -498,6 +336,16 @@ export function MessageTimeline(props: {
<Icon name="arrow-down-to-line" />
</button>
</div>
<SessionTimelineHeader
centered={props.centered}
showHeader={showHeader}
sessionKey={sessionKey}
sessionID={sessionID}
parentID={parentID}
titleValue={titleValue}
headerTitle={headerTitle}
placeholderTitle={placeholderTitle}
/>
<ScrollView
viewportRef={props.setScrollRef}
onWheel={(e) => {
@@ -532,9 +380,18 @@ export function MessageTimeline(props: {
touchGesture = undefined
}}
onPointerDown={(e) => {
const next = trigger(e.target)
if (next) props.onPreserveScrollAnchor(next)
if (e.target !== e.currentTarget) return
props.onMarkScrollGesture(e.currentTarget)
}}
onKeyDown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return
const next = trigger(e.target)
if (!next) return
props.onPreserveScrollAnchor(next)
}}
onScroll={(e) => {
props.onScheduleScrollState(e.currentTarget)
props.onTurnBackfillScroll()
@@ -543,131 +400,21 @@ export function MessageTimeline(props: {
props.onMarkScrollGesture(e.currentTarget)
if (props.isDesktop) props.onScrollSpyScroll()
}}
onClick={props.onAutoScrollInteraction}
onClick={(e) => {
props.onAutoScrollInteraction(e)
}}
class="relative min-w-0 w-full h-full"
style={{
"--session-title-height": showHeader() ? "40px" : "0px",
"--session-title-height": showHeader() ? "72px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
}}
>
<div ref={props.setContentRef} class="min-w-0 w-full">
<Show when={showHeader()}>
<div
data-session-title
classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true,
"pb-4": true,
"pl-2 pr-3 md:pl-4 md:pr-3": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<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">
<Show when={parentID()}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={navigateParent}
aria-label={language.t("common.goBack")}
/>
</Show>
<Show when={titleValue() || title.editing}>
<Show
when={title.editing}
fallback={
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
onDblClick={openTitleEditor}
>
{titleValue()}
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
value={title.draft}
disabled={title.saving}
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={closeTitleEditor}
/>
</Show>
</Show>
</div>
<Show when={sessionID()}>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<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>
</div>
)}
</Show>
</div>
</div>
</Show>
<div>
<div
ref={props.setContentRef}
role="log"
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
style={{ "padding-top": "var(--session-title-height)" }}
classList={{
"w-full": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
@@ -692,6 +439,15 @@ export function MessageTimeline(props: {
</Show>
<For each={rendered()}>
{(messageID) => {
// Capture at creation time: animate only messages added after the
// timeline finishes its initial backfill staging, plus the first
// turn while a brand new session is still using its default title.
const isNew =
staging.ready() ||
(defaultTitle() &&
sessionStatus() !== "idle" &&
props.renderedUserMessages.length === 1 &&
messageID === props.renderedUserMessages[0]?.id)
const active = createMemo(() => activeMessageID() === messageID)
const queued = createMemo(() => {
if (active()) return false
@@ -700,7 +456,10 @@ export function MessageTimeline(props: {
return false
})
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
equals: (a, b) => {
if (a.length !== b.length) return false
return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment)
},
})
const commentCount = createMemo(() => comments().length)
return (
@@ -757,7 +516,7 @@ export function MessageTimeline(props: {
messageID={messageID}
active={active()}
queued={queued()}
status={active() ? sessionStatus() : undefined}
animate={isNew || active()}
showReasoningSummaries={settings.general.showReasoningSummaries()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
editToolDefaultOpen={settings.general.editToolPartsExpanded()}

View File

@@ -87,6 +87,21 @@ export function SessionSidePanel(props: {
return out
})
const empty = (msg: string) => (
<div class="h-full flex flex-col">
<div class="h-12 shrink-0" aria-hidden />
<div class="flex-1 pb-30 flex items-center justify-center text-center">
<div class="text-12-regular text-text-weak">{msg}</div>
</div>
</div>
)
const nofiles = createMemo(() => {
const state = file.tree.state("")
if (!state?.loaded) return false
return file.tree.children("").length === 0
})
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
@@ -145,17 +160,8 @@ export function SessionSidePanel(props: {
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
fileTreeScrolled: false,
})
let changesEl: HTMLDivElement | undefined
let allEl: HTMLDivElement | undefined
const syncFileTreeScrolled = (el?: HTMLDivElement) => {
const next = (el?.scrollTop ?? 0) > 0
setStore("fileTreeScrolled", (current) => (current === next ? current : next))
}
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
@@ -176,11 +182,6 @@ export function SessionSidePanel(props: {
setStore("activeDraggable", undefined)
}
createEffect(() => {
if (!layout.fileTree.opened()) return
syncFileTreeScrolled(fileTreeTab() === "changes" ? changesEl : allEl)
})
createEffect(() => {
if (!file.ready()) return
@@ -331,7 +332,9 @@ export function SessionSidePanel(props: {
const path = createMemo(() => file.pathFromTab(tab))
return (
<div data-component="tabs-drag-preview">
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
<Show when={path()} keyed>
{(p) => <FileVisual active path={p} />}
</Show>
</div>
)
}}
@@ -354,7 +357,7 @@ export function SessionSidePanel(props: {
class="h-full"
data-scope="filetree"
>
<Tabs.List data-scrolled={store.fileTreeScrolled ? "" : undefined}>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{reviewCount()}{" "}
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
@@ -363,12 +366,7 @@ export function SessionSidePanel(props: {
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content
value="changes"
ref={(el: HTMLDivElement) => (changesEl = el)}
onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)}
class="bg-background-stronger px-3 py-0"
>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={hasReview()}>
<Show
@@ -382,6 +380,7 @@ export function SessionSidePanel(props: {
>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
@@ -390,25 +389,22 @@ export function SessionSidePanel(props: {
/>
</Show>
</Match>
<Match when={true}>
<div class="mt-8 text-center text-12-regular text-text-weak">
{language.t("session.review.noChanges")}
</div>
</Match>
<Match when={true}>{empty(language.t("session.review.noChanges"))}</Match>
</Switch>
</Tabs.Content>
<Tabs.Content
value="all"
ref={(el: HTMLDivElement) => (allEl = el)}
onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)}
class="bg-background-stronger px-3 py-0"
>
<FileTree
path=""
modified={diffFiles()}
kinds={kinds()}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
modified={diffFiles()}
kinds={kinds()}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Match>
</Switch>
</Tabs.Content>
</Tabs>
</div>

View File

@@ -0,0 +1,522 @@
import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { prefersReducedMotion } from "@opencode-ai/ui/hooks"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
import { showToast } from "@opencode-ai/ui/toast"
import { errorMessage } from "@/pages/layout/helpers"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
export function SessionTimelineHeader(props: {
centered: boolean
showHeader: () => boolean
sessionKey: () => string
sessionID: () => string | undefined
parentID: () => string | undefined
titleValue: () => string | undefined
headerTitle: () => string | undefined
placeholderTitle: () => boolean
}) {
const navigate = useNavigate()
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const dialog = useDialog()
const language = useLanguage()
const reduce = prefersReducedMotion
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
const [headerText, setHeaderText] = createStore({
session: props.sessionKey(),
value: props.headerTitle(),
prev: undefined as string | undefined,
muted: props.placeholderTitle(),
prevMuted: false,
})
let headerAnim: AnimationPlaybackControls | undefined
let enterAnim: AnimationPlaybackControls | undefined
let leaveAnim: AnimationPlaybackControls | undefined
let titleRef: HTMLInputElement | undefined
let headerRef: HTMLDivElement | undefined
let enterRef: HTMLSpanElement | undefined
let leaveRef: HTMLSpanElement | undefined
const clearHeaderAnim = () => {
headerAnim?.stop()
headerAnim = undefined
}
const animateHeader = () => {
const el = headerRef
if (!el) return
clearHeaderAnim()
if (!headerText.muted || reduce()) {
el.style.opacity = "1"
return
}
headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 })
headerAnim.finished.then(() => {
if (headerRef !== el) return
clearFadeStyles(el)
})
}
const clearTitleAnims = () => {
enterAnim?.stop()
enterAnim = undefined
leaveAnim?.stop()
leaveAnim = undefined
}
const settleTitleEnter = () => {
if (enterRef) clearFadeStyles(enterRef)
}
const hideLeave = () => {
if (!leaveRef) return
leaveRef.style.opacity = "0"
leaveRef.style.filter = ""
leaveRef.style.transform = ""
}
const animateEnterSpan = () => {
if (!enterRef) return
if (reduce()) {
settleTitleEnter()
return
}
enterAnim = animate(
enterRef,
{ opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] },
FAST_SPRING,
)
enterAnim.finished.then(() => settleTitleEnter())
}
const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => {
clearTitleAnims()
setHeaderText({ prev: headerText.value, prevMuted: headerText.muted })
setHeaderText({ value: nextTitle, muted: nextMuted })
if (reduce()) {
setHeaderText({ prev: undefined, prevMuted: false })
hideLeave()
settleTitleEnter()
return
}
if (leaveRef) {
leaveAnim = animate(
leaveRef,
{ opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] },
FAST_SPRING,
)
leaveAnim.finished.then(() => {
setHeaderText({ prev: undefined, prevMuted: false })
hideLeave()
})
}
animateEnterSpan()
}
const fadeInTitle = (nextTitle: string, nextMuted: boolean) => {
clearTitleAnims()
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
animateEnterSpan()
}
const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => {
clearTitleAnims()
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
settleTitleEnter()
}
createEffect(
on(props.showHeader, (show, prev) => {
if (!show) {
clearHeaderAnim()
return
}
if (show === prev) return
animateHeader()
}),
)
createEffect(
on(
() => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const,
([nextSession, nextTitle, nextMuted]) => {
if (nextSession !== headerText.session) {
setHeaderText("session", nextSession)
if (nextTitle && nextMuted) {
fadeInTitle(nextTitle, nextMuted)
return
}
snapTitle(nextTitle, nextMuted)
return
}
if (nextTitle === headerText.value && nextMuted === headerText.muted) return
if (!nextTitle) {
snapTitle(undefined, false)
return
}
if (!headerText.value) {
fadeInTitle(nextTitle, nextMuted)
return
}
if (title.saving || title.editing) {
snapTitle(nextTitle, nextMuted)
return
}
crossfadeTitle(nextTitle, nextMuted)
},
),
)
onCleanup(() => {
clearHeaderAnim()
clearTitleAnims()
})
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
createEffect(
on(
props.sessionKey,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!props.sessionID()) return
setTitle({ editing: true, draft: props.titleValue() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const id = props.sessionID()
if (!id) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (props.titleValue() ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID: id, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((session) => session.id === id)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: toastError(err),
})
})
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
return
}
navigate(`/${params.dir}/session`)
}
const archiveSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((item) => item.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((item) => item.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: toastError(err),
})
})
}
const deleteSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived)
const index = sessions.findIndex((item) => item.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: toastError(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((item) => !removed.has(item.id))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
return true
}
const navigateParent = () => {
const id = props.parentID()
if (!id) return
navigate(`/${params.dir}/session/${id}`)
}
function DialogDeleteSession(input: { sessionID: string }) {
const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(input.sessionID)
dialog.close()
}
return (
<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-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
return (
<Show when={props.showHeader()}>
<div
data-session-title
ref={(el) => {
headerRef = el
el.style.opacity = "0"
}}
class="pointer-events-none absolute inset-x-0 top-0 z-30"
>
<div
classList={{
"bg-[linear-gradient(to_bottom,var(--background-stronger)_38px,transparent)]": true,
"w-full": true,
"pb-10": true,
"px-4 md:px-5": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<div class="pointer-events-auto h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<Show when={props.parentID()}>
<div>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={navigateParent}
aria-label={language.t("common.goBack")}
/>
</div>
</Show>
<Show when={!!headerText.value || title.editing}>
<Show
when={title.editing}
fallback={
<h1 class="text-14-medium text-text-strong grow-1 min-w-0" onDblClick={openTitleEditor}>
<span class="grid min-w-0" style={{ overflow: "clip" }}>
<span ref={enterRef} class="col-start-1 row-start-1 min-w-0 truncate">
<span classList={{ "opacity-60": headerText.muted }}>{headerText.value}</span>
</span>
<span
ref={leaveRef}
class="col-start-1 row-start-1 min-w-0 truncate pointer-events-none"
style={{ opacity: "0" }}
>
<span classList={{ "opacity-60": headerText.prevMuted }}>{headerText.prev}</span>
</span>
</span>
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
value={title.draft}
disabled={title.saving}
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={closeTitleEditor}
/>
</Show>
</Show>
</div>
<Show when={props.sessionID()}>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<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>
</div>
)}
</Show>
</div>
</div>
</div>
</Show>
)
}

View File

@@ -191,8 +191,8 @@ export function TerminalPanel() {
<SortableProvider ids={ids()}>
<For each={ids()}>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
<Show when={byId().get(id)} keyed>
{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}
</Show>
)}
</For>
@@ -217,10 +217,10 @@ export function TerminalPanel() {
<div class="flex-1 min-h-0 relative">
<Show when={terminal.active()} keyed>
{(id) => (
<Show when={byId().get(id)}>
<Show when={byId().get(id)} keyed>
{(pty) => (
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal pty={pty()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
</div>
)}
</Show>
@@ -229,14 +229,14 @@ export function TerminalPanel() {
</div>
</div>
<DragOverlay>
<Show when={store.activeDraggable}>
<Show when={store.activeDraggable} keyed>
{(draggedId) => (
<Show when={byId().get(draggedId())}>
<Show when={byId().get(draggedId)} keyed>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({
title: t().title,
titleNumber: t().titleNumber,
title: t.title,
titleNumber: t.titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
})}
</div>

View File

@@ -1,6 +1,5 @@
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { useLocation, useNavigate } from "@solidjs/router"
import { createEffect, createMemo, onMount } from "solid-js"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { messageIdFromHash } from "./message-id-from-hash"
export { messageIdFromHash } from "./message-id-from-hash"
@@ -16,7 +15,7 @@ export const useSessionHashScroll = (input: {
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: UserMessage | undefined) => void
setTurnStart: (value: number) => void
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
autoScroll: { pause: () => void; snapToBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
scheduleScrollState: (el: HTMLDivElement) => void
@@ -27,18 +26,13 @@ export const useSessionHashScroll = (input: {
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
let pendingKey = ""
const location = useLocation()
const navigate = useNavigate()
const clearMessageHash = () => {
if (!location.hash) return
navigate(location.pathname + location.search, { replace: true })
if (!window.location.hash) return
window.history.replaceState(null, "", window.location.pathname + window.location.search)
}
const updateHash = (id: string) => {
navigate(location.pathname + location.search + `#${input.anchor(id)}`, {
replace: true,
})
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`)
}
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
@@ -47,15 +41,15 @@ export const useSessionHashScroll = (input: {
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
const sticky = root.querySelector("[data-session-title]")
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
const inset = Number.isNaN(title) ? 0 : title
// With column-reverse, scrollTop is negative — don't clamp to 0
const top = a.top - b.top + root.scrollTop - inset
root.scrollTo({ top, behavior })
return true
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
console.log({ message, behavior })
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
const index = messageIndex().get(message.id) ?? -1
@@ -103,9 +97,9 @@ export const useSessionHashScroll = (input: {
}
const applyHash = (behavior: ScrollBehavior) => {
const hash = location.hash.slice(1)
const hash = window.location.hash.slice(1)
if (!hash) {
input.autoScroll.forceScrollToBottom()
input.autoScroll.snapToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
return
@@ -129,13 +123,26 @@ export const useSessionHashScroll = (input: {
return
}
input.autoScroll.forceScrollToBottom()
input.autoScroll.snapToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
}
onMount(() => {
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual"
}
const handler = () => {
if (!input.sessionID() || !input.messagesReady()) return
requestAnimationFrame(() => applyHash("auto"))
}
window.addEventListener("hashchange", handler)
onCleanup(() => window.removeEventListener("hashchange", handler))
})
createEffect(() => {
location.hash
if (!input.sessionID() || !input.messagesReady()) return
requestAnimationFrame(() => applyHash("auto"))
})
@@ -159,7 +166,6 @@ export const useSessionHashScroll = (input: {
}
}
if (!targetId) targetId = messageIdFromHash(location.hash)
if (!targetId) return
if (input.currentMessageId() === targetId) return
@@ -171,12 +177,6 @@ export const useSessionHashScroll = (input: {
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
})
onMount(() => {
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual"
}
})
return {
clearMessageHash,
scrollToMessage,

View File

@@ -1,3 +1,12 @@
export function isEditableTarget(target: EventTarget | null | undefined) {
if (!(target instanceof HTMLElement)) return false
if (/^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName)) return true
if (target.isContentEditable) return true
if (target.closest("[contenteditable='true']")) return true
if (target.closest("input, textarea, select")) return true
return false
}
export function getCharacterOffsetInLine(lineElement: Element, targetNode: Node, offset: number): number {
const r = document.createRange()
r.selectNodeContents(lineElement)

View File

@@ -1,6 +0,0 @@
export function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}

View File

@@ -19,4 +19,4 @@ export function getRelativeTime(dateString: string, t: Translate): string {
if (diffMinutes < 60) return t("common.time.minutesAgo.short", { count: diffMinutes })
if (diffHours < 24) return t("common.time.hoursAgo.short", { count: diffHours })
return t("common.time.daysAgo.short", { count: diffDays })
}
}

View File

@@ -22,6 +22,5 @@
}
},
"include": ["src", "package.json"],
"exclude": ["dist", "ts-dist"],
"references": [{ "path": "../sdk/js" }]
"exclude": ["dist", "ts-dist"]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.17",
"version": "1.2.18",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -108,6 +108,26 @@ const DOCS_SEGMENT = new Set([
"zh-tw",
])
const DOCS_LOCALE = {
ar: "ar",
da: "da",
de: "de",
en: "en",
es: "es",
fr: "fr",
it: "it",
ja: "ja",
ko: "ko",
nb: "no",
"pt-br": "br",
root: "en",
ru: "ru",
th: "th",
tr: "tr",
"zh-cn": "zh",
"zh-tw": "zht",
} as const satisfies Record<string, Locale>
function suffix(pathname: string) {
const index = pathname.search(/[?#]/)
if (index === -1) {
@@ -130,7 +150,12 @@ export function docs(locale: Locale, pathname: string) {
return `${next.path}${next.suffix}`
}
if (value === "root") return `${next.path}${next.suffix}`
if (value === "root") {
if (next.path === "/docs/en") return `/docs${next.suffix}`
if (next.path === "/docs/en/") return `/docs/${next.suffix}`
if (next.path.startsWith("/docs/en/")) return `/docs/${next.path.slice("/docs/en/".length)}${next.suffix}`
return `${next.path}${next.suffix}`
}
if (next.path === "/docs") return `/docs/${value}${next.suffix}`
if (next.path === "/docs/") return `/docs/${value}/${next.suffix}`
@@ -154,6 +179,15 @@ export function fromPathname(pathname: string) {
return parseLocale(fix(pathname).split("/")[1])
}
export function fromDocsPathname(pathname: string) {
const next = fix(pathname)
const value = next.split("/")[2]?.toLowerCase()
if (!value) return null
if (!next.startsWith("/docs/")) return null
if (!(value in DOCS_LOCALE)) return null
return DOCS_LOCALE[value as keyof typeof DOCS_LOCALE]
}
export function strip(pathname: string) {
const locale = fromPathname(pathname)
if (!locale) return fix(pathname)
@@ -272,6 +306,9 @@ export function localeFromRequest(request: Request) {
const fromPath = fromPathname(new URL(request.url).pathname)
if (fromPath) return fromPath
const fromDocsPath = fromDocsPathname(new URL(request.url).pathname)
if (fromDocsPath) return fromDocsPath
return (
localeFromCookieHeader(request.headers.get("cookie")) ??
detectFromAcceptLanguage(request.headers.get("accept-language"))

View File

@@ -1,6 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { docs, localeFromRequest, tag } from "~/lib/language"
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
headers,
body: req.body,
})
return response
const next = new Response(response.body, response)
next.headers.append("set-cookie", cookie(locale))
return next
}
export const GET = handler

View File

@@ -1,6 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { docs, localeFromRequest, tag } from "~/lib/language"
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
headers,
body: req.body,
})
return response
const next = new Response(response.body, response)
next.headers.append("set-cookie", cookie(locale))
return next
}
export const GET = handler

View File

@@ -1,6 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { docs, localeFromRequest, tag } from "~/lib/language"
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
headers,
body: req.body,
})
return response
const next = new Response(response.body, response)
next.headers.append("set-cookie", cookie(locale))
return next
}
export const GET = handler

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.2.17",
"version": "1.2.18",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.17",
"version": "1.2.18",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.2.17",
"version": "1.2.18",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.2.17",
"version": "1.2.18",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.2.17",
"version": "1.2.18",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.17",
"version": "1.2.18",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.2.17"
version = "1.2.18"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.17/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.18/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.17/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.18/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.17/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.18/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.17/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.18/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.17/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.18/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.17",
"version": "1.2.18",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -0,0 +1,2 @@
ALTER TABLE `session` ADD `org_id` text;--> statement-breakpoint
CREATE INDEX `session_org_idx` ON `session` (`org_id`);

View File

@@ -446,7 +446,7 @@
"autoincrement": false,
"default": null,
"generated": null,
"name": "workspace_id",
"name": "org_id",
"entityType": "columns",
"table": "session"
},
@@ -939,14 +939,14 @@
{
"columns": [
{
"value": "workspace_id",
"value": "org_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_workspace_idx",
"name": "session_org_idx",
"entityType": "indexes",
"table": "session"
},

View File

@@ -1,2 +0,0 @@
ALTER TABLE `session` ADD `workspace_id` text;--> statement-breakpoint
CREATE INDEX `session_workspace_idx` ON `session` (`workspace_id`);

View File

@@ -0,0 +1,11 @@
CREATE TABLE `account` (
`id` text PRIMARY KEY,
`email` text NOT NULL,
`url` text NOT NULL,
`access_token` text NOT NULL,
`refresh_token` text NOT NULL,
`token_expiry` integer,
`org_id` text,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.17",
"version": "1.2.18",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -43,6 +43,7 @@
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@types/which": "3.0.4",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
@@ -127,6 +128,7 @@
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",

View File

@@ -1,7 +1,18 @@
import { sqliteTable, text, integer, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core"
import { eq } from "drizzle-orm"
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import { Timestamps } from "@/storage/schema.sql"
export const AccountTable = sqliteTable("account", {
id: text().primaryKey(),
email: text().notNull(),
url: text().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
org_id: text(),
...Timestamps,
})
// LEGACY
export const ControlAccountTable = sqliteTable(
"control_account",
{

View File

@@ -0,0 +1,247 @@
import { eq, sql, isNotNull } from "drizzle-orm"
import { Database } from "@/storage/db"
import { AccountTable } from "./account.sql"
import z from "zod"
export namespace Account {
export const Account = z.object({
id: z.string(),
email: z.string(),
url: z.string(),
org_id: z.string().nullable(),
})
export type Account = z.infer<typeof Account>
function fromRow(row: (typeof AccountTable)["$inferSelect"]): Account {
return {
id: row.id,
email: row.email,
url: row.url,
org_id: row.org_id,
}
}
export function active(): Account | undefined {
const row = Database.use((db) => db.select().from(AccountTable).where(isNotNull(AccountTable.org_id)).get())
return row ? fromRow(row) : undefined
}
export function list(): Account[] {
return Database.use((db) => db.select().from(AccountTable).all().map(fromRow))
}
export function remove(accountID: string) {
Database.use((db) => db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run())
}
export function use(accountID: string, orgID: string | null) {
Database.use((db) =>
db.update(AccountTable).set({ org_id: orgID }).where(eq(AccountTable.id, accountID)).run(),
)
}
export async function orgs(accountID: string): Promise<{ id: string; name: string }[]> {
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get())
if (!row) return []
const access = await token(accountID)
if (!access) return []
const res = await fetch(`${row.url}/api/orgs`, {
headers: { authorization: `Bearer ${access}` },
})
if (!res.ok) return []
const json = (await res.json()) as Array<{ id?: string; name?: string }>
return json.map((x) => ({ id: x.id ?? "", name: x.name ?? "" }))
}
export async function config(accountID: string, orgID: string): Promise<Record<string, unknown> | undefined> {
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get())
if (!row) return undefined
const access = await token(accountID)
if (!access) return undefined
const res = await fetch(`${row.url}/api/config`, {
headers: { authorization: `Bearer ${access}`, "x-org-id": orgID },
})
if (!res.ok) return undefined
const result = (await res.json()) as Record<string, any>
return result.config
}
export async function token(accountID: string): Promise<string | undefined> {
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get())
if (!row) return undefined
if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token
const res = await fetch(`${row.url}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
}).toString(),
})
if (!res.ok) return
const json = (await res.json()) as {
access_token: string
refresh_token?: string
expires_in?: number
}
Database.use((db) =>
db
.update(AccountTable)
.set({
access_token: json.access_token,
refresh_token: json.refresh_token ?? row.refresh_token,
token_expiry: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
})
.where(eq(AccountTable.id, row.id))
.run(),
)
return json.access_token
}
export type Login = {
code: string
user: string
url: string
server: string
expiry: number
interval: number
}
export async function login(url?: string): Promise<Login> {
const server = url ?? "https://web-14275-d60e67f5-pyqs0590.onporter.run"
const res = await fetch(`${server}/auth/device/code`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ client_id: "opencode-cli" }),
})
if (!res.ok) throw new Error(`Failed to initiate device flow: ${await res.text()}`)
const json = (await res.json()) as {
device_code: string
user_code: string
verification_uri_complete: string
expires_in: number
interval: number
}
const full = `${server}${json.verification_uri_complete}`
return {
code: json.device_code,
user: json.user_code,
url: full,
server,
expiry: json.expires_in,
interval: json.interval,
}
}
export async function poll(
input: Login,
): Promise<
| { type: "success"; email: string }
| { type: "pending" }
| { type: "slow" }
| { type: "expired" }
| { type: "denied" }
| { type: "error"; msg: string }
> {
const res = await fetch(`${input.server}/auth/device/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: "opencode-cli",
}),
})
const json = (await res.json()) as {
access_token?: string
refresh_token?: string
expires_in?: number
error?: string
error_description?: string
}
if (json.access_token) {
const me = await fetch(`${input.server}/api/user`, {
headers: { authorization: `Bearer ${json.access_token}` },
})
const user = (await me.json()) as { id?: string; email?: string }
if (!user.id || !user.email) {
return { type: "error", msg: "No id or email in response" }
}
const id = user.id
const email = user.email
const access = json.access_token
const expiry = Date.now() + json.expires_in! * 1000
const refresh = json.refresh_token ?? ""
// Fetch orgs and get first one
const orgsRes = await fetch(`${input.server}/api/orgs`, {
headers: { authorization: `Bearer ${access}` },
})
const orgs = (await orgsRes.json()) as Array<{ id?: string; name?: string }>
const firstOrgId = orgs.length > 0 ? orgs[0].id : null
Database.use((db) => {
db.update(AccountTable).set({ org_id: null }).run()
db.insert(AccountTable)
.values({
id,
email,
url: input.server,
access_token: access,
refresh_token: refresh,
token_expiry: expiry,
org_id: firstOrgId,
})
.onConflictDoUpdate({
target: AccountTable.id,
set: {
access_token: access,
refresh_token: refresh,
token_expiry: expiry,
org_id: firstOrgId,
},
})
.run()
})
return { type: "success", email }
}
if (json.error === "authorization_pending") {
return { type: "pending" }
}
if (json.error === "slow_down") {
return { type: "slow" }
}
if (json.error === "expired_token") {
return { type: "expired" }
}
if (json.error === "access_denied") {
return { type: "denied" }
}
return { type: "error", msg: json.error || JSON.stringify(json) }
}
}

View File

@@ -63,6 +63,7 @@ export namespace Agent {
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
edit: "ask",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",

View File

@@ -0,0 +1,161 @@
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { Account } from "@/account"
export const LoginCommand = cmd({
command: "login [url]",
describe: "log in to an opencode account",
builder: (yargs) =>
yargs.positional("url", {
describe: "server URL",
type: "string",
}),
async handler(args) {
UI.empty()
prompts.intro("Log in")
const url = args.url as string | undefined
const login = await Account.login(url)
prompts.log.info("Go to: " + login.url)
prompts.log.info("Enter code: " + login.user)
try {
const open =
process.platform === "darwin"
? ["open", login.url]
: process.platform === "win32"
? ["cmd", "/c", "start", login.url]
: ["xdg-open", login.url]
Bun.spawn(open, { stdout: "ignore", stderr: "ignore" })
} catch {}
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
let wait = login.interval * 1000
while (true) {
await Bun.sleep(wait)
const result = await Account.poll(login)
if (result.type === "success") {
spinner.stop("Logged in as " + result.email)
prompts.outro("Done")
return
}
if (result.type === "pending") continue
if (result.type === "slow") {
wait += 5000
continue
}
if (result.type === "expired") {
spinner.stop("Device code expired", 1)
return
}
if (result.type === "denied") {
spinner.stop("Authorization denied", 1)
return
}
spinner.stop("Error: " + result.msg, 1)
return
}
},
})
export const LogoutCommand = cmd({
command: "logout [email]",
describe: "log out from an account",
builder: (yargs) =>
yargs.positional("email", {
describe: "account email to log out from",
type: "string",
}),
async handler(args) {
const email = args.email as string | undefined
if (email) {
const accounts = Account.list()
const match = accounts.find((a) => a.email === email)
if (!match) {
UI.println("Account not found: " + email)
return
}
Account.remove(match.id)
UI.println("Logged out from " + email)
return
}
const active = Account.active()
if (!active) {
UI.println("Not logged in")
return
}
Account.remove(active.id)
UI.println("Logged out from " + active.email)
},
})
export const SwitchCommand = cmd({
command: "switch",
describe: "switch active org",
async handler() {
UI.empty()
const active = Account.active()
if (!active) {
UI.println("Not logged in")
return
}
const orgs = await Account.orgs(active.id)
if (orgs.length === 0) {
UI.println("No orgs found")
return
}
prompts.intro("Switch org")
const opts = orgs.map((o) => ({
value: o.id,
label: o.id === active.org_id ? o.name + UI.Style.TEXT_DIM + " (active)" : o.name,
}))
const selected = await prompts.select({
message: "Select org",
options: opts,
})
if (prompts.isCancel(selected)) return
Account.use(active.id, selected as string)
prompts.outro("Switched to " + orgs.find((o) => o.id === selected)?.name)
},
})
export const OrgsCommand = cmd({
command: "orgs",
aliases: ["org"],
describe: "list all orgs",
async handler() {
const accounts = Account.list()
if (accounts.length === 0) {
UI.println("No accounts found")
return
}
for (const account of accounts) {
const orgs = await Account.orgs(account.id)
for (const org of orgs) {
UI.println([org.name, account.email, org.id].join("\t"))
}
}
},
})

View File

@@ -13,6 +13,7 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
import { setTimeout as sleep } from "node:timers/promises"
type PluginAuth = NonNullable<Hooks["auth"]>
@@ -47,7 +48,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
const method = plugin.auth.methods[index]
// Handle prompts for all auth types
await Bun.sleep(10)
await sleep(10)
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {

View File

@@ -3,6 +3,7 @@ import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util/log"
import { EOL } from "os"
import { setTimeout as sleep } from "node:timers/promises"
export const LSPCommand = cmd({
command: "lsp",
@@ -19,7 +20,7 @@ const DiagnosticsCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
await LSP.touchFile(args.file, true)
await Bun.sleep(1000)
await sleep(1000)
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
})
},

View File

@@ -28,6 +28,7 @@ import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { $ } from "bun"
import { setTimeout as sleep } from "node:timers/promises"
type GitHubAuthor = {
login: string
@@ -353,7 +354,7 @@ export const GithubInstallCommand = cmd({
}
retries++
await Bun.sleep(1000)
await sleep(1000)
} while (true)
s.stop("Installed GitHub app")
@@ -1372,7 +1373,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
} catch (e) {
if (retries > 0) {
console.log(`Retrying after ${delayMs}ms...`)
await Bun.sleep(delayMs)
await sleep(delayMs)
return withRetry(fn, retries - 1, delayMs)
}
throw e

View File

@@ -10,7 +10,7 @@ import { ShareNext } from "../../share/share-next"
import { EOL } from "os"
import { Filesystem } from "../../util/filesystem"
/** Discriminated union returned by the ShareNext API (GET /api/share/:id/data) */
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
export type ShareData =
| { type: "session"; data: SDKSession }
| { type: "message"; data: Message }
@@ -24,6 +24,14 @@ export function parseShareUrl(url: string): string | null {
return match ? match[1] : null
}
export function shouldAttachShareAuthHeaders(shareUrl: string, controlBaseUrl: string): boolean {
try {
return new URL(shareUrl).origin === new URL(controlBaseUrl).origin
} catch {
return false
}
}
/**
* Transform ShareNext API response (flat array) into the nested structure for local file storage.
*
@@ -97,8 +105,21 @@ export const ImportCommand = cmd({
return
}
const baseUrl = await ShareNext.url()
const response = await fetch(`${baseUrl}/api/share/${slug}/data`)
const parsed = new URL(args.file)
const baseUrl = parsed.origin
const req = await ShareNext.request()
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
const dataPath = req.api.data(slug)
let response = await fetch(`${baseUrl}${dataPath}`, {
headers,
})
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
response = await fetch(`${baseUrl}/api/share/${slug}/data`, {
headers,
})
}
if (!response.ok) {
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)

View File

@@ -0,0 +1,438 @@
import { Auth } from "../../auth"
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { ModelsDev } from "../../provider/models"
import { map, pipe, sortBy, values } from "remeda"
import path from "path"
import os from "os"
import { Config } from "../../config/config"
import { Global } from "../../global"
import { Plugin } from "../../plugin"
import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
type PluginAuth = NonNullable<Hooks["auth"]>
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): Promise<boolean> {
let index = 0
if (plugin.auth.methods.length > 1) {
const method = await prompts.select({
message: "Login method",
options: [
...plugin.auth.methods.map((x, index) => ({
label: x.label,
value: index.toString(),
})),
],
})
if (prompts.isCancel(method)) throw new UI.CancelledError()
index = parseInt(method)
}
const method = plugin.auth.methods[index]
await Bun.sleep(10)
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {
if (prompt.condition && !prompt.condition(inputs)) {
continue
}
if (prompt.type === "select") {
const value = await prompts.select({
message: prompt.message,
options: prompt.options,
})
if (prompts.isCancel(value)) throw new UI.CancelledError()
inputs[prompt.key] = value
} else {
const value = await prompts.text({
message: prompt.message,
placeholder: prompt.placeholder,
validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
})
if (prompts.isCancel(value)) throw new UI.CancelledError()
inputs[prompt.key] = value
}
}
}
if (method.type === "oauth") {
const authorize = await method.authorize(inputs)
if (authorize.url) {
prompts.log.info("Go to: " + authorize.url)
}
if (authorize.method === "auto") {
if (authorize.instructions) {
prompts.log.info(authorize.instructions)
}
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
const result = await authorize.callback()
if (result.type === "failed") {
spinner.stop("Failed to authorize", 1)
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await Auth.set(saveProvider, {
type: "oauth",
refresh,
access,
expires,
...extraFields,
})
}
if ("key" in result) {
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
}
spinner.stop("Login successful")
}
}
if (authorize.method === "code") {
const code = await prompts.text({
message: "Paste the authorization code here: ",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(code)) throw new UI.CancelledError()
const result = await authorize.callback(code)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await Auth.set(saveProvider, {
type: "oauth",
refresh,
access,
expires,
...extraFields,
})
}
if ("key" in result) {
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
}
prompts.log.success("Login successful")
}
}
prompts.outro("Done")
return true
}
if (method.type === "api") {
if (method.authorize) {
const result = await method.authorize(inputs)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
prompts.log.success("Login successful")
}
prompts.outro("Done")
return true
}
}
return false
}
export function resolvePluginProviders(input: {
hooks: Hooks[]
existingProviders: Record<string, unknown>
disabled: Set<string>
enabled?: Set<string>
providerNames: Record<string, string | undefined>
}): Array<{ id: string; name: string }> {
const seen = new Set<string>()
const result: Array<{ id: string; name: string }> = []
for (const hook of input.hooks) {
if (!hook.auth) continue
const id = hook.auth.provider
if (seen.has(id)) continue
seen.add(id)
if (Object.hasOwn(input.existingProviders, id)) continue
if (input.disabled.has(id)) continue
if (input.enabled && !input.enabled.has(id)) continue
result.push({
id,
name: input.providerNames[id] ?? id,
})
}
return result
}
export const ProvidersCommand = cmd({
command: "providers",
aliases: ["auth"],
describe: "manage AI providers and credentials",
builder: (yargs) =>
yargs.command(ProvidersListCommand).command(ProvidersLoginCommand).command(ProvidersLogoutCommand).demandCommand(),
async handler() {},
})
export const ProvidersListCommand = cmd({
command: "list",
aliases: ["ls"],
describe: "list providers and credentials",
async handler(_args) {
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = Object.entries(await Auth.all())
const database = await ModelsDev.get()
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
}
prompts.outro(`${results.length} credentials`)
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
for (const envVar of provider.env) {
if (process.env[envVar]) {
activeEnvVars.push({
provider: provider.name || providerID,
envVar,
})
}
}
}
if (activeEnvVars.length > 0) {
UI.empty()
prompts.intro("Environment")
for (const { provider, envVar } of activeEnvVars) {
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
}
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
}
},
})
export const ProvidersLoginCommand = cmd({
command: "login [url]",
describe: "log in to a provider",
builder: (yargs) =>
yargs.positional("url", {
describe: "opencode auth provider",
type: "string",
}),
async handler(args) {
await Instance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Process.spawn(wellknown.auth.command, {
stdout: "pipe",
})
if (!proc.stdout) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
if (exit !== 0) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
await Auth.set(args.url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
prompts.log.success("Logged into " + args.url)
prompts.outro("Done")
return
}
await ModelsDev.refresh().catch(() => {})
const config = await Config.get()
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const providers = await ModelsDev.get().then((x) => {
const filtered: Record<string, (typeof x)[string]> = {}
for (const [key, value] of Object.entries(x)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
}
return filtered
})
const priority: Record<string, number> = {
opencode: 0,
anthropic: 1,
"github-copilot": 2,
openai: 3,
google: 4,
openrouter: 5,
vercel: 6,
}
const pluginProviders = resolvePluginProviders({
hooks: await Plugin.list(),
existingProviders: providers,
disabled,
enabled,
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
})
let provider = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: {
opencode: "recommended",
anthropic: "Claude Max or API key",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
),
...pluginProviders.map((x) => ({
label: x.name,
value: x.id,
hint: "plugin",
})),
{
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider)
if (handled) return
}
if (provider === "other") {
provider = await prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
provider = provider.replace(/^@ai-sdk\//, "")
if (prompts.isCancel(provider)) throw new UI.CancelledError()
const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider)
if (handled) return
}
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
)
}
if (provider === "opencode") {
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
if (provider === "vercel") {
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
prompts.log.info(
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
)
}
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
await Auth.set(provider, {
type: "api",
key,
})
prompts.outro("Done")
},
})
},
})
export const ProvidersLogoutCommand = cmd({
command: "logout",
describe: "log out from a configured provider",
async handler(_args) {
UI.empty()
const credentials = await Auth.all().then((x) => Object.entries(x))
prompts.intro("Remove credential")
if (credentials.length === 0) {
prompts.log.error("No credentials found")
return
}
const database = await ModelsDev.get()
const providerID = await prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})
if (prompts.isCancel(providerID)) throw new UI.CancelledError()
await Auth.remove(providerID)
prompts.outro("Logout successful")
},
})

View File

@@ -365,6 +365,11 @@ export const RunCommand = cmd({
action: "deny",
pattern: "*",
},
{
permission: "edit",
action: "allow",
pattern: "*",
},
]
function title() {

View File

@@ -9,6 +9,7 @@ import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
import { EOL } from "os"
import path from "path"
import { which } from "../../util/which"
function pagerCmd(): string[] {
const lessOptions = ["-R", "-S"]
@@ -17,7 +18,7 @@ function pagerCmd(): string[] {
}
// user could have less installed via other options
const lessOnPath = Bun.which("less")
const lessOnPath = which("less")
if (lessOnPath) {
if (Filesystem.stat(lessOnPath)?.size) return [lessOnPath, ...lessOptions]
}
@@ -27,7 +28,7 @@ function pagerCmd(): string[] {
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
}
const git = Bun.which("git")
const git = which("git")
if (git) {
const less = path.join(git, "..", "..", "usr", "bin", "less.exe")
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]

View File

@@ -20,6 +20,7 @@ import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { KeybindProvider } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
@@ -373,6 +374,22 @@ function App() {
dialog.replace(() => <DialogSessionList />)
},
},
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI
? [
{
title: "Manage workspaces",
value: "workspace.list",
category: "Workspace",
suggested: true,
slash: {
name: "workspaces",
},
onSelect: () => {
dialog.replace(() => <DialogWorkspaceList />)
},
},
]
: []),
{
title: "New session",
suggested: route.data.type === "session",
@@ -462,6 +479,7 @@ function App() {
{
title: "Toggle MCPs",
value: "mcp.list",
search: "toggle mcps",
category: "Agent",
slash: {
name: "mcps",
@@ -537,8 +555,9 @@ function App() {
category: "System",
},
{
title: "Toggle appearance",
title: mode() === "dark" ? "Light mode" : "Dark mode",
value: "theme.switch_mode",
search: "toggle appearance",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
@@ -577,6 +596,7 @@ function App() {
},
{
title: "Toggle debug panel",
search: "toggle debug",
category: "System",
value: "app.debug",
onSelect: (dialog) => {
@@ -586,6 +606,7 @@ function App() {
},
{
title: "Toggle console",
search: "toggle console",
category: "System",
value: "app.console",
onSelect: (dialog) => {
@@ -626,6 +647,7 @@ function App() {
{
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
value: "terminal.title.toggle",
search: "toggle terminal title",
keybind: "terminal_title_toggle",
category: "System",
onSelect: (dialog) => {
@@ -641,6 +663,7 @@ function App() {
{
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
value: "app.toggle.animations",
search: "toggle animations",
category: "System",
onSelect: (dialog) => {
kv.set("animations_enabled", !kv.get("animations_enabled", true))
@@ -650,6 +673,7 @@ function App() {
{
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.diffwrap",
search: "toggle diff wrapping",
category: "System",
onSelect: (dialog) => {
const current = kv.get("diff_wrap_mode", "word")

View File

@@ -7,6 +7,27 @@ import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
import type { Provider } from "@opencode-ai/sdk/v2"
function pickLatest(models: [string, Provider["models"][string]][]) {
const picks: Record<string, [string, Provider["models"][string]]> = {}
for (const item of models) {
const model = item[0]
const info = item[1]
const key = info.family ?? model
const prev = picks[key]
if (!prev) {
picks[key] = item
continue
}
if (info.release_date !== prev[1].release_date) {
if (info.release_date > prev[1].release_date) picks[key] = item
continue
}
if (model > prev[0]) picks[key] = item
}
return Object.values(picks)
}
export function useConnected() {
const sync = useSync()
@@ -21,6 +42,7 @@ export function DialogModel(props: { providerID?: string }) {
const dialog = useDialog()
const keybind = useKeybind()
const [query, setQuery] = createSignal("")
const [all, setAll] = createSignal(false)
const connected = useConnected()
const providers = createDialogProviderOptions()
@@ -72,8 +94,8 @@ export function DialogModel(props: { providerID?: string }) {
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
flatMap((provider) =>
pipe(
flatMap((provider) => {
const items = pipe(
provider.models,
entries(),
filter(([_, info]) => info.status !== "deprecated"),
@@ -104,8 +126,9 @@ export function DialogModel(props: { providerID?: string }) {
(x) => x.footer !== "Free",
(x) => x.title,
),
),
),
)
return items
}),
)
const popularProviders = !connected()
@@ -154,6 +177,13 @@ export function DialogModel(props: { providerID?: string }) {
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
},
},
{
keybind: keybind.all.model_show_all_toggle?.[0],
title: all() ? "Show latest only" : "Show all models",
onTrigger: () => {
setAll((value) => !value)
},
},
]}
onFilter={setQuery}
flat={true}

View File

@@ -0,0 +1,326 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
import type { Session } from "@opencode-ai/sdk/v2"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
workspaceID: string
forceCreate?: boolean
}) {
const cacheSession = (session: Session) => {
input.sync.set(
"session",
[...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) =>
a.id.localeCompare(b.id),
),
)
}
const client = createOpencodeClient({
baseUrl: input.sdk.url,
fetch: input.sdk.fetch,
directory: input.sync.data.path.directory || input.sdk.directory,
experimental_workspaceID: input.workspaceID,
})
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
const session = listed?.data?.[0]
if (session?.id) {
cacheSession(session)
input.route.navigate({
type: "session",
sessionID: session.id,
})
input.dialog.clear()
return
}
let created: Session | undefined
while (!created) {
const result = await client.session.create({}).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to open workspace",
variant: "error",
})
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await Bun.sleep(1000)
continue
}
if (!result.data) {
input.toast.show({
message: "Failed to open workspace",
variant: "error",
})
return
}
created = result.data
}
cacheSession(created)
input.route.navigate({
type: "session",
sessionID: created.id,
})
input.dialog.clear()
}
function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> }) {
const dialog = useDialog()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
onMount(() => {
dialog.setSize("medium")
})
const options = createMemo(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
]
}
return [
{
title: "Worktree",
value: "worktree" as const,
description: "Create a local git worktree",
},
]
})
const createWorkspace = async (type: string) => {
if (creating()) return
setCreating(type)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
console.log(err)
return undefined
})
console.log(JSON.stringify(result, null, 2))
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
toast.show({
message: "Failed to create workspace",
variant: "error",
})
return
}
await sync.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
skipFilter={true}
options={options()}
onSelect={(option) => {
if (option.value === "creating") return
void createWorkspace(option.value)
}}
/>
)
}
export function DialogWorkspaceList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const keybind = useKeybind()
const [toDelete, setToDelete] = createSignal<string>()
const [counts, setCounts] = createSignal<Record<string, number | null | undefined>>({})
const open = (workspaceID: string, forceCreate?: boolean) =>
openWorkspace({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
forceCreate,
})
async function selectWorkspace(workspaceID: string) {
if (workspaceID === "__local__") {
if (localCount() > 0) {
dialog.replace(() => <DialogSessionList localOnly={true} />)
return
}
route.navigate({
type: "home",
})
dialog.clear()
return
}
const count = counts()[workspaceID]
if (count && count > 0) {
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
return
}
if (count === 0) {
await open(workspaceID)
return
}
const client = createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.data.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
if (listed?.data?.length) {
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
return
}
await open(workspaceID)
}
const currentWorkspaceID = createMemo(() => {
if (route.data.type === "session") {
return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
}
return "__local__"
})
const localCount = createMemo(
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
)
let run = 0
createEffect(() => {
const workspaces = sync.data.workspaceList
const next = ++run
if (!workspaces.length) {
setCounts({})
return
}
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
void Promise.all(
workspaces.map(async (workspace) => {
const client = createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.data.path.directory || sdk.directory,
experimental_workspaceID: workspace.id,
})
const result = await client.session.list({ roots: true }).catch(() => undefined)
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
}),
).then((entries) => {
if (run !== next) return
setCounts(Object.fromEntries(entries))
})
})
const options = createMemo(() => [
{
title: "Local",
value: "__local__",
category: "Workspace",
description: "Use the local machine",
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
},
...sync.data.workspaceList.map((workspace) => {
const count = counts()[workspace.id]
return {
title:
toDelete() === workspace.id
? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again`
: workspace.id,
value: workspace.id,
category: workspace.type,
description: workspace.branch ? `Branch ${workspace.branch}` : undefined,
footer:
count === undefined
? "Loading sessions..."
: count === null
? "Sessions unavailable"
: `${count} session${count === 1 ? "" : "s"}`,
}
}),
{
title: "+ New workspace",
value: "__create__",
category: "Actions",
description: "Create a new workspace",
},
])
onMount(() => {
dialog.setSize("large")
void sync.workspace.sync()
})
return (
<DialogSelect
title="Workspaces"
skipFilter={true}
options={options()}
current={currentWorkspaceID()}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
setToDelete(undefined)
if (option.value === "__create__") {
dialog.replace(() => <DialogWorkspaceCreate onSelect={(workspaceID) => open(workspaceID, true)} />)
return
}
void selectWorkspace(option.value)
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (option.value === "__create__" || option.value === "__local__") return
if (toDelete() !== option.value) {
setToDelete(option.value)
return
}
const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined)
setToDelete(undefined)
if (result?.error) {
toast.show({
message: "Failed to delete workspace",
variant: "error",
})
return
}
if (currentWorkspaceID() === option.value) {
route.navigate({
type: "home",
})
}
await sync.workspace.sync()
},
},
]}
/>
)
}

View File

@@ -77,6 +77,7 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
function promptModelWarning() {
toast.show({
@@ -170,6 +171,17 @@ export function Prompt(props: PromptProps) {
command.register(() => {
return [
{
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
value: "permission.auto_accept.toggle",
search: "toggle permissions",
keybind: "permission_auto_accept_toggle",
category: "Agent",
onSelect: (dialog) => {
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
dialog.clear()
},
},
{
title: "Clear prompt",
value: "prompt.clear",
@@ -996,23 +1008,30 @@ export function Prompt(props: PromptProps) {
cursorColor={theme.text}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
<box flexDirection="row" gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
</Show>
</box>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
</text>
</Show>
</box>
</Show>
</box>
<Show when={autoaccept() === "edit"}>
<text>
<span style={{ fg: theme.warning }}>autoedit</span>
</text>
</Show>
</box>
</box>

View File

@@ -0,0 +1,151 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
import { Locale } from "@/util/locale"
import { useKeybind } from "../../context/keybind"
import { useTheme } from "../../context/theme"
import { useSDK } from "../../context/sdk"
import { DialogSessionRename } from "../dialog-session-rename"
import { useKV } from "../../context/kv"
import { createDebouncedSignal } from "../../util/signal"
import { Spinner } from "../spinner"
import { useToast } from "../../ui/toast"
export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const keybind = useKeybind()
const { theme } = useTheme()
const sdk = useSDK()
const kv = useKV()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const [listed, listedActions] = createResource(
() => props.workspaceID,
async (workspaceID) => {
if (!workspaceID) return undefined
const result = await sdk.client.session.list({ roots: true })
return result.data ?? []
},
)
const [searchResults] = createResource(search, async (query) => {
if (!query || props.localOnly) return undefined
const result = await sdk.client.session.list({
search: query,
limit: 30,
...(props.workspaceID ? { roots: true } : {}),
})
return result.data ?? []
})
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => {
if (searchResults()) return searchResults()!
if (props.workspaceID) return listed() ?? []
if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID)
return sync.data.session
})
const options = createMemo(() => {
const today = new Date().toDateString()
return sessions()
.filter((x) => {
if (x.parentID !== undefined) return false
if (props.workspaceID && listed()) return true
if (props.workspaceID) return x.workspaceID === props.workspaceID
if (props.localOnly) return !x.workspaceID
return true
})
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => {
const date = new Date(x.time.updated)
let category = date.toDateString()
if (category === today) {
category = "Today"
}
const isDeleting = toDelete() === x.id
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy"
return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
footer: Locale.time(x.time.updated),
gutter: isWorking ? <Spinner /> : undefined,
}
})
})
onMount(() => {
dialog.setSize("large")
})
return (
<DialogSelect
title={props.workspaceID ? `Workspace Sessions` : props.localOnly ? "Local Sessions" : "Sessions"}
options={options()}
skipFilter={!props.localOnly}
current={currentSessionID()}
onFilter={setSearch}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
dialog.clear()
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
const deleted = await sdk.client.session
.delete({
sessionID: option.value,
})
.then(() => true)
.catch(() => false)
setToDelete(undefined)
if (!deleted) {
toast.show({
message: "Failed to delete session",
variant: "error",
})
return
}
if (props.workspaceID) {
listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value))
return
}
sync.set(
"session",
sync.data.session.filter((session) => session.id !== option.value),
)
return
}
setToDelete(option.value)
},
},
{
keybind: keybind.all.session_rename?.[0],
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
]}
/>
)
}

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