From b5e200988c30258f32c857db300b61e9176b6383 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 6 Mar 2026 14:35:19 -0500 Subject: [PATCH 1/5] feat(ui): tool call abstraction, turn diff summary, and composer improvements Co-Authored-By: Claude Opus 4.6 --- README.ar.md | 3 +- README.bn.md | 3 +- README.br.md | 4 +- README.bs.md | 3 +- README.da.md | 4 +- README.de.md | 4 +- README.es.md | 4 +- README.fr.md | 4 +- README.gr.md | 3 +- README.it.md | 4 +- README.ja.md | 4 +- README.ko.md | 4 +- README.md | 3 +- README.no.md | 4 +- README.pl.md | 4 +- README.ru.md | 4 +- README.th.md | 4 +- README.tr.md | 4 +- README.uk.md | 3 +- README.vi.md | 141 -- README.zh.md | 4 +- README.zht.md | 4 +- bun.lock | 49 +- github/index.ts | 3 +- nix/hashes.json | 8 +- packages/app/e2e/actions.ts | 15 +- packages/app/e2e/selectors.ts | 2 + packages/app/e2e/session/session.spec.ts | 4 +- packages/app/package.json | 2 +- packages/app/src/components/prompt-input.tsx | 80 +- .../app/src/components/prompt-input/submit.ts | 31 +- .../session/session-context-tab.tsx | 3 +- .../app/src/components/settings-general.tsx | 23 - packages/app/src/context/command.tsx | 9 +- packages/app/src/context/global-sync.tsx | 1 - packages/app/src/context/language.tsx | 2 - packages/app/src/context/layout.tsx | 2 +- .../context/permission-auto-respond.test.ts | 41 +- .../src/context/permission-auto-respond.ts | 12 +- packages/app/src/context/permission.tsx | 75 +- packages/app/src/context/settings.tsx | 18 - packages/app/src/i18n/ar.ts | 6 - packages/app/src/i18n/br.ts | 6 - packages/app/src/i18n/bs.ts | 6 - packages/app/src/i18n/da.ts | 5 - packages/app/src/i18n/de.ts | 6 - packages/app/src/i18n/en.ts | 7 - packages/app/src/i18n/es.ts | 6 - packages/app/src/i18n/fr.ts | 6 - packages/app/src/i18n/ja.ts | 6 - packages/app/src/i18n/ko.ts | 6 - packages/app/src/i18n/no.ts | 5 - packages/app/src/i18n/pl.ts | 6 - packages/app/src/i18n/ru.ts | 6 - packages/app/src/i18n/th.ts | 5 - packages/app/src/i18n/tr.ts | 7 - packages/app/src/i18n/zh.ts | 4 - packages/app/src/i18n/zht.ts | 4 - packages/app/src/pages/layout.tsx | 77 +- packages/app/src/pages/session.tsx | 265 ++- .../app/src/pages/session/composer/index.ts | 26 + .../composer/session-composer-region.tsx | 119 +- .../composer/session-composer-state.ts | 6 +- .../session/composer/session-todo-dock.tsx | 92 +- .../src/pages/session/message-gesture.test.ts | 4 +- .../app/src/pages/session/message-gesture.ts | 8 +- .../src/pages/session/message-timeline.tsx | 505 ++--- .../src/pages/session/session-side-panel.tsx | 4 +- .../pages/session/session-timeline-header.tsx | 522 +++++ .../app/src/pages/session/terminal-panel.tsx | 16 +- .../pages/session/use-session-commands.tsx | 29 +- .../pages/session/use-session-hash-scroll.ts | 52 +- packages/app/src/utils/dom.ts | 9 + packages/app/src/utils/same.ts | 6 - packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop-electron/package.json | 2 +- .../src/renderer/i18n/index.ts | 1 - packages/desktop/package.json | 2 +- packages/desktop/src/i18n/index.ts | 1 - packages/enterprise/package.json | 2 +- packages/enterprise/src/core/share.ts | 140 +- .../enterprise/src/routes/api/[...path].ts | 1 - .../enterprise/src/routes/share/[shareID].tsx | 90 +- packages/enterprise/test/core/share.test.ts | 30 +- packages/extensions/zed/extension.toml | 12 +- packages/function/package.json | 2 +- packages/opencode/package.json | 4 +- packages/opencode/script/build.ts | 6 +- packages/opencode/src/acp/agent.ts | 3 +- packages/opencode/src/cli/cmd/auth.ts | 3 +- packages/opencode/src/cli/cmd/debug/lsp.ts | 3 +- packages/opencode/src/cli/cmd/github.ts | 5 +- packages/opencode/src/cli/cmd/session.ts | 5 +- .../src/cli/cmd/tui/util/clipboard.ts | 9 +- packages/opencode/src/cli/cmd/tui/worker.ts | 5 +- packages/opencode/src/cli/ui.ts | 6 +- packages/opencode/src/config/config.ts | 4 +- .../opencode/src/config/migrate-tui-config.ts | 6 +- packages/opencode/src/file/index.ts | 34 +- packages/opencode/src/file/ripgrep.ts | 3 +- packages/opencode/src/format/formatter.ts | 43 +- packages/opencode/src/lsp/server.ts | 89 +- packages/opencode/src/mcp/oauth-callback.ts | 22 +- packages/opencode/src/plugin/codex.ts | 4 +- packages/opencode/src/plugin/copilot.ts | 7 +- packages/opencode/src/project/project.ts | 3 +- packages/opencode/src/provider/provider.ts | 7 +- packages/opencode/src/session/processor.ts | 1 - packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/share/share-next.ts | 32 +- packages/opencode/src/shell/shell.ts | 10 +- packages/opencode/src/snapshot/index.ts | 10 +- packages/opencode/src/util/hash.ts | 7 - packages/opencode/src/util/which.ts | 10 - packages/opencode/src/worktree/index.ts | 9 +- packages/opencode/test/file/fsmonitor.test.ts | 62 - .../opencode/test/fixture/fixture.test.ts | 26 - packages/opencode/test/fixture/fixture.ts | 32 +- packages/opencode/test/preload.ts | 3 +- .../test/project/worktree-remove.test.ts | 31 - .../test/pty/pty-output-isolation.test.ts | 7 +- packages/opencode/test/session/retry.test.ts | 3 +- packages/opencode/test/util/which.test.ts | 82 - packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- .../.storybook/mocks/app/context/command.ts | 4 +- packages/ui/happydom.ts | 3 + packages/ui/package.json | 6 +- .../ui/src/components/animated-number.css | 11 +- .../ui/src/components/animated-number.tsx | 29 +- .../src/components/animation-debug-panel.tsx | 122 ++ packages/ui/src/components/basic-tool.css | 59 +- .../ui/src/components/basic-tool.stories.tsx | 133 -- packages/ui/src/components/basic-tool.tsx | 356 +++- packages/ui/src/components/collapsible.css | 55 +- .../components/composer-island.stories.tsx | 311 +++ .../ui/src/components/composer-island.tsx | 28 + .../src/components/context-tool-results.tsx | 290 +++ packages/ui/src/components/grow-box.tsx | 426 ++++ packages/ui/src/components/markdown.tsx | 17 +- packages/ui/src/components/message-part.css | 400 +++- packages/ui/src/components/message-part.tsx | 1855 ++++++++--------- packages/ui/src/components/motion-spring.tsx | 32 +- packages/ui/src/components/motion.tsx | 77 + .../src/components/new-composer.stories.tsx | 451 ++++ packages/ui/src/components/new-composer.tsx | 1097 ++++++++++ .../src/components/new-composer/defaults.ts | 64 + .../components/new-composer/drag-overlay.tsx | 23 + .../new-composer/editor-utils.test.ts | 100 + .../components/new-composer/editor-utils.ts | 206 ++ .../components/new-composer/history.test.ts | 128 ++ .../ui/src/components/new-composer/history.ts | 189 ++ .../components/new-composer/input-layer.tsx | 223 ++ .../components/new-composer/model-picker.tsx | 242 +++ .../new-composer/permission-body.tsx | 41 + .../src/components/new-composer/popover.tsx | 111 + .../components/new-composer/question-body.tsx | 218 ++ .../src/components/new-composer/todo-tray.tsx | 302 +++ .../ui/src/components/new-composer/tray.tsx | 410 ++++ .../ui/src/components/new-composer/types.ts | 157 ++ .../src/components/new-composer/use-editor.ts | 722 +++++++ .../src/components/new-composer/use-layout.ts | 87 + .../src/components/new-composer/use-todo.ts | 76 + .../ui/src/components/rolling-results.css | 92 + .../ui/src/components/rolling-results.tsx | 316 +++ packages/ui/src/components/scroll-view.css | 15 +- packages/ui/src/components/scroll-view.tsx | 66 +- packages/ui/src/components/session-review.tsx | 2 +- .../session-timeline-simulator.stories.tsx | 1392 +++++++++++++ packages/ui/src/components/session-turn.css | 127 +- packages/ui/src/components/session-turn.tsx | 554 +++-- .../src/components/shell-rolling-results.tsx | 249 +++ .../shell-submessage-motion.stories.tsx | 329 --- .../ui/src/components/shell-submessage.css | 12 +- packages/ui/src/components/text-reveal.css | 61 +- .../ui/src/components/text-reveal.stories.tsx | 310 --- packages/ui/src/components/text-reveal.tsx | 107 +- packages/ui/src/components/text-shimmer.css | 17 +- .../src/components/text-shimmer.stories.tsx | 92 - packages/ui/src/components/text-shimmer.tsx | 15 + packages/ui/src/components/text-utils.ts | 17 + .../components/todo-panel-motion.stories.tsx | 584 ------ .../ui/src/components/tool-count-label.css | 6 +- .../ui/src/components/tool-count-label.tsx | 21 +- .../ui/src/components/tool-count-summary.css | 22 +- .../ui/src/components/tool-status-title.css | 7 +- .../ui/src/components/tool-status-title.tsx | 67 +- packages/ui/src/components/tool-utils.ts | 154 ++ packages/ui/src/hooks/create-auto-scroll.tsx | 245 ++- packages/ui/src/hooks/index.ts | 3 + packages/ui/src/hooks/use-element-height.ts | 25 + packages/ui/src/hooks/use-page-visible.ts | 11 + packages/ui/src/hooks/use-reduced-motion.ts | 9 + packages/ui/src/i18n/ar.ts | 3 +- packages/ui/src/i18n/br.ts | 3 +- packages/ui/src/i18n/bs.ts | 3 +- packages/ui/src/i18n/da.ts | 3 +- packages/ui/src/i18n/de.ts | 3 +- packages/ui/src/i18n/en.ts | 39 +- packages/ui/src/i18n/es.ts | 3 +- packages/ui/src/i18n/fr.ts | 3 +- packages/ui/src/i18n/ja.ts | 3 +- packages/ui/src/i18n/ko.ts | 3 +- packages/ui/src/i18n/no.ts | 3 +- packages/ui/src/i18n/pl.ts | 3 +- packages/ui/src/i18n/ru.ts | 3 +- packages/ui/src/i18n/th.ts | 3 +- packages/ui/src/i18n/tr.ts | 3 +- packages/ui/src/i18n/zh.ts | 3 +- packages/ui/src/i18n/zht.ts | 3 +- packages/ui/src/styles/index.css | 1 + packages/util/package.json | 2 +- packages/util/src/array.ts | 7 + packages/web/package.json | 2 +- packages/web/src/content/docs/zen.mdx | 86 +- sdks/vscode/package.json | 2 +- 220 files changed, 12371 insertions(+), 5135 deletions(-) delete mode 100644 README.vi.md create mode 100644 packages/app/src/pages/session/session-timeline-header.tsx delete mode 100644 packages/app/src/utils/same.ts delete mode 100644 packages/opencode/src/util/hash.ts delete mode 100644 packages/opencode/src/util/which.ts delete mode 100644 packages/opencode/test/file/fsmonitor.test.ts delete mode 100644 packages/opencode/test/fixture/fixture.test.ts delete mode 100644 packages/opencode/test/util/which.test.ts create mode 100644 packages/ui/happydom.ts create mode 100644 packages/ui/src/components/animation-debug-panel.tsx delete mode 100644 packages/ui/src/components/basic-tool.stories.tsx create mode 100644 packages/ui/src/components/composer-island.stories.tsx create mode 100644 packages/ui/src/components/composer-island.tsx create mode 100644 packages/ui/src/components/context-tool-results.tsx create mode 100644 packages/ui/src/components/grow-box.tsx create mode 100644 packages/ui/src/components/motion.tsx create mode 100644 packages/ui/src/components/new-composer.stories.tsx create mode 100644 packages/ui/src/components/new-composer.tsx create mode 100644 packages/ui/src/components/new-composer/defaults.ts create mode 100644 packages/ui/src/components/new-composer/drag-overlay.tsx create mode 100644 packages/ui/src/components/new-composer/editor-utils.test.ts create mode 100644 packages/ui/src/components/new-composer/editor-utils.ts create mode 100644 packages/ui/src/components/new-composer/history.test.ts create mode 100644 packages/ui/src/components/new-composer/history.ts create mode 100644 packages/ui/src/components/new-composer/input-layer.tsx create mode 100644 packages/ui/src/components/new-composer/model-picker.tsx create mode 100644 packages/ui/src/components/new-composer/permission-body.tsx create mode 100644 packages/ui/src/components/new-composer/popover.tsx create mode 100644 packages/ui/src/components/new-composer/question-body.tsx create mode 100644 packages/ui/src/components/new-composer/todo-tray.tsx create mode 100644 packages/ui/src/components/new-composer/tray.tsx create mode 100644 packages/ui/src/components/new-composer/types.ts create mode 100644 packages/ui/src/components/new-composer/use-editor.ts create mode 100644 packages/ui/src/components/new-composer/use-layout.ts create mode 100644 packages/ui/src/components/new-composer/use-todo.ts create mode 100644 packages/ui/src/components/rolling-results.css create mode 100644 packages/ui/src/components/rolling-results.tsx create mode 100644 packages/ui/src/components/session-timeline-simulator.stories.tsx create mode 100644 packages/ui/src/components/shell-rolling-results.tsx delete mode 100644 packages/ui/src/components/shell-submessage-motion.stories.tsx delete mode 100644 packages/ui/src/components/text-reveal.stories.tsx delete mode 100644 packages/ui/src/components/text-shimmer.stories.tsx create mode 100644 packages/ui/src/components/text-utils.ts delete mode 100644 packages/ui/src/components/todo-panel-motion.stories.tsx create mode 100644 packages/ui/src/components/tool-utils.ts create mode 100644 packages/ui/src/hooks/use-element-height.ts create mode 100644 packages/ui/src/hooks/use-page-visible.ts create mode 100644 packages/ui/src/hooks/use-reduced-motion.ts diff --git a/README.ar.md b/README.ar.md index beb44589e620..865fecb22b8b 100644 --- a/README.ar.md +++ b/README.ar.md @@ -35,8 +35,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.bn.md b/README.bn.md index c7abc7346a2f..24c083e79eb4 100644 --- a/README.bn.md +++ b/README.bn.md @@ -35,8 +35,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.br.md b/README.br.md index 6d1de21562c1..f7e82fa09dae 100644 --- a/README.br.md +++ b/README.br.md @@ -27,7 +27,6 @@ 日本語 | Polski | Русский | - Bosanski | العربية | Norsk | Português (Brasil) | @@ -35,8 +34,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.bs.md b/README.bs.md index 2cff8e0279c5..5bba8708590f 100644 --- a/README.bs.md +++ b/README.bs.md @@ -35,8 +35,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.da.md b/README.da.md index ac522f29c49e..d1e686d7d7c0 100644 --- a/README.da.md +++ b/README.da.md @@ -27,7 +27,6 @@ 日本語 | Polski | Русский | - Bosanski | العربية | Norsk | Português (Brasil) | @@ -35,8 +34,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.de.md b/README.de.md index 87a670f3fce7..7a3572324a42 100644 --- a/README.de.md +++ b/README.de.md @@ -27,7 +27,6 @@ 日本語 | Polski | Русский | - Bosanski | العربية | Norsk | Português (Brasil) | @@ -35,8 +34,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.es.md b/README.es.md index 9e456af1c0b9..b45418232855 100644 --- a/README.es.md +++ b/README.es.md @@ -27,7 +27,6 @@ 日本語 | Polski | Русский | - Bosanski | العربية | Norsk | Português (Brasil) | @@ -35,8 +34,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.fr.md b/README.fr.md index c1fca23376d7..02e66e5e8740 100644 --- a/README.fr.md +++ b/README.fr.md @@ -27,7 +27,6 @@ 日本語 | Polski | Русский | - Bosanski | العربية | Norsk | Português (Brasil) | @@ -35,8 +34,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.gr.md b/README.gr.md index 2b2c2679d8eb..976eab5cc37f 100644 --- a/README.gr.md +++ b/README.gr.md @@ -35,8 +35,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.it.md b/README.it.md index 3e516a90270d..b0d724741574 100644 --- a/README.it.md +++ b/README.it.md @@ -27,7 +27,6 @@ 日本語 | Polski | Русский | - Bosanski | العربية | Norsk | Português (Brasil) | @@ -35,8 +34,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.ja.md b/README.ja.md index 144dc7b6f8a6..e381fbc6033a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -27,7 +27,6 @@ 日本語 | Polski | Русский | - Bosanski | العربية | Norsk | Português (Brasil) | @@ -35,8 +34,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.ko.md b/README.ko.md index 32defc0a5e02..63b9fb409171 100644 --- a/README.ko.md +++ b/README.ko.md @@ -27,7 +27,6 @@ 日本語 | Polski | Русский | - Bosanski | العربية | Norsk | Português (Brasil) | @@ -35,8 +34,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.md b/README.md index 79ccf8b34910..8d9245037429 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.no.md b/README.no.md index c3348286b29c..1ccefaa760d3 100644 --- a/README.no.md +++ b/README.no.md @@ -27,7 +27,6 @@ 日本語 | Polski | Русский | - Bosanski | العربية | Norsk | Português (Brasil) | @@ -35,8 +34,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.pl.md b/README.pl.md index 4c5a07665619..0b246d5d5a38 100644 --- a/README.pl.md +++ b/README.pl.md @@ -27,7 +27,6 @@ 日本語 | Polski | Русский | - Bosanski | العربية | Norsk | Português (Brasil) | @@ -35,8 +34,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.ru.md b/README.ru.md index e507be70e658..ff30d380fd6b 100644 --- a/README.ru.md +++ b/README.ru.md @@ -27,7 +27,6 @@ 日本語 | Polski | Русский | - Bosanski | العربية | Norsk | Português (Brasil) | @@ -35,8 +34,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.th.md b/README.th.md index 4a4ea62c957c..6a9a956a88ae 100644 --- a/README.th.md +++ b/README.th.md @@ -27,7 +27,6 @@ 日本語 | Polski | Русский | - Bosanski | العربية | Norsk | Português (Brasil) | @@ -35,8 +34,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.tr.md b/README.tr.md index e88b40f87512..9deedfb3c614 100644 --- a/README.tr.md +++ b/README.tr.md @@ -27,7 +27,6 @@ 日本語 | Polski | Русский | - Bosanski | العربية | Norsk | Português (Brasil) | @@ -35,8 +34,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.uk.md b/README.uk.md index a1a0259b6d08..dfd8fa8d7573 100644 --- a/README.uk.md +++ b/README.uk.md @@ -35,8 +35,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.vi.md b/README.vi.md deleted file mode 100644 index 0932c50f78ab..000000000000 --- a/README.vi.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - OpenCode logo - - -

