From 0dcedc199c83c88ef206b4d89351f4a0e4a9a61d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:36:24 +0000 Subject: [PATCH] chore: merge dev into v2 (#41460) Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> --- .github/workflows/publish.yml | 8 +- .../skills/rtl-aware-development/SKILL.md | 63 + CONTRIBUTING.md | 27 - bun.lock | 82 +- infra/console.ts | 8 +- nix/hashes.json | 8 +- package.json | 5 +- packages/ai/script/recording-cost-report.ts | 2 +- packages/app/AGENTS.md | 15 + packages/app/e2e/AGENTS.md | 16 + .../performance/timeline-stability/fixture.ts | 11 +- .../project-picker-recent-search.spec.ts | 60 + .../regression/review-line-comment.spec.ts | 57 +- .../review-terminal-stacked.spec.ts | 108 +- ...session-timeline-locale-projection.spec.ts | 2 +- .../session-timeline-transport.spec.ts | 56 +- .../regression/terminal-tab-switch.spec.ts | 2 +- packages/app/e2e/utils/sse-transport.ts | 15 +- packages/app/e2e/utils/waits.ts | 9 + packages/app/package.json | 7 +- packages/app/src/app.tsx | 24 +- packages/app/src/components/debug-bar.tsx | 91 +- .../components/dialog-connect-provider.tsx | 29 +- .../src/components/dialog-custom-provider.tsx | 10 +- .../components/dialog-select-directory.tsx | 8 +- .../src/components/dialog-usage-exceeded.tsx | 6 +- .../directory-picker-domain.test.ts | 62 + .../src/components/directory-picker-domain.ts | 9 +- packages/app/src/components/external-link.tsx | 21 + packages/app/src/components/file-tree-v2.tsx | 16 +- packages/app/src/components/file-tree.tsx | 4 +- packages/app/src/components/link.tsx | 26 - .../app/src/components/prompt-input-v2.tsx | 10 +- packages/app/src/components/prompt-input.tsx | 5 +- .../components/prompt-input/attachments.ts | 27 +- .../prompt-input/build-request-parts.ts | 2 +- .../components/prompt-input/history-store.ts | 28 +- .../components/prompt-input/history.test.ts | 2 +- .../prompt-input/image-attachments.tsx | 5 +- .../prompt-input/placeholder.test.ts | 17 +- .../components/prompt-input/placeholder.ts | 8 +- .../app/src/components/prompt-input/submit.ts | 29 +- .../session/session-context-tab.tsx | 44 +- .../src/components/session/session-header.tsx | 12 +- .../general-controller-behavior.ts | 70 + .../settings-v2/general-controllers.test.ts | 53 + .../settings-v2/general-controllers.ts | 172 + .../src/components/settings-v2/general.tsx | 661 ++-- .../components/settings-v2/settings-v2.css | 8 +- packages/app/src/components/terminal.tsx | 15 +- .../app/src/components/titlebar-tab-nav.css | 90 +- .../app/src/components/titlebar-tab-nav.tsx | 21 +- packages/app/src/components/titlebar.tsx | 29 +- packages/app/src/components/ui/drawer.tsx | 2 +- .../app/src/components/windows-app-menu.tsx | 12 +- packages/app/src/constants/file-picker.ts | 4 +- .../context/global-sync/event-reducer.test.ts | 22 +- .../src/context/global-sync/event-reducer.ts | 15 +- packages/app/src/context/language.tsx | 237 +- packages/app/src/context/notification.tsx | 11 +- packages/app/src/context/platform.tsx | 24 +- packages/app/src/context/prompt-state.ts | 23 +- .../app/src/context/server-session.test.ts | 3 +- packages/app/src/context/server-session.ts | 53 +- packages/app/src/context/server-sync.tsx | 2 +- .../app/src/context/sync-optimistic.test.ts | 40 +- packages/app/src/context/sync.tsx | 9 +- packages/app/src/context/tab-migration.ts | 23 + packages/app/src/context/tabs.test.ts | 18 + packages/app/src/context/tabs.tsx | 14 +- packages/app/src/desktop-menu.test.ts | 12 +- packages/app/src/desktop-menu.ts | 181 +- packages/app/src/entry.tsx | 80 +- packages/app/src/i18n/am.ts | 1130 ++++++ packages/app/src/i18n/ar.ts | 327 +- packages/app/src/i18n/az.ts | 1168 ++++++ packages/app/src/i18n/bg.ts | 1170 ++++++ packages/app/src/i18n/bn.ts | 1152 ++++++ packages/app/src/i18n/br.ts | 245 +- packages/app/src/i18n/bs.ts | 241 +- packages/app/src/i18n/ca.ts | 1173 ++++++ packages/app/src/i18n/cs.ts | 1163 ++++++ packages/app/src/i18n/da.ts | 232 +- packages/app/src/i18n/de.ts | 336 +- packages/app/src/i18n/desktop-native.test.ts | 151 + packages/app/src/i18n/desktop-native.ts | 365 ++ packages/app/src/i18n/dv.ts | 1177 ++++++ packages/app/src/i18n/dz.ts | 1181 ++++++ packages/app/src/i18n/el.ts | 1173 ++++++ packages/app/src/i18n/en.ts | 48 + packages/app/src/i18n/es.ts | 359 +- packages/app/src/i18n/et.ts | 1151 ++++++ packages/app/src/i18n/fa.ts | 1155 ++++++ packages/app/src/i18n/fi.ts | 1168 ++++++ packages/app/src/i18n/fo.ts | 1154 ++++++ packages/app/src/i18n/fr.ts | 338 +- packages/app/src/i18n/hi.ts | 1163 ++++++ packages/app/src/i18n/hr.ts | 1168 ++++++ packages/app/src/i18n/hu.ts | 1170 ++++++ packages/app/src/i18n/hy.ts | 1165 ++++++ packages/app/src/i18n/id.ts | 1250 +++++++ packages/app/src/i18n/is.ts | 1157 ++++++ packages/app/src/i18n/it.ts | 1176 ++++++ packages/app/src/i18n/ja.ts | 187 +- packages/app/src/i18n/ka.ts | 1156 ++++++ packages/app/src/i18n/km.ts | 1151 ++++++ packages/app/src/i18n/ko.ts | 198 +- packages/app/src/i18n/lo.ts | 1148 ++++++ packages/app/src/i18n/lt.ts | 1173 ++++++ packages/app/src/i18n/lv.ts | 1162 ++++++ packages/app/src/i18n/mk.ts | 1166 ++++++ packages/app/src/i18n/mn.ts | 1168 ++++++ packages/app/src/i18n/ms.ts | 1157 ++++++ packages/app/src/i18n/my.ts | 1176 ++++++ packages/app/src/i18n/ne.ts | 1157 ++++++ packages/app/src/i18n/nl.ts | 1171 ++++++ packages/app/src/i18n/no.ts | 266 +- packages/app/src/i18n/pa.ts | 1161 ++++++ packages/app/src/i18n/parity.test.ts | 124 +- packages/app/src/i18n/pl.ts | 280 +- packages/app/src/i18n/ro.ts | 1162 ++++++ packages/app/src/i18n/ru.ts | 321 +- packages/app/src/i18n/si.ts | 1153 ++++++ packages/app/src/i18n/sk.ts | 1159 ++++++ packages/app/src/i18n/sl.ts | 1163 ++++++ packages/app/src/i18n/sq.ts | 1163 ++++++ packages/app/src/i18n/sr.ts | 1161 ++++++ packages/app/src/i18n/sv.ts | 1160 ++++++ packages/app/src/i18n/tg.ts | 1164 ++++++ packages/app/src/i18n/th.ts | 281 +- packages/app/src/i18n/tk.ts | 1159 ++++++ packages/app/src/i18n/tr.ts | 276 +- packages/app/src/i18n/uk.ts | 230 +- packages/app/src/i18n/ur.ts | 1164 ++++++ packages/app/src/i18n/uz.ts | 1171 ++++++ packages/app/src/i18n/vi.ts | 1169 ++++++ packages/app/src/i18n/zh.ts | 337 +- packages/app/src/i18n/zht.ts | 393 +- packages/app/src/index.ts | 4 +- packages/app/src/pages/directory-layout.tsx | 1 + packages/app/src/pages/error.tsx | 2 +- .../app/src/pages/home/home-controller.ts | 14 +- .../pages/home/home-projects-controller.tsx | 2 +- .../app/src/pages/home/home-projects-view.tsx | 9 +- .../pages/home/home-sessions-controller.tsx | 8 +- .../app/src/pages/home/home-sessions-view.tsx | 38 +- packages/app/src/pages/home/home-sessions.tsx | 1 - packages/app/src/pages/layout.tsx | 4 - packages/app/src/pages/layout/helpers.test.ts | 26 + packages/app/src/pages/layout/helpers.ts | 22 +- .../pages/new-session/new-session-view.tsx | 4 +- packages/app/src/pages/session.tsx | 10 +- .../composer/session-followup-dock.tsx | 6 +- .../composer/session-question-dock.tsx | 2 +- .../session/composer/session-revert-dock.tsx | 6 +- .../src/pages/session/session-side-panel.tsx | 10 +- .../session/timeline/message-timeline.tsx | 67 +- .../src/pages/session/timeline/model.test.ts | 6 +- .../app/src/pages/session/timeline/model.ts | 3 +- .../session/timeline/rows-current.test.ts | 49 +- .../app/src/pages/session/timeline/rows.ts | 10 +- .../pages/session/use-session-commands.tsx | 51 +- packages/app/src/utils/draft-store.ts | 171 + .../app/src/utils/notification-click.test.ts | 27 - packages/app/src/utils/notification-click.ts | 13 - packages/app/src/utils/persist.ts | 55 +- packages/app/src/utils/prompt.test.ts | 14 +- packages/app/src/utils/prompt.ts | 3 +- packages/app/src/utils/session-export.test.ts | 57 + packages/app/src/utils/session-export.ts | 55 + packages/app/src/utils/session-message.ts | 8 + .../src/utils/terminal-connect-token.test.ts | 34 + .../app/src/utils/terminal-connect-token.ts | 19 + packages/app/src/utils/toast.tsx | 17 +- packages/app/src/wsl/settings-model.test.ts | 4 +- packages/app/src/wsl/settings-model.ts | 4 +- packages/app/src/wsl/settings.tsx | 2 +- .../test-browser/prompt-attachments.test.ts | 85 + .../test-browser/prompt-persistence.test.ts | 88 +- .../test-browser/solid-router-cleanup.test.ts | 64 + packages/app/test-browser/toast-owner.test.ts | 40 +- packages/cli/script/generate.ts | 2 +- packages/cli/src/config/migrate.ts | 1 + packages/cli/test/config.test.ts | 26 + packages/console/app/package.json | 2 +- packages/console/app/src/config.ts | 8 +- packages/console/app/src/i18n/ar.ts | 31 +- packages/console/app/src/i18n/br.ts | 32 +- packages/console/app/src/i18n/da.ts | 33 +- packages/console/app/src/i18n/de.ts | 32 +- packages/console/app/src/i18n/en.ts | 33 +- packages/console/app/src/i18n/es.ts | 32 +- packages/console/app/src/i18n/fr.ts | 33 +- packages/console/app/src/i18n/it.ts | 32 +- packages/console/app/src/i18n/ja.ts | 30 +- packages/console/app/src/i18n/ko.ts | 30 +- packages/console/app/src/i18n/no.ts | 33 +- packages/console/app/src/i18n/pl.ts | 32 +- packages/console/app/src/i18n/ru.ts | 33 +- packages/console/app/src/i18n/th.ts | 32 +- packages/console/app/src/i18n/tr.ts | 33 +- packages/console/app/src/i18n/uk.ts | 33 +- packages/console/app/src/i18n/zh.ts | 32 +- packages/console/app/src/i18n/zht.ts | 32 +- packages/console/app/src/routes/go/index.css | 95 +- packages/console/app/src/routes/go/index.tsx | 142 +- .../routes/workspace/[id]/go/lite-section.tsx | 4 +- .../app/src/routes/zen/util/handler.ts | 35 +- .../src/routes/zen/util/provider/google.ts | 2 +- .../migration.sql | 2 + .../snapshot.json | 3167 +++++++++++++++++ packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- .../console/function/src/log-processor.ts | 22 +- packages/console/mail/package.json | 2 +- packages/console/support/package.json | 2 +- packages/core/package.json | 2 +- packages/core/src/models-dev.ts | 4 +- packages/core/test/models.test.ts | 2 +- packages/desktop/AGENTS.md | 10 + packages/desktop/package.json | 4 +- .../desktop/src/main/attachment-picker.ts | 7 +- packages/desktop/src/main/draft-store.test.ts | 16 + packages/desktop/src/main/draft-store.ts | 82 + .../desktop/src/main/external-url.test.ts | 29 + packages/desktop/src/main/external-url.ts | 19 + packages/desktop/src/main/index.ts | 44 +- packages/desktop/src/main/ipc.ts | 63 +- packages/desktop/src/main/logging.ts | 5 + packages/desktop/src/main/markdown.ts | 16 - packages/desktop/src/main/menu.ts | 14 +- .../desktop/src/main/native-translations.ts | 24 + packages/desktop/src/main/updater.ts | 19 +- packages/desktop/src/main/windows.ts | 83 +- packages/desktop/src/main/wsl/ipc.ts | 5 +- packages/desktop/src/main/wsl/runtime.ts | 17 +- packages/desktop/src/main/wsl/servers.ts | 16 +- packages/desktop/src/main/wsl/sidecar.ts | 13 +- packages/desktop/src/main/wsl/startup.ts | 8 +- packages/desktop/src/preload/index.ts | 12 +- packages/desktop/src/preload/types.ts | 13 +- packages/desktop/src/renderer/i18n/am.ts | 21 + packages/desktop/src/renderer/i18n/ar.ts | 12 +- packages/desktop/src/renderer/i18n/az.ts | 25 + packages/desktop/src/renderer/i18n/bg.ts | 22 + packages/desktop/src/renderer/i18n/bn.ts | 22 + packages/desktop/src/renderer/i18n/br.ts | 3 + packages/desktop/src/renderer/i18n/bs.ts | 9 +- packages/desktop/src/renderer/i18n/ca.ts | 22 + packages/desktop/src/renderer/i18n/cs.ts | 22 + packages/desktop/src/renderer/i18n/da.ts | 5 +- packages/desktop/src/renderer/i18n/de.ts | 5 +- packages/desktop/src/renderer/i18n/dv.ts | 22 + packages/desktop/src/renderer/i18n/dz.ts | 22 + packages/desktop/src/renderer/i18n/el.ts | 22 + packages/desktop/src/renderer/i18n/en.ts | 3 + packages/desktop/src/renderer/i18n/es.ts | 5 +- packages/desktop/src/renderer/i18n/et.ts | 22 + packages/desktop/src/renderer/i18n/fa.ts | 22 + packages/desktop/src/renderer/i18n/fi.ts | 22 + packages/desktop/src/renderer/i18n/fo.ts | 22 + packages/desktop/src/renderer/i18n/fr.ts | 9 +- packages/desktop/src/renderer/i18n/hi.ts | 22 + packages/desktop/src/renderer/i18n/hr.ts | 22 + packages/desktop/src/renderer/i18n/hu.ts | 22 + packages/desktop/src/renderer/i18n/hy.ts | 22 + packages/desktop/src/renderer/i18n/id.ts | 25 + packages/desktop/src/renderer/i18n/index.ts | 219 +- packages/desktop/src/renderer/i18n/is.ts | 22 + packages/desktop/src/renderer/i18n/it.ts | 22 + packages/desktop/src/renderer/i18n/ja.ts | 3 + packages/desktop/src/renderer/i18n/ka.ts | 22 + packages/desktop/src/renderer/i18n/km.ts | 22 + packages/desktop/src/renderer/i18n/ko.ts | 5 +- packages/desktop/src/renderer/i18n/lo.ts | 22 + packages/desktop/src/renderer/i18n/lt.ts | 22 + packages/desktop/src/renderer/i18n/lv.ts | 22 + packages/desktop/src/renderer/i18n/mk.ts | 22 + packages/desktop/src/renderer/i18n/mn.ts | 22 + packages/desktop/src/renderer/i18n/ms.ts | 22 + packages/desktop/src/renderer/i18n/my.ts | 22 + packages/desktop/src/renderer/i18n/ne.ts | 22 + packages/desktop/src/renderer/i18n/nl.ts | 22 + packages/desktop/src/renderer/i18n/no.ts | 3 + packages/desktop/src/renderer/i18n/pa.ts | 22 + packages/desktop/src/renderer/i18n/pl.ts | 7 +- packages/desktop/src/renderer/i18n/ro.ts | 22 + packages/desktop/src/renderer/i18n/ru.ts | 5 +- packages/desktop/src/renderer/i18n/si.ts | 22 + packages/desktop/src/renderer/i18n/sk.ts | 22 + packages/desktop/src/renderer/i18n/sl.ts | 22 + packages/desktop/src/renderer/i18n/sq.ts | 22 + packages/desktop/src/renderer/i18n/sr.ts | 22 + packages/desktop/src/renderer/i18n/sv.ts | 22 + packages/desktop/src/renderer/i18n/tg.ts | 22 + packages/desktop/src/renderer/i18n/th.ts | 24 + packages/desktop/src/renderer/i18n/tk.ts | 22 + packages/desktop/src/renderer/i18n/tr.ts | 25 + packages/desktop/src/renderer/i18n/uk.ts | 5 +- packages/desktop/src/renderer/i18n/ur.ts | 22 + packages/desktop/src/renderer/i18n/uz.ts | 22 + packages/desktop/src/renderer/i18n/vi.ts | 22 + packages/desktop/src/renderer/i18n/zh.ts | 6 +- packages/desktop/src/renderer/i18n/zht.ts | 4 +- packages/desktop/src/renderer/index.tsx | 77 +- .../src/renderer/wsl/connections.test.ts | 4 + .../desktop/src/renderer/wsl/connections.ts | 4 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/enterprise/src/app.tsx | 7 +- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/plugin/package.json | 2 +- packages/session-ui/AGENTS.md | 7 + packages/session-ui/package.json | 7 +- .../session-ui/src/components/basic-tool.tsx | 2 +- packages/session-ui/src/components/file.tsx | 38 +- .../src/components/markdown-cache.tsx | 39 +- .../src/components/markdown-preload.test.ts | 18 - .../src/components/markdown-projection.ts | 11 + .../src/components/markdown-stream.test.ts | 39 +- .../src/components/markdown-stream.ts | 28 +- .../markdown-worker-protocol.test.ts | 8 +- .../components/markdown-worker-protocol.ts | 12 +- .../src/components/markdown-worker.ts | 134 +- .../session-ui/src/components/markdown.css | 30 +- .../session-ui/src/components/markdown.tsx | 70 +- ...own-shiki.worker.ts => markdown.worker.ts} | 65 +- .../src/components/message-file.test.ts | 14 +- .../session-ui/src/components/message-file.ts | 4 +- .../src/components/message-part.css | 21 +- .../src/components/message-part.tsx | 107 +- .../src/components/part-default-open.test.ts | 66 + .../src/components/part-default-open.ts | 26 + .../src/components/session-review.css | 2 + .../src/components/session-turn.css | 2 + .../src/components/session-turn.tsx | 5 +- .../src/components/tool-count-label.tsx | 15 +- .../components/tool-count-summary.stories.tsx | 19 +- .../src/components/tool-count-summary.tsx | 11 +- .../src/components/tool-error-card.tsx | 7 +- packages/session-ui/src/context/data.tsx | 4 + packages/session-ui/src/pierre/index.ts | 90 +- packages/session-ui/src/pierre/worker.ts | 3 + .../line-comment-annotations-v2.tsx | 11 +- .../components/prompt-input/attachments.css | 44 + .../v2/components/prompt-input/attachments.ts | 50 +- .../src/v2/components/prompt-input/index.tsx | 81 +- .../prompt-input/prompt-input.stories.tsx | 4 +- .../v2/components/prompt-input/store.test.ts | 4 +- .../src/v2/components/prompt-input/types.ts | 2 +- .../src/v2/components/session-review-v2.css | 95 +- .../src/v2/components/session-review-v2.tsx | 21 +- packages/simulation/src/backend/index.ts | 2 +- packages/slack/package.json | 2 +- packages/stats/app/package.json | 2 +- .../src/component/model-compare-detail.tsx | 7 +- packages/stats/app/src/entry-server.tsx | 11 +- .../stats/app/src/routes/[lab]/[model].tsx | 210 +- packages/stats/app/src/routes/[lab]/index.tsx | 88 +- .../stats/app/src/routes/compare-cards.tsx | 8 +- .../stats/app/src/routes/compare/index.tsx | 7 +- packages/stats/app/src/routes/geo-map.ts | 120 + packages/stats/app/src/routes/index.css | 40 +- packages/stats/app/src/routes/index.tsx | 485 +-- .../stats/app/src/routes/model-catalog.ts | 6 +- packages/stats/app/src/routes/stats-shell.tsx | 5 +- packages/stats/app/vite.config.ts | 3 +- packages/stats/core/package.json | 2 +- packages/stats/core/src/athena.ts | 2 +- packages/stats/server/package.json | 2 +- packages/stats/server/src/stat-sync.ts | 16 +- .../.storybook/mocks/app/context/language.ts | 6 + .../.storybook/mocks/app/context/platform.ts | 5 +- packages/tui/src/clipboard.ts | 3 +- .../component/dialog-project-copy-name.tsx | 3 + packages/tui/src/component/prompt/index.tsx | 3 + packages/tui/src/config/index.tsx | 22 +- packages/tui/src/config/v1/index.tsx | 22 +- packages/tui/src/routes/session/form.tsx | 4 + packages/tui/src/routes/session/index.tsx | 33 +- .../tui/src/routes/session/permission.tsx | 2 + packages/tui/src/ui/dialog-prompt.tsx | 3 + packages/tui/src/ui/dialog-select.tsx | 1 + packages/tui/test/config.test.tsx | 19 +- packages/ui/AGENTS.md | 10 + packages/ui/package.json | 4 +- .../src/assets/icons/provider/openrouter.svg | 7 +- packages/ui/src/components/diff-changes.css | 2 + packages/ui/src/components/dropdown-menu.css | 4 +- packages/ui/src/components/icon.css | 5 + packages/ui/src/components/icon.tsx | 13 +- .../src/components/provider-icons/sprite.svg | 29 +- packages/ui/src/components/resize-handle.css | 16 +- packages/ui/src/components/resize-handle.tsx | 6 +- packages/ui/src/components/scroll-view.css | 2 +- packages/ui/src/components/select.css | 8 +- packages/ui/src/components/switch.css | 4 + packages/ui/src/components/tabs.css | 80 +- packages/ui/src/components/tabs.tsx | 1 + packages/ui/src/components/text-field.css | 4 +- packages/ui/src/components/toast.css | 2 +- packages/ui/src/context/i18n.test.ts | 24 + packages/ui/src/context/i18n.tsx | 40 +- packages/ui/src/context/marked-code-span.ts | 17 - packages/ui/src/context/marked-parser.test.ts | 19 + packages/ui/src/context/marked-parser.tsx | 68 + ...span.test.ts => marked-regression.test.ts} | 3 +- .../ui/src/context/marked-theme-register.tsx | 10 + packages/ui/src/context/marked-theme.tsx | 372 ++ packages/ui/src/context/marked.tsx | 582 +-- packages/ui/src/i18n/am.ts | 194 + packages/ui/src/i18n/ar.ts | 84 +- packages/ui/src/i18n/az.ts | 195 + packages/ui/src/i18n/bg.ts | 195 + packages/ui/src/i18n/bn.ts | 197 + packages/ui/src/i18n/br.ts | 40 +- packages/ui/src/i18n/bs.ts | 74 +- packages/ui/src/i18n/ca.ts | 199 ++ packages/ui/src/i18n/cs.ts | 203 ++ packages/ui/src/i18n/da.ts | 48 +- packages/ui/src/i18n/de.ts | 94 +- packages/ui/src/i18n/dv.ts | 196 + packages/ui/src/i18n/dz.ts | 197 + packages/ui/src/i18n/el.ts | 195 + packages/ui/src/i18n/en.ts | 24 + packages/ui/src/i18n/es.ts | 62 +- packages/ui/src/i18n/et.ts | 195 + packages/ui/src/i18n/fa.ts | 195 + packages/ui/src/i18n/fi.ts | 195 + packages/ui/src/i18n/fo.ts | 195 + packages/ui/src/i18n/fr.ts | 72 +- packages/ui/src/i18n/hi.ts | 197 + packages/ui/src/i18n/hr.ts | 200 ++ packages/ui/src/i18n/hu.ts | 197 + packages/ui/src/i18n/hy.ts | 195 + packages/ui/src/i18n/id.ts | 217 ++ packages/ui/src/i18n/is.ts | 195 + packages/ui/src/i18n/it.ts | 200 ++ packages/ui/src/i18n/ja.ts | 34 +- packages/ui/src/i18n/ka.ts | 195 + packages/ui/src/i18n/km.ts | 196 + packages/ui/src/i18n/ko.ts | 48 +- packages/ui/src/i18n/lo.ts | 195 + packages/ui/src/i18n/lt.ts | 203 ++ packages/ui/src/i18n/lv.ts | 199 ++ packages/ui/src/i18n/mk.ts | 195 + packages/ui/src/i18n/mn.ts | 195 + packages/ui/src/i18n/ms.ts | 195 + packages/ui/src/i18n/my.ts | 196 + packages/ui/src/i18n/ne.ts | 197 + packages/ui/src/i18n/nl.ts | 195 + packages/ui/src/i18n/no.ts | 70 +- packages/ui/src/i18n/pa.ts | 196 + packages/ui/src/i18n/pl.ts | 81 +- packages/ui/src/i18n/ro.ts | 199 ++ packages/ui/src/i18n/ru.ts | 78 +- packages/ui/src/i18n/si.ts | 195 + packages/ui/src/i18n/sk.ts | 203 ++ packages/ui/src/i18n/sl.ts | 204 ++ packages/ui/src/i18n/sq.ts | 195 + packages/ui/src/i18n/sr.ts | 200 ++ packages/ui/src/i18n/sv.ts | 195 + packages/ui/src/i18n/tg.ts | 195 + packages/ui/src/i18n/th.ts | 65 +- packages/ui/src/i18n/tk.ts | 195 + packages/ui/src/i18n/tr.ts | 74 +- packages/ui/src/i18n/uk.ts | 62 +- packages/ui/src/i18n/ur.ts | 196 + packages/ui/src/i18n/uz.ts | 198 ++ packages/ui/src/i18n/vi.ts | 195 + packages/ui/src/i18n/zh.ts | 81 +- packages/ui/src/i18n/zht.ts | 67 +- packages/ui/src/theme/themes/oc-2.json | 6 +- packages/ui/src/v2/components/button-v2.css | 4 +- packages/ui/src/v2/components/dialog-v2.css | 4 +- packages/ui/src/v2/components/dialog-v2.tsx | 4 +- .../ui/src/v2/components/diff-changes-v2.css | 2 + .../ui/src/v2/components/file-tree-v2.css | 8 +- .../ui/src/v2/components/inline-input-v2.tsx | 4 +- .../ui/src/v2/components/line-comment-v2.css | 2 +- .../ui/src/v2/components/line-comment-v2.tsx | 10 +- packages/ui/src/v2/components/select-v2.css | 21 +- packages/ui/src/v2/components/switch-v2.css | 8 + packages/ui/src/v2/components/tabs-v2.css | 10 +- packages/ui/src/v2/components/tabs-v2.tsx | 6 +- .../ui/src/v2/components/text-input-v2.css | 12 +- .../ui/src/v2/components/text-input-v2.tsx | 8 +- packages/ui/src/v2/components/toast-v2.css | 336 +- .../ui/src/v2/components/toast-v2.stories.tsx | 8 +- packages/ui/src/v2/components/toast-v2.tsx | 281 +- packages/ui/vite.config.ts | 2 +- packages/web/package.json | 2 +- packages/web/src/components/Share.tsx | 4 +- packages/web/src/content/docs/ar/go.mdx | 40 +- packages/web/src/content/docs/ar/zen.mdx | 24 +- packages/web/src/content/docs/bs/go.mdx | 40 +- packages/web/src/content/docs/bs/zen.mdx | 24 +- packages/web/src/content/docs/config.mdx | 6 + packages/web/src/content/docs/da/go.mdx | 40 +- packages/web/src/content/docs/da/zen.mdx | 24 +- packages/web/src/content/docs/de/go.mdx | 40 +- packages/web/src/content/docs/de/zen.mdx | 24 +- packages/web/src/content/docs/ecosystem.mdx | 1 + packages/web/src/content/docs/es/go.mdx | 40 +- packages/web/src/content/docs/es/zen.mdx | 24 +- packages/web/src/content/docs/fr/go.mdx | 40 +- packages/web/src/content/docs/fr/zen.mdx | 24 +- packages/web/src/content/docs/go.mdx | 40 +- packages/web/src/content/docs/it/go.mdx | 40 +- packages/web/src/content/docs/it/zen.mdx | 24 +- packages/web/src/content/docs/ja/go.mdx | 40 +- packages/web/src/content/docs/ja/zen.mdx | 24 +- packages/web/src/content/docs/ko/go.mdx | 40 +- packages/web/src/content/docs/ko/zen.mdx | 24 +- packages/web/src/content/docs/nb/go.mdx | 40 +- packages/web/src/content/docs/nb/zen.mdx | 24 +- packages/web/src/content/docs/pl/go.mdx | 40 +- packages/web/src/content/docs/pl/zen.mdx | 24 +- packages/web/src/content/docs/providers.mdx | 118 +- packages/web/src/content/docs/pt-br/go.mdx | 40 +- packages/web/src/content/docs/pt-br/zen.mdx | 24 +- packages/web/src/content/docs/ru/go.mdx | 40 +- packages/web/src/content/docs/ru/zen.mdx | 24 +- packages/web/src/content/docs/th/go.mdx | 40 +- packages/web/src/content/docs/th/zen.mdx | 24 +- packages/web/src/content/docs/tr/go.mdx | 40 +- packages/web/src/content/docs/tr/zen.mdx | 24 +- packages/web/src/content/docs/tui.mdx | 5 + packages/web/src/content/docs/zen.mdx | 24 +- packages/web/src/content/docs/zh-cn/go.mdx | 40 +- packages/web/src/content/docs/zh-cn/zen.mdx | 24 +- packages/web/src/content/docs/zh-tw/go.mdx | 40 +- packages/web/src/content/docs/zh-tw/zen.mdx | 24 +- .../@ai-sdk%2Fopenai-compatible@2.0.41.patch | 39 + patches/@dnd-kit%2Fdom@0.5.0.patch | 40 + patches/solid-js@1.9.10.patch | 156 +- script/translate-app.md | 5 +- script/translate-app.test.ts | 55 +- script/translate-app.ts | 120 +- sdks/vscode/package.json | 2 +- 541 files changed, 77359 insertions(+), 5532 deletions(-) create mode 100644 .opencode/skills/rtl-aware-development/SKILL.md create mode 100644 packages/app/e2e/AGENTS.md create mode 100644 packages/app/e2e/regression/project-picker-recent-search.spec.ts create mode 100644 packages/app/src/components/external-link.tsx delete mode 100644 packages/app/src/components/link.tsx create mode 100644 packages/app/src/components/settings-v2/general-controller-behavior.ts create mode 100644 packages/app/src/components/settings-v2/general-controllers.test.ts create mode 100644 packages/app/src/components/settings-v2/general-controllers.ts create mode 100644 packages/app/src/context/tab-migration.ts create mode 100644 packages/app/src/i18n/am.ts create mode 100644 packages/app/src/i18n/az.ts create mode 100644 packages/app/src/i18n/bg.ts create mode 100644 packages/app/src/i18n/bn.ts create mode 100644 packages/app/src/i18n/ca.ts create mode 100644 packages/app/src/i18n/cs.ts create mode 100644 packages/app/src/i18n/desktop-native.test.ts create mode 100644 packages/app/src/i18n/desktop-native.ts create mode 100644 packages/app/src/i18n/dv.ts create mode 100644 packages/app/src/i18n/dz.ts create mode 100644 packages/app/src/i18n/el.ts create mode 100644 packages/app/src/i18n/et.ts create mode 100644 packages/app/src/i18n/fa.ts create mode 100644 packages/app/src/i18n/fi.ts create mode 100644 packages/app/src/i18n/fo.ts create mode 100644 packages/app/src/i18n/hi.ts create mode 100644 packages/app/src/i18n/hr.ts create mode 100644 packages/app/src/i18n/hu.ts create mode 100644 packages/app/src/i18n/hy.ts create mode 100644 packages/app/src/i18n/id.ts create mode 100644 packages/app/src/i18n/is.ts create mode 100644 packages/app/src/i18n/it.ts create mode 100644 packages/app/src/i18n/ka.ts create mode 100644 packages/app/src/i18n/km.ts create mode 100644 packages/app/src/i18n/lo.ts create mode 100644 packages/app/src/i18n/lt.ts create mode 100644 packages/app/src/i18n/lv.ts create mode 100644 packages/app/src/i18n/mk.ts create mode 100644 packages/app/src/i18n/mn.ts create mode 100644 packages/app/src/i18n/ms.ts create mode 100644 packages/app/src/i18n/my.ts create mode 100644 packages/app/src/i18n/ne.ts create mode 100644 packages/app/src/i18n/nl.ts create mode 100644 packages/app/src/i18n/pa.ts create mode 100644 packages/app/src/i18n/ro.ts create mode 100644 packages/app/src/i18n/si.ts create mode 100644 packages/app/src/i18n/sk.ts create mode 100644 packages/app/src/i18n/sl.ts create mode 100644 packages/app/src/i18n/sq.ts create mode 100644 packages/app/src/i18n/sr.ts create mode 100644 packages/app/src/i18n/sv.ts create mode 100644 packages/app/src/i18n/tg.ts create mode 100644 packages/app/src/i18n/tk.ts create mode 100644 packages/app/src/i18n/ur.ts create mode 100644 packages/app/src/i18n/uz.ts create mode 100644 packages/app/src/i18n/vi.ts create mode 100644 packages/app/src/utils/draft-store.ts delete mode 100644 packages/app/src/utils/notification-click.test.ts delete mode 100644 packages/app/src/utils/notification-click.ts create mode 100644 packages/app/src/utils/session-export.test.ts create mode 100644 packages/app/src/utils/session-export.ts create mode 100644 packages/app/src/utils/terminal-connect-token.test.ts create mode 100644 packages/app/src/utils/terminal-connect-token.ts create mode 100644 packages/app/test-browser/solid-router-cleanup.test.ts create mode 100644 packages/console/core/migrations/20260803084635_married_misty_knight/migration.sql create mode 100644 packages/console/core/migrations/20260803084635_married_misty_knight/snapshot.json create mode 100644 packages/desktop/src/main/draft-store.test.ts create mode 100644 packages/desktop/src/main/draft-store.ts create mode 100644 packages/desktop/src/main/external-url.test.ts create mode 100644 packages/desktop/src/main/external-url.ts delete mode 100644 packages/desktop/src/main/markdown.ts create mode 100644 packages/desktop/src/main/native-translations.ts create mode 100644 packages/desktop/src/renderer/i18n/am.ts create mode 100644 packages/desktop/src/renderer/i18n/az.ts create mode 100644 packages/desktop/src/renderer/i18n/bg.ts create mode 100644 packages/desktop/src/renderer/i18n/bn.ts create mode 100644 packages/desktop/src/renderer/i18n/ca.ts create mode 100644 packages/desktop/src/renderer/i18n/cs.ts create mode 100644 packages/desktop/src/renderer/i18n/dv.ts create mode 100644 packages/desktop/src/renderer/i18n/dz.ts create mode 100644 packages/desktop/src/renderer/i18n/el.ts create mode 100644 packages/desktop/src/renderer/i18n/et.ts create mode 100644 packages/desktop/src/renderer/i18n/fa.ts create mode 100644 packages/desktop/src/renderer/i18n/fi.ts create mode 100644 packages/desktop/src/renderer/i18n/fo.ts create mode 100644 packages/desktop/src/renderer/i18n/hi.ts create mode 100644 packages/desktop/src/renderer/i18n/hr.ts create mode 100644 packages/desktop/src/renderer/i18n/hu.ts create mode 100644 packages/desktop/src/renderer/i18n/hy.ts create mode 100644 packages/desktop/src/renderer/i18n/id.ts create mode 100644 packages/desktop/src/renderer/i18n/is.ts create mode 100644 packages/desktop/src/renderer/i18n/it.ts create mode 100644 packages/desktop/src/renderer/i18n/ka.ts create mode 100644 packages/desktop/src/renderer/i18n/km.ts create mode 100644 packages/desktop/src/renderer/i18n/lo.ts create mode 100644 packages/desktop/src/renderer/i18n/lt.ts create mode 100644 packages/desktop/src/renderer/i18n/lv.ts create mode 100644 packages/desktop/src/renderer/i18n/mk.ts create mode 100644 packages/desktop/src/renderer/i18n/mn.ts create mode 100644 packages/desktop/src/renderer/i18n/ms.ts create mode 100644 packages/desktop/src/renderer/i18n/my.ts create mode 100644 packages/desktop/src/renderer/i18n/ne.ts create mode 100644 packages/desktop/src/renderer/i18n/nl.ts create mode 100644 packages/desktop/src/renderer/i18n/pa.ts create mode 100644 packages/desktop/src/renderer/i18n/ro.ts create mode 100644 packages/desktop/src/renderer/i18n/si.ts create mode 100644 packages/desktop/src/renderer/i18n/sk.ts create mode 100644 packages/desktop/src/renderer/i18n/sl.ts create mode 100644 packages/desktop/src/renderer/i18n/sq.ts create mode 100644 packages/desktop/src/renderer/i18n/sr.ts create mode 100644 packages/desktop/src/renderer/i18n/sv.ts create mode 100644 packages/desktop/src/renderer/i18n/tg.ts create mode 100644 packages/desktop/src/renderer/i18n/th.ts create mode 100644 packages/desktop/src/renderer/i18n/tk.ts create mode 100644 packages/desktop/src/renderer/i18n/tr.ts create mode 100644 packages/desktop/src/renderer/i18n/ur.ts create mode 100644 packages/desktop/src/renderer/i18n/uz.ts create mode 100644 packages/desktop/src/renderer/i18n/vi.ts create mode 100644 packages/session-ui/AGENTS.md delete mode 100644 packages/session-ui/src/components/markdown-preload.test.ts create mode 100644 packages/session-ui/src/components/markdown-projection.ts rename packages/session-ui/src/components/{markdown-shiki.worker.ts => markdown.worker.ts} (56%) create mode 100644 packages/session-ui/src/components/part-default-open.test.ts create mode 100644 packages/session-ui/src/components/part-default-open.ts create mode 100644 packages/session-ui/src/v2/components/prompt-input/attachments.css create mode 100644 packages/stats/app/src/routes/geo-map.ts create mode 100644 packages/ui/AGENTS.md create mode 100644 packages/ui/src/context/i18n.test.ts delete mode 100644 packages/ui/src/context/marked-code-span.ts create mode 100644 packages/ui/src/context/marked-parser.test.ts create mode 100644 packages/ui/src/context/marked-parser.tsx rename packages/ui/src/context/{marked-code-span.test.ts => marked-regression.test.ts} (82%) create mode 100644 packages/ui/src/context/marked-theme-register.tsx create mode 100644 packages/ui/src/context/marked-theme.tsx create mode 100644 packages/ui/src/i18n/am.ts create mode 100644 packages/ui/src/i18n/az.ts create mode 100644 packages/ui/src/i18n/bg.ts create mode 100644 packages/ui/src/i18n/bn.ts create mode 100644 packages/ui/src/i18n/ca.ts create mode 100644 packages/ui/src/i18n/cs.ts create mode 100644 packages/ui/src/i18n/dv.ts create mode 100644 packages/ui/src/i18n/dz.ts create mode 100644 packages/ui/src/i18n/el.ts create mode 100644 packages/ui/src/i18n/et.ts create mode 100644 packages/ui/src/i18n/fa.ts create mode 100644 packages/ui/src/i18n/fi.ts create mode 100644 packages/ui/src/i18n/fo.ts create mode 100644 packages/ui/src/i18n/hi.ts create mode 100644 packages/ui/src/i18n/hr.ts create mode 100644 packages/ui/src/i18n/hu.ts create mode 100644 packages/ui/src/i18n/hy.ts create mode 100644 packages/ui/src/i18n/id.ts create mode 100644 packages/ui/src/i18n/is.ts create mode 100644 packages/ui/src/i18n/it.ts create mode 100644 packages/ui/src/i18n/ka.ts create mode 100644 packages/ui/src/i18n/km.ts create mode 100644 packages/ui/src/i18n/lo.ts create mode 100644 packages/ui/src/i18n/lt.ts create mode 100644 packages/ui/src/i18n/lv.ts create mode 100644 packages/ui/src/i18n/mk.ts create mode 100644 packages/ui/src/i18n/mn.ts create mode 100644 packages/ui/src/i18n/ms.ts create mode 100644 packages/ui/src/i18n/my.ts create mode 100644 packages/ui/src/i18n/ne.ts create mode 100644 packages/ui/src/i18n/nl.ts create mode 100644 packages/ui/src/i18n/pa.ts create mode 100644 packages/ui/src/i18n/ro.ts create mode 100644 packages/ui/src/i18n/si.ts create mode 100644 packages/ui/src/i18n/sk.ts create mode 100644 packages/ui/src/i18n/sl.ts create mode 100644 packages/ui/src/i18n/sq.ts create mode 100644 packages/ui/src/i18n/sr.ts create mode 100644 packages/ui/src/i18n/sv.ts create mode 100644 packages/ui/src/i18n/tg.ts create mode 100644 packages/ui/src/i18n/tk.ts create mode 100644 packages/ui/src/i18n/ur.ts create mode 100644 packages/ui/src/i18n/uz.ts create mode 100644 packages/ui/src/i18n/vi.ts create mode 100644 patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch create mode 100644 patches/@dnd-kit%2Fdom@0.5.0.patch diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 180c6b77c05..f18b7929594 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -281,7 +281,6 @@ jobs: build-electron: needs: - - build-cli - version if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' continue-on-error: false @@ -378,16 +377,14 @@ jobs: env: OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} - OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }} RUST_TARGET: ${{ matrix.settings.target }} - GH_TOKEN: ${{ github.token }} - GITHUB_RUN_ID: ${{ github.run_id }} - name: Build run: bun run build working-directory: packages/desktop env: NODE_OPTIONS: --max-old-space-size=4096 + OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} @@ -405,8 +402,7 @@ jobs: env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} GH_TOKEN: ${{ steps.committer.outputs.token }} - CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }} - CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + CSC_KEYCHAIN: build.keychain APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} diff --git a/.opencode/skills/rtl-aware-development/SKILL.md b/.opencode/skills/rtl-aware-development/SKILL.md new file mode 100644 index 00000000000..595c981ead8 --- /dev/null +++ b/.opencode/skills/rtl-aware-development/SKILL.md @@ -0,0 +1,63 @@ +--- +name: rtl-aware-development +description: OpenCode Desktop should be RTL-aware. Use when implementing or reviewing RTL/LTR behavior in the web app, desktop app, CSS, menus, scrolling, resizing, icons, mixed-direction text, or Electron title bars. +--- + +# RTL-Aware Development + +Treat direction as independent from language. Test English in both directions as well as real RTL and mixed-script content. + +## Guidelines + +- Set `lang` and `dir` on the document, and propagate direction through component providers used by portaled menus and popovers. Do not change the selected locale merely to force RTL. +- Keep DOM and focus order semantic. Flexbox and Grid already follow `dir`; do not add `row-reverse`, CSS `order`, or reversed markup just to mirror a layout. +- Prefer logical CSS for semantic layout. Reserve physical coordinates for pointer positions, canvas geometry, native window controls, and other genuinely physical placement. + +```css +/* Avoid */ +padding-left: 12px; +right: 0; +border-right: 1px solid; +text-align: left; + +/* Prefer */ +padding-inline-start: 12px; +inset-inline-end: 0; +border-inline-end: 1px solid; +text-align: start; +``` + +- Isolate mixed-direction text. Use `dir="auto"` or `` for unknown text; keep code, URLs, IDs, and filesystem paths LTR without forcing the surrounding component LTR. + +```html +README.md C:\src\app.ts +``` + +- Mirror directional meaning, not every image. Back/forward, previous/next, disclosure, indentation, and directional progress may need mirroring. Do not mirror brands, clocks, media controls, charts, or text. Reverse physical gradients, `translateX`, SVG transforms, and animation deltas explicitly. +- Map interactions through direction. `clientX` remains physical; resizing a logical edge needs an RTL-aware delta. Logical previous/next keyboard controls may swap ArrowLeft/ArrowRight. Follow the relevant WAI-ARIA widget pattern. +- Do not assume LTR scrolling. RTL `scrollLeft` can start at `0` and become negative. Prefer `scrollIntoView({ inline: "nearest" })` or a tested direction-normalizing helper. +- For Electron title bars, prefer native caption controls and use `titleBarOverlay` plus `env(titlebar-area-*)` for the safe content rectangle. Keep Windows/macOS native-control avoidance and `trafficLightPosition` physical; keep app navigation inside that rectangle logical. Mark interactive titlebar children `app-region: no-drag`. +- Verify behavior, not screenshots alone. Check computed styles, pseudo-element geometry, hit zones, focus order, keyboard behavior, submenu direction, zoom/scaling, and both LTR and RTL scroll endpoints. + +## Test Matrix + +- English + LTR +- English + forced RTL +- A real RTL locale + RTL +- Mixed RTL/LTR content, long labels, numbers, code, and paths +- Keyboard, pointer resize, scrolling, menus/submenus, and Electron titlebar controls in both directions + +## References + +- [RTL Styling 101, Ahmad Shadeed](https://rtlstyling.com/posts/rtl-styling/) +- [CSS-Tricks: RTL Styling 101](https://css-tricks.com/rtl-styling-101/) +- [CSS-Tricks: CSS Logical Properties and Values](https://css-tricks.com/css-logical-properties-and-values/) +- [W3C: Structural markup and right-to-left text](https://www.w3.org/International/questions/qa-html-dir) +- [W3C: Inline bidirectional markup](https://www.w3.org/International/articles/inline-bidi-markup/) +- [MDN: CSS logical properties and values](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Logical_properties_and_values) +- [MDN: `dir`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir) +- [MDN: `scrollLeft`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft) +- [web.dev: Logical properties](https://web.dev/learn/css/logical-properties/) +- [Electron: Custom title bar](https://www.electronjs.org/docs/latest/tutorial/custom-title-bar) +- [WAI-ARIA: Window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/) +- [Kobalte: I18n Provider](https://kobalte.dev/docs/core/components/i18n-provider/) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e1a62ae9ca6..1ab14a7b628 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -252,33 +252,6 @@ These are not strictly enforced, they are just general guidelines: For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly. -## Trust & Vouch System - -This project uses [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. The vouch list is maintained in [`.github/VOUCHED.td`](.github/VOUCHED.td). - -### How it works - -- **Vouched users** are explicitly trusted contributors. -- **Denounced users** are explicitly blocked. Issues and pull requests from denounced users are automatically closed. If you have been denounced, you can request to be unvouched by reaching out to a maintainer on [Discord](https://opencode.ai/discord) -- **Everyone else** can participate normally — you don't need to be vouched to open issues or PRs. - -### For maintainers - -Collaborators with write access can manage the vouch list by commenting on any issue: - -- `vouch` — vouch for the issue author -- `vouch @username` — vouch for a specific user -- `denounce` — denounce the issue author -- `denounce @username` — denounce a specific user -- `denounce @username ` — denounce with a reason -- `unvouch` / `unvouch @username` — remove someone from the list - -Changes are committed automatically to `.github/VOUCHED.td`. - -### Denouncement policy - -Denouncement is reserved for users who repeatedly submit low-quality AI-generated contributions, spam, or otherwise act in bad faith. It is not used for disagreements or honest mistakes. - ## Issue Requirements All issues **must** use one of our issue templates: diff --git a/bun.lock b/bun.lock index 7ccc658e81a..f9bf3bb758c 100644 --- a/bun.lock +++ b/bun.lock @@ -53,7 +53,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -220,7 +220,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -256,7 +256,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -283,7 +283,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@openauthjs/openauth": "0.0.0-20250322224806", "@opencode-ai/console-core": "workspace:*", @@ -300,7 +300,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -324,7 +324,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -388,7 +388,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.11.1", + "gitlab-ai-provider": "6.12.1", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", "htmlparser2": "8.0.2", @@ -424,16 +424,16 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@zip.js/zip.js": "2.7.62", + "drizzle-orm": "catalog:", "effect": "catalog:", "electron-context-menu": "4.1.2", "electron-log": "^5", "electron-store": "11.0.2", "electron-updater": "6.8.9", "electron-window-state": "^5.0.3", - "marked": "^15", }, "devDependencies": { "@actions/artifact": "4.0.0", @@ -478,7 +478,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -492,7 +492,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -525,7 +525,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -541,7 +541,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@effect/platform-node-shared": "4.0.0-beta.101", }, @@ -587,7 +587,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/ai": "workspace:*", @@ -703,7 +703,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/client": "workspace:*", @@ -718,15 +718,11 @@ "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "@solidjs/meta": "catalog:", - "@solidjs/router": "catalog:", "diff": "catalog:", "dompurify": "3.3.1", "fuzzysort": "catalog:", - "katex": "0.16.27", "luxon": "catalog:", "marked": "catalog:", - "marked-katex-extension": "5.1.6", - "marked-shiki": "catalog:", "morphdom": "2.7.8", "motion": "12.34.5", "remeda": "catalog:", @@ -739,7 +735,6 @@ "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", - "@types/katex": "0.16.7", "@types/luxon": "catalog:", "@typescript/native-preview": "catalog:", "typescript": "catalog:", @@ -770,7 +765,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@opencode-ai/sdk": "1.18.5", "@slack/bolt": "^3.17.1", @@ -783,7 +778,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -817,7 +812,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -836,7 +831,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -927,7 +922,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -943,7 +938,6 @@ "katex": "0.16.27", "luxon": "catalog:", "marked": "catalog:", - "marked-katex-extension": "5.1.6", "marked-shiki": "catalog:", "morphdom": "2.7.8", "motion": "12.34.5", @@ -953,6 +947,7 @@ "remend": "catalog:", "shiki": "catalog:", "solid-list": "catalog:", + "solid-sonner": "catalog:", "strip-ansi": "7.1.2", }, "devDependencies": { @@ -1024,7 +1019,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.8", + "version": "1.18.15", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1079,16 +1074,18 @@ "tree-sitter-bash", ], "patchedDependencies": { - "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", + "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", + "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch", }, "overrides": { "@opentui/core": "catalog:", @@ -1146,7 +1143,7 @@ "hono": "4.10.7", "hono-openapi": "1.1.2", "luxon": "3.6.1", - "marked": "17.0.6", + "marked": "18.0.7", "marked-shiki": "1.2.1", "opentui-spinner": "0.0.7", "remeda": "2.26.0", @@ -1156,6 +1153,7 @@ "shiki": "4.2.0", "solid-js": "1.9.10", "solid-list": "0.3.0", + "solid-sonner": "0.3.1", "sst": "4.13.1", "string-width": "7.2.0", "tailwindcss": "4.1.11", @@ -3654,7 +3652,7 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], @@ -4192,7 +4190,7 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.11.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -4666,9 +4664,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], - - "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="], + "marked": ["marked@18.0.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA=="], "marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="], @@ -5468,6 +5464,8 @@ "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], + "solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="], + "solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="], "solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="], @@ -6382,8 +6380,6 @@ "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], - "@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], - "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], "@opencode-ai/session-ui/@opencode-ai/sdk": ["@opencode-ai/sdk@../app/vendor/opencode-ai-sdk-1.18.8-dev.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-C2nfk4x0sPINwE5V6DPkFSuH3PkUmKPWHPzxpXC1j+3Ui5hslLCWJbkk8WcOG1Lyt3C0+yp4ea64v/kmtYCO4w=="], @@ -6624,8 +6620,6 @@ "blume/katex": ["katex@0.17.0", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw=="], - "blume/marked": ["marked@18.0.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA=="], - "blume/node-html-parser": ["node-html-parser@9.0.0", "", { "dependencies": { "css-select": "^5.1.0", "entities": "^8.0.0" } }, "sha512-MhdaHPyxnyYu/sf0TpiRvDnTrkum0UKHC7FdbDGIUQNlx3I7xzwXoyV0eMUMv/XU+lkJT1glOUzpDPq7b2p1Ew=="], "blume/react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], @@ -6760,6 +6754,8 @@ "jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -6890,12 +6886,8 @@ "terser/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], - "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], - "tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], - "topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], - "tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="], "tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -7718,6 +7710,8 @@ "blume/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "blume/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "blume/node-html-parser/entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], "blume/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -7790,6 +7784,8 @@ "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "motion/framer-motion/motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="], "motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], diff --git a/infra/console.ts b/infra/console.ts index 5fbf35de007..79556f5e0c7 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -288,8 +288,12 @@ new sst.cloudflare.x.SolidStart("Console", { server: { placement: { region: "aws:us-east-2" }, transform: { - worker: { - tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }], + worker: (args) => { + args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [ + ...(flags ?? []), + "global_fetch_strictly_public", + ]) + args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }] }, }, }, diff --git a/nix/hashes.json b/nix/hashes.json index 1b85cf68b21..6f321d88bf2 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-RFek0QoEEjsgbqmTE/SxQAmPtYyzs0IPR2ugFn5Okrs=", - "aarch64-linux": "sha256-BmAxapY1YrAFn7mVq3/6A9+6Au5UIvSqBboHMkyJH3I=", - "aarch64-darwin": "sha256-Sx3bGWQqLlgoa/RudJxanjSzhFRNklckT2ffnO2I5F4=", - "x86_64-darwin": "sha256-CMOhiisHNowg06qadvgg4K+60zrynglwiT0qKYQ4NiA=" + "x86_64-linux": "sha256-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=", + "aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=", + "aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=", + "x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU=" } } diff --git a/package.json b/package.json index b2dcdf14bfc..3ec53a2d4c0 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "fuzzysort": "3.1.0", "get-east-asian-width": "1.6.0", "luxon": "3.6.1", - "marked": "17.0.6", + "marked": "18.0.7", "marked-shiki": "1.2.1", "remend": "1.3.0", "@playwright/test": "1.59.1", @@ -100,6 +100,7 @@ "@sentry/solid": "10.36.0", "@sentry/vite-plugin": "4.6.0", "solid-js": "1.9.10", + "solid-sonner": "0.3.1", "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.12" } @@ -157,6 +158,8 @@ "effect": "catalog:" }, "patchedDependencies": { + "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch", + "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", diff --git a/packages/ai/script/recording-cost-report.ts b/packages/ai/script/recording-cost-report.ts index 5b08e72d5c7..1f42dc59323 100644 --- a/packages/ai/script/recording-cost-report.ts +++ b/packages/ai/script/recording-cost-report.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises" import * as path from "node:path" const RECORDINGS_DIR = path.resolve(import.meta.dir, "..", "test", "fixtures", "recordings") -const MODELS_DEV_URL = "https://models.dev/api.json" +const MODELS_DEV_URL = "https://models.opencode.ai/api.json" type JsonRecord = Record diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md index 2e56066e7d1..72a973ebd40 100644 --- a/packages/app/AGENTS.md +++ b/packages/app/AGENTS.md @@ -19,6 +19,21 @@ - Always prefer `createStore` over multiple `createSignal` calls +## Localization + +- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors. +- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change. +- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it. +- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar. +- Prefer complete translated phrases. Do not concatenate grammatical fragments or make call sites assemble sentences. Keep placeholders to irreducible dynamic values such as names, paths, and counts. +- If a translation cannot be expressed by the current API, deepen the shared language/UI i18n module so one typed call owns locale selection, plural resolution, fallback, and interpolation. Do not leak that machinery into product code. +- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`. +- For developer-facing terminology, prefer the words already used by the target language's developer community over literal dictionary translations. Cross-check maintained localized developer products such as Firefox, KDE, and VS Code; use at least two independent corpora when they are available. If established practice keeps an English loanword or acronym, keep it rather than inventing a translation. +- Translate complete UI phrases in context. A glossary hit is evidence, not permission to translate word-by-word. Check terse labels such as session, prompt, agent, model, fork, shell, terminal, workspace, and worktree in the same grammatical role before choosing a term. +- Before a locale is ready, audit recurring concepts for one consistent translation and review every value that still equals English. Classify retained English as a product name, provider/tool name, URL, code token, keyboard legend, acronym, asset name, or established borrowing; translate unexplained leftovers. +- In translation review notes, name the corpora used and call out uncertain or region-specific terminology so native speakers can focus review where it matters. +- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels. + ## Tool Calling - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. diff --git a/packages/app/e2e/AGENTS.md b/packages/app/e2e/AGENTS.md new file mode 100644 index 00000000000..e0c6c006021 --- /dev/null +++ b/packages/app/e2e/AGENTS.md @@ -0,0 +1,16 @@ +## Required Reading + +- Before writing, changing, or reviewing E2E tests, ALWAYS read and follow Playwright's official [Best Practices](https://playwright.dev/docs/best-practices), [Auto-waiting](https://playwright.dev/docs/actionability), and [Assertions](https://playwright.dev/docs/test-assertions) guides. +- Use the official [Locators](https://playwright.dev/docs/locators), [Network](https://playwright.dev/docs/network), and [Test Isolation](https://playwright.dev/docs/browser-contexts) guides when those concerns apply. + +## Test Hygiene + +- Test user-visible behavior with isolated, deterministic data and scoped, unique locators. +- Prefer role, label, text, and explicit test-contract locators. Do not use `.first()` or `.last()` merely to silence strictness errors. +- Use locator actions, Playwright auto-waiting, and web-first assertions for observable readiness and outcomes. +- NEVER use `waitForTimeout`, `setTimeout`, sleeps, animation-frame counts, or other wall-clock delays to synchronize a test. Wait for the specific UI state, request, response, event, or application outcome instead. +- Do not treat navigation, a network response, DOM attachment, or visibility alone as proof that asynchronously rendered UI is ready. Assert the state the next action actually requires. +- Register event and network waits before the action that triggers them. +- Do not retry state-changing actions. Retry idempotent readiness checks, then perform the action once and assert its outcome. +- Keep action and assertion timeouts adaptive. Do not use short timeouts as readiness probes or rely on retries to hide flakes. +- Assert exact outcomes and identities so stale state, duplicate rendering, and interactions with the wrong element cannot pass. diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index f718c9b7bee..513cc378671 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -8,7 +8,7 @@ import { expect, type Page } from "@playwright/test" import { Schema } from "effect" import { mockOpenCodeServer } from "../../utils/mock-server" import { installSseTransport } from "../../utils/sse-transport" -import { expectSessionTitle } from "../../utils/waits" +import { expectSessionReady } from "../../utils/waits" export const directory = "C:/OpenCode/TimelineStability" export const projectID = "proj_timeline_stability" @@ -111,8 +111,9 @@ export async function setupTimeline( active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" }, decodeOptions, ) + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const transport = await installSseTransport(page, { - server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + server, retry: input.eventRetry ?? 20, }) await mockOpenCodeServer(page, { @@ -161,8 +162,8 @@ export async function setupTimeline( }) } await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionReady(page, { server, sessionID, title }) await transport.waitForConnection() - await expectSessionTitle(page, title) if (input.cpuRate && input.cpuRate > 1) { const devtools = await page.context().newCDPSession(page) await devtools.send("Emulation.setCPUThrottlingRate", { rate: input.cpuRate }) @@ -198,7 +199,9 @@ export async function setupTimeline( ) }, async waitForPart(partID: string) { - await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible() + const part = page.locator(`[data-timeline-part-id="${partID}"]`) + await expect(part).toHaveCount(1) + await expect(part).toBeVisible() }, } } diff --git a/packages/app/e2e/regression/project-picker-recent-search.spec.ts b/packages/app/e2e/regression/project-picker-recent-search.spec.ts new file mode 100644 index 00000000000..2cdb0b4a03b --- /dev/null +++ b/packages/app/e2e/regression/project-picker-recent-search.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from "@playwright/test" +import type { Page } from "@playwright/test" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"] +const worktrees = NAMES.map((name) => `/opencode-demo/${name}`) + +// The sixth project sits outside the five-item recent cap, so it is only reachable if the +// dialog hands every recent project to the list filter instead of a pre-truncated slice. +const OUTSIDE_CAP = "foxtrot-docs" + +// Dialog rows carry data-directory-path; the sidebar project list does not, so this +// scopes assertions to the picker instead of matching the sidebar entry of the same name. +const rows = (page: Page) => page.locator("[data-directory-path]") +const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`) + +async function openProjectDialog(page: Page) { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + fileList: () => [], + findFiles: () => [], + }) + await page.addInitScript((dirs) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) }, + lastProject: {}, + }), + ) + }, worktrees) + await page.goto("/") + const add = page.getByRole("button", { name: "Add project" }).first() + await expectAppVisible(add) + await add.click() + await expect(rows(page)).toHaveCount(5) + return page.getByRole("textbox").last() +} + +test("searches every recent project, not just the five most recent", async ({ page }) => { + const search = await openProjectDialog(page) + await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) + + await search.fill("foxtrot") + + await expect(row(page, OUTSIDE_CAP)).toHaveCount(1) +}) + +test("still caps the idle recent list at five projects", async ({ page }) => { + await openProjectDialog(page) + + await expect(row(page, NAMES[4])).toHaveCount(1) + await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) +}) diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts index 7850f7820ac..3e50dab65d9 100644 --- a/packages/app/e2e/regression/review-line-comment.spec.ts +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -18,6 +18,7 @@ test("opens the comment editor when code is clicked", async ({ page }) => { await line.click() await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2") }) test("opens the comment editor when a line number is clicked", async ({ page }) => { @@ -27,6 +28,7 @@ test("opens the comment editor when a line number is clicked", async ({ page }) await lineNumber.click() await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1") }) test("opens the comment editor for a line number range", async ({ page }) => { @@ -36,15 +38,10 @@ test("opens the comment editor for a line number range", async ({ page }) => { await expectAppVisible(start) await expectAppVisible(end) - const from = await start.boundingBox() - const to = await end.boundingBox() - if (!from || !to) throw new Error("Missing line number bounds") - await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2) - await page.mouse.down() - await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2) - await page.mouse.up() + await start.dragTo(end) await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3") }) test("shows a comment button when a line number is hovered", async ({ page }) => { @@ -54,31 +51,38 @@ test("shows a comment button when a line number is hovered", async ({ page }) => const comment = review.getByRole("button", { name: "Comment", exact: true }) await expect(async () => { - await page.mouse.move(0, 0) await lineNumber.hover() - await expect(comment).toBeVisible({ timeout: 500 }) - await comment.click({ timeout: 500 }) - }).toPass() + await expect(lineNumber).toHaveAttribute("data-hovered", "") + await expect(comment).toHaveCount(1) + await expect(comment).toHaveCSS("pointer-events", "auto") + await comment.focus() + await expect(comment).toBeFocused() + }).toPass({ timeout: 10_000 }) + await comment.press("Enter") await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1") }) test("stages a submitted line comment in the prompt context", async ({ page }) => { - const requests: string[] = [] page.on("request", (request) => { - if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`) + expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET") }) const review = page.locator('[data-component="session-review"]') await review.getByText("export const value = 'after'", { exact: true }).click() - await review.getByRole("textbox").fill("Use the existing value instead") - await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click() + const textbox = review.getByRole("textbox") + await expect(textbox).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2") + await textbox.fill("Use the existing value instead") + const submit = review.locator('[data-slot="line-comment-action"][data-variant="primary"]') + await expect(submit).toBeEnabled() + await submit.click() await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible() await page.getByRole("tab", { name: "Session" }).click() const context = page.getByText("Use the existing value instead", { exact: true }).last() await expect(context).toBeVisible() await expect(context.locator("..")).toContainText("review.ts:2") - expect(requests).toEqual([]) }) async function openReview(page: Page) { @@ -144,15 +148,22 @@ async function openReview(page: Page) { await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, title) - const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff") - await page.getByRole("tab", { name: "Changes" }).click() + const changes = page.getByRole("tab", { name: "Changes" }) + const diffResponse = page.waitForResponse( + (response) => + response.request().method() === "GET" && response.ok() && new URL(response.url()).pathname === "/api/vcs/diff", + ) + await changes.click() expect((await (await diffResponse).json()).data).toHaveLength(1) + await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/) const review = page.locator('[data-component="session-review"]') await expectAppVisible(review) - await review - .getByRole("heading", { name: /review\.ts/ }) - .getByRole("button") - .first() - .click() + const file = review.locator('[data-file="src/review.ts"]') + await expectAppVisible(file) + const trigger = file.getByRole("button", { expanded: false }) + await expect(trigger).toHaveCount(1) + await trigger.click() + await expect(file.getByRole("button", { expanded: true })).toBeVisible() + await expect(file.getByText("export const value = 'after'", { exact: true })).toBeVisible() } diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index ba222eb3a64..6d102d863e8 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" -import { expectSessionTitle } from "../utils/waits" +import { expectSessionReady } from "../utils/waits" const directory = "C:/OpenCode/ReviewTerminalStacked" const projectID = "proj_review_terminal_stacked" @@ -19,10 +19,6 @@ const branchDiffs = [ test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => { test.setTimeout(120_000) - const events: Array<{ directory: string; payload: Record }> = [] - const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } } - let detailVersion = 1 - let detailFailures = 1 await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { protocol: "v2", @@ -57,10 +53,8 @@ test("keeps the review tree and terminal sized when both panels are open", async time: { created: 1700000000000, updated: 1700000000000 }, }, ], - sessionStatus: () => sessionStatus, + sessionStatus: { [sessionID]: { type: "idle" } }, pageMessages: () => ({ items: [] }), - events: () => events.splice(0, 1), - eventRetry: 16, }) await page.route(/\/api\/vcs(?:\?.*)?$/, (route) => route.fulfill({ @@ -73,20 +67,12 @@ test("keeps the review tree and terminal sized when both panels are open", async }), ) await page.route("**/api/vcs/diff**", (route) => { - const url = new URL(route.request().url()) - const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/") - const detail = scope?.endsWith("/src/branch/d00027") - if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" }) return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ location: { directory, project: { id: projectID, directory, canonical: directory } }, - data: detail - ? branchDiffs - .filter((diff) => diff.file.startsWith("src/branch/d00027/")) - .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion)) - : branchDiffs, + data: branchDiffs, }), }) }) @@ -146,7 +132,8 @@ test("keeps the review tree and terminal sized when both panels are open", async }) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await expectSessionTitle(page, title) + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + await expectSessionReady(page, { server, sessionID, title }) await expect(page.locator("#review-panel")).toBeVisible() await expectTree(page, 2_773, "action.yml") await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740") @@ -154,77 +141,6 @@ test("keeps the review tree and terminal sized when both panels are open", async await expect(page.locator("#terminal-panel")).toBeVisible() await expectTree(page, 2_773, "action.yml") await expectStackGeometry(page) - - const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') - await treeViewport.hover() - await page.mouse.wheel(0, 100_000) - await expect - .poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) - .toBeLessThanOrEqual(1) - const lastFile = page.getByRole("button", { name: "generated-2738.ts" }) - await expect(lastFile).toBeVisible() - const bottomGap = await lastFile.evaluate((element) => { - const viewport = element.closest(".scroll-view__viewport")!.getBoundingClientRect() - return viewport.bottom - element.getBoundingClientRect().bottom - }) - expect(bottomGap).toBeGreaterThanOrEqual(0) - expect(bottomGap).toBeLessThanOrEqual(16) - const lazyDiff = page.waitForRequest((request) => { - const url = new URL(request.url()) - return ( - url.pathname === "/api/vcs/diff" && - url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true - ) - }) - await lastFile.click() - await lazyDiff - const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]') - await expect(preview).toContainText("after-1") - detailVersion = 2 - sessionStatus[sessionID] = { type: "busy" } - events.push(statusEvent("busy")) - await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() - const refreshedDiff = page.waitForRequest((request) => { - const url = new URL(request.url()) - return ( - url.pathname === "/api/vcs/diff" && - url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true - ) - }) - sessionStatus[sessionID] = { type: "idle" } - events.push(statusEvent("idle")) - await refreshedDiff - await expect(preview).toContainText("after-2") - const filter = page.getByRole("searchbox", { name: "Filter files" }) - await filter.fill("generated-2738") - await expectTree(page, 1, "generated-2738.ts") - await filter.fill("") - await expectTree(page, 2_773, "generated-2738.ts") - - await page.getByRole("button", { name: "Toggle file tree" }).click() - await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0) - await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0) - await page.getByRole("button", { name: "Toggle file tree" }).click() - await expectTree(page, 2_773, "generated-2738.ts") - - await page.keyboard.press("Control+Backquote") - await expect(page.locator("#terminal-panel")).toHaveCount(0) - await expectTree(page, 2_773, "generated-2738.ts") - await page.keyboard.press("Control+Backquote") - await expect(page.locator("#terminal-panel")).toBeVisible() - await expectTree(page, 2_773, "generated-2738.ts") - - await page.getByRole("button", { name: "Toggle review" }).click() - await expect(page.locator("#review-panel")).toHaveCount(0) - await page.getByRole("button", { name: "Toggle review" }).click() - await expectTree(page, 2_773, "generated-2738.ts") - await page.setViewportSize({ width: 1_000, height: 700 }) - await expectTree(page, 2_773, "generated-2738.ts") - await expectStackGeometry(page) - await page.setViewportSize({ width: 1_000, height: 120 }) - await page.setViewportSize({ width: 1_400, height: 900 }) - await expectTree(page, 2_773, "generated-2738.ts") - await expectStackGeometry(page) }) async function expectTree(page: Page, total: number, file: string) { @@ -255,36 +171,32 @@ async function expectStackGeometry(page: Page) { const terminal = document.querySelector("#terminal-panel")! const reviewParent = review.parentElement!.getBoundingClientRect() const terminalParent = terminal.parentElement!.getBoundingClientRect() + const sidebar = review.querySelector('[data-slot="session-review-v2-sidebar"]')! return { review: review.getBoundingClientRect().height, reviewParent: reviewParent.height, terminal: terminal.getBoundingClientRect().height, terminalParent: terminalParent.height, + sidebar: sidebar.getBoundingClientRect().width, } }) expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1) expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1) + expect(geometry.sidebar).toBeGreaterThanOrEqual(240) } function base64Encode(value: string) { return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") } -function statusEvent(type: "busy" | "idle") { - return { - directory, - payload: { type: "session.status", properties: { sessionID, status: { type } } }, - } -} - -function fileDiff(file: string, additions: number, loaded = true, version = 1) { +function fileDiff(file: string, additions: number, loaded = true) { return { file, additions, deletions: 0, status: "modified", patch: loaded - ? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n` + ? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n` : `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`, } } diff --git a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts index 3901f8865e3..d45ec0bd627 100644 --- a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test" import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" for (const profile of [ - { locale: "de", label: "Erkundet" }, + { locale: "de", label: "Erkundung abgeschlossen" }, { locale: "ar", label: "تم الاستكشاف" }, ] as const) { test(`projects translated context status in ${profile.locale}`, async ({ page }) => { diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index 681e1bcd664..8f00343ecf9 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -1,12 +1,5 @@ -import { expect, test } from "@playwright/test" -import { - assistantMessage, - partUpdated, - setupTimeline, - status, - textPart, - userMessage, -} from "../performance/timeline-stability/fixture" +import { expect, test, type Page } from "@playwright/test" +import { partUpdated, setupTimeline, textPart } from "../performance/timeline-stability/fixture" test("keeps one connection open while delivering multiple events", async ({ page }) => { const timeline = await setupTimeline(page) @@ -17,7 +10,7 @@ test("keeps one connection open while delivering multiple events", async ({ page await timeline.waitForPart("prt_transport_first") await timeline.waitForPart("prt_transport_second") expect(first.connectionID).toBe(second.connectionID) - expect(await timeline.transport.connections()).toHaveLength(1) + await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) expect(await timeline.transport.acknowledgements()).toHaveLength(2) }) @@ -51,20 +44,23 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => { }) test("delivers server heartbeat without mutating the timeline", async ({ page }) => { - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])], - }) - const before = await page.locator("[data-timeline-row]").allTextContents() + const timeline = await setupTimeline(page) + const partID = "prt_transport_heartbeat_sentinel" + const sentinel = await timeline.transport.send(partUpdated(textPart(partID, "heartbeat sentinel"))) + await timeline.waitForPart(partID) + await expect( + page.locator(`[data-timeline-part-id="${partID}"] [data-component="markdown"]`), + ).toHaveAttribute("data-markdown-ready", "") + const before = await timelineRows(page) + const heartbeat = await timeline.transport.heartbeat() - await timeline.transport.heartbeat() - await timeline.settle() - - expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before) - expect(await timeline.transport.connections()).toHaveLength(1) + await expect.poll(() => timelineRows(page)).toEqual(before) + expect(heartbeat.connectionID).toBe(sentinel.connectionID) + await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) }) test("reconnects after a clean close", async ({ page }) => { - const timeline = await setupTimeline(page, { eventRetry: 10 }) + const timeline = await setupTimeline(page) const first = await timeline.transport.waitForConnection() await timeline.transport.close() @@ -77,13 +73,14 @@ test("reconnects after a clean close", async ({ page }) => { }) test("reconnects after a stream error", async ({ page }) => { - const timeline = await setupTimeline(page, { eventRetry: 10 }) + const timeline = await setupTimeline(page) const first = await timeline.transport.waitForConnection() await timeline.transport.error("contract failure") const second = await timeline.transport.waitForConnection({ after: first.id }) - await timeline.transport.send(status("busy")) + await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error"))) + await timeline.waitForPart("prt_transport_error") await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2) expect(second.id).toBeGreaterThan(first.id) expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") @@ -112,5 +109,18 @@ test("passes through non-event fetches", async ({ page }) => { }) expect(health).toEqual({ healthy: true, version: "2.0.0", pid: 1 }) - expect(await timeline.transport.connections()).toHaveLength(1) + await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) }) + +function timelineRows(page: Page) { + return page.locator("[data-timeline-row]").evaluateAll((rows) => + rows.map((row) => ({ + kind: row.getAttribute("data-timeline-row"), + message: row.getAttribute("data-message-id"), + parts: Array.from(row.querySelectorAll("[data-timeline-part-id]"), (part) => + part.getAttribute("data-timeline-part-id"), + ), + text: row.textContent, + })), + ) +} diff --git a/packages/app/e2e/regression/terminal-tab-switch.spec.ts b/packages/app/e2e/regression/terminal-tab-switch.spec.ts index 5b8d53affaa..b4932158261 100644 --- a/packages/app/e2e/regression/terminal-tab-switch.spec.ts +++ b/packages/app/e2e/regression/terminal-tab-switch.spec.ts @@ -32,7 +32,7 @@ test("keeps the terminal session alive when switching session tabs in a workspac const connection = new URL(connections[0]!) expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`) expect(connection.searchParams.get("location[directory]")).toBe(directory) - expect(connection.searchParams.get("ticket")).toBeNull() + expect(connection.searchParams.get("ticket")).toBe("e2e-ticket") await writeProbe(page) await switchTab(page, titleB) diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index b1f5ba9918f..fdfe13f85b2 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -234,18 +234,23 @@ export async function installSseTransport( return { server, async waitForConnection(input = {}) { - await page.waitForFunction( + const connection = await page.waitForFunction( (after) => { const transport = (window as BrowserTransport).__testSseTransport const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined - return connections?.some((connection) => connection.id > after) + return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined) }, input.after ?? 0, { timeout: input.timeout }, ) - return (await command({ type: "connections" })).findLast( - (connection) => connection.id > (input.after ?? 0), - )! + let result: SseConnectionRecord | undefined + try { + result = await connection.jsonValue() + } finally { + await connection.dispose() + } + if (!result) throw new Error("SSE transport connection disappeared while waiting") + return result }, send(payload, eventOptions) { return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false }) diff --git a/packages/app/e2e/utils/waits.ts b/packages/app/e2e/utils/waits.ts index 8a47815674d..488dc056343 100644 --- a/packages/app/e2e/utils/waits.ts +++ b/packages/app/e2e/utils/waits.ts @@ -1,4 +1,5 @@ import { expect, type Locator, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" export const APP_READY_TIMEOUT = 30_000 @@ -9,3 +10,11 @@ export async function expectAppVisible(locator: Locator) { export async function expectSessionTitle(page: Page, title: string) { await expectAppVisible(page.getByRole("heading", { name: title })) } + +export async function expectSessionReady( + page: Page, + input: { server: string; sessionID: string; title: string }, +) { + await expect(page).toHaveURL(`/server/${base64Encode(input.server)}/session/${input.sessionID}`) + await expectSessionTitle(page, input.title) +} diff --git a/packages/app/package.json b/packages/app/package.json index 60d03064c67..88009d6d916 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,11 +1,12 @@ { "name": "@opencode-ai/app", - "version": "1.18.8", + "version": "1.18.15", "description": "", "type": "module", "exports": { ".": "./src/index.ts", "./desktop-menu": "./src/desktop-menu.ts", + "./i18n/desktop-native": "./src/i18n/desktop-native.ts", "./updater": "./src/updater.ts", "./wsl/types": "./src/wsl/types.ts", "./vite": "./vite.js", @@ -19,9 +20,9 @@ "build": "vite build", "serve": "vite preview", "test": "bun run test:unit && bun run test:browser", - "test:unit": "bun test --only-failures --preload ./happydom.ts ./src", + "test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src", "test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser", - "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", + "test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src", "test:e2e": "playwright test", "test:e2e:local": "playwright test", "test:e2e:ui": "playwright test --ui", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 5f95b51fc6c..0ea785d29ec 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -3,7 +3,6 @@ import * as Sentry from "@sentry/solid" import { I18nProvider } from "@opencode-ai/ui/context" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" -import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { File } from "@opencode-ai/session-ui/file" import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" @@ -155,7 +154,13 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) { function UiI18nBridge(props: ParentProps) { const language = useLanguage() - return {props.children} + return ( + + {props.children} + + ) } declare global { @@ -218,7 +223,7 @@ function DesktopCommands() { if (platform.platform === "desktop" && platform.exportDebugLogs) { commands.push({ id: "logs.export", - title: "Export logs", + title: language.t("command.logs.export"), category: language.t("command.category.settings"), onSelect: () => { void platform.exportDebugLogs?.() @@ -267,7 +272,12 @@ function DraftProviders(props: ParentProps) { ) } -export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { +export function AppBaseProviders( + props: ParentProps<{ + locale?: Locale + onNativeTranslations?: Parameters[0]["onNativeTranslations"] + }>, +) { return ( @@ -276,7 +286,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { void window.api?.setTitlebar?.({ mode, scheme }) }} > - + { @@ -287,9 +297,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { - - {props.children} - + {props.children} diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx index 67c9a899294..9465374cbb0 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/packages/app/src/components/debug-bar.tsx @@ -60,35 +60,44 @@ function Cell(props: { label: string tip: string value: string - wide?: boolean + span?: 2 | 3 }) { const content = () => (
- {props.label} -
-
- {props.value} +
+ {props.label} +
+
+ {props.value} +
) @@ -108,37 +117,50 @@ function Cell(props: { ) } -function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) { +function ToggleCell(props: { + active: boolean + inline?: boolean + label: string + onClick: () => void + tip: string + value: string +}) { const content = () => ( ) if (props.inline) { return ( - + {content()} ) } return ( - + {content()} ) @@ -457,7 +479,7 @@ export function DebugBar(props: { inline?: boolean } = {}) { "gap-[9px]": !!props.inline, "gap-px": !props.inline, "flex w-full flex-nowrap items-center justify-start": !!props.inline, - "grid-cols-5": !props.inline, + "grid-cols-4": !props.inline, grid: !props.inline, }} > @@ -539,10 +561,25 @@ export function DebugBar(props: { inline?: boolean } = {}) { bad={bad(heap(), 0.8)} dim={state.heap.used === undefined} inline={props.inline} - wide={!platform.setForceFocus} + span={platform.setForceFocus ? 2 : 3} + /> + language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")} /> {platform.setForceFocus && ( - void toggleFocus()} /> + void toggleFocus()} + /> )} diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index a6fcf191734..607e0bb6aa4 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -29,7 +29,7 @@ import { } from "solid-js" import { createStore, produce } from "solid-js/store" import { useParams } from "@solidjs/router" -import { Link } from "@/components/link" +import { ExternalLink } from "@/components/external-link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" @@ -511,7 +511,12 @@ function ProviderConnection(props: { const hint = suffix?.[1] return { label: suffix ? label.slice(0, -suffix[0].length) : label, - hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined, + hint: + hint?.toLowerCase() === "headless" + ? language.t("provider.connect.method.headless") + : hint?.toLowerCase() === "browser" || (!hint && value?.type === "key") + ? language.t("provider.connect.method.browser") + : undefined, } } @@ -848,12 +853,12 @@ function ProviderConnection(props: {
{language.t("provider.connect.opencodeZen.line2")}
{language.t("provider.connect.opencodeZen.visit.prefix")} - {language.t("provider.connect.opencodeZen.visit.link")} - + {language.t("provider.connect.opencodeZen.visit.suffix")}
@@ -898,9 +903,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.opencodeZen.line2")}
{language.t("provider.connect.opencodeZen.visit.prefix")} - + {language.t("provider.connect.opencodeZen.visit.link")} - + {language.t("provider.connect.opencodeZen.visit.suffix")}
@@ -979,9 +984,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.oauth.code.visit.prefix")} - + {language.t("provider.connect.oauth.code.visit.link")} - + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
@@ -1018,7 +1023,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.oauth.code.visit.prefix")} - {language.t("provider.connect.oauth.code.visit.link")} + + {language.t("provider.connect.oauth.code.visit.link")} + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
@@ -1089,7 +1096,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.oauth.auto.visit.prefix")} - {language.t("provider.connect.oauth.auto.visit.link")} + + {language.t("provider.connect.oauth.auto.visit.link")} + {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
({ @@ -132,7 +130,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) { const saveMutation = useMutation(() => ({ mutationFn: async (result: NonNullable>): Promise => { // TODO: Restore custom providers when V2 exposes config and arbitrary credential APIs. - throw new Error(`Custom provider ${result.providerID} is unavailable`) + throw new Error(language.t("provider.custom.unavailable")) }, onSuccess: (result) => { dialog.close() @@ -168,9 +166,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {

{language.t("provider.custom.description.prefix")} - + {language.t("provider.custom.description.link")} - + {language.t("provider.custom.description.suffix")}

diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 33c8a73d41e..3d843787d7d 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -18,6 +18,8 @@ interface DialogSelectDirectoryProps { server: ServerConnection.Any } +const RECENT_PROJECT_LIMIT = 5 + type Row = { absolute: string search: string @@ -107,7 +109,6 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { return projects .map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index })) .sort((a, b) => b.at - a.at || a.index - b.index) - .slice(0, 5) .map(({ project }) => { const row = toRow(project.worktree, home(), "recent") const name = project.name || getFilename(project.worktree) @@ -121,7 +122,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const items = async (value: string) => { const results = await directories(value) const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders")) - return uniqueRows([...recentProjects(), ...directoryRows]) + // Cap the idle list only. Once a query narrows the results, every project stays searchable. + const recent = recentProjects() + const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT) + return uniqueRows([...visible, ...directoryRows]) } function resolve(absolute: string) { diff --git a/packages/app/src/components/dialog-usage-exceeded.tsx b/packages/app/src/components/dialog-usage-exceeded.tsx index e428d4c2bbb..de6451a63b6 100644 --- a/packages/app/src/components/dialog-usage-exceeded.tsx +++ b/packages/app/src/components/dialog-usage-exceeded.tsx @@ -1,4 +1,5 @@ import { usePlatform } from "@/context/platform" +import { useLanguage } from "@/context/language" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -14,10 +15,11 @@ export type DialogGoUpsellProps = { export function DialogUsageExceeded(props: DialogGoUpsellProps) { const dialog = useDialog() + const language = useLanguage() const platform = usePlatform() const runAction = () => { - if (props.link) platform.openLink(props.link) + if (props.link) platform.openExternal(props.link) props.onClose?.() dialog.close() } @@ -32,7 +34,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
+
{(message) => ( diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index da1770cd130..dc6cdaa6f3f 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -289,9 +289,9 @@ export function SessionHeader() { return ( <> - + {(mount) => ( - +
- + {(keybind) => ( - {keybind()} + {keybind} )} @@ -319,9 +319,9 @@ export function SessionHeader() { )} - + {(mount) => ( - + { + result.set(shell.name, (result.get(shell.name) ?? 0) + 1) + return result + }, new Map()) + const options: ShellSelectOption[] = [ + { id: "auto", value: "", name: "", terminalOnly: false }, + ...input.shells.map((shell) => { + const ambiguous = (counts.get(shell.name) ?? 0) > 1 + const name = ambiguous ? shell.path : shell.name + return { + id: shell.path, + value: ambiguous ? shell.path : shell.name, + name, + terminalOnly: !shell.acceptable, + } + }), + ] + if (input.current && !options.some((option) => option.value === input.current)) { + options.push({ id: input.current, value: input.current, name: input.current, terminalOnly: false }) + } + return options +} + +export function createSoundPreviewController(player: (id: string | undefined) => Promise<(() => void) | undefined>) { + let cleanup: (() => void) | undefined + let timeout: ReturnType | undefined + let run = 0 + + const stop = () => { + run += 1 + cleanup?.() + clearTimeout(timeout) + cleanup = undefined + timeout = undefined + } + const play = (id: string | undefined) => { + stop() + if (!id) return + const current = ++run + timeout = setTimeout(() => { + timeout = undefined + void player(id).then((next) => { + if (run === current) { + cleanup = next + return + } + next?.() + }) + }, 100) + } + + onCleanup(stop) + return { play, stop } +} diff --git a/packages/app/src/components/settings-v2/general-controllers.test.ts b/packages/app/src/components/settings-v2/general-controllers.test.ts new file mode 100644 index 00000000000..09c8d21bd53 --- /dev/null +++ b/packages/app/src/components/settings-v2/general-controllers.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test, vi } from "bun:test" +import { createRoot } from "solid-js" +import { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" + +describe("settings v2 controllers", () => { + test("normalizes shell names and preserves an unavailable configured shell", () => { + expect( + createShellOptions({ + shells: [ + { path: "/bin/bash", name: "bash", acceptable: true }, + { path: "/opt/bash", name: "bash", acceptable: false }, + { path: "/bin/zsh", name: "zsh", acceptable: true }, + ], + current: "fish", + }), + ).toEqual([ + { id: "auto", value: "", name: "", terminalOnly: false }, + { id: "/bin/bash", value: "/bin/bash", name: "/bin/bash", terminalOnly: false }, + { id: "/opt/bash", value: "/opt/bash", name: "/opt/bash", terminalOnly: true }, + { id: "/bin/zsh", value: "zsh", name: "zsh", terminalOnly: false }, + { id: "fish", value: "fish", name: "fish", terminalOnly: false }, + ]) + }) + + test("debounces previews and stops owned audio on disposal", async () => { + vi.useFakeTimers() + try { + const played: string[] = [] + const stopped: string[] = [] + const owned = createRoot((dispose) => ({ + dispose, + preview: createSoundPreviewController(async (id) => { + played.push(id ?? "") + return () => stopped.push(id ?? "") + }), + })) + + owned.preview.play("first") + vi.advanceTimersByTime(99) + expect(played).toEqual([]) + + owned.preview.play("second") + vi.advanceTimersByTime(100) + await Promise.resolve() + expect(played).toEqual(["second"]) + + owned.dispose() + expect(stopped).toEqual(["second"]) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/packages/app/src/components/settings-v2/general-controllers.ts b/packages/app/src/components/settings-v2/general-controllers.ts new file mode 100644 index 00000000000..d3a3b7a5788 --- /dev/null +++ b/packages/app/src/components/settings-v2/general-controllers.ts @@ -0,0 +1,172 @@ +import { createMemo, createResource, onMount, type Accessor } from "solid-js" +import type { ColorScheme } from "@opencode-ai/ui/theme/context" +import { useTheme } from "@opencode-ai/ui/theme/context" +import { usePermission } from "@/context/permission" +import { useServerSync } from "@/context/server-sync" +import { + monoDefault, + monoFontFamily, + monoInput, + sansDefault, + sansFontFamily, + sansInput, + terminalDefault, + terminalFontFamily, + terminalInput, + useSettings, +} from "@/context/settings" +import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" +import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior" + +export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" +export type { ShellOption, ShellSelectOption } from "./general-controller-behavior" + +export function createPermissionScopeController(sessionID: Accessor) { + const permission = usePermission() + const serverSync = useServerSync() + const directory = createMemo(() => { + const id = sessionID() + if (!id) return undefined + return serverSync().session.lineage.peek(id)?.session.location.directory + }) + + return { + accepting: createMemo(() => { + const id = sessionID() + const dir = directory() + if (!id || !dir) return false + return permission.isAutoAccepting(id, dir) + }), + enabled: createMemo(() => !!directory()), + set: (checked: boolean) => { + const id = sessionID() + const dir = directory() + if (!id || !dir) return + if (checked) return permission.enableAutoAccept(id, dir) + permission.disableAutoAccept(id, dir) + }, + } +} + +export function createShellSettingsController() { + const serverSync = useServerSync() + const [shells] = createResource( + async () => { + // TODO: Dax is considering the V2 shell discovery and config update APIs. + // return (await sdk.api.pty.shells()).data + return [] as ShellOption[] + }, + { initialValue: [] as ShellOption[] }, + ) + const current = createMemo(() => serverSync().data.config.shell ?? "") + + return { + shells: () => shells.latest, + current, + select: (value: string) => { + if (value === current()) return + // TODO: Dax is considering the V2 shell discovery and config update APIs. + // void serverSync().updateConfig({ shell: value }) + }, + } +} + +export function createAppearanceSettingsController() { + const settings = useSettings() + const theme = useTheme() + const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) + + onMount(() => void theme.loadThemes()) + + return { + scheme: { + current: theme.colorScheme, + select: (value: ColorScheme) => theme.setColorScheme(value), + }, + theme: { + options: themes, + current: createMemo(() => themes().find((option) => option.id === theme.themeId())), + select: (option: { id: string } | null) => option && theme.setTheme(option.id), + }, + fonts: { + ui: createMemo(() => ({ + value: sansInput(settings.appearance.uiFont()), + family: sansFontFamily(settings.appearance.uiFont()), + placeholder: sansDefault, + })), + code: createMemo(() => ({ + value: monoInput(settings.appearance.font()), + family: monoFontFamily(settings.appearance.font()), + placeholder: monoDefault, + })), + terminal: createMemo(() => ({ + value: terminalInput(settings.appearance.terminalFont()), + family: terminalFontFamily(settings.appearance.terminalFont()), + placeholder: terminalDefault, + })), + setUI: (value: string) => settings.appearance.setUIFont(value), + setCode: (value: string) => settings.appearance.setFont(value), + setTerminal: (value: string) => settings.appearance.setTerminalFont(value), + }, + } +} + +const noneSound = { id: "none", label: "sound.option.none" } as const +export const soundOptions = [noneSound, ...SOUND_OPTIONS] +export type SoundSelectOption = (typeof soundOptions)[number] + +export function createSoundSettingsController() { + const settings = useSettings() + const preview = createSoundPreviewController(playSoundById) + const channel = ( + enabled: Accessor, + current: Accessor, + setEnabled: (value: boolean) => void, + set: (id: string) => void, + ) => ({ + current: createMemo(() => + enabled() ? (soundOptions.find((option) => option.id === current()) ?? noneSound) : noneSound, + ), + highlight: (option: SoundSelectOption | undefined) => { + if (!option) return + preview.play(option.id === "none" ? undefined : option.id) + }, + select: (option: SoundSelectOption | null) => { + if (!option) return + if (option.id === "none") { + setEnabled(false) + preview.stop() + return + } + setEnabled(true) + set(option.id) + preview.play(option.id) + }, + }) + + return { + agent: channel( + settings.sounds.agentEnabled, + settings.sounds.agent, + (value) => settings.sounds.setAgentEnabled(value), + (id) => settings.sounds.setAgent(id), + ), + permissions: channel( + settings.sounds.permissionsEnabled, + settings.sounds.permissions, + (value) => settings.sounds.setPermissionsEnabled(value), + (id) => settings.sounds.setPermissions(id), + ), + errors: channel( + settings.sounds.errorsEnabled, + settings.sounds.errors, + (value) => settings.sounds.setErrorsEnabled(value), + (id) => settings.sounds.setErrors(id), + ), + } +} + +export type PermissionScopeController = ReturnType +export type ShellSettingsController = ReturnType +export type AppearanceSettingsController = ReturnType +export type SoundSettingsController = ReturnType diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 2f0d95e690e..66b8ccd69c2 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -1,174 +1,294 @@ -import { Component, Show, createMemo, createResource, onMount } from "solid-js" +import { Component, Show, createMemo, createResource } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useLanguage } from "@/context/language" -import { usePermission } from "@/context/permission" import { usePlatform } from "@/context/platform" -import { useServerSync } from "@/context/server-sync" import { useUpdaterAction } from "../updater-action" -import { - monoDefault, - monoFontFamily, - monoInput, - sansDefault, - sansFontFamily, - sansInput, - terminalDefault, - terminalFontFamily, - terminalInput, - useSettings, -} from "@/context/settings" -import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" -import { Link } from "../link" +import { useSettings } from "@/context/settings" +import { ExternalLink } from "../external-link" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" +import { + createAppearanceSettingsController, + createPermissionScopeController, + createShellOptions, + createShellSettingsController, + createSoundSettingsController, + soundOptions, + type AppearanceSettingsController, + type PermissionScopeController, + type ShellSettingsController, + type SoundSettingsController, +} from "./general-controllers" import "./settings-v2.css" -let demoSoundState = { - cleanup: undefined as (() => void) | undefined, - timeout: undefined as NodeJS.Timeout | undefined, - run: 0, +const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"] +const fontSettings = { + ui: { + action: "settings-ui-font", + title: "settings.general.row.uiFont.title", + description: "settings.general.row.uiFont.description", + font: "ui", + input: "setUI", + }, + code: { + action: "settings-code-font", + title: "settings.general.row.font.title", + description: "settings.general.row.font.description", + font: "code", + input: "setCode", + }, + terminal: { + action: "settings-terminal-font", + title: "settings.general.row.terminalFont.title", + description: "settings.general.row.terminalFont.description", + font: "terminal", + input: "setTerminal", + }, +} as const +const soundSettings = { + agent: { + action: "settings-sounds-agent", + title: "settings.general.sounds.agent.title", + description: "settings.general.sounds.agent.description", + }, + permissions: { + action: "settings-sounds-permissions", + title: "settings.general.sounds.permissions.title", + description: "settings.general.sounds.permissions.description", + }, + errors: { + action: "settings-sounds-errors", + title: "settings.general.sounds.errors.title", + description: "settings.general.sounds.errors.description", + }, +} as const + +const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => { + const language = useLanguage() + return ( + +
+ +
+
+ ) } -type ThemeOption = { - id: string - name: string +const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { + const language = useLanguage() + const options = createMemo(() => + createShellOptions({ + shells: props.controller.shells(), + current: props.controller.current(), + }), + ) + return ( + + option.value === props.controller.current()) ?? options()[0]} + placement="bottom-end" + gutter={6} + value={(option) => option.id} + label={(option) => { + if (option.id === "auto") return language.t("settings.general.row.shell.autoDefault") + if (!option.terminalOnly) return option.name + return `${option.name} (${language.t("settings.general.row.shell.terminalOnly")})` + }} + onSelect={(option) => option && props.controller.select(option.value)} + /> + + ) } -type ShellOption = { - path: string - name: string - acceptable: boolean +const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => { + const language = useLanguage() + return ( +
+

{language.t("settings.general.section.appearance")}

+ + + option === props.controller.scheme.current())} + placement="bottom-end" + gutter={6} + label={(option) => { + if (option === "system") return language.t("theme.scheme.system") + if (option === "light") return language.t("theme.scheme.light") + return language.t("theme.scheme.dark") + }} + onSelect={(option) => option && props.controller.scheme.select(option)} + /> + + + + {language.t("settings.general.row.theme.description")}{" "} + + {language.t("common.learnMore")} + + + } + > + option.id} + label={(option) => option.name} + onSelect={props.controller.theme.select} + /> + + + + + + +
+ ) } -type ShellSelectOption = { - id: string - value: string - label: string +const FontSetting: Component<{ + kind: "ui" | "code" | "terminal" + fonts: AppearanceSettingsController["fonts"] +}> = (props) => { + const language = useLanguage() + const config = () => fontSettings[props.kind] + return ( + +
+ props.fonts[config().input](event.currentTarget.value)} + placeholder={props.fonts[config().font]().placeholder} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + aria-label={language.t(config().title)} + style={{ "font-family": props.fonts[config().font]().family }} + /> +
+
+ ) } -// To prevent audio from overlapping/playing very quickly when navigating the settings menus, -// delay the playback by 100ms during quick selection changes and pause existing sounds. -const stopDemoSound = () => { - demoSoundState.run += 1 - if (demoSoundState.cleanup) { - demoSoundState.cleanup() - } - clearTimeout(demoSoundState.timeout) - demoSoundState.cleanup = undefined +const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => { + const language = useLanguage() + return ( +
+

{language.t("settings.general.section.sounds")}

+ + + + + +
+ ) } -const playDemoSound = (id: string | undefined) => { - stopDemoSound() - if (!id) return +const SoundSetting: Component<{ + kind: "agent" | "permissions" | "errors" + channel: SoundSettingsController["agent"] +}> = (props) => { + const language = useLanguage() + const config = () => soundSettings[props.kind] + return ( + + option.id} + label={(option) => language.t(option.label)} + onHighlight={props.channel.highlight} + onSelect={props.channel.select} + placement="bottom-end" + gutter={6} + /> + + ) +} - const run = ++demoSoundState.run - demoSoundState.timeout = setTimeout(() => { - void playSoundById(id).then((cleanup) => { - if (demoSoundState.run !== run) { - cleanup?.() - return - } - demoSoundState.cleanup = cleanup - }) - }, 100) +const LanguageSetting = () => { + const language = useLanguage() + const options = createMemo(() => + language.locales.map((locale) => ({ + value: locale, + label: language.label(locale), + })), + ) + return ( + + option.value === language.locale())} + value={(option) => option.value} + label={(option) => option.label} + onSelect={(option) => option && language.setLocale(option.value)} + /> + + ) } export const SettingsGeneralV2: Component<{ sessionID?: string }> = (props) => { - const theme = useTheme() const language = useLanguage() - const permission = usePermission() const platform = usePlatform() const settings = useSettings() - const serverSync = useServerSync() const mobile = createMediaQuery("(max-width: 767px)") - const updater = useUpdaterAction() - - const dir = createMemo(() => { - if (!props.sessionID) return undefined - return serverSync().session.lineage.peek(props.sessionID)?.session.location.directory - }) - const accepting = createMemo(() => { - const value = dir() - if (!value || !props.sessionID) return false - return permission.isAutoAccepting(props.sessionID, value) - }) - - const toggleAccept = (checked: boolean) => { - const value = dir() - if (!value || !props.sessionID) return - - if (checked) { - permission.enableAutoAccept(props.sessionID, value) - return - } - - permission.disableAutoAccept(props.sessionID, value) - } + const permissionScope = createPermissionScopeController(() => props.sessionID) + const shell = createShellSettingsController() + const appearance = createAppearanceSettingsController() + const sounds = createSoundSettingsController() const desktop = createMemo(() => platform.platform === "desktop") - const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) - - const [shells] = createResource( - async () => { - // TODO: Restore executable shell discovery when the V2 client exposes it. - // return (await sdk.api.pty.shells()).data - return [] as ShellOption[] - }, - { initialValue: [] as ShellOption[] }, - ) - const [pinchZoom, { mutate: setPinchZoom }] = createResource( - () => (desktop() && platform.getPinchZoomEnabled ? true : false), + () => desktop() && "getPinchZoomEnabled" in platform, () => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false), { initialValue: false }, ) - onMount(() => { - void theme.loadThemes() - }) - - const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") } - const currentShell = createMemo(() => serverSync().data.config.shell ?? "") - - const shellOptions = createMemo(() => { - const list = shells.latest - const current = serverSync().data.config.shell - - const nameCounts = new Map() - for (const s of list) { - nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1) - } - - const options = [ - autoOption, - ...list.map((s) => { - const ambiguousName = (nameCounts.get(s.name) || 0) > 1 - const text = ambiguousName ? s.path : s.name - const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})` - return { - id: s.path, - // Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH. - value: ambiguousName ? s.path : s.name, - label, - } - }), - ] - - if (current && !options.some((o) => o.value === current)) { - options.push({ id: current, value: current, label: current }) - } - - return options - }) - const onPinchZoomChange = (checked: boolean) => { setPinchZoom(checked) const update = platform.setPinchZoomEnabled?.(checked) @@ -176,101 +296,14 @@ export const SettingsGeneralV2: Component<{ void update.catch(() => setPinchZoom(!checked)) } - const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ - { value: "system", label: language.t("theme.scheme.system") }, - { value: "light", label: language.t("theme.scheme.light") }, - { value: "dark", label: language.t("theme.scheme.dark") }, - ]) - - const languageOptions = createMemo(() => - language.locales.map((locale) => ({ - value: locale, - label: language.label(locale), - })), - ) - - const noneSound = { id: "none", label: "sound.option.none" } as const - const soundOptions = [noneSound, ...SOUND_OPTIONS] - const mono = () => monoInput(settings.appearance.font()) - const sans = () => sansInput(settings.appearance.uiFont()) - const terminal = () => terminalInput(settings.appearance.terminalFont()) - - const soundSelectProps = ( - enabled: () => boolean, - current: () => string, - setEnabled: (value: boolean) => void, - set: (id: string) => void, - ) => ({ - options: soundOptions, - current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound, - value: (o: (typeof soundOptions)[number]) => o.id, - label: (o: (typeof soundOptions)[number]) => language.t(o.label), - onHighlight: (option: (typeof soundOptions)[number] | undefined) => { - if (!option) return - playDemoSound(option.id === "none" ? undefined : option.id) - }, - onSelect: (option: (typeof soundOptions)[number] | null) => { - if (!option) return - if (option.id === "none") { - setEnabled(false) - stopDemoSound() - return - } - setEnabled(true) - set(option.id) - playDemoSound(option.id) - }, - }) - const GeneralSection = () => (
- - o.value === language.locale())} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(option) => option && language.setLocale(option.value)} - /> - + - -
- -
-
+ - - o.value === currentShell()) ?? autoOption} - placement="bottom-end" - gutter={6} - value={(o) => o.id} - label={(o) => o.label} - onSelect={(option) => { - if (!option) return - if (option.value === currentShell()) return - serverSync().updateConfig({ shell: option.value }) - }} - /> - + ) - const AppearanceSection = () => ( -
-

{language.t("settings.general.section.appearance")}

- - - - o.value === theme.colorScheme())} - placement="bottom-end" - gutter={6} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(option) => option && theme.setColorScheme(option.value)} - /> - - - - {language.t("settings.general.row.theme.description")}{" "} - - {language.t("common.learnMore")} - - - } - > - o.id === theme.themeId())} - placement="bottom-end" - gutter={6} - value={(o) => o.id} - label={(o) => o.name} - onSelect={(option) => { - if (!option) return - theme.setTheme(option.id) - }} - /> - - - -
- settings.appearance.setUIFont(event.currentTarget.value)} - placeholder={sansDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={language.t("settings.general.row.uiFont.title")} - style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }} - /> -
-
- - -
- settings.appearance.setFont(event.currentTarget.value)} - placeholder={monoDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={language.t("settings.general.row.font.title")} - style={{ "font-family": monoFontFamily(settings.appearance.font()) }} - /> -
-
- - -
- settings.appearance.setTerminalFont(event.currentTarget.value)} - placeholder={terminalDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={language.t("settings.general.row.terminalFont.title")} - style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }} - /> -
-
-
-
- ) - const NotificationsSection = () => (

{language.t("settings.general.section.notifications")}

@@ -543,68 +458,6 @@ export const SettingsGeneralV2: Component<{
) - const SoundsSection = () => ( -
-

{language.t("settings.general.section.sounds")}

- - - - settings.sounds.agentEnabled(), - () => settings.sounds.agent(), - (value) => settings.sounds.setAgentEnabled(value), - (id) => settings.sounds.setAgent(id), - )} - placement="bottom-end" - gutter={6} - /> - - - - settings.sounds.permissionsEnabled(), - () => settings.sounds.permissions(), - (value) => settings.sounds.setPermissionsEnabled(value), - (id) => settings.sounds.setPermissions(id), - )} - placement="bottom-end" - gutter={6} - /> - - - - settings.sounds.errorsEnabled(), - () => settings.sounds.errors(), - (value) => settings.sounds.setErrorsEnabled(value), - (id) => settings.sounds.setErrors(id), - )} - placement="bottom-end" - gutter={6} - /> - - -
- ) - const UpdatesSection = () => (

{language.t("settings.general.section.updates")}

@@ -626,7 +479,7 @@ export const SettingsGeneralV2: Component<{ title={language.t("settings.updates.row.check.title")} description={language.t("settings.updates.row.check.description")} > - + updater.run()}> {language.t(updater.action().label)} @@ -663,11 +516,11 @@ export const SettingsGeneralV2: Component<{
- + - + diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index c4d47344a26..b73269b496d 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -287,7 +287,7 @@ } .settings-v2-provider-env-hint { - padding-right: 12px; + padding-inline-end: 12px; font-size: 13px; font-weight: 440; line-height: 1; @@ -311,7 +311,7 @@ line-height: 1; color: var(--v2-text-text-accent); cursor: pointer; - text-align: left; + text-align: start; } .settings-v2-providers-view-all:hover { @@ -361,13 +361,13 @@ } .settings-v2-tab-search [data-slot="text-input-v2-input"] { - padding-right: 28px; + padding-inline-end: 28px; } .settings-v2-tab-search-clear { position: absolute; top: 50%; - right: 6px; + inset-inline-end: 6px; z-index: 1; transform: translateY(-50%); } diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 69032dd208d..d603614689a 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -14,6 +14,7 @@ import { useSDK } from "@/context/sdk" import { terminalFontFamily, useSettings } from "@/context/settings" import type { LocalPTY } from "@/context/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" +import { terminalConnectToken } from "@/utils/terminal-connect-token" import { terminalWriter } from "@/utils/terminal-writer" import { terminalWebSocketURL } from "@/utils/terminal-websocket-url" @@ -364,7 +365,11 @@ export const Terminal = (props: TerminalProps) => { event.preventDefault() event.stopImmediatePropagation() - platform.openLink(text) + if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) { + platform.openLocalFile(text) + return + } + platform.openExternal(text) } onMount(() => { @@ -526,8 +531,11 @@ export const Terminal = (props: TerminalProps) => { } const connectToken = async () => { - // TODO: Add PTY tickets when the V2 client exposes a connect-token API. - return undefined + const result = await terminalConnectToken({ url, id, directory }) + if (result.ticket) return result.ticket + if (result.status === 404 || result.status === 405) return + if (result.status === 403) throw new Error(language.t("terminal.connectTicket.csrfError")) + throw new Error(language.t("terminal.connectTicket.statusError", { status: result.status })) } const retry = (err: unknown) => { @@ -679,6 +687,7 @@ export const Terminal = (props: TerminalProps) => {
[data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not( + [data-dragging="true"] + ):not([data-editing="true"]) { + --tab-base: var(--v2-background-bg-layer-02); + --tab-overlay: var(--v2-overlay-simple-overlay-hover); +} + +[data-titlebar-tab]:is([data-state="pressed"], [data-dragging="true"], [data-editing="true"]) { + --tab-base: var(--v2-background-bg-layer-02); + --tab-overlay: var(--v2-overlay-simple-overlay-pressed); +} + +[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] { + background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base); +} + [data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] { display: none; } [data-titlebar-tab-list] { - gap: 13.5px; + gap: 6px; } [data-titlebar-tab-slot] { + --tab-separator: var(--v2-background-bg-layer-03); position: relative; } +[data-color-scheme="dark"] [data-titlebar-tab-slot] { + --tab-separator: var(--v2-background-bg-layer-02); +} + [data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before { content: ""; position: absolute; top: 8px; - left: -6.75px; + inset-inline-start: -3.75px; width: 1.5px; height: 12px; border-radius: 9999px; - background: var(--v2-background-bg-layer-02); + background: var(--tab-separator); } [data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before { display: none; } -[data-titlebar-tab] [data-slot="tab-close"]::before { - content: ""; - position: absolute; - top: 0; - height: 28px; - width: 16px; - pointer-events: none; - background: linear-gradient(to right, transparent, var(--tab-bg)); +[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before, +[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + [data-titlebar-tab-slot]::before { display: none; } -[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before { - display: block; - right: 0; +[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] { + --tab-title-fade-offset: 4px; + -webkit-mask-image: linear-gradient( + to right, + black 0, + black calc(100% - var(--tab-title-fade-offset) - 16px), + transparent calc(100% - var(--tab-title-fade-offset)), + transparent 100% + ); + mask-image: linear-gradient( + to right, + black 0, + black calc(100% - var(--tab-title-fade-offset) - 16px), + transparent calc(100% - var(--tab-title-fade-offset)), + transparent 100% + ); } -[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before, -[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"]) - [data-slot="tab-close"]::before { - right: 100%; +[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] { + -webkit-mask-image: linear-gradient( + to left, + black 0, + black calc(100% - var(--tab-title-fade-offset) - 16px), + transparent calc(100% - var(--tab-title-fade-offset)), + transparent 100% + ); + mask-image: linear-gradient( + to left, + black 0, + black calc(100% - var(--tab-title-fade-offset) - 16px), + transparent calc(100% - var(--tab-title-fade-offset)), + transparent 100% + ); +} + +[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"]) + [data-slot="tab-link"] { + --tab-title-fade-offset: 24px; } [data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] { - padding-right: 0; + padding-inline-end: 0; } [data-titlebar-tab] [data-slot="tab-title"] { diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index 4c26db564b2..2862a793530 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -5,6 +5,7 @@ import { createMutation } from "@tanstack/solid-query" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" import { ServerConnection, serverName } from "@/context/server" import { displayName, projectForSession } from "@/pages/layout/helpers" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" @@ -184,11 +185,11 @@ export function TabNavItem(props: { data-slot="titlebar-tab-item" data-title-overflow={titleOverflowing()} data-editing={editing()} - class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" + class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]" classList={{ invisible: props.hidden }} data-active={props.active} data-dragging={props.dragging} - data-pressed={props.pressed} + data-state={props.active || props.pressed ? "pressed" : undefined} onMouseDown={(event) => { if (event.button !== MIDDLE_MOUSE_BUTTON) return event.preventDefault() @@ -228,6 +229,7 @@ export function TabNavItem(props: {