-

Trợ lý lập trình AI mã nguồn mở.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### Cài đặt - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# Các trình quản lý gói (Package managers) -npm i -g opencode-ai@latest # hoặc bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS và Linux (khuyên dùng, luôn cập nhật) -brew install opencode # macOS và Linux (công thức brew chính thức, ít cập nhật hơn) -sudo pacman -S opencode # Arch Linux (Bản ổn định) -paru -S opencode-bin # Arch Linux (Bản mới nhất từ AUR) -mise use -g opencode # Mọi hệ điều hành -nix run nixpkgs#opencode # hoặc github:anomalyco/opencode cho nhánh dev mới nhất -``` - -> [!TIP] -> Hãy xóa các phiên bản cũ hơn 0.1.x trước khi cài đặt. - -### Ứng dụng Desktop (BETA) - -OpenCode cũng có sẵn dưới dạng ứng dụng desktop. Tải trực tiếp từ [trang releases](https://github.com/anomalyco/opencode/releases) hoặc [opencode.ai/download](https://opencode.ai/download). - -| Nền tảng | Tải xuống | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, hoặc AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### Thư mục cài đặt - -Tập lệnh cài đặt tuân theo thứ tự ưu tiên sau cho đường dẫn cài đặt: - -1. `$OPENCODE_INSTALL_DIR` - Thư mục cài đặt tùy chỉnh -2. `$XDG_BIN_DIR` - Đường dẫn tuân thủ XDG Base Directory Specification -3. `$HOME/bin` - Thư mục nhị phân tiêu chuẩn của người dùng (nếu tồn tại hoặc có thể tạo) -4. `$HOME/.opencode/bin` - Mặc định dự phòng - -```bash -# Ví dụ -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Agents (Đại diện) - -OpenCode bao gồm hai agent được tích hợp sẵn mà bạn có thể chuyển đổi bằng phím `Tab`. - -- **build** - Agent mặc định, có toàn quyền truy cập cho công việc lập trình -- **plan** - Agent chỉ đọc dùng để phân tích và khám phá mã nguồn - - Mặc định từ chối việc chỉnh sửa tệp - - Hỏi quyền trước khi chạy các lệnh bash - - Lý tưởng để khám phá các codebase lạ hoặc lên kế hoạch thay đổi - -Ngoài ra còn có một subagent **general** dùng cho các tìm kiếm phức tạp và tác vụ nhiều bước. -Agent này được sử dụng nội bộ và có thể gọi bằng cách dùng `@general` trong tin nhắn. - -Tìm hiểu thêm về [agents](https://opencode.ai/docs/agents). - -### Tài liệu - -Để biết thêm thông tin về cách cấu hình OpenCode, [**hãy truy cập tài liệu của chúng tôi**](https://opencode.ai/docs). - -### Đóng góp - -Nếu bạn muốn đóng góp cho OpenCode, vui lòng đọc [tài liệu hướng dẫn đóng góp](./CONTRIBUTING.md) trước khi gửi pull request. - -### Xây dựng trên nền tảng OpenCode - -Nếu bạn đang làm việc trên một dự án liên quan đến OpenCode và sử dụng "opencode" như một phần của tên dự án, ví dụ "opencode-dashboard" hoặc "opencode-mobile", vui lòng thêm một ghi chú vào README của bạn để làm rõ rằng dự án đó không được xây dựng bởi đội ngũ OpenCode và không liên kết với chúng tôi dưới bất kỳ hình thức nào. - -### Các câu hỏi thường gặp (FAQ) - -#### OpenCode khác biệt thế nào so với Claude Code? - -Về mặt tính năng, nó rất giống Claude Code. Dưới đây là những điểm khác biệt chính: - -- 100% mã nguồn mở -- Không bị ràng buộc với bất kỳ nhà cung cấp nào. Mặc dù chúng tôi khuyên dùng các mô hình được cung cấp qua [OpenCode Zen](https://opencode.ai/zen), OpenCode có thể được sử dụng với Claude, OpenAI, Google, hoặc thậm chí các mô hình chạy cục bộ. Khi các mô hình phát triển, khoảng cách giữa chúng sẽ thu hẹp lại và giá cả sẽ giảm, vì vậy việc không phụ thuộc vào nhà cung cấp là rất quan trọng. -- Hỗ trợ LSP ngay từ đầu -- Tập trung vào TUI (Giao diện người dùng dòng lệnh). OpenCode được xây dựng bởi những người dùng neovim và đội ngũ tạo ra [terminal.shop](https://terminal.shop); chúng tôi sẽ đẩy giới hạn của những gì có thể làm được trên terminal lên mức tối đa. -- Kiến trúc client/server. Chẳng hạn, điều này cho phép OpenCode chạy trên máy tính của bạn trong khi bạn điều khiển nó từ xa qua một ứng dụng di động, nghĩa là frontend TUI chỉ là một trong những client có thể dùng. - ---- - -**Tham gia cộng đồng của chúng tôi** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.zh.md b/README.zh.md index b11d9857c9c6..9a1e1b2fb6ec 100644 --- a/README.zh.md +++ b/README.zh.md @@ -27,7 +27,6 @@ 日本語 | Polski | Русский | - Bosanski | العربية | Norsk | Português (Brasil) | @@ -35,8 +34,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.zht.md b/README.zht.md index 573ca85ab43b..238f11289f14 100644 --- a/README.zht.md +++ b/README.zht.md @@ -27,7 +27,6 @@ 日本語 | Polski | Русский | - Bosanski | العربية | Norsk | Português (Brasil) | @@ -35,8 +34,7 @@ Türkçe | Українська | বাংলা | - Ελληνικά | - Tiếng Việt + Ελληνικά

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/bun.lock b/bun.lock index 97292974d246..a27fa0da4843 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.2.20", + "version": "1.2.18", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -76,7 +76,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.2.20", + "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.20", + "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.20", + "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.20", + "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.20", + "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.20", + "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.20", + "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.20", + "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.20", + "version": "1.2.18", "bin": { "opencode": "./bin/opencode", }, @@ -373,7 +373,6 @@ "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:", @@ -396,7 +395,6 @@ "@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.16-ea816b6", @@ -409,7 +407,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.2.20", + "version": "1.2.18", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -429,7 +427,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.2.20", + "version": "1.2.18", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -440,7 +438,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.2.20", + "version": "1.2.18", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -475,7 +473,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.2.20", + "version": "1.2.18", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -506,6 +504,7 @@ "virtua": "catalog:", }, "devDependencies": { + "@happy-dom/global-registrator": "20.0.11", "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -521,7 +520,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.2.20", + "version": "1.2.18", "dependencies": { "zod": "catalog:", }, @@ -532,7 +531,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.2.20", + "version": "1.2.18", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -2122,8 +2121,6 @@ "@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=="], @@ -3240,7 +3237,7 @@ "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], - "isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], @@ -4590,7 +4587,7 @@ "when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="], - "which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "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=="], @@ -5206,8 +5203,6 @@ "app-builder-lib/minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="], - "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], - "archiver-utils/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -5396,8 +5391,6 @@ "node-gyp/nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], - "node-gyp/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], - "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], @@ -5928,8 +5921,6 @@ "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], - "archiver-utils/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "archiver-utils/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], @@ -6012,8 +6003,6 @@ "node-gyp/nopt/abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], - "node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], - "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], "opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], diff --git a/github/index.ts b/github/index.ts index 1a0a99262248..da310178a7dc 100644 --- a/github/index.ts +++ b/github/index.ts @@ -8,7 +8,6 @@ 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 @@ -282,7 +281,7 @@ async function assertOpencodeConnected() { connected = true break } catch (e) {} - await sleep(300) + await Bun.sleep(300) } while (retry++ < 30) if (!connected) { diff --git a/nix/hashes.json b/nix/hashes.json index 326cc98a6679..47e3e240bb42 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-pBTIT8Pgdm3272YhBjiAZsmj0SSpHTklh6lGc8YcMoE=", - "aarch64-linux": "sha256-prt039++d5UZgtldAN6+RVOR557ifIeusiy5XpzN8QU=", - "aarch64-darwin": "sha256-Y3f+cXcIGLqz6oyc5fG22t6CLD4wGkvwqO6RNXjFriQ=", - "x86_64-darwin": "sha256-BjbBBhQUgGhrlP56skABcrObvutNUZSWnrnPCg1OTKE=" + "x86_64-linux": "sha256-v83hWzYVg/g4zJiBpGsQ71wTdndPk3BQVZ2mjMApUIQ=", + "aarch64-linux": "sha256-inpMwkQqwBFP2wL8w/pTOP7q3fg1aOqvE0wgzVd3/B8=", + "aarch64-darwin": "sha256-r42LGrQWqDyIy62mBSU5Nf3M22dJ3NNo7mjN/1h8d8Y=", + "x86_64-darwin": "sha256-J6XrrdK5qBK3sQBQOO/B3ZluOnsAf5f65l4q/K1nDTI=" } } diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 919a1add8159..852bef05435a 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -8,6 +8,7 @@ import { sessionItemSelector, dropdownMenuTriggerSelector, dropdownMenuContentSelector, + sessionHeaderSelector, projectMenuTriggerSelector, projectWorkspacesToggleSelector, titlebarRightSelector, @@ -197,7 +198,6 @@ export async function createTestProject() { await fs.writeFile(path.join(root, "README.md"), "# e2e\n") execSync("git init", { cwd: root, stdio: "ignore" }) - execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" }) execSync("git add -A", { cwd: root, stdio: "ignore" }) execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { cwd: root, @@ -208,10 +208,7 @@ export async function createTestProject() { } export async function cleanupTestProject(directory: string) { - try { - execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" }) - } catch {} - await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined) + await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined) } export function sessionIDFromUrl(url: string) { @@ -229,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) @@ -247,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() diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 5fad2c06b528..d546cc668efd 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -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) => diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts index 68d992949964..afbea91ca688 100644 --- a/packages/app/e2e/session/session.spec.ts +++ b/packages/app/e2e/session/session.spec.ts @@ -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) diff --git a/packages/app/package.json b/packages/app/package.json index c91a91383dbc..37ccd9b53a19 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.2.20", + "version": "1.2.18", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 532edd3bcdc4..7bd18fdff072 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -244,6 +244,7 @@ export const PromptInput: Component = (props) => { draggingType: "image" | "@mention" | null mode: "normal" | "shell" applyingHistory: boolean + pendingAutoAccept: boolean }>({ popover: null, historyIndex: -1, @@ -252,9 +253,20 @@ export const PromptInput: Component = (props) => { draggingType: null, mode: "normal", applyingHistory: false, + 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 => ({ + 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 @@ -304,6 +316,12 @@ export const PromptInput: Component = (props) => { }), ) + createEffect( + on(sessionKey, () => { + setStore("pendingAutoAccept", false) + }), + ) + const historyComments = () => { const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) return prompt.context.items().flatMap((item) => { @@ -953,7 +971,7 @@ export const PromptInput: Component = (props) => { const variants = createMemo(() => ["default", ...local.model.variant.list()]) const accepting = createMemo(() => { const id = params.id - if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) + if (!id) return store.pendingAutoAccept return permission.isAutoAccepting(id, sdk.directory) }) @@ -1246,9 +1264,7 @@ export const PromptInput: Component = (props) => {
0.5 ? "auto" : "none", - }} + style={{ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none" }} > = (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} @@ -1302,11 +1314,7 @@ export const PromptInput: Component = (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")} /> @@ -1328,7 +1336,7 @@ export const PromptInput: Component = (props) => { variant="ghost" onClick={() => { if (!params.id) { - permission.toggleAutoAcceptDirectory(sdk.directory) + setStore("pendingAutoAccept", (value) => !value) return } permission.toggleAutoAccept(params.id, sdk.directory) @@ -1362,13 +1370,7 @@ export const PromptInput: Component = (props) => {
{language.t("prompt.mode.shell")}
@@ -1387,13 +1389,7 @@ export const PromptInput: Component = (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" /> @@ -1411,13 +1407,7 @@ export const PromptInput: Component = (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(() => )} > @@ -1446,13 +1436,7 @@ export const PromptInput: Component = (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", }} > @@ -1484,13 +1468,7 @@ export const PromptInput: Component = (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" /> diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index db1b5a5ca172..5a8abc020143 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -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) diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 39eb4b4c0eb0..0a13e46f6952 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -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" diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 42ee4092f68c..409c89bdc4b3 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -288,29 +288,6 @@ export const SettingsGeneral: Component = () => {
- -
- settings.general.setShellToolPartsExpanded(checked)} - /> -
-
- - -
- settings.general.setEditToolPartsExpanded(checked)} - /> -
-
) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 03bd6318dab4..00d0511a22de 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -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: () => { diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index b3a351382f3f..85945a80b397 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -353,7 +353,6 @@ function createGlobalSync() { .update({ config }) .then(bootstrap) .then(() => { - queue.refresh() setGlobalStore("reload", undefined) queue.refresh() }) diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index b1edd541c3c2..be1a1769bf18 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -146,7 +146,6 @@ const DICT: Record = { } const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ - { locale: "en", match: (language) => language.startsWith("en") }, { locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") }, { locale: "zh", match: (language) => language.startsWith("zh") }, { locale: "ko", match: (language) => language.startsWith("ko") }, @@ -218,7 +217,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont ) const locale = createMemo(() => normalizeLocale(store.locale)) - console.log("locale", locale()) const intl = createMemo(() => INTL[locale()]) const dict = createMemo(() => DICT[locale()]) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 5199e5a26be4..130cc3a5cee1 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -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" diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts index 755611300554..2e4cf4fafb4b 100644 --- a/packages/app/src/context/permission-auto-respond.test.ts +++ b/packages/app/src/context/permission-auto-respond.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" import { base64Encode } from "@opencode-ai/util/encode" -import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond" +import { autoRespondsPermission } from "./permission-auto-respond" const session = (input: { id: string; parentID?: string }) => ({ @@ -60,43 +60,4 @@ describe("autoRespondsPermission", () => { expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true) }) - - test("falls back to directory-level auto-accept", () => { - const directory = "/tmp/project" - const sessions = [session({ id: "root" })] - const autoAccept = { - [`${base64Encode(directory)}/*`]: true, - } - - expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true) - }) - - test("session-level override takes precedence over directory-level", () => { - const directory = "/tmp/project" - const sessions = [session({ id: "root" })] - const autoAccept = { - [`${base64Encode(directory)}/*`]: true, - [`${base64Encode(directory)}/root`]: false, - } - - expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false) - }) -}) - -describe("isDirectoryAutoAccepting", () => { - test("returns true when directory key is set", () => { - const directory = "/tmp/project" - const autoAccept = { [`${base64Encode(directory)}/*`]: true } - expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true) - }) - - test("returns false when directory key is not set", () => { - expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false) - }) - - test("returns false when directory key is explicitly false", () => { - const directory = "/tmp/project" - const autoAccept = { [`${base64Encode(directory)}/*`]: false } - expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false) - }) }) diff --git a/packages/app/src/context/permission-auto-respond.ts b/packages/app/src/context/permission-auto-respond.ts index b206deedff93..727ccc937561 100644 --- a/packages/app/src/context/permission-auto-respond.ts +++ b/packages/app/src/context/permission-auto-respond.ts @@ -5,19 +5,9 @@ export function acceptKey(sessionID: string, directory?: string) { return `${base64Encode(directory)}/${sessionID}` } -export function directoryAcceptKey(directory: string) { - return `${base64Encode(directory)}/*` -} - function accepted(autoAccept: Record, sessionID: string, directory?: string) { const key = acceptKey(sessionID, directory) - const directoryKey = directory ? directoryAcceptKey(directory) : undefined - return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined) -} - -export function isDirectoryAutoAccepting(autoAccept: Record, directory: string) { - const key = directoryAcceptKey(directory) - return autoAccept[key] ?? false + return autoAccept[key] ?? autoAccept[sessionID] } function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) { diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index 672f84f82a60..73ee08c9ac0d 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, onCleanup } from "solid-js" +import { createMemo, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" @@ -7,12 +7,7 @@ import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "./global-sync" import { useParams } from "@solidjs/router" import { decode64 } from "@/utils/base64" -import { - acceptKey, - directoryAcceptKey, - isDirectoryAutoAccepting, - autoRespondsPermission, -} from "./permission-auto-respond" +import { acceptKey, autoRespondsPermission } from "./permission-auto-respond" type PermissionRespondFn = (input: { sessionID: string @@ -81,25 +76,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple }), ) - // When config has permission: "allow", auto-enable directory-level auto-accept - createEffect(() => { - if (!ready()) return - const directory = decode64(params.dir) - if (!directory) return - const [childStore] = globalSync.child(directory) - const perm = childStore.config.permission - if (typeof perm === "string" && perm === "allow") { - const key = directoryAcceptKey(directory) - if (store.autoAccept[key] === undefined) { - setStore( - produce((draft) => { - draft.autoAccept[key] = true - }), - ) - } - } - }) - const MAX_RESPONDED = 1000 const RESPONDED_TTL_MS = 60 * 60 * 1000 const responded = new Map() @@ -143,10 +119,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory) } - function isAutoAcceptingDirectory(directory: string) { - return isDirectoryAutoAccepting(store.autoAccept, directory) - } - function shouldAutoRespond(permission: PermissionRequest, directory?: string) { const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : [] return autoRespondsPermission(store.autoAccept, session, permission, directory) @@ -170,36 +142,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple }) onCleanup(unsubscribe) - function enableDirectory(directory: string) { - const key = directoryAcceptKey(directory) - setStore( - produce((draft) => { - draft.autoAccept[key] = true - }), - ) - - globalSDK.client.permission - .list({ directory }) - .then((x) => { - if (!isAutoAcceptingDirectory(directory)) return - for (const perm of x.data ?? []) { - if (!perm?.id) continue - if (!shouldAutoRespond(perm, directory)) continue - respondOnce(perm, directory) - } - }) - .catch(() => undefined) - } - - function disableDirectory(directory: string) { - const key = directoryAcceptKey(directory) - setStore( - produce((draft) => { - draft.autoAccept[key] = false - }), - ) - } - function enable(sessionID: string, directory: string) { const key = acceptKey(sessionID, directory) const version = bumpEnableVersion(sessionID, directory) @@ -243,7 +185,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple return shouldAutoRespond(permission, directory) }, isAutoAccepting, - isAutoAcceptingDirectory, toggleAutoAccept(sessionID: string, directory: string) { if (isAutoAccepting(sessionID, directory)) { disable(sessionID, directory) @@ -252,13 +193,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple enable(sessionID, directory) }, - toggleAutoAcceptDirectory(directory: string) { - if (isAutoAcceptingDirectory(directory)) { - disableDirectory(directory) - return - } - enableDirectory(directory) - }, enableAutoAccept(sessionID: string, directory: string) { if (isAutoAccepting(sessionID, directory)) return enable(sessionID, directory) @@ -267,11 +201,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple disable(sessionID, directory) }, permissionsEnabled, - isPermissionAllowAll(directory: string) { - const [childStore] = globalSync.child(directory) - const perm = childStore.config.permission - return typeof perm === "string" && perm === "allow" - }, } }, }) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index b43469b5c37c..d279a7f321bb 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -23,8 +23,6 @@ export interface Settings { autoSave: boolean releaseNotes: boolean showReasoningSummaries: boolean - shellToolPartsExpanded: boolean - editToolPartsExpanded: boolean } updates: { startup: boolean @@ -46,8 +44,6 @@ const defaultSettings: Settings = { autoSave: true, releaseNotes: true, showReasoningSummaries: false, - shellToolPartsExpanded: true, - editToolPartsExpanded: false, }, updates: { startup: true, @@ -133,20 +129,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setShowReasoningSummaries(value: boolean) { setStore("general", "showReasoningSummaries", value) }, - shellToolPartsExpanded: withFallback( - () => store.general?.shellToolPartsExpanded, - defaultSettings.general.shellToolPartsExpanded, - ), - setShellToolPartsExpanded(value: boolean) { - setStore("general", "shellToolPartsExpanded", value) - }, - editToolPartsExpanded: withFallback( - () => store.general?.editToolPartsExpanded, - defaultSettings.general.editToolPartsExpanded, - ), - setEditToolPartsExpanded(value: boolean) { - setStore("general", "editToolPartsExpanded", value) - }, }, updates: { startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup), diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 16f2fbf49251..e3183c4846dc 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -541,12 +541,6 @@ export const dict = { "settings.general.row.theme.description": "تخصيص سمة OpenCode.", "settings.general.row.font.title": "الخط", "settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية", - "settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell", - "settings.general.row.shellToolPartsExpanded.description": - "إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني", - "settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit", - "settings.general.row.editToolPartsExpanded.description": - "إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني", "settings.general.row.wayland.title": "استخدام Wayland الأصلي", "settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 26cf433e0e36..84d1f9d10f5b 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -547,12 +547,6 @@ export const dict = { "settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.", "settings.general.row.font.title": "Fonte", "settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código", - "settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell", - "settings.general.row.shellToolPartsExpanded.description": - "Mostrar partes da ferramenta shell expandidas por padrão na linha do tempo", - "settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição", - "settings.general.row.editToolPartsExpanded.description": - "Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo", "settings.general.row.wayland.title": "Usar Wayland nativo", "settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 6c8198bd7154..58c3ba606703 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -613,12 +613,6 @@ export const dict = { "settings.general.row.font.title": "Font", "settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda", - "settings.general.row.shellToolPartsExpanded.title": "Proširi dijelove shell alata", - "settings.general.row.shellToolPartsExpanded.description": - "Prikaži dijelove shell alata podrazumijevano proširene na vremenskoj traci", - "settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje", - "settings.general.row.editToolPartsExpanded.description": - "Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci", "settings.general.row.wayland.title": "Koristi nativni Wayland", "settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 11da681760a6..53bbb09b84cb 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -608,11 +608,6 @@ export const dict = { "settings.general.row.font.title": "Skrifttype", "settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke", - "settings.general.row.shellToolPartsExpanded.title": "Udvid shell-værktøjsdele", - "settings.general.row.shellToolPartsExpanded.description": "Vis shell-værktøjsdele udvidet som standard i tidslinjen", - "settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele", - "settings.general.row.editToolPartsExpanded.description": - "Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen", "settings.general.row.wayland.title": "Brug native Wayland", "settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 51b9ec353159..753b95c63bdf 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -556,12 +556,6 @@ export const dict = { "settings.general.row.theme.description": "Das Thema von OpenCode anpassen.", "settings.general.row.font.title": "Schriftart", "settings.general.row.font.description": "Die in Codeblöcken verwendete Monospace-Schriftart anpassen", - "settings.general.row.shellToolPartsExpanded.title": "Shell-Tool-Abschnitte ausklappen", - "settings.general.row.shellToolPartsExpanded.description": - "Shell-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen", - "settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen", - "settings.general.row.editToolPartsExpanded.description": - "Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen", "settings.general.row.wayland.title": "Natives Wayland verwenden", "settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7e95fd739df7..e709e23a3141 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -636,13 +636,6 @@ export const dict = { "settings.general.row.font.description": "Customise the mono font used in code blocks", "settings.general.row.reasoningSummaries.title": "Show reasoning summaries", "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline", - "settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts", - "settings.general.row.shellToolPartsExpanded.description": - "Show shell tool parts expanded by default in the timeline", - "settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts", - "settings.general.row.editToolPartsExpanded.description": - "Show edit, write, and patch tool parts expanded by default in the timeline", - "settings.general.row.wayland.title": "Use native Wayland", "settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 2665a808508b..cdfc17289a02 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -616,12 +616,6 @@ export const dict = { "settings.general.row.font.title": "Fuente", "settings.general.row.font.description": "Personaliza la fuente monoespaciada usada en bloques de código", - "settings.general.row.shellToolPartsExpanded.title": "Expandir partes de la herramienta shell", - "settings.general.row.shellToolPartsExpanded.description": - "Mostrar las partes de la herramienta shell expandidas por defecto en la línea de tiempo", - "settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición", - "settings.general.row.editToolPartsExpanded.description": - "Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo", "settings.general.row.wayland.title": "Usar Wayland nativo", "settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 1e67db193332..b606c33017cc 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -553,12 +553,6 @@ export const dict = { "settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.", "settings.general.row.font.title": "Police", "settings.general.row.font.description": "Personnaliser la police mono utilisée dans les blocs de code", - "settings.general.row.shellToolPartsExpanded.title": "Développer les parties de l'outil shell", - "settings.general.row.shellToolPartsExpanded.description": - "Afficher les parties de l'outil shell développées par défaut dans la chronologie", - "settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit", - "settings.general.row.editToolPartsExpanded.description": - "Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie", "settings.general.row.wayland.title": "Utiliser Wayland natif", "settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index ecd38d332498..d751318ce540 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -545,12 +545,6 @@ export const dict = { "settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。", "settings.general.row.font.title": "フォント", "settings.general.row.font.description": "コードブロックで使用する等幅フォントをカスタマイズします", - "settings.general.row.shellToolPartsExpanded.title": "shell ツールパーツを展開", - "settings.general.row.shellToolPartsExpanded.description": - "タイムラインで shell ツールパーツをデフォルトで展開して表示します", - "settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開", - "settings.general.row.editToolPartsExpanded.description": - "タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します", "settings.general.row.wayland.title": "ネイティブWaylandを使用", "settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 8f54b8abdc72..8ba55b630882 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -546,12 +546,6 @@ export const dict = { "settings.general.row.theme.description": "OpenCode 테마 사용자 지정", "settings.general.row.font.title": "글꼴", "settings.general.row.font.description": "코드 블록에 사용되는 고정폭 글꼴 사용자 지정", - "settings.general.row.shellToolPartsExpanded.title": "shell 도구 파트 펼치기", - "settings.general.row.shellToolPartsExpanded.description": - "타임라인에서 기본적으로 shell 도구 파트를 펼친 상태로 표시합니다", - "settings.general.row.editToolPartsExpanded.title": "edit 도구 파트 펼치기", - "settings.general.row.editToolPartsExpanded.description": - "타임라인에서 기본적으로 edit, write, patch 도구 파트를 펼친 상태로 표시합니다", "settings.general.row.wayland.title": "네이티브 Wayland 사용", "settings.general.row.wayland.description": "Wayland에서 X11 폴백을 비활성화합니다. 다시 시작해야 합니다.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 0c94046eb066..9005f66a4022 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -616,11 +616,6 @@ export const dict = { "settings.general.row.font.title": "Skrift", "settings.general.row.font.description": "Tilpass mono-skriften som brukes i kodeblokker", - "settings.general.row.shellToolPartsExpanded.title": "Utvid shell-verktøydeler", - "settings.general.row.shellToolPartsExpanded.description": "Vis shell-verktøydeler utvidet som standard i tidslinjen", - "settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler", - "settings.general.row.editToolPartsExpanded.description": - "Vis edit-, write- og patch-verktøydeler utvidet som standard i tidslinjen", "settings.general.row.wayland.title": "Bruk innebygd Wayland", "settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Krever omstart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 59c0513be620..66ac90d4e1a0 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -546,12 +546,6 @@ export const dict = { "settings.general.row.theme.description": "Dostosuj motyw OpenCode.", "settings.general.row.font.title": "Czcionka", "settings.general.row.font.description": "Dostosuj czcionkę mono używaną w blokach kodu", - "settings.general.row.shellToolPartsExpanded.title": "Rozwijaj elementy narzędzia shell", - "settings.general.row.shellToolPartsExpanded.description": - "Domyślnie pokazuj rozwinięte elementy narzędzia shell na osi czasu", - "settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit", - "settings.general.row.editToolPartsExpanded.description": - "Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu", "settings.general.row.wayland.title": "Użyj natywnego Wayland", "settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 2071eaae7b90..23317be70b61 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -614,12 +614,6 @@ export const dict = { "settings.general.row.font.title": "Шрифт", "settings.general.row.font.description": "Настройте моноширинный шрифт для блоков кода", - "settings.general.row.shellToolPartsExpanded.title": "Разворачивать элементы инструмента shell", - "settings.general.row.shellToolPartsExpanded.description": - "Показывать элементы инструмента shell в ленте развернутыми по умолчанию", - "settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit", - "settings.general.row.editToolPartsExpanded.description": - "Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию", "settings.general.row.wayland.title": "Использовать нативный Wayland", "settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 9871555536f8..818c35c3c28f 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -608,11 +608,6 @@ export const dict = { "settings.general.row.font.title": "ฟอนต์", "settings.general.row.font.description": "ปรับแต่งฟอนต์โมโนที่ใช้ในบล็อกโค้ด", - "settings.general.row.shellToolPartsExpanded.title": "ขยายส่วนเครื่องมือ shell", - "settings.general.row.shellToolPartsExpanded.description": "แสดงส่วนเครื่องมือ shell แบบขยายตามค่าเริ่มต้นในไทม์ไลน์", - "settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit", - "settings.general.row.editToolPartsExpanded.description": - "แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์", "settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ", "settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท", "settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 701ee0919297..13816bf7f542 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -623,13 +623,6 @@ export const dict = { "settings.general.row.font.description": "Kod bloklarında kullanılan monospace yazı tipini özelleştirin", "settings.general.row.reasoningSummaries.title": "Akıl yürütme özetlerini göster", "settings.general.row.reasoningSummaries.description": "Zaman çizelgesinde model akıl yürütme özetlerini görüntüle", - "settings.general.row.shellToolPartsExpanded.title": "Kabuk araç bileşenlerini genişlet", - "settings.general.row.shellToolPartsExpanded.description": - "Zaman çizelgesinde kabuk araç bileşenlerini varsayılan olarak genişletilmiş göster", - "settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet", - "settings.general.row.editToolPartsExpanded.description": - "Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster", - "settings.general.row.wayland.title": "Yerel Wayland kullan", "settings.general.row.wayland.description": "Wayland'da X11 geri dönüşünü devre dışı bırak. Yeniden başlatma gerektirir.", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index e72d4c0e3bee..217d444a6f42 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -607,10 +607,6 @@ export const dict = { "settings.general.row.theme.description": "自定义 OpenCode 的主题。", "settings.general.row.font.title": "字体", "settings.general.row.font.description": "自定义代码块使用的等宽字体", - "settings.general.row.shellToolPartsExpanded.title": "展开 shell 工具部分", - "settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分", - "settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分", - "settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分", "settings.general.row.wayland.title": "使用原生 Wayland", "settings.general.row.wayland.description": "在 Wayland 上禁用 X11 回退。需要重启。", "settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 70421dfe103f..84fe42efe59a 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -603,10 +603,6 @@ export const dict = { "settings.general.row.font.title": "字型", "settings.general.row.font.description": "自訂程式碼區塊使用的等寬字型", - "settings.general.row.shellToolPartsExpanded.title": "展開 shell 工具區塊", - "settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊", - "settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊", - "settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊", "settings.general.row.wayland.title": "使用原生 Wayland", "settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。", "settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index f6165461b1ee..2019ca4e5a8e 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -10,8 +10,9 @@ import { ParentProps, Show, untrack, + type JSX, } from "solid-js" -import { useNavigate, useParams } from "@solidjs/router" +import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { Persist, persisted } from "@/utils/persist" @@ -19,8 +20,9 @@ import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" -import { Tooltip } from "@opencode-ai/ui/tooltip" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" import { getFilename } from "@opencode-ai/util/path" @@ -57,6 +59,7 @@ import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" import { + childMapByParent, displayName, effectiveWorkspaceOrder, errorMessage, @@ -1843,7 +1846,7 @@ export default function Layout(props: ParentProps) { }} style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }} > - + {(p) => ( <>
@@ -1852,7 +1855,7 @@ export default function Layout(props: ParentProps) { 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 @@ -1861,7 +1864,7 @@ export default function Layout(props: ParentProps) { - {p().worktree.replace(homedir(), "~")} + {p.worktree.replace(homedir(), "~")}
@@ -1880,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, @@ -1889,24 +1892,24 @@ export default function Layout(props: ParentProps) { /> - showEditProjectDialog(p())}> + showEditProjectDialog(p)}> {language.t("common.edit")} toggleProjectWorkspaces(p())} + data-project={base64Encode(p.worktree)} + disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()} + onSelect={() => toggleProjectWorkspaces(p)} > - {layout.sidebar.workspaces(p().worktree)() + {layout.sidebar.workspaces(p.worktree)() ? language.t("sidebar.workspaces.disable") : language.t("sidebar.workspaces.enable")} @@ -1917,8 +1920,8 @@ export default function Layout(props: ParentProps) { closeProject(p().worktree)} + data-project={base64Encode(p.worktree)} + onSelect={() => closeProject(p.worktree)} > {language.t("common.close")} @@ -1934,19 +1937,25 @@ export default function Layout(props: ParentProps) { fallback={ <>
- + +
@@ -1956,9 +1965,15 @@ export default function Layout(props: ParentProps) { > <>
- + + +
@@ -2093,9 +2108,11 @@ export default function Layout(props: ParentProps) { />
-
- -
+ {(worktree) => ( +
+ +
+ )}
{ - 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) { @@ -755,67 +734,35 @@ export default function Page() { loadingClass: string emptyClass: string }) => ( - - - - 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} - /> - - - {language.t("session.review.loadingChanges")}
} - > - 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} - /> -
- - + + + 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} + /> + + + {language.t("session.review.loadingChanges")}
} + > - -
{language.t(reviewEmptyKey())}
- - ) - } diffs={reviewDiffs} view={view} diffStyle={input.diffStyle} @@ -832,9 +779,39 @@ export default function Page() { onViewFile={openReviewFile} classes={input.classes} /> - - - + + + + + +
{language.t(reviewEmptyKey())}
+ + ) + } + 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} + /> +
+ ) const reviewPanel = () => ( @@ -1045,7 +1022,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 }) @@ -1068,7 +1048,7 @@ export default function Page() { const resumeScroll = () => { setStore("messageId", undefined) - autoScroll.forceScrollToBottom() + autoScroll.smoothScrollToBottom() clearMessageHash() const el = scroller @@ -1136,9 +1116,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 @@ -1204,50 +1183,49 @@ export default function Page() {
- - { - 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} - /> - + { + 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} + /> { inputRef = el diff --git a/packages/app/src/pages/session/composer/index.ts b/packages/app/src/pages/session/composer/index.ts index e244a15363a9..066a586a3b89 100644 --- a/packages/app/src/pages/session/composer/index.ts +++ b/packages/app/src/pages/session/composer/index.ts @@ -1,3 +1,29 @@ +/** + * Composer Island migration tracker + * + * Goal + * - Replace the split composer stack (PromptInput + question/permission/todo docks) + * with a single morphing ComposerIsland + app runtime adapter. + * + * Current status + * - [x] Storybook prototype with morphing surfaces exists (`packages/ui/src/components/composer-island.tsx`). + * - [ ] App still renders the existing production stack (`session-composer-region` + `prompt-input`). + * + * Feature parity checklist + * - [ ] Submit pipeline parity (session/worktree/create, optimistic user message, abort, restore on error). + * - [x] Runtime adapter API boundary in island (`runtime.submit`, `runtime.abort`, lookup, permission/question handlers). + * - [x] Shell mode wiring parity (single mode source for tray + editor). + * - [x] Cursor scroll-into-view behavior in island editor. + * - [x] Attachment parity in island UI: image + PDF support and file picker wiring. + * - [x] Drag/drop parity in island UI: attachment drop + `file:` text drop into @mention. + * - [x] Keyboard parity in island UI: mod+u, ctrl+g, ctrl+n/ctrl+p, popover navigation. + * - [x] Auto-accept + stop/send state parity in island UI. + * - [x] Async @mention/slash sourcing parity in island via runtime search hooks. + * - [ ] Context item parity (comment chips/open behavior is in progress; app comment wiring still pending). + * - [ ] Question flow parity (island submit/reject hooks + sending locks done; app SDK/cache wiring pending). + * - [ ] Permission flow parity (island responding lock done; app tool-description wiring pending). + * - [x] Todo parity in island UI (in-progress pulse + auto-scroll active item). + */ export { SessionComposerRegion } from "./session-composer-region" export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state" export type { SessionComposerState } from "./session-composer-state" diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 93ea3d465c5e..6f9b16f336ee 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -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() - - 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 (
@@ -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()} />
@@ -214,7 +127,7 @@ export function SessionComposerRegion(props: { "relative z-10": true, }} style={{ - "margin-top": `${-36 * value()}px`, + "margin-top": `${-36 * progress()}px`, }} > number) }) { +export function createSessionComposerState( + options?: { + closeMs?: number | (() => number) + }, +) { const params = useParams() const sdk = useSDK() const sync = useSync() diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index da2b8c8da17f..ab6cca83e176 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -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 (
@@ -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", }} > @@ -157,9 +123,9 @@ export function SessionTodoDock(props: { { event.preventDefault() event.stopPropagation() @@ -189,14 +155,15 @@ export function SessionTodoDock(props: {
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", }} > @@ -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", }} > diff --git a/packages/app/src/pages/session/message-gesture.test.ts b/packages/app/src/pages/session/message-gesture.test.ts index b2af4bb83423..62758b1d8968 100644 --- a/packages/app/src/pages/session/message-gesture.test.ts +++ b/packages/app/src/pages/session/message-gesture.test.ts @@ -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, }), diff --git a/packages/app/src/pages/session/message-gesture.ts b/packages/app/src/pages/session/message-gesture.ts index 731cb1bdeb6c..cd29b5392d57 100644 --- a/packages/app/src/pages/session/message-gesture.ts +++ b/packages/app/src/pages/session/message-gesture.ts @@ -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 } diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index ce6a01378c1c..784d1707370c 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,27 +1,31 @@ -import { For, createEffect, createMemo, on, onCleanup, Show, 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 = () => { @@ -160,10 +191,11 @@ function createTimelineStaging(input: TimelineStageInput) { } const currentTotal = input.messages().length count = Math.min(currentTotal, count + input.config.batch) - setState("count", count) + startTransition(() => setState("count", count)) 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"], [data-scroll-preserve]') + : 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([sessionID]) - - const byParent = new Map() - 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 ( - -
-
- - {language.t("session.delete.confirm", { name: name() })} - -
-
- - -
-
-
- ) - } + const rendered = createMemo(() => staging.messages().map((message) => message.id)) return (
+ { @@ -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", }} > -
- -
-
-
- - - - - - {titleValue()} - - } - > - { - 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} - /> - - -
- - {(id) => ( -
- - setTitle("menuOpen", open)} - > - - - { - if (!title.pendingRename) return - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - }} - > - { - setTitle("pendingRename", true) - setTitle("menuOpen", false) - }} - > - {language.t("common.rename")} - - void archiveSession(id())}> - {language.t("common.archive")} - - - dialog.show(() => )} - > - {language.t("common.delete")} - - - - -
- )} -
-
-
-
- +
{(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,10 +516,10 @@ 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()} + shellToolDefaultOpen={false} + editToolDefaultOpen={false} classes={{ root: "min-w-0 w-full relative", content: "flex flex-col justify-between !overflow-visible", diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index ad802d15d186..55c1607a093e 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -331,7 +331,9 @@ export function SessionSidePanel(props: { const path = createMemo(() => file.pathFromTab(tab)) return (
- {(p) => } + + {(p) => } +
) }} diff --git a/packages/app/src/pages/session/session-timeline-header.tsx b/packages/app/src/pages/session/session-timeline-header.tsx new file mode 100644 index 000000000000..fcddb38a4781 --- /dev/null +++ b/packages/app/src/pages/session/session-timeline-header.tsx @@ -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([sessionID]) + const byParent = new Map() + + 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 ( + +
+
+ + {language.t("session.delete.confirm", { name: name() })} + +
+
+ + +
+
+
+ ) + } + + return ( + +
{ + headerRef = el + el.style.opacity = "0" + }} + class="pointer-events-none absolute inset-x-0 top-0 z-30" + > +
+
+
+ +
+ +
+
+ + + + + {headerText.value} + + + {headerText.prev} + + + + } + > + { + 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} + /> + + +
+ + {(id) => ( +
+ + setTitle("menuOpen", open)} + > + + + { + if (!title.pendingRename) return + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + }} + > + { + setTitle("pendingRename", true) + setTitle("menuOpen", false) + }} + > + {language.t("common.rename")} + + void archiveSession(id())}> + {language.t("common.archive")} + + + dialog.show(() => )}> + {language.t("common.delete")} + + + + +
+ )} +
+
+
+
+
+ ) +} diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index cc4c17ee2169..c8bfc140533f 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -191,8 +191,8 @@ export function TerminalPanel() { {(id) => ( - - {(pty) => } + + {(pty) => } )} @@ -217,10 +217,10 @@ export function TerminalPanel() {
{(id) => ( - + {(pty) => (
- terminal.clone(id)} /> + terminal.clone(id)} />
)}
@@ -229,14 +229,14 @@ export function TerminalPanel() {
- + {(draggedId) => ( - + {(t) => (
{terminalTabLabel({ - title: t().title, - titleNumber: t().titleNumber, + title: t.title, + titleNumber: t.titleNumber, t: language.t as (key: string, vars?: Record) => string, })}
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index b8ddeda82352..461351878b68 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -261,35 +261,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => { }), ]) - const isAutoAcceptActive = () => { - const sessionID = params.id - if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory) - return permission.isAutoAcceptingDirectory(sdk.directory) - } - const permissionCommands = createMemo(() => [ permissionsCommand({ id: "permissions.autoaccept", - title: isAutoAcceptActive() - ? language.t("command.permissions.autoaccept.disable") - : language.t("command.permissions.autoaccept.enable"), + title: + params.id && permission.isAutoAccepting(params.id, sdk.directory) + ? language.t("command.permissions.autoaccept.disable") + : language.t("command.permissions.autoaccept.enable"), keybind: "mod+shift+a", - disabled: false, + disabled: !params.id || !permission.permissionsEnabled(), onSelect: () => { const sessionID = params.id - if (sessionID) { - permission.toggleAutoAccept(sessionID, sdk.directory) - } else { - permission.toggleAutoAcceptDirectory(sdk.directory) - } - const active = sessionID - ? permission.isAutoAccepting(sessionID, sdk.directory) - : permission.isAutoAcceptingDirectory(sdk.directory) + if (!sessionID) return + permission.toggleAutoAccept(sessionID, sdk.directory) showToast({ - title: active + title: permission.isAutoAccepting(sessionID, sdk.directory) ? language.t("toast.permissions.autoaccept.on.title") : language.t("toast.permissions.autoaccept.off.title"), - description: active + description: permission.isAutoAccepting(sessionID, sdk.directory) ? language.t("toast.permissions.autoaccept.on.description") : language.t("toast.permissions.autoaccept.off.description"), }) diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 20e88a3ea3ef..278a1ba6e566 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -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, diff --git a/packages/app/src/utils/dom.ts b/packages/app/src/utils/dom.ts index 4f3724c7c950..4169eb7e5a9f 100644 --- a/packages/app/src/utils/dom.ts +++ b/packages/app/src/utils/dom.ts @@ -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) diff --git a/packages/app/src/utils/same.ts b/packages/app/src/utils/same.ts deleted file mode 100644 index c956f92998a6..000000000000 --- a/packages/app/src/utils/same.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function same(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]) -} diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 4d20c1b8bc4f..67a7eafa7cb1 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.2.20", + "version": "1.2.18", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 408e2a7aca4e..37c94aecbb72 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.2.20", + "version": "1.2.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 8df6594d0ba7..bb7caccc585f 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.2.20", + "version": "1.2.18", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 591c4bd3693f..00e7378fe54e 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.2.20", + "version": "1.2.18", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 31321c92a5b7..41791066eafb 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.2.20", + "version": "1.2.18", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop-electron/src/renderer/i18n/index.ts b/packages/desktop-electron/src/renderer/i18n/index.ts index be87f94f9159..81158ad244ae 100644 --- a/packages/desktop-electron/src/renderer/i18n/index.ts +++ b/packages/desktop-electron/src/renderer/i18n/index.ts @@ -76,7 +76,6 @@ function detectLocale(): Locale { const languages = navigator.languages?.length ? navigator.languages : [navigator.language] for (const language of languages) { if (!language) continue - if (language.toLowerCase().startsWith("en")) return "en" if (language.toLowerCase().startsWith("zh")) { if (language.toLowerCase().includes("hant")) return "zht" return "zh" diff --git a/packages/desktop/package.json b/packages/desktop/package.json index da4d51bcc770..49699ff85e9d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.2.20", + "version": "1.2.18", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/desktop/src/i18n/index.ts b/packages/desktop/src/i18n/index.ts index e1c1e63d9708..7b1ebfe696a0 100644 --- a/packages/desktop/src/i18n/index.ts +++ b/packages/desktop/src/i18n/index.ts @@ -77,7 +77,6 @@ function detectLocale(): Locale { const languages = navigator.languages?.length ? navigator.languages : [navigator.language] for (const language of languages) { if (!language) continue - if (language.toLowerCase().startsWith("en")) return "en" if (language.toLowerCase().startsWith("zh")) { if (language.toLowerCase().includes("hant")) return "zht" return "zh" diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 2531cf34fbbf..065015bc50f5 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.2.20", + "version": "1.2.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/enterprise/src/core/share.ts b/packages/enterprise/src/core/share.ts index c6291b75d22e..d7f5c8b8d523 100644 --- a/packages/enterprise/src/core/share.ts +++ b/packages/enterprise/src/core/share.ts @@ -1,8 +1,10 @@ import { FileDiff, Message, Model, Part, Session } from "@opencode-ai/sdk/v2" import { fn } from "@opencode-ai/util/fn" import { iife } from "@opencode-ai/util/iife" +import { Identifier } from "@opencode-ai/util/identifier" import z from "zod" import { Storage } from "./storage" +import { Binary } from "@opencode-ai/util/binary" export namespace Share { export const Info = z.object({ @@ -36,81 +38,6 @@ export namespace Share { ]) export type Data = z.infer - type Snapshot = { - data: Data[] - } - - type Compaction = { - event?: string - data: Data[] - } - - function key(item: Data) { - switch (item.type) { - case "session": - return "session" - case "message": - return `message/${item.data.id}` - case "part": - return `part/${item.data.messageID}/${item.data.id}` - case "session_diff": - return "session_diff" - case "model": - return "model" - } - } - - function merge(...items: Data[][]) { - const map = new Map() - for (const list of items) { - for (const item of list) { - map.set(key(item), item) - } - } - return Array.from(map.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([, item]) => item) - } - - async function readSnapshot(shareID: string) { - return (await Storage.read(["share_snapshot", shareID]))?.data - } - - async function writeSnapshot(shareID: string, data: Data[]) { - await Storage.write(["share_snapshot", shareID], { data }) - } - - async function legacy(shareID: string) { - const compaction: Compaction = (await Storage.read(["share_compaction", shareID])) ?? { - data: [], - event: undefined, - } - const list = await Storage.list({ - prefix: ["share_event", shareID], - before: compaction.event, - }).then((x) => x.toReversed()) - if (list.length === 0) { - if (compaction.data.length > 0) await writeSnapshot(shareID, compaction.data) - return compaction.data - } - - const next = merge( - compaction.data, - await Promise.all(list.map(async (event) => await Storage.read(event))).then((x) => - x.flatMap((item) => item ?? []), - ), - ) - - await Promise.all([ - Storage.write(["share_compaction", shareID], { - event: list.at(-1)?.at(-1), - data: next, - }), - writeSnapshot(shareID, next), - ]) - return next - } - export const create = fn(z.object({ sessionID: z.string() }), async (body) => { const isTest = process.env.NODE_ENV === "test" || body.sessionID.startsWith("test_") const info: Info = { @@ -120,7 +47,7 @@ export namespace Share { } const exists = await get(info.id) if (exists) throw new Errors.AlreadyExists(info.id) - await Promise.all([Storage.write(["share", info.id], info), writeSnapshot(info.id, [])]) + await Storage.write(["share", info.id], info) return info }) @@ -133,13 +60,8 @@ export namespace Share { if (!share) throw new Errors.NotFound(body.id) if (share.secret !== body.secret) throw new Errors.InvalidSecret(body.id) await Storage.remove(["share", body.id]) - const groups = await Promise.all([ - Storage.list({ prefix: ["share_snapshot", body.id] }), - Storage.list({ prefix: ["share_compaction", body.id] }), - Storage.list({ prefix: ["share_event", body.id] }), - Storage.list({ prefix: ["share_data", body.id] }), - ]) - for (const item of groups.flat()) { + const list = await Storage.list({ prefix: ["share_data", body.id] }) + for (const item of list) { await Storage.remove(item) } }) @@ -153,13 +75,59 @@ export namespace Share { const share = await get(input.share.id) if (!share) throw new Errors.NotFound(input.share.id) if (share.secret !== input.share.secret) throw new Errors.InvalidSecret(input.share.id) - const data = (await readSnapshot(input.share.id)) ?? (await legacy(input.share.id)) - await writeSnapshot(input.share.id, merge(data, input.data)) + await Storage.write(["share_event", input.share.id, Identifier.descending()], input.data) }, ) + type Compaction = { + event?: string + data: Data[] + } + export async function data(shareID: string) { - return (await readSnapshot(shareID)) ?? legacy(shareID) + console.log("reading compaction") + const compaction: Compaction = (await Storage.read(["share_compaction", shareID])) ?? { + data: [], + event: undefined, + } + console.log("reading pending events") + const list = await Storage.list({ + prefix: ["share_event", shareID], + before: compaction.event, + }).then((x) => x.toReversed()) + + console.log("compacting", list.length) + + if (list.length > 0) { + const data = await Promise.all(list.map(async (event) => await Storage.read(event))).then((x) => x.flat()) + for (const item of data) { + if (!item) continue + const key = (item: Data) => { + switch (item.type) { + case "session": + return "session" + case "message": + return `message/${item.data.id}` + case "part": + return `${item.data.messageID}/${item.data.id}` + case "session_diff": + return "session_diff" + case "model": + return "model" + } + } + const id = key(item) + const result = Binary.search(compaction.data, id, key) + if (result.found) { + compaction.data[result.index] = item + } else { + compaction.data.splice(result.index, 0, item) + } + } + compaction.event = list.at(-1)?.at(-1) + await Storage.write(["share_compaction", shareID], compaction) + } + return compaction.data } export const syncOld = fn( diff --git a/packages/enterprise/src/routes/api/[...path].ts b/packages/enterprise/src/routes/api/[...path].ts index f97788bd03db..e77c00de9205 100644 --- a/packages/enterprise/src/routes/api/[...path].ts +++ b/packages/enterprise/src/routes/api/[...path].ts @@ -108,7 +108,6 @@ app validator("param", z.object({ shareID: z.string() })), async (c) => { const { shareID } = c.req.valid("param") - c.header("Cache-Control", "public, max-age=30, s-maxage=300, stale-while-revalidate=86400") return c.json(await Share.data(shareID)) }, ) diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index e755ea75a194..007b4c268dff 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -5,11 +5,12 @@ import { DataProvider } from "@opencode-ai/ui/context" import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool" import { createAsync, query, useParams } from "@solidjs/router" -import { createMemo, createSignal, ErrorBoundary, For, Match, Show, Switch } from "solid-js" +import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js" import { Share } from "~/core/share" import { Logo, Mark } from "@opencode-ai/ui/logo" import { IconButton } from "@opencode-ai/ui/icon-button" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { createDefaultOptions } from "@opencode-ai/ui/pierre" import { iife } from "@opencode-ai/util/iife" import { Binary } from "@opencode-ai/util/binary" import { NamedError } from "@opencode-ai/util/error" @@ -19,11 +20,11 @@ import z from "zod" import NotFound from "../[...404]" import { Tabs } from "@opencode-ai/ui/tabs" import { MessageNav } from "@opencode-ai/ui/message-nav" +import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { FileSSR } from "@opencode-ai/ui/file-ssr" import { clientOnly } from "@solidjs/start" import { Meta, Title } from "@solidjs/meta" import { Base64 } from "js-base64" -import { getRequestEvent } from "solid-js/web" const ClientOnlyWorkerPoolProvider = clientOnly(() => import("@opencode-ai/ui/pierre/worker").then((m) => ({ @@ -53,6 +54,12 @@ const getData = query(async (shareID) => { session_diff: { [sessionID: string]: FileDiff[] } + session_diff_preload: { + [sessionID: string]: PreloadMultiFileDiffResult[] + } + session_diff_preload_split: { + [sessionID: string]: PreloadMultiFileDiffResult[] + } session_status: { [sessionID: string]: SessionStatus } @@ -72,6 +79,12 @@ const getData = query(async (shareID) => { session_diff: { [share.sessionID]: [], }, + session_diff_preload: { + [share.sessionID]: [], + }, + session_diff_preload_split: { + [share.sessionID]: [], + }, session_status: { [share.sessionID]: { type: "idle", @@ -88,6 +101,28 @@ const getData = query(async (shareID) => { break case "session_diff": result.session_diff[share.sessionID] = item.data + await Promise.all([ + Promise.all( + item.data.map(async (diff) => + preloadMultiFileDiff({ + oldFile: { name: diff.file, contents: diff.before }, + newFile: { name: diff.file, contents: diff.after }, + options: createDefaultOptions("unified"), + // annotations, + }), + ), + ).then((r) => (result.session_diff_preload[share.sessionID] = r)), + Promise.all( + item.data.map(async (diff) => + preloadMultiFileDiff({ + oldFile: { name: diff.file, contents: diff.before }, + newFile: { name: diff.file, contents: diff.after }, + options: createDefaultOptions("split"), + // annotations, + }), + ), + ).then((r) => (result.session_diff_preload_split[share.sessionID] = r)), + ]) break case "message": result.message[item.data.sessionID] = result.message[item.data.sessionID] ?? [] @@ -108,15 +143,17 @@ const getData = query(async (shareID) => { }, "getShareData") export default function () { - getRequestEvent()?.response.headers.set( - "Cache-Control", - "public, max-age=30, s-maxage=300, stale-while-revalidate=86400", - ) - const params = useParams() const data = createAsync(async () => { if (!params.shareID) throw new Error("Missing shareID") - return getData(params.shareID) + const now = Date.now() + const data = getData(params.shareID) + console.log("getData", Date.now() - now) + return data + }) + + createEffect(() => { + console.log(data()) }) return ( @@ -204,8 +241,22 @@ export default function () { const provider = createMemo(() => activeMessage()?.model?.providerID) const modelID = createMemo(() => activeMessage()?.model?.modelID) const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID())) - const diffs = createMemo(() => data().session_diff[data().sessionID] ?? []) - const [diffStyle, setDiffStyle] = createSignal<"unified" | "split">("unified") + const diffs = createMemo(() => { + const diffs = data().session_diff[data().sessionID] ?? [] + const preloaded = data().session_diff_preload[data().sessionID] ?? [] + return diffs.map((diff) => ({ + ...diff, + preloaded: preloaded.find((d) => d.newFile.name === diff.file), + })) + }) + const splitDiffs = createMemo(() => { + const diffs = data().session_diff[data().sessionID] ?? [] + const preloaded = data().session_diff_preload_split[data().sessionID] ?? [] + return diffs.map((diff) => ({ + ...diff, + preloaded: preloaded.find((d) => d.newFile.name === diff.file), + })) + }) const title = () => (
@@ -329,9 +380,18 @@ export default function () { 0}>
+
- + ) }, }) @@ -1697,13 +1630,15 @@ ToolRegistry.register({ const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") - const pending = () => props.status === "pending" || props.status === "running" + const pending = () => busy(props.status) + const reveal = useToolReveal(pending, () => props.reveal !== false) return (
-
@@ -1711,20 +1646,17 @@ ToolRegistry.register({ - - {filename()} + + {(name) => ( + + )}
- -
- {getDirectory(props.input.filePath!)} -
-
-
-
- - -
} @@ -1734,7 +1666,7 @@ ToolRegistry.register({ path={path()} actions={ - + {(diff) => } } > @@ -1755,7 +1687,7 @@ ToolRegistry.register({ - +
) }, @@ -1769,13 +1701,15 @@ ToolRegistry.register({ const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const path = createMemo(() => props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") - const pending = () => props.status === "pending" || props.status === "running" + const pending = () => busy(props.status) + const reveal = useToolReveal(pending, () => props.reveal !== false) return (
-
@@ -1783,17 +1717,17 @@ ToolRegistry.register({ - - {filename()} + + {(name) => ( + + )}
- -
- {getDirectory(props.input.filePath!)} -
-
-
{/* */}
} > @@ -1814,7 +1748,7 @@ ToolRegistry.register({ - +
) }, @@ -1838,7 +1772,8 @@ ToolRegistry.register({ const i18n = useI18n() const fileComponent = useFileComponent() const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) - const pending = createMemo(() => props.status === "pending" || props.status === "running") + const pending = createMemo(() => busy(props.status)) + const reveal = useToolReveal(pending, () => props.reveal !== false) const single = createMemo(() => { const list = files() if (list.length !== 1) return @@ -1846,7 +1781,6 @@ ToolRegistry.register({ }) const [expanded, setExpanded] = createSignal([]) let seeded = false - createEffect(() => { const list = files() if (list.length === 0) return @@ -1854,7 +1788,6 @@ ToolRegistry.register({ seeded = true setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath)) }) - const subtitle = createMemo(() => { const count = files().length if (count === 0) return "" @@ -1862,24 +1795,44 @@ ToolRegistry.register({ }) return ( - - +
+ +
+
+ + + + + {(file) => ( + + )} + + {(text) => } +
+
+
+ } + > + 0}> setExpanded(Array.isArray(value) ? value : value ? [value] : [])} > @@ -1887,13 +1840,11 @@ ToolRegistry.register({ {(file) => { const active = createMemo(() => expanded().includes(file.filePath)) const [visible, setVisible] = createSignal(false) - createEffect(() => { if (!active()) { setVisible(false) return } - requestAnimationFrame(() => { if (!active()) return setVisible(true) @@ -1958,77 +1909,50 @@ ToolRegistry.register({ -
- - } - > -
- -
-
- - - - - {getFilename(single()!.relativePath)} - -
- -
- {getDirectory(single()!.relativePath)} -
-
-
-
- - - -
-
} > - - - - {i18n.t("ui.patch.action.created")} - - - - - {i18n.t("ui.patch.action.deleted")} - - - - - {i18n.t("ui.patch.action.moved")} - - - - - - - } - > -
- -
-
- - -
+ {(file) => ( + + + + {i18n.t("ui.patch.action.created")} + + + + + {i18n.t("ui.patch.action.deleted")} + + + + + {i18n.t("ui.patch.action.moved")} + + + + + + + } + > +
+ +
+
+ )} + + + ) }, }) @@ -2046,6 +1970,7 @@ ToolRegistry.register({ return [] }) + const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { const list = todos() @@ -2054,14 +1979,19 @@ ToolRegistry.register({ }) return ( - + } >
@@ -2079,7 +2009,7 @@ ToolRegistry.register({
-
+ ) }, }) @@ -2091,6 +2021,7 @@ ToolRegistry.register({ const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[]) const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[]) const completed = createMemo(() => answers().length > 0) + const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { const count = questions().length @@ -2100,14 +2031,19 @@ ToolRegistry.register({ }) return ( - + } >
@@ -2124,7 +2060,7 @@ ToolRegistry.register({
-
+ ) }, }) @@ -2132,21 +2068,28 @@ ToolRegistry.register({ ToolRegistry.register({ name: "skill", render(props) { - const title = createMemo(() => props.input.name || "skill") - const running = createMemo(() => props.status === "pending" || props.status === "running") - - const titleContent = () => - - const trigger = () => ( -
-
- - {titleContent()} - -
-
+ const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) + const name = createMemo(() => { + const value = props.input.name || props.metadata.name + if (typeof value === "string") return value + }) + return ( + + } + animate + /> ) - - return }, }) diff --git a/packages/ui/src/components/motion-spring.tsx b/packages/ui/src/components/motion-spring.tsx index a5104a1a3ef7..5deefcfa61cc 100644 --- a/packages/ui/src/components/motion-spring.tsx +++ b/packages/ui/src/components/motion-spring.tsx @@ -1,8 +1,9 @@ import { attachSpring, motionValue } from "motion" import type { SpringOptions } from "motion" import { createEffect, createSignal, onCleanup } from "solid-js" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" -type Opt = Partial> +type Opt = Pick const eq = (a: Opt | undefined, b: Opt | undefined) => a?.visualDuration === b?.visualDuration && a?.bounce === b?.bounce && @@ -13,24 +14,41 @@ const eq = (a: Opt | undefined, b: Opt | undefined) => export function useSpring(target: () => number, options?: Opt | (() => Opt)) { const read = () => (typeof options === "function" ? options() : options) + const reduce = prefersReducedMotion const [value, setValue] = createSignal(target()) const source = motionValue(value()) const spring = motionValue(value()) let config = read() - let stop = attachSpring(spring, source, config) - let off = spring.on("change", (next: number) => setValue(next)) + let reduced = reduce() + let stop = reduced ? () => {} : attachSpring(spring, source, config) + let off = spring.on("change", (next) => setValue(next)) createEffect(() => { - source.set(target()) + const next = target() + if (reduced) { + source.set(next) + spring.set(next) + setValue(next) + return + } + source.set(next) }) createEffect(() => { - if (!options) return const next = read() - if (eq(config, next)) return + const skip = reduce() + if (eq(config, next) && reduced === skip) return config = next + reduced = skip stop() - stop = attachSpring(spring, source, next) + stop = skip ? () => {} : attachSpring(spring, source, next) + if (skip) { + const value = target() + source.set(value) + spring.set(value) + setValue(value) + return + } setValue(spring.get()) }) diff --git a/packages/ui/src/components/motion.tsx b/packages/ui/src/components/motion.tsx new file mode 100644 index 000000000000..6cdf01c73143 --- /dev/null +++ b/packages/ui/src/components/motion.tsx @@ -0,0 +1,77 @@ +import { followValue } from "motion" +import type { MotionValue } from "motion" + +export { animate, springValue } from "motion" +export type { AnimationPlaybackControls } from "motion" + +/** + * Like `springValue` but preserves getters on the config object. + * `springValue` spreads config at creation, snapshotting getter values. + * This passes the config through to `followValue` intact, so getters + * on `visualDuration` etc. fire on every `.set()` call. + */ +export function tunableSpringValue(initial: T, config: SpringConfig): MotionValue { + return followValue(initial, config as any) +} + +let _growDuration = 0.5 +let _collapsibleDuration = 0.3 + +export const GROW_SPRING = { + type: "spring" as const, + get visualDuration() { + return _growDuration + }, + bounce: 0, +} + +export const COLLAPSIBLE_SPRING = { + type: "spring" as const, + get visualDuration() { + return _collapsibleDuration + }, + bounce: 0, +} + +export const setGrowDuration = (v: number) => { + _growDuration = v +} +export const setCollapsibleDuration = (v: number) => { + _collapsibleDuration = v +} +export const getGrowDuration = () => _growDuration +export const getCollapsibleDuration = () => _collapsibleDuration + +export type SpringConfig = { type: "spring"; visualDuration: number; bounce: number } + +export const FAST_SPRING = { + type: "spring" as const, + visualDuration: 0.35, + bounce: 0, +} + +export const GLOW_SPRING = { + type: "spring" as const, + visualDuration: 0.4, + bounce: 0.15, +} + +export const WIPE_MASK = + "linear-gradient(to right, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 100%)" + +export const clearMaskStyles = (el: HTMLElement) => { + el.style.maskImage = "" + el.style.webkitMaskImage = "" + el.style.maskSize = "" + el.style.webkitMaskSize = "" + el.style.maskRepeat = "" + el.style.webkitMaskRepeat = "" + el.style.maskPosition = "" + el.style.webkitMaskPosition = "" +} + +export const clearFadeStyles = (el: HTMLElement) => { + el.style.opacity = "" + el.style.filter = "" + el.style.transform = "" +} diff --git a/packages/ui/src/components/new-composer.stories.tsx b/packages/ui/src/components/new-composer.stories.tsx new file mode 100644 index 000000000000..1ae957fbfa8d --- /dev/null +++ b/packages/ui/src/components/new-composer.stories.tsx @@ -0,0 +1,451 @@ +// @ts-nocheck +import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" +import { NewComposer } from "./new-composer" + +const docs = `### Overview +Runtime-ready composer story with visible canvas controls + Storybook controls. + +### Canvas controls +- Input / Question / Permission mode +- Todos, collapse, context, drag overlay +- Keyboard: Ctrl+1..7 toggles, Ctrl+8/9 answer count (Q1), Ctrl+[/] answer count (Q2), Ctrl+;/' answer count (Q3), Ctrl+0/- todo count + +### Storybook controls +- mode +- working +- accepting +- showTodos +- todoCollapsed +- forceDragType +- answerCount1 +- answerCount2 +- answerCount3 +- todoCount +` + +export default { + title: "UI/NewComposer", + id: "components-new-composer", + component: NewComposer, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + mode: { + control: "select", + options: ["input", "question", "permission"], + }, + working: { + control: "boolean", + }, + accepting: { + control: "boolean", + }, + showTodos: { + control: "boolean", + }, + todoCollapsed: { + control: "boolean", + }, + forceDragType: { + control: "select", + options: [null, "image", "@mention"], + }, + answerCount1: { + control: { type: "range", min: 1, max: 6, step: 1 }, + }, + answerCount2: { + control: { type: "range", min: 1, max: 6, step: 1 }, + }, + answerCount3: { + control: { type: "range", min: 1, max: 6, step: 1 }, + }, + todoCount: { + control: { type: "range", min: 0, max: 8, step: 1 }, + }, + }, +} + +const questions = [ + { + text: "Which editor do you use most often?", + options: [ + { label: "Neovim", description: "Fast keyboard-driven workflow" }, + { label: "VS Code", description: "Feature-rich and extensible" }, + { label: "Zed", description: "Lightweight modern editor" }, + { label: "JetBrains IDE", description: "Deep language tooling" }, + { label: "Sublime Text", description: "Fast, lightweight setup" }, + { label: "Helix", description: "Modal editor with tree-sitter" }, + ], + multiple: false, + }, + { + text: "Which testing frameworks should we add?", + options: [ + { label: "Vitest", description: "Fast unit testing" }, + { label: "Playwright", description: "E2E browser testing" }, + { label: "Testing Library", description: "Component testing" }, + { label: "Cypress", description: "Browser integration tests" }, + { label: "Jest", description: "Legacy compatibility" }, + { label: "Storybook Tests", description: "Interaction smoke tests" }, + ], + multiple: true, + }, + { + text: "How strict should linting be?", + options: [ + { label: "Minimal", description: "Keep only important rules" }, + { label: "Balanced", description: "Recommended defaults" }, + { label: "Strict", description: "Catch everything possible" }, + { label: "Very strict", description: "Treat warnings as errors" }, + { label: "Preset by package", description: "Different rules per surface" }, + { label: "Experimental", description: "Try strict mode for one sprint" }, + ], + multiple: false, + }, +] + +const todos = [ + { content: "Read auth module", status: "completed" }, + { content: "Refactor token logic", status: "in_progress" }, + { content: "Add tests", status: "pending" }, + { content: "Ship changes", status: "pending" }, + { content: "Check telemetry events", status: "pending" }, + { content: "Validate markdown rendering", status: "pending" }, + { content: "Run regression suite", status: "pending" }, + { content: "Update release notes", status: "pending" }, +] + +const ctx = [ + { id: "a", path: "src/auth.ts", selection: { startLine: 5, endLine: 10 }, comment: "Check token refresh" }, + { id: "b", path: "src/routes/login.ts", comment: "Review edge cases" }, +] + +const dragModes = [null, "image", "@mention"] as const + +const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)) + +const panel = { + position: "fixed", + top: "14px", + left: "14px", + display: "flex", + "flex-wrap": "wrap", + gap: "8px", + padding: "8px", + "border-radius": "10px", + background: "rgba(0, 0, 0, 0.55)", + border: "1px solid rgba(255, 255, 255, 0.12)", + color: "#fff", + "font-family": "monospace", + "font-size": "11px", + "z-index": 50, +} as const + +const btn = (on: boolean) => + ({ + padding: "4px 8px", + "border-radius": "8px", + border: "1px solid rgba(255, 255, 255, 0.2)", + background: on ? "rgba(255, 255, 255, 0.2)" : "rgba(255, 255, 255, 0.07)", + color: "#fff", + cursor: "pointer", + }) as const + +export const Interactive = { + args: { + mode: "input", + working: false, + accepting: false, + showTodos: true, + todoCollapsed: false, + forceDragType: null, + answerCount1: 4, + answerCount2: 3, + answerCount3: 5, + todoCount: 4, + }, + render: (args) => { + const limit = (value: number, i: number) => Math.max(1, Math.min(questions[i].options.length, value)) + + const [mode, setMode] = createSignal(args.mode ?? "input") + const [tab, setTab] = createSignal(0) + const [work, setWork] = createSignal(!!args.working) + const [accept, setAccept] = createSignal(!!args.accepting) + const [showTodos, setShowTodos] = createSignal(args.showTodos ?? true) + const [todoCollapsed, setTodoCollapsed] = createSignal(args.todoCollapsed ?? false) + const [drag, setDrag] = createSignal(args.forceDragType ?? null) + const [a1, setA1] = createSignal(limit(args.answerCount1 ?? 4, 0)) + const [a2, setA2] = createSignal(limit(args.answerCount2 ?? 3, 1)) + const [a3, setA3] = createSignal(limit(args.answerCount3 ?? 5, 2)) + const [tCount, setTCount] = createSignal(Math.max(0, Math.min(todos.length, args.todoCount ?? 4))) + const [showCtx, setShowCtx] = createSignal(true) + const [agent, setAgent] = createSignal("ask") + const [model, setModel] = createSignal("OpenAI/GPT-5.3 Codex") + const [variant, setVariant] = createSignal("default") + const [history, setHistory] = createSignal<{ normal: string[]; shell: string[] }>({ normal: [], shell: [] }) + + createEffect(() => setMode(args.mode ?? "input")) + createEffect(() => setWork(!!args.working)) + createEffect(() => setAccept(!!args.accepting)) + createEffect(() => setShowTodos(args.showTodos ?? true)) + createEffect(() => setTodoCollapsed(args.todoCollapsed ?? false)) + createEffect(() => setDrag(args.forceDragType ?? null)) + createEffect(() => setA1(limit(args.answerCount1 ?? 4, 0))) + createEffect(() => setA2(limit(args.answerCount2 ?? 3, 1))) + createEffect(() => setA3(limit(args.answerCount3 ?? 5, 2))) + createEffect(() => setTCount(Math.max(0, Math.min(todos.length, args.todoCount ?? 4)))) + + const qList = createMemo(() => [ + { ...questions[0], options: questions[0].options.slice(0, a1()) }, + { ...questions[1], options: questions[1].options.slice(0, a2()) }, + { ...questions[2], options: questions[2].options.slice(0, a3()) }, + ]) + const tList = createMemo(() => todos.slice(0, tCount())) + + createEffect(() => { + if (tab() <= qList().length - 1) return + setTab(qList().length - 1) + }) + + const q = createMemo(() => qList()[tab()] ?? qList()[0]) + + const runtime = { + submit: async (input) => { + setWork(true) + await wait(550) + setWork(false) + console.log("submit", input) + }, + abort: () => { + setWork(false) + }, + toggleAccept: () => setAccept((v) => !v), + runSlash: (cmd) => { + if (cmd.type !== "builtin") return false + console.log("slash:run", cmd.id) + return true + }, + historyRead: (mode) => history()[mode], + historyWrite: (mode, list) => { + setHistory((prev) => ({ ...prev, [mode]: list })) + }, + decidePermission: async (response) => { + console.log("permission", response) + await wait(200) + setMode("input") + }, + submitQuestion: async (answers) => { + console.log("question", answers) + await wait(250) + setMode("input") + setTab(0) + }, + rejectQuestion: async () => { + await wait(150) + setMode("input") + setTab(0) + }, + openContext: (item) => console.log("open context", item), + removeContext: (item) => console.log("remove context", item), + } + + const cycle = () => { + const i = dragModes.findIndex((v) => v === drag()) + const n = (i + 1) % dragModes.length + setDrag(dragModes[n]) + } + + onMount(() => { + const onKey = (event: KeyboardEvent) => { + const target = event.target + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return + if (target instanceof HTMLElement && target.isContentEditable) return + + if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return + + let hit = false + if (event.key === "1") { + setMode("input") + hit = true + } + if (event.key === "2") { + setMode("question") + hit = true + } + if (event.key === "3") { + setMode("permission") + hit = true + } + if (event.key === "4") { + setTodoCollapsed((v) => !v) + hit = true + } + if (event.key === "5") { + setShowTodos((v) => !v) + hit = true + } + if (event.key === "6") { + setShowCtx((v) => !v) + hit = true + } + if (event.key === "7") { + cycle() + hit = true + } + if (event.key === "8") { + setA1((v) => limit(v - 1, 0)) + hit = true + } + if (event.key === "9") { + setA1((v) => limit(v + 1, 0)) + hit = true + } + if (event.key === "[") { + setA2((v) => limit(v - 1, 1)) + hit = true + } + if (event.key === "]") { + setA2((v) => limit(v + 1, 1)) + hit = true + } + if (event.key === ";") { + setA3((v) => limit(v - 1, 2)) + hit = true + } + if (event.key === "'") { + setA3((v) => limit(v + 1, 2)) + hit = true + } + if (event.key === "0") { + setTCount((v) => Math.max(0, v - 1)) + hit = true + } + if (event.key === "-") { + setTCount((v) => Math.min(todos.length, v + 1)) + hit = true + } + + if (!hit) return + event.preventDefault() + event.stopPropagation() + } + + window.addEventListener("keydown", onKey) + onCleanup(() => window.removeEventListener("keydown", onKey)) + }) + + return ( +
+
+ + + + + + + + + + + Q1:{a1()} + + + Q2:{a2()} + + + Q3:{a3()} + + + T:{tCount()} + +
+ + setTab((v) => Math.max(0, v - 1))} + onQuestionNext={() => setTab((v) => Math.min(qList().length - 1, v + 1))} + onQuestionJump={setTab} + onQuestionDismiss={() => { + setMode("input") + setTab(0) + }} + showTodos={showTodos()} + todoCollapsed={todoCollapsed()} + onTodoCollapseChange={setTodoCollapsed} + todos={tList()} + contextItems={showCtx() ? ctx : []} + forceDragType={drag()} + permissionDescription="This action needs write access to project files." + permissionPatterns={["src/**/*.ts", "tests/**/*.ts"]} + /> +
+ ) + }, +} diff --git a/packages/ui/src/components/new-composer.tsx b/packages/ui/src/components/new-composer.tsx new file mode 100644 index 000000000000..e5877506cf2c --- /dev/null +++ b/packages/ui/src/components/new-composer.tsx @@ -0,0 +1,1097 @@ +import { + Match, + Show, + Switch, + createEffect, + createMemo, + createSignal, + on, + onCleanup, + onMount, + type Component, +} from "solid-js" +import { useElementHeight } from "@opencode-ai/ui/hooks" +import { useI18n } from "../context/i18n" +import { ComposerDragOverlay } from "./new-composer/drag-overlay" +import { DEFAULT_AT_OPTIONS, DEFAULT_SLASH_COMMANDS } from "./new-composer/defaults" +import { ComposerInputLayer } from "./new-composer/input-layer" +import { PermissionBody } from "./new-composer/permission-body" +import { ComposerPopover } from "./new-composer/popover" +import { QuestionBody, QuestionSizer } from "./new-composer/question-body" +import { ComposerTodoTray } from "./new-composer/todo-tray" +import { ComposerTray } from "./new-composer/tray" +import type { ComposerSource, ContextItem, ImageAttachment, NewComposerProps } from "./new-composer/types" +import { ACCEPTED_FILE_TYPES } from "./new-composer/types" +import { useEditor } from "./new-composer/use-editor" +import { useLayout } from "./new-composer/use-layout" +import { useTodo } from "./new-composer/use-todo" +import { showToast } from "./toast" + +const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) + +const normalize = (key: string, code?: string) => { + if (code === "Quote") return "'" + if (key === ",") return "comma" + if (key === "+") return "plus" + if (key === " ") return "space" + if (key === "Dead" && code === "Quote") return "'" + return key.toLowerCase() +} + +const match = (config: string | undefined, event: KeyboardEvent) => { + if (!config || config === "none") return false + const key = normalize(event.key, event.code) + + const combos = config.split(",") + for (const combo of combos) { + const kb = { + key: "", + ctrl: false, + meta: false, + shift: false, + alt: false, + } + + for (const part of combo.trim().toLowerCase().split("+")) { + if (part === "ctrl" || part === "control") { + kb.ctrl = true + continue + } + if (part === "meta" || part === "cmd" || part === "command") { + kb.meta = true + continue + } + if (part === "mod") { + if (IS_MAC) kb.meta = true + else kb.ctrl = true + continue + } + if (part === "alt" || part === "option") { + kb.alt = true + continue + } + if (part === "shift") { + kb.shift = true + continue + } + kb.key = part + } + + if ( + kb.key === key && + kb.ctrl === !!event.ctrlKey && + kb.meta === !!event.metaKey && + kb.shift === !!event.shiftKey && + kb.alt === !!event.altKey + ) { + return true + } + } + + return false +} + +/** + * New Composer — split architecture version of composer island. + */ +export const NewComposer: Component = (props) => { + const i18n = useI18n() + const [answers, setAnswers] = createSignal>( + (props.questionAnswers ?? []).reduce>((map, row, i) => { + map[i] = [...row] + return map + }, {}), + ) + const [customs, setCustoms] = createSignal>({}) + const [customOns, setCustomOns] = createSignal>({}) + const [sending, setSending] = createSignal(false) + const [questionSending, setQuestionSending] = createSignal(false) + const [permissionSending, setPermissionSending] = createSignal(false) + const [accept, setAccept] = createSignal(props.accepting ?? false) + const [modelTick, setModelTick] = createSignal(0) + const [images, setImages] = createSignal([]) + const [contexts, setContexts] = createSignal(props.contextItems ?? []) + const [drag, setDrag] = createSignal<"image" | "@mention" | null>(null) + let id = 0 + let editor: HTMLDivElement | undefined + let input: HTMLInputElement | undefined + const pick = () => input?.click() + + const isQuestion = () => props.mode === "question" + const isPermission = () => props.mode === "permission" + const isMulti = () => props.questionMultiple ?? false + const working = createMemo(() => props.working ?? sending()) + const accepting = createMemo(() => props.accepting ?? accept()) + const questionBusy = createMemo(() => props.questionBusy ?? questionSending()) + const permissionBusy = createMemo(() => props.permissionBusy ?? permissionSending()) + const tab = () => props.questionIndex ?? 0 + + const mapFromList = (list: string[][] | undefined) => { + const map: Record = {} + if (!list) return map + list.forEach((row, i) => { + if (!row) return + map[i] = [...row] + }) + return map + } + + const listFromMap = (map: Record) => { + const keys = Object.keys(map).map(Number) + const count = Math.max(props.questionTotal ?? 0, keys.length > 0 ? Math.max(...keys) + 1 : 0) + return Array.from({ length: count }, (_, i) => map[i] ?? []) + } + + const setAnswerMap = ( + next: Record | ((prev: Record) => Record), + ) => { + setAnswers((prev) => { + const map = typeof next === "function" ? next(prev) : next + props.onQuestionAnswersChange?.(listFromMap(map)) + return map + }) + } + + const selected = createMemo(() => answers()[tab()] ?? []) + const custom = createMemo(() => customs()[tab()] ?? "") + const customOn = createMemo(() => customOns()[tab()] ?? false) + const answered = createMemo(() => { + if (props.questionAnswered) return props.questionAnswered + const total = props.questionTotal ?? 0 + const map = answers() + const text = customs() + const flags = customOns() + return Array.from({ length: total }, (_, i) => { + if ((map[i]?.length ?? 0) > 0) return true + if (flags[i] === true && (text[i] ?? "").trim().length > 0) return true + return false + }) + }) + const activeDrag = createMemo(() => props.forceDragType ?? drag()) + + const [trayRef, setTrayRef] = createSignal() + const trayHeight = useElementHeight(trayRef, 42) + const trayOverlap = 14 + + createEffect( + on( + () => props.questionAnswers, + (next) => { + if (next === undefined) return + setAnswers(mapFromList(next)) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => props.contextItems, + (next) => { + if (next) setContexts(next) + }, + ), + ) + + createEffect( + on( + () => props.accepting, + (next) => { + if (next === undefined) return + setAccept(next) + }, + { defer: true }, + ), + ) + + createEffect(() => { + const i = tab() + const list = answers()[i] ?? [] + const options = new Set((props.questionOptions ?? []).map((item) => item.label.trim())) + const free = list.find((item) => !options.has(item.trim())) ?? "" + + setCustoms((map) => { + if ((map[i] ?? "") === free) return map + return { ...map, [i]: free } + }) + + const hasOption = list.some((item) => options.has(item.trim())) + setCustomOns((map) => { + const prev = map[i] ?? false + const next = free ? true : hasOption ? false : prev + if (prev === next) return map + return { ...map, [i]: next } + }) + }) + + const toggleAccept = () => { + if (props.runtime?.toggleAccept) { + void Promise.resolve() + .then(() => props.runtime?.toggleAccept?.()) + .catch(() => {}) + return + } + if (props.onAcceptToggle) { + void Promise.resolve() + .then(() => props.onAcceptToggle?.()) + .catch(() => {}) + return + } + setAccept((value) => !value) + } + + const cycle = (list: string[] | undefined, curr: string | undefined, pick?: (value: string) => void) => { + if (!list || list.length === 0 || !pick) return + const i = curr ? list.findIndex((item) => item === curr) : -1 + const next = i < 0 ? 0 : (i + 1) % list.length + const value = list[next] + if (!value) return + pick(value) + } + + const openModel = () => { + if (props.runtime?.openModel) { + void Promise.resolve() + .then(() => props.runtime?.openModel?.()) + .catch(() => {}) + return + } + if (props.onModelOpen) { + void Promise.resolve() + .then(() => props.onModelOpen?.()) + .catch(() => {}) + return + } + setModelTick((value) => value + 1) + } + + const cycleAgent = () => { + if (props.runtime?.cycleAgent) { + void Promise.resolve() + .then(() => props.runtime?.cycleAgent?.()) + .catch(() => {}) + return + } + if (props.onAgentCycle) { + void Promise.resolve() + .then(() => props.onAgentCycle?.()) + .catch(() => {}) + return + } + cycle(props.agentOptions, props.agentCurrent ?? props.agentName, props.onAgentSelect) + } + + const cycleVariant = () => { + if (props.runtime?.cycleVariant) { + void Promise.resolve() + .then(() => props.runtime?.cycleVariant?.()) + .catch(() => {}) + return + } + if (props.onVariantCycle) { + void Promise.resolve() + .then(() => props.onVariantCycle?.()) + .catch(() => {}) + return + } + cycle(props.variantOptions, props.variantCurrent ?? props.variant, props.onVariantSelect) + } + + onMount(() => { + const onKey = (event: KeyboardEvent) => { + const target = event.target + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + (target instanceof HTMLElement && target.isContentEditable) + ) { + return + } + + if (match(props.modelKeybind ?? "mod+'", event)) { + event.preventDefault() + event.stopPropagation() + openModel() + return + } + + if (match(props.agentKeybind ?? "mod+.", event)) { + event.preventDefault() + event.stopPropagation() + cycleAgent() + return + } + + if (match(props.variantKeybind ?? "shift+mod+d", event)) { + event.preventDefault() + event.stopPropagation() + cycleVariant() + } + } + + window.addEventListener("keydown", onKey) + onCleanup(() => window.removeEventListener("keydown", onKey)) + }) + + const layout = useLayout({ + isQuestion, + isPermission, + imageCount: () => images().length, + contextCount: () => contexts().length, + heightSpring: props.heightSpring, + morphSpring: props.morphSpring, + }) + + const edit = useEditor({ + value: props.value, + onValueChange: props.onValueChange, + onSubmit: (input) => { + if (working()) { + if (props.runtime?.abort) { + void Promise.resolve() + .then(() => props.runtime?.abort?.()) + .catch(() => {}) + return + } + void Promise.resolve() + .then(() => props.onAbort?.()) + .catch(() => {}) + return + } + + const payload = { + source: input.source, + mode: input.mode, + text: input.text, + parts: input.parts, + files: images(), + context: contexts(), + } + + const text = payload.text.trim() + if (!text && payload.files.length === 0 && payload.context.length === 0) return + if (!props.runtime?.submit && !props.onSubmit) return + + const run = props.runtime?.submit ? () => props.runtime?.submit?.(payload) : () => props.onSubmit?.(payload) + + setSending(true) + void Promise.resolve() + .then(run) + .then(() => { + setImages([]) + edit.setText("") + }) + .catch(() => {}) + .finally(() => { + setSending(false) + }) + }, + onAbort: () => { + if (props.runtime?.abort) { + return Promise.resolve() + .then(() => props.runtime?.abort?.()) + .catch(() => {}) + } + if (props.onAbort) { + return Promise.resolve() + .then(() => props.onAbort?.()) + .catch(() => {}) + } + if (!sending()) return + setSending(false) + }, + onAuto: toggleAccept, + onPick: pick, + onModel: openModel, + onAgent: cycleAgent, + onVariant: cycleVariant, + modelKeybind: props.modelKeybind ?? "mod+'", + agentKeybind: props.agentKeybind ?? "mod+.", + variantKeybind: props.variantKeybind ?? "shift+mod+d", + onSlash: props.runtime?.runSlash ?? props.onSlashCommand, + historyRead: props.runtime?.historyRead ?? props.historyRead, + historyWrite: props.runtime?.historyWrite ?? props.historyWrite, + working, + atOptions: props.runtime?.searchAt ?? props.atOptions ?? DEFAULT_AT_OPTIONS, + slashCommands: props.runtime?.searchSlash ?? props.slashCommands ?? DEFAULT_SLASH_COMMANDS, + editor: () => editor, + measure: () => layout.measure(editor), + }) + + const listAnswers = () => { + const count = Math.max((props.questionTotal ?? 0) || 0, tab() + 1) + return Array.from({ length: count }, (_, i) => answers()[i] ?? []) + } + + const updateCustom = (value: string, on: boolean = customOn()) => { + const i = tab() + const prev = (customs()[i] ?? "").trim() + const next = value.trim() + + setCustoms((map) => ({ ...map, [i]: value })) + if (!on) return + + if (isMulti()) { + setAnswerMap((map) => { + const list = map[i] ?? [] + const clean = prev ? list.filter((item) => item.trim() !== prev) : list + if (!next) return { ...map, [i]: clean } + if (clean.some((item) => item.trim() === next)) return { ...map, [i]: clean } + return { ...map, [i]: [...clean, next] } + }) + return + } + setAnswerMap((map) => ({ ...map, [i]: next ? [next] : [] })) + } + + const setCustom = (value: string) => { + updateCustom(value) + } + + const setCustomOn = (value: boolean) => { + const i = tab() + setCustomOns((map) => ({ ...map, [i]: value })) + if (value) { + updateCustom(custom(), true) + return + } + + const text = custom().trim() + if (!text) return + setAnswerMap((map) => { + const list = map[i] ?? [] + return { ...map, [i]: list.filter((item) => item.trim() !== text) } + }) + } + + const questionSubmit = () => { + if (questionBusy()) return + const run = props.runtime?.submitQuestion ?? props.onQuestionSubmit + if (!run) { + props.onQuestionNext?.() + return + } + + setQuestionSending(true) + void Promise.resolve() + .then(() => run(listAnswers())) + .catch(() => {}) + .finally(() => { + setQuestionSending(false) + }) + } + + const questionNext = () => { + if (questionBusy()) return + const last = (props.questionIndex ?? 0) >= (props.questionTotal ?? 1) - 1 + if (last) { + questionSubmit() + return + } + props.onQuestionNext?.() + } + + const questionForward = () => { + if (questionBusy()) return + const last = (props.questionIndex ?? 0) >= (props.questionTotal ?? 1) - 1 + if (last) return + props.onQuestionNext?.() + } + + const questionBack = () => { + if (questionBusy()) return + props.onQuestionBack?.() + } + + const questionDismiss = () => { + if (questionBusy()) return + + const run = props.runtime?.rejectQuestion ?? props.onQuestionReject + if (!run) { + props.onQuestionDismiss?.() + return + } + + setQuestionSending(true) + void Promise.resolve() + .then(run) + .then(() => { + props.onQuestionDismiss?.() + }) + .catch(() => {}) + .finally(() => { + setQuestionSending(false) + }) + } + + const decidePermission = (response: "once" | "always" | "reject") => { + if (permissionBusy()) return + + const run = props.runtime?.decidePermission ?? props.onPermissionDecide + if (!run) return + + setPermissionSending(true) + void Promise.resolve() + .then(() => run(response)) + .catch(() => {}) + .finally(() => { + setPermissionSending(false) + }) + } + + const todo = useTodo({ + todos: () => props.todos ?? [], + show: () => props.showTodos ?? false, + blocked: () => isQuestion() || isPermission(), + collapsed: () => props.todoCollapsed, + onCollapsed: props.onTodoCollapseChange, + shellHeight: layout.height, + trayHeight, + trayOverlap, + }) + + const toggleOption = (label: string) => { + const index = tab() + if (isMulti()) { + setAnswerMap((prev) => { + const list = prev[index] ?? [] + const next = list.includes(label) ? list.filter((item) => item !== label) : [...list, label] + return { ...prev, [index]: next } + }) + return + } + setCustomOns((prev) => ({ ...prev, [index]: false })) + setAnswerMap((prev) => ({ ...prev, [index]: [label] })) + } + + const ctxKey = (item: ContextItem) => { + if (item.id) return `id:${item.id}` + if (item.commentID) return `comment:${item.path}:${item.commentID}` + const start = item.selection?.startLine ?? 0 + const end = item.selection?.endLine ?? 0 + return `path:${item.path}:${start}:${end}` + } + + const dropContext = (item: ContextItem) => { + const id = ctxKey(item) + setContexts((prev) => prev.filter((x) => ctxKey(x) !== id)) + props.runtime?.removeContext?.(item) + props.onContextDrop?.(item) + } + + const openContext = (item: ContextItem) => { + props.runtime?.openContext?.(item) + props.onContextOpen?.(item) + } + + const rejectFile = (source: "paste" | "drop" | "pick", file?: File) => { + const input = { source, file } + const runtime = props.runtime?.fileRejected + const handler = props.onFileRejected + + if (runtime) { + runtime(input) + return + } + if (handler) { + handler(input) + return + } + if (source !== "paste") return + + showToast({ + title: i18n.t("ui.prompt.toast.pasteUnsupported.title"), + description: i18n.t("ui.prompt.toast.pasteUnsupported.description"), + }) + } + + const dialogOn = () => props.runtime?.dialogActive?.() ?? props.dialogActive ?? false + + const addFile = (file: File, source: "paste" | "drop" | "pick") => { + if (!ACCEPTED_FILE_TYPES.includes(file.type)) { + rejectFile(source, file) + return false + } + + const reader = new FileReader() + reader.onload = () => { + const dataUrl = reader.result as string + setImages((prev) => [ + ...prev, + { + id: `img-${++id}-${Date.now()}`, + filename: file.name, + mime: file.type, + dataUrl, + }, + ]) + } + reader.readAsDataURL(file) + return true + } + + const dropImage = (itemID: string) => setImages((prev) => prev.filter((item) => item.id !== itemID)) + + const handlePaste = (event: ClipboardEvent) => { + const data = event.clipboardData + if (!data) return + event.preventDefault() + event.stopPropagation() + + const items = Array.from(data.items) + const fileItems = items.filter((item) => item.kind === "file") + const accepted = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) + + if (accepted.length > 0) { + for (const item of accepted) { + const file = item.getAsFile() + if (file) addFile(file, "paste") + } + return + } + + if (fileItems.length > 0) { + const file = fileItems[0]?.getAsFile() ?? undefined + rejectFile("paste", file) + return + } + + const text = data.getData("text/plain") ?? "" + if (!text) { + const read = props.runtime?.readClipboardImage ?? props.readClipboardImage + if (!read) return + void Promise.resolve() + .then(read) + .then((file) => { + if (!file) return + addFile(file, "paste") + }) + .catch(() => {}) + return + } + + document.execCommand("insertText", false, text) + } + + const handleGlobalDragOver = (event: DragEvent) => { + if (dialogOn()) return + event.preventDefault() + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy" + + const hasFiles = event.dataTransfer?.types.includes("Files") + if (hasFiles) { + setDrag("image") + return + } + + const hasText = event.dataTransfer?.types.includes("text/plain") + if (hasText) { + setDrag("@mention") + return + } + + setDrag(null) + } + + const handleGlobalDragLeave = (event: DragEvent) => { + if (dialogOn()) return + if (!event.relatedTarget) setDrag(null) + } + + const handleGlobalDrop = (event: DragEvent) => { + if (dialogOn()) return + event.preventDefault() + setDrag(null) + + const text = event.dataTransfer?.getData("text/plain") + if (text?.startsWith("file:")) { + edit.insertFile(text.slice(5)) + return + } + + const list = event.dataTransfer?.files + if (!list) return + for (const file of Array.from(list)) { + addFile(file, "drop") + } + } + + const onPick = (event: Event) => { + const target = event.currentTarget as HTMLInputElement + const files = target.files + if (!files) return + for (const file of Array.from(files)) addFile(file, "pick") + target.value = "" + } + + const send = (source: ComposerSource = "button") => { + if (working()) { + if (props.runtime?.abort) { + void Promise.resolve() + .then(() => props.runtime?.abort?.()) + .catch(() => {}) + } else { + void Promise.resolve() + .then(() => props.onAbort?.()) + .catch(() => {}) + } + return + } + edit.submit(source) + } + + const pickShortcut = (n: number) => { + const opts = props.questionOptions ?? [] + if (n <= 0) return + + const option = opts[n - 1] + if (option) { + toggleOption(option.label) + return + } + + if (n === opts.length + 1) { + setCustomOn(true) + const input = document.querySelector('[data-slot="question-custom-input"]') + if (input instanceof HTMLTextAreaElement) input.focus() + } + } + + onMount(() => { + const onKey = (event: KeyboardEvent) => { + if (!isQuestion()) return + if (questionBusy()) return + + const target = event.target + const writing = + target instanceof HTMLInputElement || + target instanceof HTMLSelectElement || + target instanceof HTMLTextAreaElement || + (target instanceof HTMLElement && target.isContentEditable) + + if ( + event.key === "Escape" && + target instanceof HTMLTextAreaElement && + target.dataset.slot === "question-custom-input" + ) { + target.blur() + event.preventDefault() + return + } + + if ( + event.key === "Enter" && + !event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + target instanceof HTMLTextAreaElement && + target.dataset.slot === "question-custom-input" + ) { + target.blur() + event.preventDefault() + return + } + + const mod = event.metaKey || event.ctrlKey + if (mod && event.key === "Enter") { + event.preventDefault() + questionSubmit() + return + } + + if (writing) return + + if (event.key >= "1" && event.key <= "9") { + pickShortcut(Number(event.key)) + event.preventDefault() + return + } + + if (event.key === "ArrowLeft") { + questionBack() + event.preventDefault() + return + } + + if (event.key === "ArrowRight") { + questionForward() + event.preventDefault() + return + } + + if (event.key !== "Enter") return + event.preventDefault() + questionNext() + } + + window.addEventListener("keydown", onKey) + onCleanup(() => window.removeEventListener("keydown", onKey)) + }) + + onMount(() => { + document.addEventListener("dragover", handleGlobalDragOver) + document.addEventListener("dragleave", handleGlobalDragLeave) + document.addEventListener("drop", handleGlobalDrop) + + onCleanup(() => { + document.removeEventListener("dragover", handleGlobalDragOver) + document.removeEventListener("dragleave", handleGlobalDragLeave) + document.removeEventListener("drop", handleGlobalDrop) + }) + }) + + return ( +
+ + + 0.001}> + + + + + +
+ + + + + + +
0.5 ? "none" : "auto", + }} + > + send("button")} + onEditorRef={(el) => { + editor = el + requestAnimationFrame(() => { + layout.measure(editor) + el.focus() + }) + }} + onInput={edit.handleInput} + onKeyDown={edit.handleKeyDown} + onPaste={handlePaste} + onCompStart={() => edit.setComposing(true)} + onCompEnd={() => edit.setComposing(false)} + /> +
+ +
+ + +
+
+ +
+
+
+ +
+
+ +
+
+
+
+
+
+ + { + input = el + }} + type="file" + multiple + accept={ACCEPTED_FILE_TYPES.join(",")} + style={{ display: "none" }} + onChange={onPick} + /> +
+ ) +} + +export const Composer = NewComposer + +export type { + AtOption, + ComposerHistoryItem, + ContextItem, + ImageAttachment, + NewComposerProps, + SlashCommand, + TodoItem, +} from "./new-composer/types" diff --git a/packages/ui/src/components/new-composer/defaults.ts b/packages/ui/src/components/new-composer/defaults.ts new file mode 100644 index 000000000000..d0c61ea642cc --- /dev/null +++ b/packages/ui/src/components/new-composer/defaults.ts @@ -0,0 +1,64 @@ +import type { AtOption, SlashCommand } from "./types" + +export const DEFAULT_AT_OPTIONS: AtOption[] = [ + { type: "file", path: "src/auth.ts", display: "src/auth.ts" }, + { type: "file", path: "src/middleware.ts", display: "src/middleware.ts" }, + { type: "file", path: "src/routes/login.ts", display: "src/routes/login.ts" }, + { type: "file", path: "src/utils/token.ts", display: "src/utils/token.ts" }, + { type: "file", path: "src/config/database.ts", display: "src/config/database.ts" }, + { type: "agent", name: "coder", display: "coder" }, + { type: "agent", name: "reviewer", display: "reviewer" }, + { type: "agent", name: "planner", display: "planner" }, +] + +export const DEFAULT_SLASH_COMMANDS: SlashCommand[] = [ + { + id: "help", + trigger: "help", + title: "Help", + description: "Show available commands", + type: "builtin", + keybind: "?", + source: "command", + }, + { + id: "clear", + trigger: "clear", + title: "Clear", + description: "Clear conversation", + type: "builtin", + source: "command", + }, + { + id: "compact", + trigger: "compact", + title: "Compact", + description: "Compact conversation history", + type: "builtin", + source: "command", + }, + { + id: "init", + trigger: "init", + title: "Init", + description: "Initialize CLAUDE.md", + type: "builtin", + source: "command", + }, + { + id: "review", + trigger: "review", + title: "Review", + description: "Review code changes", + type: "custom", + source: "skill", + }, + { + id: "test", + trigger: "test", + title: "Test", + description: "Run tests", + type: "custom", + source: "mcp", + }, +] diff --git a/packages/ui/src/components/new-composer/drag-overlay.tsx b/packages/ui/src/components/new-composer/drag-overlay.tsx new file mode 100644 index 000000000000..7869f13b26a9 --- /dev/null +++ b/packages/ui/src/components/new-composer/drag-overlay.tsx @@ -0,0 +1,23 @@ +import { Show } from "solid-js" +import { useI18n } from "../../context/i18n" +import { Icon } from "../icon" + +interface Props { + type: "image" | "@mention" | null +} + +export function ComposerDragOverlay(props: Props) { + const i18n = useI18n() + return ( + +
+
+ + + {props.type === "image" ? i18n.t("ui.prompt.dropzone.attach") : i18n.t("ui.prompt.dropzone.mention")} + +
+
+
+ ) +} diff --git a/packages/ui/src/components/new-composer/editor-utils.test.ts b/packages/ui/src/components/new-composer/editor-utils.test.ts new file mode 100644 index 000000000000..5ebfe40ef2c5 --- /dev/null +++ b/packages/ui/src/components/new-composer/editor-utils.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test" +import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-utils" + +describe("new-composer editor utils", () => { + test("createTextFragment preserves newlines with consecutive br nodes", () => { + const fragment = createTextFragment("foo\n\nbar") + const box = document.createElement("div") + box.appendChild(fragment) + + expect(box.childNodes.length).toBe(4) + expect(box.childNodes[0]?.textContent).toBe("foo") + expect((box.childNodes[1] as HTMLElement).tagName).toBe("BR") + expect((box.childNodes[2] as HTMLElement).tagName).toBe("BR") + expect(box.childNodes[3]?.textContent).toBe("bar") + }) + + test("createTextFragment keeps trailing newline as terminal break", () => { + const fragment = createTextFragment("foo\n") + const box = document.createElement("div") + box.appendChild(fragment) + + expect(box.childNodes.length).toBe(2) + expect(box.childNodes[0]?.textContent).toBe("foo") + expect((box.childNodes[1] as HTMLElement).tagName).toBe("BR") + }) + + test("createTextFragment avoids break-node explosion for large multiline content", () => { + const value = Array.from({ length: 220 }, () => "line").join("\n") + const fragment = createTextFragment(value) + const box = document.createElement("div") + box.appendChild(fragment) + + expect(box.childNodes.length).toBe(1) + expect(box.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE) + expect(box.textContent).toBe(value) + }) + + test("createTextFragment keeps terminal break in large multiline fallback", () => { + const value = `${Array.from({ length: 220 }, () => "line").join("\n")}\n` + const fragment = createTextFragment(value) + const box = document.createElement("div") + box.appendChild(fragment) + + expect(box.childNodes.length).toBe(2) + expect(box.childNodes[0]?.textContent).toBe(value.slice(0, -1)) + expect((box.childNodes[1] as HTMLElement).tagName).toBe("BR") + }) + + test("length helpers treat breaks as one char and ignore zero-width chars", () => { + const box = document.createElement("div") + box.appendChild(document.createTextNode("ab\u200B")) + box.appendChild(document.createElement("br")) + box.appendChild(document.createTextNode("cd")) + + expect(getNodeLength(box.childNodes[0]!)).toBe(2) + expect(getNodeLength(box.childNodes[1]!)).toBe(1) + expect(getTextLength(box)).toBe(5) + }) + + test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => { + const box = document.createElement("div") + const pill = document.createElement("span") + pill.dataset.type = "file" + pill.textContent = "@file" + + box.appendChild(document.createTextNode("ab")) + box.appendChild(pill) + box.appendChild(document.createElement("br")) + box.appendChild(document.createTextNode("cd")) + document.body.appendChild(box) + + setCursorPosition(box, 2) + expect(getCursorPosition(box)).toBe(2) + + setCursorPosition(box, 7) + expect(getCursorPosition(box)).toBe(7) + + setCursorPosition(box, 8) + expect(getCursorPosition(box)).toBe(8) + + box.remove() + }) + + test("setCursorPosition and getCursorPosition round-trip across blank lines", () => { + const box = document.createElement("div") + box.appendChild(document.createTextNode("a")) + box.appendChild(document.createElement("br")) + box.appendChild(document.createElement("br")) + box.appendChild(document.createTextNode("b")) + document.body.appendChild(box) + + setCursorPosition(box, 2) + expect(getCursorPosition(box)).toBe(2) + + setCursorPosition(box, 3) + expect(getCursorPosition(box)).toBe(3) + + box.remove() + }) +}) diff --git a/packages/ui/src/components/new-composer/editor-utils.ts b/packages/ui/src/components/new-composer/editor-utils.ts new file mode 100644 index 000000000000..c2294da3cb6c --- /dev/null +++ b/packages/ui/src/components/new-composer/editor-utils.ts @@ -0,0 +1,206 @@ +import type { ComposerPart } from "./types" + +const MAX_BREAKS = 200 + +export function createTextFragment(content: string): DocumentFragment { + const fragment = document.createDocumentFragment() + + let breaks = 0 + for (const char of content) { + if (char !== "\n") continue + breaks += 1 + if (breaks > MAX_BREAKS) { + const tail = content.endsWith("\n") + const text = tail ? content.slice(0, -1) : content + if (text) fragment.appendChild(document.createTextNode(text)) + if (tail) fragment.appendChild(document.createElement("br")) + return fragment + } + } + + const parts = content.split("\n") + parts.forEach((part, i) => { + if (part) fragment.appendChild(document.createTextNode(part)) + if (i < parts.length - 1) fragment.appendChild(document.createElement("br")) + }) + return fragment +} + +export function getNodeLength(node: Node): number { + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + return (node.textContent ?? "").replace(/\u200B/g, "").length +} + +export function getTextLength(node: Node): number { + if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + let len = 0 + for (const child of Array.from(node.childNodes)) len += getTextLength(child) + return len +} + +export function getCursorPosition(parent: HTMLElement): number { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return 0 + const range = selection.getRangeAt(0) + if (!parent.contains(range.startContainer)) return 0 + const copy = range.cloneRange() + copy.selectNodeContents(parent) + copy.setEnd(range.startContainer, range.startOffset) + return getTextLength(copy.cloneContents()) +} + +export function setCursorPosition(parent: HTMLElement, position: number) { + let rest = position + let node = parent.firstChild + while (node) { + const len = getNodeLength(node) + const text = node.nodeType === Node.TEXT_NODE + const pill = + node.nodeType === Node.ELEMENT_NODE && + ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") + const br = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" + + if (text && rest <= len) { + const range = document.createRange() + const selection = window.getSelection() + range.setStart(node, rest) + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) + return + } + if ((pill || br) && rest <= len) { + const range = document.createRange() + const selection = window.getSelection() + if (rest === 0) range.setStartBefore(node) + else if (pill) range.setStartAfter(node) + else { + const next = node.nextSibling + if (next && next.nodeType === Node.TEXT_NODE) range.setStart(next, 0) + else range.setStartAfter(node) + } + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) + return + } + rest -= len + node = node.nextSibling + } + + const end = document.createRange() + end.selectNodeContents(parent) + end.collapse(false) + window.getSelection()?.removeAllRanges() + window.getSelection()?.addRange(end) +} + +export function setRangeEdge(parent: HTMLElement, range: Range, edge: "start" | "end", offset: number) { + let rest = offset + for (const node of Array.from(parent.childNodes)) { + const len = getNodeLength(node) + const text = node.nodeType === Node.TEXT_NODE + const pill = + node.nodeType === Node.ELEMENT_NODE && + ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") + const br = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" + + if (text && rest <= len) { + if (edge === "start") range.setStart(node, rest) + else range.setEnd(node, rest) + return + } + if ((pill || br) && rest <= len) { + if (edge === "start") rest === 0 ? range.setStartBefore(node) : range.setStartAfter(node) + else rest === 0 ? range.setEndBefore(node) : range.setEndAfter(node) + return + } + rest -= len + } +} + +export function createPill(type: "file" | "agent", content: string, path?: string) { + const el = document.createElement("span") + el.textContent = content + el.setAttribute("data-type", type) + if (type === "file" && path) el.setAttribute("data-path", path) + if (type === "agent") el.setAttribute("data-name", content.replace("@", "")) + el.setAttribute("contenteditable", "false") + el.style.userSelect = "text" + el.style.cursor = "default" + return el +} + +export function parseEditorText(editor: HTMLElement): string { + return parseEditorParts(editor) + .map((part) => part.content) + .join("") +} + +function pushText(parts: ComposerPart[], text: string) { + if (!text) return + const last = parts[parts.length - 1] + if (last?.type === "text") { + last.content += text + return + } + parts.push({ type: "text", content: text }) +} + +export function parseEditorParts(editor: HTMLElement): ComposerPart[] { + const parts: ComposerPart[] = [] + let text = "" + + const flush = () => { + if (!text) return + pushText(parts, text) + text = "" + } + + const visit = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + text += (node.textContent ?? "").replace(/\u200B/g, "") + return + } + if (node.nodeType !== Node.ELEMENT_NODE) return + const el = node as HTMLElement + if (el.dataset.type === "file" || el.dataset.type === "agent") { + flush() + const content = el.textContent ?? "" + if (el.dataset.type === "file") { + parts.push({ + type: "file", + path: el.dataset.path ?? content, + content, + }) + } + if (el.dataset.type === "agent") { + parts.push({ + type: "agent", + name: el.dataset.name ?? content.replace(/^@/, ""), + content, + }) + } + return + } + if (el.tagName === "BR") { + text += "\n" + return + } + for (const child of Array.from(el.childNodes)) visit(child) + } + + const nodes = Array.from(editor.childNodes) + nodes.forEach((node, i) => { + const block = node.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((node as HTMLElement).tagName) + visit(node) + if (block && i < nodes.length - 1) text += "\n" + }) + flush() + return parts +} + +export function isImeComposing(event: KeyboardEvent): boolean { + return event.isComposing || (event as unknown as { keyCode?: number }).keyCode === 229 +} diff --git a/packages/ui/src/components/new-composer/history.test.ts b/packages/ui/src/components/new-composer/history.test.ts new file mode 100644 index 000000000000..67f6695eaa47 --- /dev/null +++ b/packages/ui/src/components/new-composer/history.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "bun:test" +import { + canNavigateAtCursor, + cloneParts, + navigateEntry, + normalizeEntry, + partText, + prependEntry, + type HistoryEntry, +} from "./history" + +const text = (value: string): HistoryEntry => ({ + text: value, + parts: [{ type: "text", content: value }], +}) + +describe("new-composer history", () => { + test("prependEntry skips empty entries and deduplicates consecutive entries", () => { + const blank: HistoryEntry[] = [] + const first = prependEntry(blank, text("")) + expect(first).toBe(blank) + + const one = prependEntry([], text("hello")) + expect(one).toHaveLength(1) + + const dup = prependEntry(one, text("hello")) + expect(dup).toBe(one) + }) + + test("navigateEntry restores saved draft when moving down from newest", () => { + const entries = [text("third"), text("second"), text("first")] + + const up = navigateEntry({ + direction: "up", + entries, + historyIndex: -1, + current: text("draft"), + saved: null, + }) + + expect(up.handled).toBe(true) + if (!up.handled) throw new Error("expected handled") + expect(up.historyIndex).toBe(0) + expect(up.cursor).toBe("start") + expect(up.entry.text).toBe("third") + + const down = navigateEntry({ + direction: "down", + entries, + historyIndex: up.historyIndex, + current: text("ignored"), + saved: up.saved, + }) + + expect(down.handled).toBe(true) + if (!down.handled) throw new Error("expected handled") + expect(down.historyIndex).toBe(-1) + expect(down.entry.text).toBe("draft") + expect(down.entry.parts).toEqual([{ type: "text", content: "draft" }]) + }) + + test("navigateEntry keeps structured parts when navigating history", () => { + const entry: HistoryEntry = { + text: "@src/auth.ts", + parts: [{ type: "file", path: "src/auth.ts", content: "@src/auth.ts" }], + } + + const up = navigateEntry({ + direction: "up", + entries: [entry], + historyIndex: -1, + current: text("draft"), + saved: null, + }) + + expect(up.handled).toBe(true) + if (!up.handled) throw new Error("expected handled") + expect(up.entry.parts).toEqual(entry.parts) + }) + + test("normalizeEntry supports legacy string entries", () => { + const entry = normalizeEntry("legacy") + expect(entry.text).toBe("legacy") + expect(entry.parts).toEqual([{ type: "text", content: "legacy" }]) + }) + + test("helpers clone parts and count combined content", () => { + const src = [ + { type: "text", content: "one" }, + { type: "file", path: "src/a.ts", content: "@src/a.ts" }, + { type: "agent", name: "coder", content: "@coder" }, + ] as const + + const copy = cloneParts([...src]) + expect(copy).not.toBe(src) + expect(partText(copy)).toBe("one@src/a.ts@coder") + + if (copy[1]?.type !== "file") throw new Error("expected file") + copy[1].path = "src/b.ts" + if (src[1].type !== "file") throw new Error("expected file") + expect(src[1].path).toBe("src/a.ts") + }) + + test("canNavigateAtCursor only allows history navigation at boundaries", () => { + const value = "a\nb\nc" + + expect(canNavigateAtCursor("up", value, 0)).toBe(true) + expect(canNavigateAtCursor("down", value, 0)).toBe(false) + + expect(canNavigateAtCursor("up", value, 2)).toBe(false) + expect(canNavigateAtCursor("down", value, 2)).toBe(false) + + expect(canNavigateAtCursor("up", value, 5)).toBe(false) + expect(canNavigateAtCursor("down", value, 5)).toBe(true) + + expect(canNavigateAtCursor("up", "abc", 0)).toBe(true) + expect(canNavigateAtCursor("down", "abc", 3)).toBe(true) + expect(canNavigateAtCursor("up", "abc", 1)).toBe(false) + expect(canNavigateAtCursor("down", "abc", 1)).toBe(false) + + expect(canNavigateAtCursor("up", "abc", 0, true)).toBe(true) + expect(canNavigateAtCursor("up", "abc", 3, true)).toBe(true) + expect(canNavigateAtCursor("down", "abc", 0, true)).toBe(true) + expect(canNavigateAtCursor("down", "abc", 3, true)).toBe(true) + expect(canNavigateAtCursor("up", "abc", 1, true)).toBe(false) + expect(canNavigateAtCursor("down", "abc", 1, true)).toBe(false) + }) +}) diff --git a/packages/ui/src/components/new-composer/history.ts b/packages/ui/src/components/new-composer/history.ts new file mode 100644 index 000000000000..ff88ecb12e8c --- /dev/null +++ b/packages/ui/src/components/new-composer/history.ts @@ -0,0 +1,189 @@ +import type { ComposerHistoryItem, ComposerPart } from "./types" + +export type HistoryEntry = { + text: string + parts: ComposerPart[] +} + +export type HistoryStored = string | ComposerHistoryItem + +export type NavInput = { + direction: "up" | "down" + entries: HistoryStored[] + historyIndex: number + current: HistoryEntry + saved: HistoryEntry | null +} + +export type NavResult = + | { + handled: false + historyIndex: number + saved: HistoryEntry | null + } + | { + handled: true + historyIndex: number + saved: HistoryEntry | null + entry: HistoryEntry + cursor: "start" | "end" + } + +export const MAX_HISTORY = 50 + +export const EMPTY_ENTRY: HistoryEntry = { + text: "", + parts: [{ type: "text", content: "" }], +} + +const clonePart = (part: ComposerPart): ComposerPart => { + if (part.type === "text") return { ...part } + if (part.type === "file") return { ...part } + return { ...part } +} + +export const cloneParts = (parts: ComposerPart[]) => parts.map(clonePart) + +const cloneEntry = (entry: HistoryEntry): HistoryEntry => ({ + text: entry.text, + parts: cloneParts(entry.parts), +}) + +export const partText = (parts: ComposerPart[]) => parts.map((part) => part.content).join("") + +export const normalizeEntry = (item: HistoryStored): HistoryEntry => { + if (typeof item === "string") { + return { + text: item, + parts: [{ type: "text", content: item }], + } + } + + if (!item.parts || item.parts.length === 0) { + return { + text: item.text, + parts: [{ type: "text", content: item.text }], + } + } + + const parts = cloneParts(item.parts) + return { + text: partText(parts), + parts, + } +} + +const samePart = (a: ComposerPart, b: ComposerPart) => { + if (a.type !== b.type) return false + if (a.type === "text" && b.type === "text") return a.content === b.content + if (a.type === "file" && b.type === "file") return a.path === b.path && a.content === b.content + if (a.type === "agent" && b.type === "agent") return a.name === b.name && a.content === b.content + return false +} + +export const sameEntry = (a: HistoryEntry | undefined, b: HistoryEntry) => { + if (!a) return false + if (a.text !== b.text) return false + if (a.parts.length !== b.parts.length) return false + + for (let i = 0; i < a.parts.length; i++) { + const x = a.parts[i] + const y = b.parts[i] + if (!x || !y || !samePart(x, y)) return false + } + + return true +} + +export const prependEntry = (entries: HistoryEntry[], item: HistoryEntry, max = MAX_HISTORY) => { + if (!item.text.trim()) return entries + + const next = normalizeEntry(item) + if (sameEntry(entries[0], next)) return entries + return [next, ...entries].slice(0, max) +} + +export const canNavigateAtCursor = (direction: "up" | "down", text: string, cursor: number, inHistory = false) => { + const pos = Math.max(0, Math.min(cursor, text.length)) + const start = pos === 0 + const end = pos === text.length + if (inHistory) return start || end + if (direction === "up") return start + return end +} + +export const navigateEntry = (input: NavInput): NavResult => { + if (input.direction === "up") { + if (input.entries.length === 0) { + return { + handled: false, + historyIndex: input.historyIndex, + saved: input.saved, + } + } + + if (input.historyIndex === -1) { + return { + handled: true, + historyIndex: 0, + saved: cloneEntry(input.current), + entry: normalizeEntry(input.entries[0] ?? EMPTY_ENTRY), + cursor: "start", + } + } + + if (input.historyIndex < input.entries.length - 1) { + const next = input.historyIndex + 1 + return { + handled: true, + historyIndex: next, + saved: input.saved, + entry: normalizeEntry(input.entries[next] ?? EMPTY_ENTRY), + cursor: "start", + } + } + + return { + handled: false, + historyIndex: input.historyIndex, + saved: input.saved, + } + } + + if (input.historyIndex > 0) { + const next = input.historyIndex - 1 + return { + handled: true, + historyIndex: next, + saved: input.saved, + entry: normalizeEntry(input.entries[next] ?? EMPTY_ENTRY), + cursor: "end", + } + } + + if (input.historyIndex === 0) { + if (input.saved) { + return { + handled: true, + historyIndex: -1, + saved: null, + entry: cloneEntry(input.saved), + cursor: "end", + } + } + + return { + handled: true, + historyIndex: -1, + saved: null, + entry: normalizeEntry(EMPTY_ENTRY), + cursor: "end", + } + } + + return { + handled: false, + historyIndex: input.historyIndex, + saved: input.saved, + } +} diff --git a/packages/ui/src/components/new-composer/input-layer.tsx b/packages/ui/src/components/new-composer/input-layer.tsx new file mode 100644 index 000000000000..4ecc93158a12 --- /dev/null +++ b/packages/ui/src/components/new-composer/input-layer.tsx @@ -0,0 +1,223 @@ +import { For, Show } from "solid-js" +import { useSpring } from "@opencode-ai/ui/motion-spring" +import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" +import { useI18n } from "../../context/i18n" +import { Button } from "../button" +import { FileIcon } from "../file-icon" +import { Icon } from "../icon" +import { IconButton } from "../icon-button" +import { Tooltip } from "../tooltip" +import type { ContextItem, ImageAttachment } from "./types" + +interface Props { + value: string + mode: "normal" | "shell" + images: ImageAttachment[] + contexts: ContextItem[] + working: boolean + accepting: boolean + placeholder?: string + onImageDrop: (id: string) => void + contextActive?: (item: ContextItem) => boolean + onContextOpen?: (item: ContextItem) => void + onContextDrop: (item: ContextItem) => void + onAccept: () => void + onPick: () => void + onSend: () => void + onEditorRef: (el: HTMLDivElement) => void + onInput: () => void + onKeyDown: (event: KeyboardEvent) => void + onPaste: (event: ClipboardEvent) => void + onCompStart: () => void + onCompEnd: () => void +} + +export function ComposerInputLayer(props: Props) { + const i18n = useI18n() + const isShell = () => props.mode === "shell" + const shell = useSpring(() => (isShell() ? 1 : 0), { visualDuration: 0.1, bounce: 0 }) + const buttonsOpacity = () => 1 - shell() + const buttonsBlur = () => shell() + + return ( + <> + 0}> +
+ + {(item) => ( +
+ + +
+ } + > + {item.filename} + + +
+ {item.filename} +
+
+ )} + + +
+ + 0}> +
+ + {(item) => { + const active = () => props.contextActive?.(item) ?? false + const dir = () => getDirectory(item.path) + const file = () => getFilename(item.path) + const filename = () => getFilenameTruncated(item.path, 14) + const label = () => { + if (!item.selection) return null + if (item.selection.startLine === item.selection.endLine) return `:${item.selection.startLine}` + return `:${item.selection.startLine}-${item.selection.endLine}` + } + return ( + + {dir()} + {file()} + + } + placement="top" + openDelay={2000} + > +
props.onContextOpen?.(item)} + > +
+ +
+ {filename()} + + {label()} + +
+ { + event.stopPropagation() + props.onContextDrop(item) + }} + aria-label={i18n.t("ui.prompt.context.removeFile")} + /> +
+ + {(note) =>
{note()}
} +
+
+
+ ) + }} +
+
+
+ +
+
+ +
+ {props.mode === "shell" + ? i18n.t("ui.prompt.placeholder.shell") + : (props.placeholder ?? i18n.t("ui.prompt.placeholder.simple"))} +
+
+
+ +
0.01 ? `blur(${buttonsBlur()}px)` : "none", + "pointer-events": shell() < 0.5 ? "auto" : "none", + }} + > + +
+ + +
+
+ + ) +} diff --git a/packages/ui/src/components/new-composer/model-picker.tsx b/packages/ui/src/components/new-composer/model-picker.tsx new file mode 100644 index 000000000000..2ae8bf9ea4ac --- /dev/null +++ b/packages/ui/src/components/new-composer/model-picker.tsx @@ -0,0 +1,242 @@ +import { Popover as Kobalte } from "@kobalte/core/popover" +import { For, Show, createEffect, createMemo, createSignal, on } from "solid-js" +import { useI18n } from "../../context/i18n" +import { Button } from "../button" +import { Icon } from "../icon" +import { IconButton } from "../icon-button" +import { ProviderIcon } from "../provider-icon" + +interface Props { + options: string[] + current?: string + onSelect?: (value: string) => void + openTick?: number +} + +type Item = { + value: string + provider: string + label: string +} + +function parse(value: string, fallback: string): Item { + const i = value.indexOf("/") + if (i <= 0 || i >= value.length - 1) { + return { + value, + provider: fallback, + label: value, + } + } + + return { + value, + provider: value.slice(0, i), + label: value.slice(i + 1), + } +} + +export function ModelPicker(props: Props) { + const i18n = useI18n() + const [open, setOpen] = createSignal(false) + const [full, setFull] = createSignal(false) + const [query, setQuery] = createSignal("") + let input: HTMLInputElement | undefined + + const focus = () => { + requestAnimationFrame(() => { + input?.focus() + input?.select() + }) + } + + createEffect( + on( + () => props.openTick, + (next, prev) => { + if (next === undefined) return + if (next === prev) return + setFull(true) + setOpen(true) + focus() + }, + { defer: true }, + ), + ) + + const list = createMemo(() => { + const q = query().trim().toLowerCase() + const fallback = i18n.t("ui.prompt.model.group.default") + const items = props.options.map((item) => parse(item, fallback)) + if (!q) return items + return items.filter((item) => { + return item.label.toLowerCase().includes(q) || item.provider.toLowerCase().includes(q) + }) + }) + + const groups = createMemo(() => { + const map = new Map() + for (const item of list()) { + const group = map.get(item.provider) + if (group) { + group.push(item) + continue + } + map.set(item.provider, [item]) + } + return Array.from(map.entries()).map(([provider, items]) => ({ provider, items })) + }) + + const label = createMemo(() => { + const fallback = i18n.t("ui.prompt.model.group.default") + const hit = props.options.map((item) => parse(item, fallback)).find((item) => item.value === props.current) + if (!hit) return props.current ?? i18n.t("ui.prompt.control.model") + return hit.label + }) + + const provider = createMemo(() => { + const fallback = i18n.t("ui.prompt.model.group.default") + const hit = props.options.map((item) => parse(item, fallback)).find((item) => item.value === props.current) + if (!hit) return undefined + if (hit.provider === fallback) return undefined + return hit.provider.toLowerCase() + }) + + return ( + { + setOpen(next) + if (!next) { + setQuery("") + setFull(false) + } + if (next) focus() + }} + modal={false} + placement="top-start" + gutter={4} + > + setFull(false)} + > + }> + {(id) => ( + + )} + + {label()} + + + + + +
setOpen(false)} /> + + { + setOpen(false) + event.preventDefault() + event.stopPropagation() + }} + > + +
+
{i18n.t("ui.prompt.model.select")}
+ +
+
+ +
+ + setQuery(event.currentTarget.value)} + placeholder={i18n.t("ui.prompt.model.search")} + class="min-w-0 flex-1 bg-transparent border-none outline-none text-14-regular text-text-strong placeholder:text-text-weak" + /> + + + + +
+ +
+ 0} + fallback={
{i18n.t("ui.prompt.model.none")}
} + > + + {(group) => ( +
+
{group.provider}
+ + {(item) => ( + + )} + +
+ )} +
+
+
+ + +
{i18n.t("ui.prompt.model.manage")}
+
+
+ + + ) +} diff --git a/packages/ui/src/components/new-composer/permission-body.tsx b/packages/ui/src/components/new-composer/permission-body.tsx new file mode 100644 index 000000000000..98f13c801a2e --- /dev/null +++ b/packages/ui/src/components/new-composer/permission-body.tsx @@ -0,0 +1,41 @@ +import { For, Show } from "solid-js" +import { useI18n } from "../../context/i18n" +import { Icon } from "../icon" + +interface Props { + tool?: string + description?: string + patterns?: string[] +} + +export function PermissionBody(props: Props) { + const i18n = useI18n() + const hint = () => props.description ?? props.tool ?? "" + + return ( + <> +
+ + + +
{i18n.t("ui.permission.title")}
+
+ +
+
+
+ 0}> +
+
+
+ + ) +} diff --git a/packages/ui/src/components/new-composer/popover.tsx b/packages/ui/src/components/new-composer/popover.tsx new file mode 100644 index 000000000000..c664004dafb7 --- /dev/null +++ b/packages/ui/src/components/new-composer/popover.tsx @@ -0,0 +1,111 @@ +import { For, Match, Show, Switch } from "solid-js" +import { useI18n } from "../../context/i18n" +import { Icon } from "../icon" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import type { AtOption, SlashCommand } from "./types" + +interface Props { + kind: "at" | "slash" | null + bottom: number + atFlat: AtOption[] + atActive: string | null + atKey: (item: AtOption) => string + onAtHover: (key: string) => void + onAtPick: (item: AtOption) => void + slashFlat: SlashCommand[] + slashActive: string | null + onSlashHover: (key: string) => void + onSlashPick: (item: SlashCommand) => void +} + +export function ComposerPopover(props: Props) { + const i18n = useI18n() + return ( + +
event.preventDefault()} + > + + + 0} + fallback={
{i18n.t("ui.list.empty")}
} + > + + {(item) => { + const key = props.atKey(item) + const isDir = item.type === "file" && item.path.endsWith("/") + const dir = item.type === "file" ? (isDir ? item.path : getDirectory(item.path)) : "" + const file = item.type === "file" && !isDir ? getFilename(item.path) : "" + return ( + + ) + }} + +
+
+ + 0} + fallback={
{i18n.t("ui.prompt.list.noCommands")}
} + > + + {(cmd) => ( + + )} + +
+
+
+
+
+ ) +} diff --git a/packages/ui/src/components/new-composer/question-body.tsx b/packages/ui/src/components/new-composer/question-body.tsx new file mode 100644 index 000000000000..47dfda8934e1 --- /dev/null +++ b/packages/ui/src/components/new-composer/question-body.tsx @@ -0,0 +1,218 @@ +import { For, Index, Show } from "solid-js" +import { useI18n } from "../../context/i18n" +import { Icon } from "../icon" + +interface Option { + label: string + description?: string +} + +interface Base { + text?: string + options?: Option[] + index?: number + total?: number + multi?: boolean + answered?: boolean[] +} + +interface BodyProps extends Base { + selected: string[] + custom: string + customOn: boolean + busy?: boolean + onToggle: (label: string) => void + onCustomOn: (value: boolean) => void + onCustom: (value: string) => void + onJump?: (index: number) => void +} + +function Header(props: Base & { onJump?: (index: number) => void; click?: boolean }) { + const i18n = useI18n() + const tab = () => props.index ?? 0 + const done = (i: number) => { + if (props.answered) return props.answered[i] === true + return i < tab() + } + + return ( + 0}> +
+
+ {i18n.t("ui.question.progress", { index: tab() + 1, total: props.total ?? 0 })} +
+
+ + {(_, i) => ( + + } + > +
+
+
+ ) +} + +export function QuestionBody(props: BodyProps) { + const i18n = useI18n() + const multi = () => props.multi ?? false + const selected = (label: string) => props.selected.includes(label) + const customPicked = () => { + if (props.customOn) return true + if (!multi()) return false + const text = props.custom.trim() + if (!text) return false + return props.selected.some((item) => item.trim() === text) + } + + return ( + <> +
+
+
{props.text}
+ {i18n.t("ui.question.singleHint")}
}> +
{i18n.t("ui.question.multiHint")}
+ +
+ + {(opt) => { + const picked = () => selected(opt.label) + return ( + + ) + }} + + +
{ + if (event.target instanceof HTMLTextAreaElement) return + const input = event.currentTarget.querySelector('[data-slot="question-custom-input"]') + if (input instanceof HTMLTextAreaElement) input.focus() + }} + onSubmit={(event) => event.preventDefault()} + > + + + {i18n.t("ui.messagePart.option.typeOwnAnswer")} +