diff --git a/DIVERGENCES.md b/DIVERGENCES.md index f8f9f20c7e..8c938fc194 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -121,12 +121,12 @@ from the squash message first, or use a plain merge. | `packages/tui/src/component/prompt/index.tsx` | Removed the editor open-file label from the footer's right-side group and lifted the state behind it out of `Prompt`: the local `dismissedEditorSelectionKey` signal and the `editorContext`/`editorPath`/`editorSelectionLabel`/`editorFileLabel`/`editorFileLabelDisplay`/`editorContextLabelState` memos are gone, replaced by `useEditorLabel()` (fork-only `src/context/editor-label.tsx`) — `selection()` feeds the `prompt.editor_context.clear` `enabled` predicate and the submit-time editor part, `state()` replaces `editor.labelState()` there, and `dismiss()` replaces the local `dismissEditorContext`. The pure helpers `hasEditorRangeSelection`/`getEditorRangeLabel` moved to that context module and are imported back for `formatEditorContext`. | The label now lives in the sidebar footer (see its row above). The dismissal state had to move with it: the sidebar must label the *effective* selection — the one this file actually attaches on submit — or it would advertise context the prompt has dismissed and won't send. | Keep the deletions, the `useEditorLabel` import + its three call sites, and the helper import from `../../context/editor-label`; take upstream's surrounding changes. If upstream reworks the editor-context label, apply the change to `editorContextFileLabel` in the fork-only sidebar module rather than restoring the footer render. | | `packages/tui/src/app.tsx` | Mounted `` (fork-only `./context/editor-label`) immediately inside `` | The effective-editor-selection state is shared by the prompt and the sidebar footer, so it has to be provided above both. | Keep the import and the wrapper; take upstream's surrounding provider changes. If upstream moves `EditorContextProvider`, keep ours directly inside it. | | `packages/app/index.html` | `` → "CoDev Code" | Web UI tab title. | Keep our title. | -| `packages/app/src/pages/layout.tsx`, `packages/app/src/pages/layout-new.tsx` | Dropped the `<TabsInfoPopup />` mount and its import from both layouts (added upstream in v1.17.18); `HelpButton` stays. | `TabsInfoPopup` is a dev-channel-only "Introducing Tabs" promo whose `muted autoplay loop` `<video>` mounts on every layout render. The fork's Windows e2e runs on GPU-less GitHub-hosted runners (software GPU — see the runner-swap row), where continuous video decode wedges the GPU command buffer (`kTransientFailure`), making the whole app-e2e suite flaky and ~3–5× slower. It never ships (release builds use a non-`dev` `CODEV_CHANNEL`), so unmounting it costs users nothing. | Keep ours: don't re-add the `<TabsInfoPopup />` mount or restore it to the import. | -| `packages/app/src/components/help-button.tsx` | Removed the `TabsInfoPopup` component, its `introducing-tabs.mp4` import, and the imports only it used; `HelpButton` stays. (The file's `VITE_OPENCODE_CHANNEL` → `VITE_CODEV_CHANNEL` gates follow the env-var rename row.) | The popup is upstream's temporary, OpenCode-branded "Introducing Tabs" promo (upstream's own comment says it's removable after the rollout), already unmounted per the row above; removing the definition lets the 13 MB video be deleted (row below). | Keep our removal; take upstream's changes to `HelpButton`. If upstream mounts `TabsInfoPopup` somewhere new, drop that mount per the row above. When upstream deletes the popup itself, this row and the two rows around it retire. | +| `packages/app/src/pages/layout.tsx`, `packages/app/src/pages/layout-new.tsx` | Dropped the `<TabsInfoPopup />` mount and its import from both layouts (added upstream in v1.17.18; since v1.18.3 upstream itself removed the sibling `HelpButton`, so nothing is imported from `help-button` anymore) | `TabsInfoPopup` is a dev-channel-only "Introducing Tabs" promo whose `muted autoplay loop` `<video>` mounts on every layout render. The fork's Windows e2e runs on GPU-less GitHub-hosted runners (software GPU — see the runner-swap row), where continuous video decode wedges the GPU command buffer (`kTransientFailure`), making the whole app-e2e suite flaky and ~3–5× slower. It never ships (release builds use a non-`dev` `CODEV_CHANNEL`), so unmounting it costs users nothing. | Keep ours: don't re-add the `<TabsInfoPopup />` mount or restore it to the import. | +| `packages/app/src/components/help-button.tsx` | Deleted (as of the v1.18.3 sync). The fork had removed the `TabsInfoPopup` component and its `introducing-tabs.mp4` import, keeping the rebranded `HelpButton`; upstream v1.18.3 deleted `HelpButton` itself, leaving only the popup — so nothing remains and the file is gone. | The popup is upstream's temporary, OpenCode-branded "Introducing Tabs" promo (upstream's own comment says it's removable after the rollout), unmounted per the row above; removing the definition lets the 13 MB video be deleted (row below). | Keep deleted (`git rm` the upstream side of modify/delete conflicts). If upstream mounts `TabsInfoPopup` somewhere new, drop that mount per the row above. When upstream deletes the popup itself, this row and the rows around it retire. | | `packages/app/src/assets/help/` (introducing-tabs.mp4, placeholder.png, home.png, tabs.png — whole directory) | Deleted | The 13 MB promo video's and the v1.18 home/tabs screenshots' only consumer was the removed `TabsInfoPopup` (row above) and they bloat every clone; `placeholder.png` is referenced by nothing. | Keep deleted (`git rm` the upstream side of modify/delete conflicts); delete new upstream assets under this directory whose only consumer is the removed popup. | | `packages/app/src/desktop-menu.ts`, `packages/app/src/components/windows-app-menu.tsx` | App-menu label "OpenCode" → "CoDev Code"; "OpenCode Documentation" item → "CoDev Code Documentation" (href `opencode.ai/docs` stays — real URL); trigger `aria-label` "OpenCode menu" → "CoDev Code menu" (×2) | Visible menu branding, incl. screen-reader labels. | Keep our labels; take upstream's surrounding menu changes. Rebrand any new "OpenCode" label/aria-label. | -| Hardcoded product-string rebrand in TUI + web app: `packages/tui/src/app.tsx`, `packages/tui/src/routes/session/permission.tsx`, `packages/tui/src/attention.ts` (+ `packages/opencode/test/cli/cmd/tui/attention.test.ts`), `packages/app/src/wsl/settings-model.ts` (+ `.test.ts`), `packages/app/src/components/help-button.tsx`, `packages/ui/src/components/favicon.tsx` | User-visible "OpenCode" strings outside the i18n dicts → fork brand: update-complete toast "Successfully updated to OpenCode v…" → "CoDev Code"; permission dialog "…until OpenCode is restarted" → "CoDev Code" (the sibling patterns line was already rebranded); OS-notification fallback title `opencode` → `codev` and built-in sound-pack display name "OpenCode Default" → "CoDev Code Default" (pack id `opencode.default` stays — internal identifier; the three default-title expectations in `packages/opencode/test/cli/cmd/tui/attention.test.ts` follow, while its explicit-title pass-through fixture stays `opencode`); WSL settings actions "Install/Update OpenCode" → "…CoDev Code" (+ test expectations/titles; fixture paths `/usr/local/bin/opencode` stay); help-button `aria-label` → "Open the CoDev Code website" (href repointed at the fork docs site — see the docs-site links row); PWA `apple-mobile-web-app-title` "OpenCode" → "CoDev Code" (matches the rebranded `site.webmanifest`, favicon-assets row) | These render in surfaces the fork ships (TUI + embedded web UI); the i18n-dict strings remain a known gap (UI branding bullet below). | Keep our strings; take upstream's surrounding changes. Rebrand any new hardcoded user-visible "OpenCode" string in these surfaces; leave identifiers, URLs, and fixture paths. | -| Browser-opened docs-site links: `packages/tui/src/app.tsx`, `packages/app/src/desktop-menu.ts`, `packages/app/src/components/{help-button,settings-general,settings-v2/general,dialog-custom-provider}.tsx`, `packages/app/src/pages/{home,layout,error}.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/opencode/src/cli/cmd/providers.ts` | Links the app opens in the user's browser repointed from `opencode.ai` to the fork's hosted docs, base `https://netmind.viettel.vn/codev-landing-page/en/docs` (no trailing slash on the root; deep links keep their paths): TUI "Open docs" command and app Documentation menu item + help button → docs root; settings theme "Learn more" (v1 + v2) → `…/docs/themes/`; custom-provider dialog link → `…/docs/providers/#custom-provider`; CLI Cloudflare-gateway hint → `…/docs/providers/#cloudflare-ai-gateway`; home/layout sidebar Help buttons `opencode.ai/desktop-feedback` → docs root. The error page's report button `opencode.ai/desktop-feedback` → `github.com/quickbeard/codev-code/issues` instead (it's a report action, not a docs lookup), icon `discord` → `github`, and the en/es `error.page.report.*` strings → "…CoDev Code team" / "on GitHub". Help submenu (rendered by the shipped web UI's Windows app menu too): "Support Forum" (upstream Discord invite) deleted — the fork has no forum; "Share Feedback"/"Report a Bug" → `github.com/quickbeard/codev-code/issues/new?template=feature-request.yml`/`bug-report.yml` (the fork's template names are hyphenated, unlike upstream's underscored ones). | Fork users should land on the fork's docs and report issues to the fork, not upstream's site/Discord. NOT repointed: Zen/Go product URLs (`opencode.ai/zen`, `/go`, `/auth`), `$schema`/theme-schema ids, API/share/changelog endpoints, MCP OAuth `client_uri`, prompt-file mentions. | Keep our URLs, icon, and strings; take upstream's surrounding changes. Repoint any new browser-opened `opencode.ai` docs/help link to the fork docs base; leave the NOT-repointed list. | +| Hardcoded product-string rebrand in TUI + web app: `packages/tui/src/app.tsx`, `packages/tui/src/routes/session/permission.tsx`, `packages/tui/src/attention.ts` (+ `packages/opencode/test/cli/cmd/tui/attention.test.ts`), `packages/app/src/wsl/settings-model.ts` (+ `.test.ts`), `packages/ui/src/components/favicon.tsx` | User-visible "OpenCode" strings outside the i18n dicts → fork brand: update-complete toast "Successfully updated to OpenCode v…" → "CoDev Code"; permission dialog "…until OpenCode is restarted" → "CoDev Code" (the sibling patterns line was already rebranded); OS-notification fallback title `opencode` → `codev` and built-in sound-pack display name "OpenCode Default" → "CoDev Code Default" (pack id `opencode.default` stays — internal identifier; the three default-title expectations in `packages/opencode/test/cli/cmd/tui/attention.test.ts` follow, while its explicit-title pass-through fixture stays `opencode`); WSL settings actions "Install/Update OpenCode" → "…CoDev Code" (+ test expectations/titles; fixture paths `/usr/local/bin/opencode` stay); PWA `apple-mobile-web-app-title` "OpenCode" → "CoDev Code" (matches the rebranded `site.webmanifest`, favicon-assets row) | These render in surfaces the fork ships (TUI + embedded web UI); the i18n-dict strings remain a known gap (UI branding bullet below). | Keep our strings; take upstream's surrounding changes. Rebrand any new hardcoded user-visible "OpenCode" string in these surfaces; leave identifiers, URLs, and fixture paths. | +| Browser-opened docs-site links: `packages/tui/src/app.tsx`, `packages/app/src/desktop-menu.ts`, `packages/app/src/components/{settings-general,settings-v2/general,dialog-custom-provider}.tsx`, `packages/app/src/pages/{home,layout,error}.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/opencode/src/cli/cmd/providers.ts` | Links the app opens in the user's browser repointed from `opencode.ai` to the fork's hosted docs, base `https://netmind.viettel.vn/codev-landing-page/en/docs` (no trailing slash on the root; deep links keep their paths): TUI "Open docs" command and app Documentation menu item → docs root; settings theme "Learn more" (v1 + v2) → `…/docs/themes/`; custom-provider dialog link → `…/docs/providers/#custom-provider`; CLI Cloudflare-gateway hint → `…/docs/providers/#cloudflare-ai-gateway`; home/layout sidebar Help buttons `opencode.ai/desktop-feedback` → docs root. The error page's report button `opencode.ai/desktop-feedback` → `github.com/quickbeard/codev-code/issues` instead (it's a report action, not a docs lookup), icon `discord` → `github`, and the en/es `error.page.report.*` strings → "…CoDev Code team" / "on GitHub". Help submenu (rendered by the shipped web UI's Windows app menu too): "Support Forum" (upstream Discord invite) deleted — the fork has no forum; "Share Feedback"/"Report a Bug" → `github.com/quickbeard/codev-code/issues/new?template=feature-request.yml`/`bug-report.yml` (the fork's template names are hyphenated, unlike upstream's underscored ones). | Fork users should land on the fork's docs and report issues to the fork, not upstream's site/Discord. NOT repointed: Zen/Go product URLs (`opencode.ai/zen`, `/go`, `/auth`), `$schema`/theme-schema ids, API/share/changelog endpoints, MCP OAuth `client_uri`, prompt-file mentions. | Keep our URLs, icon, and strings; take upstream's surrounding changes. Repoint any new browser-opened `opencode.ai` docs/help link to the fork docs base; leave the NOT-repointed list. | | `packages/desktop/src/main/index.ts`, `packages/desktop/src/main/windows.ts` | APP_NAMES + window title → "CoDev Code" (Dev/Beta variants too) | Desktop app display name. | Keep our names. | | `packages/desktop/electron-builder.config.ts` | `productName` ×3 → "CoDev Code…"; artifactName → `codev-code-desktop-…`; protocol display name | Installer/product branding. appId (`ai.opencode.desktop`) and the `opencode://` deep-link scheme stay upstream — identity plumbing wired into main/index.ts and app deep-links; do not rename casually. | Keep our names; take upstream's other config. | | `packages/ui/src/assets/favicon/` (favicon-v3.svg, site.webmanifest, favicon-96x96-v3.png, apple-touch-icon-v3.png, web-app-manifest-192/512.png) | New codev mark: blocky "C" in #00cdcd on dark; manifest name "CoDev Code"/"codev" | Web/desktop favicon + PWA branding (served via packages/app/public symlinks). | Keep our assets (binary: take ours wholesale). | diff --git a/bun.lock b/bun.lock index f58f2fc861..753a5a5282 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -95,7 +95,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.2-1", + "version": "1.18.3", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -143,7 +143,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -164,7 +164,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -200,7 +200,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -227,7 +227,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -249,7 +249,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -273,7 +273,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -293,7 +293,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.2-1", + "version": "1.18.3", "bin": { "opencode": "./bin/opencode", }, @@ -387,7 +387,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -441,7 +441,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -455,7 +455,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "effect": "catalog:", }, @@ -467,7 +467,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -499,7 +499,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -515,7 +515,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -546,7 +546,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -565,7 +565,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.2-1", + "version": "1.18.3", "bin": { "codev": "./bin/opencode", }, @@ -696,7 +696,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -772,7 +772,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "cross-spawn": "catalog:", }, @@ -787,7 +787,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -802,7 +802,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -846,7 +846,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -859,7 +859,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -893,7 +893,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -912,7 +912,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -953,7 +953,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@codev/gateway": "workspace:*", "@opencode-ai/core": "workspace:*", @@ -981,7 +981,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1032,7 +1032,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index cee3588609..482fe7ff85 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.2-1", + "version": "1.18.3", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts index b61a61977e..59d3cbd1da 100644 --- a/packages/app/src/components/command-palette.ts +++ b/packages/app/src/components/command-palette.ts @@ -1,17 +1,18 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" import { getFilename } from "@opencode-ai/core/util/path" +import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useNavigate } from "@solidjs/router" import { createMemo, onCleanup } from "solid-js" -import { useCommand, type CommandOption } from "@/context/command" +import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command" import { useFile } from "@/context/file" +import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" -import { useLayout } from "@/context/layout" -import { useServerSDK, type ServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" +import { useLayout, type LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/server" +import { useServerSDK } from "@/context/server-sdk" +import { useTabs } from "@/context/tabs" +import { displayName, projectForSession } from "@/pages/layout/helpers" import { createSessionTabs } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" -import { decode64 } from "@/utils/base64" export type CommandPaletteEntry = { id: string @@ -24,6 +25,8 @@ export type CommandPaletteEntry = { path?: string directory?: string sessionID?: string + server?: ServerConnection.Key + project?: LocalProject archived?: number updated?: number } @@ -75,28 +78,25 @@ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => vo export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) { const command = useCommand() + const global = useGlobal() const language = useLanguage() - const layout = useLayout() const file = useFile() const dialog = useDialog() - const navigate = useNavigate() const serverSDK = useServerSDK()() - const serverSync = useServerSync() - const { params, tabs } = useSessionLayout() + const serverCtx = global.ensureServerCtx(serverSDK.server) + const appTabs = useTabs() + const { tabs: sessionTabs } = useSessionLayout() const openFile = createCommandPaletteFileOpener(props.onOpenFile) const state = { cleanup: undefined as (() => void) | void, committed: false } const filesOnly = () => props.filesOnly?.() ?? false const allowedCommands = createMemo(() => { if (filesOnly()) return [] - return command.options.filter( - (option) => - !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open", - ) + return commandPaletteOptions(command.options) }) const commandEntries = createMemo(() => { const category = language.t("palette.group.commands") - return allowedCommands().map((option) => createCommandEntry(option, category)) + return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category)) }) const preferredCommandEntries = createMemo(() => { const all = allowedCommands() @@ -105,11 +105,11 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT) const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base const category = language.t("palette.group.commands") - return sorted.map((option) => createCommandEntry(option, category)) + return sorted.map((option) => createCommandPaletteCommandEntry(option, category)) }) const tabState = createSessionTabs({ - tabs, + tabs: sessionTabs, pathFromTab: file.pathFromTab, normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), }) @@ -140,36 +140,12 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on .map((path) => createCommandPaletteFileEntry(path, category)) }) - const projectDirectory = createMemo(() => decode64(params.dir) ?? "") - const project = createMemo(() => { - const directory = projectDirectory() - if (!directory) return undefined - return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) - }) - const workspaces = createMemo(() => { - const directory = projectDirectory() - const current = project() - if (!current) return directory ? [directory] : [] - const dirs = [current.worktree, ...(current.sandboxes ?? [])] - if (directory && !dirs.includes(directory)) return [...dirs, directory] - return dirs - }) - const homedir = createMemo(() => serverSync().data.path.home) - const sessions = createSessionEntries({ - workspaces, - label: (directory) => { - const current = project() - const kind = - current && directory === current.worktree - ? language.t("workspace.type.local") - : language.t("workspace.type.sandbox") - const [store] = serverSync().child(directory, { bootstrap: false }) - const home = homedir() - const path = home ? directory.replace(home, "~") : directory - const name = store.vcs?.branch ?? getFilename(directory) - return `${kind} : ${name || path}` - }, - load: (directory) => serverSDK.client.session.list({ directory, roots: true }), + const sessions = createServerSessionEntries({ + server: ServerConnection.key(serverSDK.server), + opened: serverCtx.projects.list, + stored: () => serverCtx.sync.data.project, + load: (search, signal) => + serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) @@ -191,8 +167,17 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on return } if (item.type === "session") { - if (!item.directory || !item.sessionID) return - navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`) + if (!item.sessionID || !item.server) return + const directory = item.project?.worktree ?? item.directory + if (directory) { + serverCtx.projects.open(directory) + serverCtx.projects.touch(directory) + } + const tab = appTabs.addSessionTab({ + server: item.server, + sessionId: item.sessionID, + }) + appTabs.select(tab) return } if (!item.path) return @@ -218,7 +203,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on } } -function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry { +export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry { return { id: "command:" + option.id, type: "command", @@ -230,96 +215,65 @@ function createCommandEntry(option: CommandOption, category: string): CommandPal } } -function createSessionEntries(props: { - workspaces: () => string[] - label: (directory: string) => string - load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]> +export function createServerSessionEntries(props: { + server: ServerConnection.Key + opened: () => LocalProject[] + stored: () => Project[] + load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }> untitled: () => string category: () => string }) { - const state: { - token: number - inflight: Promise<CommandPaletteEntry[]> | undefined - cached: CommandPaletteEntry[] | undefined - } = { token: 0, inflight: undefined, cached: undefined } - - return (text: string) => { - if (!text.trim()) { - state.token += 1 - state.inflight = undefined - state.cached = undefined - return [] as CommandPaletteEntry[] - } - if (state.cached) return state.cached - if (state.inflight) return state.inflight + let abort: AbortController | undefined - const current = state.token - const dirs = props.workspaces() - if (dirs.length === 0) return [] as CommandPaletteEntry[] + onCleanup(() => abort?.abort()) - state.inflight = Promise.all( - dirs.map((directory) => { - const description = props.label(directory) - return props - .load(directory) - .then((result) => - (result.data ?? []) - .filter((session) => !!session?.id) - .map((session) => ({ - id: session.id, - title: session.title ?? props.untitled(), - description, - directory, - archived: session.time?.archived, - updated: session.time?.updated, - })), - ) - .catch(() => [] as SessionEntryInput[]) - }), - ) - .then((results) => { - if (state.token !== current) return [] as CommandPaletteEntry[] - const seen = new Set<string>() - const next = results - .flat() - .filter((item) => { - const key = `${item.directory}:${item.id}` - if (seen.has(key)) return false - seen.add(key) - return true - }) - .map((item) => createSessionEntry(item, props.category())) - state.cached = next - return next - }) + return async (text: string): Promise<CommandPaletteEntry[]> => { + const search = text.trim() + if (!search) { + abort?.abort() + return [] + } + abort?.abort() + const current = new AbortController() + abort = current + await new Promise<void>((resolve) => { + const timer = setTimeout(resolve, 100) + current.signal.addEventListener( + "abort", + () => { + clearTimeout(timer) + resolve() + }, + { once: true }, + ) + }) + if (current.signal.aborted) return [] + const opened = props.opened() + const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : []))) + const stored = props.stored().map((project) => ({ ...project, expanded: false })) + const storedByID = new Map(stored.map((project) => [project.id, project] as const)) + return props + .load(search, current.signal) + .then((result) => + (result.data ?? []) + .filter((session) => !session.time.archived) + .map((session) => { + const project = + projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID) + return { + id: `session:${props.server}:${session.id}`, + type: "session" as const, + title: session.title || props.untitled(), + description: project ? displayName(project) : session.project?.name || getFilename(session.directory), + category: props.category(), + directory: session.directory, + sessionID: session.id, + server: props.server, + project, + updated: session.time.updated, + } + }), + ) .catch(() => [] as CommandPaletteEntry[]) - .finally(() => { - state.inflight = undefined - }) - - return state.inflight - } -} - -type SessionEntryInput = { - directory: string - id: string - title: string - description: string - archived?: number - updated?: number -} - -function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry { - return { - id: `session:${input.directory}:${input.id}`, - type: "session", - title: input.title, - description: input.description, - category, - directory: input.directory, - sessionID: input.id, - archived: input.archived, - updated: input.updated, } } diff --git a/packages/app/src/components/dialog-command-palette-v2.tsx b/packages/app/src/components/dialog-command-palette-v2.tsx index b1cafe1e7a..85ca44ae69 100644 --- a/packages/app/src/components/dialog-command-palette-v2.tsx +++ b/packages/app/src/components/dialog-command-palette-v2.tsx @@ -5,13 +5,20 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2" import { Icon } from "@opencode-ai/ui/v2/icon" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" -import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js" -import { formatKeybindParts } from "@/context/command" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" +import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command" +import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import { useTabs } from "@/context/tabs" +import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { getRelativeTime } from "@/utils/time" import { + createCommandPaletteCommandEntry, createCommandPaletteFileEntry, createCommandPaletteModel, + createServerSessionEntries, uniqueCommandPaletteEntries, type CommandPaletteEntry, } from "./command-palette" @@ -30,9 +37,6 @@ function matchesEntry(entry: CommandPaletteEntry, query: string) { export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) { const palette = createCommandPaletteModel(props) - const [query, setQuery] = createSignal("") - const [active, setActive] = createSignal(0) - const loadItems = async (text: string) => { const q = text.trim() if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] @@ -41,16 +45,105 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v const category = palette.language.t("palette.group.files") return [ ...palette.commandEntries().filter((entry) => matchesEntry(entry, q)), - ...nextSessions.filter((entry) => matchesEntry(entry, q)), + ...nextSessions, ...files.map((path) => createCommandPaletteFileEntry(path, category)), ] } - const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] }) + return ( + <CommandPaletteView + placeholder={palette.language.t("palette.search.placeholder")} + loadItems={loadItems} + highlight={palette.highlight} + select={palette.select} + close={palette.close} + /> + ) +} + +export function DialogHomeCommandPaletteV2(props: { + server: ServerConnection.Any + onSelectSession: (entry: CommandPaletteEntry) => void +}) { + const command = useCommand() + const dialog = useDialog() + const global = useGlobal() + const language = useLanguage() + const serverCtx = global.ensureServerCtx(props.server) + const state = { cleanup: undefined as (() => void) | void, committed: false } + const commandEntries = createMemo(() => { + const category = language.t("palette.group.commands") + return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category)) + }) + const sessions = createServerSessionEntries({ + server: ServerConnection.key(props.server), + opened: serverCtx.projects.list, + stored: () => serverCtx.sync.data.project, + load: (search, signal) => + serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }), + untitled: () => language.t("command.session.new"), + category: () => language.t("command.category.session"), + }) + + const highlight = (item: CommandPaletteEntry | undefined) => { + state.cleanup?.() + state.cleanup = undefined + if (item?.type !== "command") return + state.cleanup = item.option?.onHighlight?.() + } + const select = (item: CommandPaletteEntry | undefined) => { + if (!item) return + state.committed = true + state.cleanup = undefined + dialog.close() + if (item.type === "command") { + item.option?.onSelect?.("palette") + return + } + if (item.type === "session") props.onSelectSession(item) + } + const loadItems = async (text: string) => { + const query = text.trim() + if (!query) return commandEntries().slice(0, 5) + return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))] + } + + onCleanup(() => { + if (state.committed) return + state.cleanup?.() + }) + + return ( + <CommandPaletteView + placeholder={language.t("palette.search.placeholder.home")} + loadItems={loadItems} + highlight={highlight} + select={select} + close={() => dialog.close()} + /> + ) +} + +function CommandPaletteView(props: { + placeholder: string + loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]> + highlight: (item: CommandPaletteEntry | undefined) => void + select: (item: CommandPaletteEntry | undefined) => void + close: () => void +}) { + const language = useLanguage() + const tabs = useTabs() + const [query, setQuery] = createSignal("") + const [active, setActive] = createSignal(0) + + const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] }) // Render stale results while a new query loads to avoid flashing "Loading" per keystroke. const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? [])) const groupedEntries = createMemo(() => groups(visibleEntries())) const activeEntry = createMemo(() => visibleEntries()[active()]) + const openSessions = createMemo( + () => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))), + ) createEffect(() => { query() @@ -59,7 +152,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v }) createEffect(() => { - palette.highlight(activeEntry()) + props.highlight(activeEntry()) }) let resultsRef: HTMLDivElement | undefined @@ -86,12 +179,12 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v } if (event.key === "Enter") { event.preventDefault() - palette.select(activeEntry()) + props.select(activeEntry()) return } if (event.key === "Escape") { event.preventDefault() - palette.close() + props.close() } } @@ -105,7 +198,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v autocomplete="off" spellcheck={false} appearance="large" - placeholder={palette.language.t("palette.search.placeholder")} + placeholder={props.placeholder} leadingIcon={<Icon name="magnifying-glass" />} onInput={(event) => setQuery(event.currentTarget.value)} onKeyDown={handleKeyDown} @@ -117,7 +210,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v when={visibleEntries().length > 0} fallback={ <div class="command-palette-v2-state"> - {entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")} + {entries.loading ? language.t("common.loading") : language.t("palette.empty")} </div> } > @@ -132,9 +225,14 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v <PaletteRow item={item} active={activeEntry()?.id === item.id} - language={palette.language} + language={language} + sessionOpen={ + item.server && item.sessionID + ? openSessions().has(`${item.server}\0${item.sessionID}`) + : false + } onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))} - onSelect={() => palette.select(item)} + onSelect={() => props.select(item)} /> )} </For> @@ -153,13 +251,19 @@ function PaletteRow(props: { item: CommandPaletteEntry active: boolean language: ReturnType<typeof useLanguage> + sessionOpen: boolean onActive: () => void onSelect: () => void }) { + const session = () => + props.item.server && props.item.directory && props.item.sessionID + ? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID } + : undefined + return ( <button type="button" - class="command-palette-v2-row" + class="command-palette-v2-row group" role="option" aria-selected={props.active} data-active={props.active ? "" : undefined} @@ -197,7 +301,25 @@ function PaletteRow(props: { </Match> <Match when={props.item.type === "session"}> <div class="command-palette-v2-row-main"> - <Icon name="status" class="command-palette-v2-row-icon" /> + <div class="relative shrink-0"> + <Show when={props.sessionOpen}> + <span + aria-hidden="true" + class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04" + style={{ right: "calc(100% + 4px)" }} + /> + </Show> + <Show when={session()}> + {(session) => ( + <SessionTabAvatar + project={props.item.project} + directory={session().directory} + sessionId={session().sessionID} + server={session().server} + /> + )} + </Show> + </div> <div class="command-palette-v2-row-text"> <span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}> {props.item.title} diff --git a/packages/app/src/components/help-button.tsx b/packages/app/src/components/help-button.tsx deleted file mode 100644 index 1747602a54..0000000000 --- a/packages/app/src/components/help-button.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { usePlatform } from "@/context/platform" - -const helpIcon = ( - <svg - width="16" - height="16" - viewBox="0 0 16 16" - fill="none" - xmlns="http://www.w3.org/2000/svg" - aria-hidden="true" - data-slot="icon-svg" - > - <path - d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z" - fill="var(--v2-icon-icon-base)" - /> - </svg> -) - -const triggerClass = - "size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]" - -// TODO: wire to changelog / seen-state when available -const showPopover = () => true - -export function HelpButton() { - const platform = usePlatform() - - return ( - <a - href="https://netmind.viettel.vn/codev-landing-page/en/docs" - aria-label="Open the CoDev Code website" - data-component="icon-button-v2" - data-size="large" - class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`} - onClick={(event) => { - event.preventDefault() - platform.openLink(event.currentTarget.href) - }} - > - {helpIcon} - </a> - ) -} diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 3e3db45bcf..cdec8b435c 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -10,7 +10,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { showToast } from "@/utils/toast" import fuzzysort from "fuzzysort" -import { formatKeybind, parseKeybind, useCommand } from "@/context/command" +import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { SettingsList } from "./settings-list" @@ -20,7 +20,6 @@ const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const PALETTE_ID = "command.palette" -const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt" diff --git a/packages/app/src/context/command.test.ts b/packages/app/src/context/command.test.ts index 4297dd89bd..cf98ff3825 100644 --- a/packages/app/src/context/command.test.ts +++ b/packages/app/src/context/command.test.ts @@ -1,5 +1,19 @@ import { describe, expect, test } from "bun:test" -import { resolveKeybindOption, upsertCommandRegistration } from "./command" +import { commandPaletteOptions, resolveKeybindOption, upsertCommandRegistration, type CommandOption } from "./command" + +const paletteOptions: CommandOption[] = [ + { id: "settings.open", title: "Open settings" }, + { id: "session.undo", title: "Undo" }, + { id: "file.open", title: "Open file" }, + { id: "hidden", title: "Hidden", hidden: true }, + { id: "disabled", title: "Disabled", disabled: true }, +] + +describe("commandPaletteOptions", () => { + test("keeps visible enabled commands", () => { + expect(commandPaletteOptions(paletteOptions).map((option) => option.id)).toEqual(["settings.open", "session.undo"]) + }) +}) describe("upsertCommandRegistration", () => { test("replaces keyed registrations", () => { diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 467a1115b5..f88033bd7e 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -11,7 +11,7 @@ import { Persist, persisted } from "@/utils/persist" const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const PALETTE_ID = "command.palette" -const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" +export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p" const SUGGESTED_PREFIX = "suggested." const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"]) @@ -87,6 +87,13 @@ export interface CommandOption { onHighlight?: () => (() => void) | void } +export function commandPaletteOptions(options: CommandOption[]) { + return options.filter( + (option) => + !option.disabled && !option.hidden && !option.id.startsWith(SUGGESTED_PREFIX) && option.id !== "file.open", + ) +} + export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) { return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when) } @@ -375,7 +382,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex } const showPalette = () => { - run("file.open", "palette") + run(PALETTE_ID, "palette") } const handleKeyDown = (event: KeyboardEvent) => { diff --git a/packages/app/src/context/local-agent.test.ts b/packages/app/src/context/local-agent.test.ts new file mode 100644 index 0000000000..c7a95f9a5b --- /dev/null +++ b/packages/app/src/context/local-agent.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { hasCustomAgent, resolveAgent } from "./local-agent" + +describe("hasCustomAgent", () => { + test("detects explicitly custom agents", () => { + expect(hasCustomAgent([{ native: true }, { native: false }])).toBe(true) + }) + + test("ignores built-in and unclassified agents", () => { + expect(hasCustomAgent([{ native: true }, {}])).toBe(false) + }) +}) + +describe("resolveAgent", () => { + const agents = [{ name: "plan" }, { name: "build" }, { name: "custom" }] + + test("uses the requested available agent", () => { + expect(resolveAgent(agents, "custom")?.name).toBe("custom") + }) + + test("defaults to build", () => { + expect(resolveAgent(agents)?.name).toBe("build") + expect(resolveAgent(agents, "missing")?.name).toBe("build") + }) + + test("uses the first agent when build is unavailable", () => { + expect(resolveAgent([{ name: "custom" }], "missing")?.name).toBe("custom") + }) +}) diff --git a/packages/app/src/context/local-agent.ts b/packages/app/src/context/local-agent.ts new file mode 100644 index 0000000000..f5c76d1469 --- /dev/null +++ b/packages/app/src/context/local-agent.ts @@ -0,0 +1,7 @@ +export function hasCustomAgent(items: Array<{ native?: boolean }>) { + return items.some((item) => item.native === false) +} + +export function resolveAgent<T extends { name: string }>(items: T[], name?: string) { + return items.find((item) => item.name === name) ?? items.find((item) => item.name === "build") ?? items[0] +} diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index a12fd832fd..83db571385 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -4,8 +4,10 @@ import { useParams } from "@solidjs/router" import { batch, createEffect, createMemo, startTransition } from "solid-js" import { createStore } from "solid-js/store" import { useModels } from "@/context/models" +import { useSettings } from "@/context/settings" import { useProviders } from "@/hooks/use-providers" import { Persist, persisted } from "@/utils/persist" +import { hasCustomAgent, resolveAgent } from "./local-agent" import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" import { useSDK } from "./sdk" import { useSync } from "./sync" @@ -62,9 +64,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const serverSDK = useServerSDK() const providers = useProviders(() => sdk().directory) const models = useModels() + const settings = useSettings() const id = createMemo(() => params.id || undefined) const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) + const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list())) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) const [saved, setSaved, , savedReady] = persisted( @@ -107,9 +111,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } const pickAgent = (name: string | undefined) => { - const items = list() - if (items.length === 0) return - return items.find((item) => item.name === name) ?? items[0] + return resolveAgent(list(), name) } createEffect(() => { @@ -180,8 +182,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const agent = { list, + visible: agentsVisible, current() { - return pickAgent(scope()?.agent ?? store.current) + return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build") }, set(name: string | undefined) { const item = pickAgent(name) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index aa40598c2b..8ec7db5982 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -93,6 +93,7 @@ export const dict = { "command.session.unshare.description": "Stop sharing this session", "palette.search.placeholder": "Search files, commands, and sessions", + "palette.search.placeholder.home": "Search commands and sessions", "palette.empty": "No results found", "palette.group.commands": "Commands", "palette.group.files": "Files", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 4a87e5ceeb..cddb836ca8 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -93,6 +93,7 @@ export const dict = { "command.session.unshare.description": "Dejar de compartir esta sesión", "palette.search.placeholder": "Buscar archivos, comandos y sesiones", + "palette.search.placeholder.home": "Buscar comandos y sesiones", "palette.empty": "No se encontraron resultados", "palette.group.commands": "Comandos", "palette.group.files": "Archivos", diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index fc7a2f8484..811aefb2a3 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -5,6 +5,7 @@ import { createMemo, createResource, createRoot, + createSignal, For, Match, on, @@ -78,6 +79,23 @@ const HOME_SESSION_LIMIT = 64 const HOME_SESSION_HEADER_STICKY_TOP = 12 const HOME_SESSION_HEADER_TEXT_HEIGHT = 16 const HOME_SESSION_HEADER_FADE_DISTANCE = 16 + +function containHomeWheel(event: WheelEvent, viewport: HTMLElement) { + if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return + if (!(event.target instanceof Element)) return + + const scrollable = event.target.closest<HTMLElement>("[data-scrollable]") + if ( + scrollable !== viewport && + scrollable && + (event.deltaY < 0 + ? scrollable.scrollTop > 0 + : scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight) + ) + return + + event.preventDefault() +} const SHOW_HOME_SESSION_ARCHIVE = false const HOME_ROW_LAYOUT = "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none" @@ -150,6 +168,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) { let content: HTMLDivElement | undefined let positionFrame: number | undefined let resizeObserver: ResizeObserver | undefined + let stickyTop = HOME_SESSION_HEADER_STICKY_TOP const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>() const headerOffsets = new Map<HomeSessionGroup["id"], number>() const [state, setState] = createStore({ @@ -208,6 +227,13 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) { function updatePositionCache() { if (!viewport) return + const header = groups() + .map((group) => headerRefs.get(group.id)) + .find((el) => el !== undefined) + if (header && typeof getComputedStyle === "function") { + const top = Number.parseFloat(getComputedStyle(header).top) + if (Number.isFinite(top)) stickyTop = top + } groups().forEach((group) => { const el = headerRefs.get(group.id) if (!el) return @@ -223,7 +249,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) { .slice(index + 1) .map((item) => headerOffsets.get(item.id)) .find((offset) => offset !== undefined) - const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT + const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop const opacity = nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE)) @@ -277,6 +303,9 @@ export function NewHome() { const marked = useMarked() const openSettings = useSettingsCommand() let focusSessionSearch: (() => void) | undefined + let sessionViewport: HTMLDivElement | undefined + const [sessionThumbTrack, setSessionThumbTrack] = createSignal<HTMLDivElement>() + const [sessionHoverTarget, setSessionHoverTarget] = createSignal<HTMLElement>() const [state, setState] = createStore({ search: "", searchFocused: false, @@ -431,6 +460,34 @@ export function NewHome() { } command.register("home", () => [ + { + id: "command.palette", + title: language.t("command.palette"), + hidden: true, + onSelect: async () => { + const conn = focusedServer() + if (!conn) return + const ctx = global.ensureServerCtx(conn) + const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2") + void dialog.show(() => ( + <DialogHomeCommandPaletteV2 + server={conn} + onSelectSession={(entry) => { + if (!entry.sessionID || !entry.directory || !entry.server) return + const sessionID = entry.sessionID + const server = entry.server + const directory = entry.project?.worktree ?? entry.directory + ctx.projects.open(directory) + ctx.projects.touch(directory) + void startTransition(() => { + const tab = tabs.addSessionTab({ server, sessionId: sessionID }) + tabs.select(tab) + }) + }} + /> + )) + }, + }, { id: "home.sessions.search.focus", title: searchPlaceholder(), @@ -576,127 +633,158 @@ export function NewHome() { } return ( - <div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1"> - <div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6"> - <HomeProjectColumn - projects={projects()} - recentlyClosed={recentlyClosed()} - homedir={homedir()} - selected={selection()} - focusServer={focusServer} - selectProject={selectProject} - openNewSession={openProjectNewSession} - openRecentProject={(conn, directory) => addProjects(conn, [directory])} - chooseProject={(conn) => void chooseProject(conn)} - editProject={editProject} - closeProject={(conn, directory) => { - const next = closeHomeProject( - selection(), - ServerConnection.key(conn), - global.ensureServerCtx(conn).projects, - directory, - ) - if (next) setSelection(next) - }} - clearNotifications={clearNotifications} - unseenCount={unseenCount} - openSettings={openSettings} - openHelp={() => platform.openLink("https://netmind.viettel.vn/codev-landing-page/en/docs")} - language={language} - /> - - <section - class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative" - aria-label={language.t("sidebar.project.recentSessions")} - > - <HomeSessionSearch - value={state.search} - placeholder={searchPlaceholder()} - open={searchOpen()} - loading={sessionLoad.isLoading} - results={searchResults()} - showProjectName={!selectedProject()} - server={selection().server} - noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })} - bindFocus={(focus) => { - focusSessionSearch = focus + <div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1"> + <ScrollView + class="h-full [container-type:size]" + thumbContainer={sessionThumbTrack} + thumbHoverTarget={sessionHoverTarget} + viewportRef={(el) => { + sessionViewport = el + sessionHeaderOpacity.setViewport(el) + }} + onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)} + onWheel={(event) => { + if (!sessionViewport) return + if (event.target instanceof Node && sessionViewport.contains(event.target)) return + containHomeWheel(event, sessionViewport) + }} + > + <div class="mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6"> + <HomeProjectColumn + projects={projects()} + recentlyClosed={recentlyClosed()} + homedir={homedir()} + selected={selection()} + focusServer={focusServer} + selectProject={selectProject} + openNewSession={openProjectNewSession} + openRecentProject={(conn, directory) => addProjects(conn, [directory])} + chooseProject={(conn) => void chooseProject(conn)} + editProject={editProject} + closeProject={(conn, directory) => { + const next = closeHomeProject( + selection(), + ServerConnection.key(conn), + global.ensureServerCtx(conn).projects, + directory, + ) + if (next) setSelection(next) + }} + clearNotifications={clearNotifications} + unseenCount={unseenCount} + openSettings={openSettings} + openHelp={() => platform.openLink("https://netmind.viettel.vn/codev-landing-page/en/docs")} + language={language} + onWheel={(event) => { + if (sessionViewport) containHomeWheel(event, sessionViewport) }} - onInput={(value) => setState("search", value)} - onFocus={() => setState("searchFocused", true)} - onClose={closeSearch} - onSelect={selectSearchSession} /> - <ScrollView - class="mt-3 -mr-3 min-h-0 flex-1 relative" - viewportRef={sessionHeaderOpacity.setViewport} - onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)} + + <section + ref={setSessionHoverTarget} + class="min-h-0 min-w-0 flex-1 flex flex-col" + aria-label={language.t("sidebar.project.recentSessions")} > - <Show when={groups().length > 0 && newSessionProject()}> - <div class="pointer-events-none absolute top-3 right-3 z-20 flex"> - <ButtonV2 - data-action="home-new-session" - variant="ghost-muted" - size="normal" - icon="edit" - class="pointer-events-auto h-7 px-2 [font-weight:530]" - onClick={openNewSession} - > - {language.t("command.session.new")} - </ButtonV2> - </div> - </Show> - <Show - when={!sessionLoad.isLoading} - fallback={ - <div class="pt-3"> - <HomeSessionSkeleton label={language.t("common.loading")} /> - </div> - } + <div + class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" + onWheel={(event) => { + if (sessionViewport) containHomeWheel(event, sessionViewport) + }} > + <HomeSessionSearch + value={state.search} + placeholder={searchPlaceholder()} + open={searchOpen()} + loading={sessionLoad.isLoading} + results={searchResults()} + showProjectName={!selectedProject()} + server={selection().server} + noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })} + bindFocus={(focus) => { + focusSessionSearch = focus + }} + onInput={(value) => setState("search", value)} + onFocus={() => setState("searchFocused", true)} + onClose={closeSearch} + onSelect={selectSearchSession} + /> + <Show when={groups().length > 0 && newSessionProject()}> + <div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]"> + <ButtonV2 + data-action="home-new-session" + variant="ghost-muted" + size="normal" + icon="edit" + class="pointer-events-auto h-7 px-2 [font-weight:530]" + onClick={openNewSession} + > + {language.t("command.session.new")} + </ButtonV2> + </div> + </Show> + </div> + {/* Sticky chrome for the portaled session scrollbar — matches old sessions ScrollView bounds */} + <div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]"> + <div + ref={setSessionThumbTrack} + data-component="home-session-scroll-track" + class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]" + /> + </div> + <div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]"> <Show - when={groups().length > 0} - fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />} + when={!sessionLoad.isLoading} + fallback={ + <div class="pt-3"> + <HomeSessionSkeleton label={language.t("common.loading")} /> + </div> + } > - <div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16"> - <For each={groups()}> - {(group, index) => ( - <> - <HomeSessionGroupHeader - title={group.title} - titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)} - ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)} - elevated={index() === 0} - /> - <div - class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`} - > - <For each={group.sessions}> - {(record) => ( - <HomeSessionRow - record={record} - showProjectName={!selectedProject()} - server={selection().server} - openSession={openSession} - archiveSession={archiveSession} - /> - )} - </For> - </div> - </> - )} - </For> - </div> + <Show + when={groups().length > 0} + fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />} + > + <div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16"> + <For each={groups()}> + {(group, index) => ( + <> + <HomeSessionGroupHeader + title={group.title} + titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)} + ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)} + elevated={index() === 0} + /> + <div + class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`} + > + <For each={group.sessions}> + {(record) => ( + <HomeSessionRow + record={record} + showProjectName={!selectedProject()} + server={selection().server} + openSession={openSession} + archiveSession={archiveSession} + /> + )} + </For> + </div> + </> + )} + </For> + </div> + </Show> </Show> - </Show> - </ScrollView> - </section> - <HomeUtilityNav - class="flex lg:hidden" - openSettings={openSettings} - openHelp={() => platform.openLink("https://netmind.viettel.vn/codev-landing-page/en/docs")} - language={language} - /> - </div> + </div> + </section> + <HomeUtilityNav + class="flex lg:hidden" + openSettings={openSettings} + openHelp={() => platform.openLink("https://netmind.viettel.vn/codev-landing-page/en/docs")} + language={language} + /> + </div> + </ScrollView> </div> ) } @@ -718,6 +806,7 @@ function HomeProjectColumn(props: { openSettings: () => void openHelp: () => void language: ReturnType<typeof useLanguage> + onWheel: (event: WheelEvent) => void }) { const global = useGlobal() const dialog = useDialog() @@ -734,8 +823,12 @@ function HomeProjectColumn(props: { return ( <aside - class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]" + class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]" aria-label={props.language.t("home.projects")} + onWheel={(event) => { + if (event.target === event.currentTarget) return + props.onWheel(event) + }} > <div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3"> <div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div> @@ -1461,7 +1554,7 @@ function HomeSessionGroupHeader(props: { return ( <div ref={props.ref} - class={`pointer-events-none sticky top-3 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`} + class={`pointer-events-none sticky top-[84px] lg:top-[108px] flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`} > <div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}> {props.title} diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx index 329b7c56b0..1fb8ded0a9 100644 --- a/packages/app/src/pages/layout-new.tsx +++ b/packages/app/src/pages/layout-new.tsx @@ -1,7 +1,6 @@ import { createEffect, Suspense, type ParentProps } from "solid-js" import { useNavigate } from "@solidjs/router" import { DebugBar } from "@/components/debug-bar" -import { HelpButton } from "@/components/help-button" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { usePlatform } from "@/context/platform" import { setNavigate } from "@/utils/notification-click" @@ -37,7 +36,6 @@ export default function NewLayout(props: ParentProps) { <Suspense>{props.children}</Suspense> </main> {import.meta.env.DEV && <DebugBar inline />} - <HelpButton /> <ToastRegion v2 /> </div> ) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 2a3de8bd67..45dc66d7c9 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -54,7 +54,6 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" import { DebugBar } from "@/components/debug-bar" -import { HelpButton } from "@/components/help-button" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { useDirectoryPicker } from "@/components/directory-picker" import { ServerConnection, useServer } from "@/context/server" @@ -2395,7 +2394,6 @@ export default function LegacyLayout(props: ParentProps) { </div> {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />} </div> - <HelpButton /> <ToastRegion v2={false} /> </div> ) diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 8167efc08e..a7f436fc6f 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -3,6 +3,7 @@ import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useSearchParams } from "@solidjs/router" import { Tooltip } from "@opencode-ai/ui/tooltip" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { NewSessionDesignView } from "@/components/session" @@ -51,6 +52,7 @@ export default function NewSessionPage() { const comments = useComments() const language = useLanguage() const settings = useSettings() + const dialog = useDialog() const command = useCommand() const providers = useProviders(() => sdk().directory) const openProviderSettings = useSettingsDialog("providers") @@ -77,6 +79,15 @@ export default function NewSessionPage() { }) command.register("new-session", () => [ + { + id: "command.palette", + title: language.t("command.palette"), + hidden: true, + onSelect: async () => { + const { DialogSelectFile } = await import("@/components/dialog-select-file") + void dialog.show(() => <DialogSelectFile />) + }, + }, { id: "input.focus", title: language.t("command.input.focus"), diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index cfa5498c2e..03d783047b 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -41,6 +41,7 @@ import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/r import { NewSessionView, SessionHeader } from "@/components/session" import { ErrorPage } from "@/pages/error" import { CommentsProvider, useComments } from "@/context/comments" +import { useCommand } from "@/context/command" import { DirectoryDataProvider } from "@/pages/directory-layout" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" @@ -362,6 +363,7 @@ export default function Page() { const platform = usePlatform() const prompt = usePrompt() const comments = useComments() + const command = useCommand() const terminal = useTerminal() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const location = useLocation() @@ -1135,6 +1137,14 @@ export default function Page() { review: reviewTab, fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id, }) + command.register("session-palette", () => [ + { + id: "command.palette", + title: language.t("command.palette"), + hidden: true, + onSelect: () => command.trigger("file.open", "palette"), + }, + ]) const openReviewFile = createOpenReviewFile({ showAllFiles, diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index 8d5d96719c..3ead9599bb 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -41,7 +41,7 @@ export function createPromptInputController(input: { options: local.agent.list().map((agent) => agent.name), current: local.agent.current()?.name ?? "", loading: agentsQuery.isLoading, - visible: settings.visibility.customAgents(), + visible: local.agent.visible(), select: local.agent.set, }, model: { diff --git a/packages/app/src/pages/session/use-composer-commands.tsx b/packages/app/src/pages/session/use-composer-commands.tsx index 9b75c7bf4c..e7e51489ee 100644 --- a/packages/app/src/pages/session/use-composer-commands.tsx +++ b/packages/app/src/pages/session/use-composer-commands.tsx @@ -1,7 +1,6 @@ import { useCommand, type CommandOption } from "@/context/command" import { useLanguage } from "@/context/language" import { useLocal, type ModelSelection } from "@/context/local" -import { useSettings } from "@/context/settings" import { useDialog } from "@opencode-ai/ui/context/dialog" import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom" import { useSessionLayout } from "./session-layout" @@ -19,7 +18,6 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { const dialog = useDialog() const language = useLanguage() const local = useLocal() - const settings = useSettings() const { sessionKey } = useSessionLayout() const sessionOwnership = createSessionOwnership(sessionKey) const model = input.model ?? local.model @@ -70,7 +68,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { description: language.t("command.agent.cycle.description"), keybind: "mod+.", slash: "agent", - disabled: !settings.visibility.customAgents(), + disabled: !local.agent.visible(), onSelect: () => local.agent.move(1), }), agentCommand({ @@ -78,7 +76,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { title: language.t("command.agent.cycle.reverse"), description: language.t("command.agent.cycle.reverse.description"), keybind: "shift+mod+.", - disabled: !settings.visibility.customAgents(), + disabled: !local.agent.visible(), onSelect: () => local.agent.move(-1), }), ]) diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 8ad6cb6072..275e6ec4bc 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -468,7 +468,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { id: "file.open", title: language.t("command.file.open"), description: language.t("palette.search.placeholder"), - keybind: "mod+k,mod+p", + keybind: "mod+p", slash: "open", onSelect: openFile, }), diff --git a/packages/app/test-browser/command-palette.test.ts b/packages/app/test-browser/command-palette.test.ts new file mode 100644 index 0000000000..421a2e71fd --- /dev/null +++ b/packages/app/test-browser/command-palette.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test" +import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client" +import { createRoot } from "solid-js" +import { createServerSessionEntries } from "@/components/command-palette" +import type { LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/server" +import { getProjectAvatarSource } from "@/pages/layout/helpers" + +const stored: Project = { + id: "project-1", + name: "Palette project", + worktree: "/workspace/project", + sandboxes: [], + time: { created: 1, updated: 1 }, +} + +const session: GlobalSession = { + id: "session-1", + slug: "session-1", + projectID: stored.id, + directory: stored.worktree, + title: "Palette session", + version: "1", + time: { created: 1, updated: 2 }, + project: { id: stored.id, name: stored.name, worktree: stored.worktree }, +} + +describe("command palette sessions", () => { + test("uses the home project avatar and cancels superseded searches", async () => { + const server = ServerConnection.Key.make("selected-server") + const opened: LocalProject = { + ...stored, + icon: { override: "home-project-avatar" }, + expanded: true, + } + const searches: string[] = [] + const result = await new Promise<Awaited<ReturnType<ReturnType<typeof createServerSessionEntries>>>>( + (resolve, reject) => { + createRoot((dispose) => { + const search = createServerSessionEntries({ + server, + opened: () => [opened], + stored: () => [{ ...stored, icon: { url: "stored-project-avatar" } }], + load: async (text) => { + searches.push(text) + return { + data: [session, { ...session, id: "archived-session", time: { ...session.time, archived: 3 } }], + } + }, + untitled: () => "Untitled", + category: () => "Sessions", + }) + const first = search("palette") + const second = search("palette session") + Promise.all([first, second]) + .then(([cancelled, entries]) => { + expect(cancelled).toEqual([]) + resolve(entries) + }) + .catch(reject) + .finally(dispose) + }) + }, + ) + + expect(searches).toEqual(["palette session"]) + expect(result).toHaveLength(1) + expect(getProjectAvatarSource(result[0]?.project?.id, result[0]?.project?.icon)).toBe("home-project-avatar") + expect(result[0]).toMatchObject({ + server, + sessionID: session.id, + description: stored.name, + project: { id: stored.id, icon: opened.icon }, + }) + }) +}) diff --git a/packages/cli/package.json b/packages/cli/package.json index 0563e030ae..2152bc3e2a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.18.2-1", + "version": "1.18.3", "type": "module", "license": "MIT", "bin": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index a660bfeb30..45a7eb80d9 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.18.2-1", + "version": "1.18.3", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 3ea398960c..c10a9aff34 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.18.2-1", + "version": "1.18.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index a84e83e767..51636b62e6 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.18.2-1", + "version": "1.18.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 735a493dd6..4f93f2e019 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.18.2-1", + "version": "1.18.3", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 065e292d40..c1a2053608 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.18.2-1", + "version": "1.18.3", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index 3ad88f95b0..2aee1456cb 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.18.2-1", + "version": "1.18.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 9f03d2caf6..93c334486f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.2-1", + "version": "1.18.3", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 3d8f87c22e..18d5bfd603 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.18.2-1", + "version": "1.18.3", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 0a5eff0169..1ca98b7307 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -382,7 +382,8 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { function App() { const wslServers = useWslServers() const ready = createMemo( - () => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading, + () => + !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading && !wslServers.isLoading, ) const servers = createMemo(() => { const data = initializationData(sidecar) diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 07038a0612..eddf6290d6 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.2-1", + "version": "1.18.3", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 123b4fdcc2..78c37fe0cb 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.2-1", + "version": "1.18.3", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index e707e682da..5902cef4dc 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.18.2-1", + "version": "1.18.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index 7b6771815a..e8b9f68146 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.18.2-1", + "version": "1.18.3", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index c66a7a6aa9..2789327792 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.2-1", + "version": "1.18.3", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index 80eddd390b..932f21e44e 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.2-1", + "version": "1.18.3", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e8bf29593a..5e581103b8 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.2-1", + "version": "1.18.3", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx index c97af0ce09..3176c1724f 100644 --- a/packages/opencode/src/cli/cmd/run/footer.command.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.command.tsx @@ -633,6 +633,12 @@ export function RunSubagentSelectBody(props: { return } + if (event.name.toLowerCase() === "up" && menu.selected() === 0) { + event.preventDefault() + props.onClose() + return + } + handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) }) diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index c0ef837042..198ef37a70 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -645,6 +645,42 @@ test("direct subagent panel renders active subagents", async () => { } }) +test("direct subagent panel closes when moving up from the first item", async () => { + const [tabs] = createSignal([ + subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }), + subagent({ sessionID: "s-2", label: "General", description: "Write migration plan" }), + ]) + const [current] = createSignal<string | undefined>() + let closed = 0 + + const app = await testRender( + () => ( + <box width={100} height={RUN_SUBAGENT_PANEL_ROWS}> + <RunSubagentSelectBody + theme={() => RUN_THEME_FALLBACK.footer} + tabs={tabs} + current={current} + onClose={() => closed++} + onSelect={() => {}} + /> + </box> + ), + { width: 100, height: RUN_SUBAGENT_PANEL_ROWS }, + ) + + try { + await app.renderOnce() + app.mockInput.pressKey("ARROW_DOWN") + app.mockInput.pressKey("ARROW_UP") + expect(closed).toBe(0) + + app.mockInput.pressKey("ARROW_UP") + expect(closed).toBe(1) + } finally { + app.renderer.destroy() + } +}) + test("direct queued prompt panel renders pending prompt actions", async () => { const [prompts] = createSignal([ { messageID: "m-1", partID: "p-1", prompt: { text: "fix the auth test", parts: [] } }, diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d12dd4773f..bc41bb9116 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.18.2-1", + "version": "1.18.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index fb19365453..d8f06de772 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.18.2-1", + "version": "1.18.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 22138ebf94..f7b635012e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.18.2-1", + "version": "1.18.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 3c219cf0ce..317b335997 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.18.2-1", + "version": "1.18.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index e7aaa26414..5f1eba75ef 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.18.2-1", + "version": "1.18.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index 60618e1b5e..ee85693737 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.18.2-1", + "version": "1.18.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index ab0785d148..d942879028 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.18.2-1", + "version": "1.18.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 49e99307c5..eba0143a2b 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.18.2-1", + "version": "1.18.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/package.json b/packages/tui/package.json index 3fb6f15f6c..639bb9f7d2 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.18.2-1", + "version": "1.18.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 9b60078840..1681b66fab 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.18.2-1", + "version": "1.18.3", "type": "module", "license": "MIT", "repository": { diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css index 7c5d8ed0f7..e2425a01e4 100644 --- a/packages/ui/src/components/scroll-view.css +++ b/packages/ui/src/components/scroll-view.css @@ -27,6 +27,7 @@ transition: opacity 200ms ease; cursor: default; user-select: none; + pointer-events: auto; opacity: 0; } diff --git a/packages/ui/src/components/scroll-view.test.ts b/packages/ui/src/components/scroll-view.test.ts index d338fa59bc..84131b1f17 100644 --- a/packages/ui/src/components/scroll-view.test.ts +++ b/packages/ui/src/components/scroll-view.test.ts @@ -72,4 +72,19 @@ describe("scrollTopFromThumbPointer", () => { expect(scrollTopFromThumbPointer({ ...input, pointer: 0 })).toBe(0) expect(scrollTopFromThumbPointer({ ...input, pointer: 1_000 })).toBe(5_400) }) + + test("uses scrollClientHeight when the thumb track differs from the viewport", () => { + const input = { + pointer: 400, + viewportTop: 100, + grabOffset: 0, + clientHeight: 400, + scrollClientHeight: 800, + scrollHeight: 8_000, + thumbHeight: 40, + } + // track usable = 400 - 16 - 40 = 344; thumbTop = 400 - 100 - 8 = 292 + // maxScroll = 8000 - 800 = 7200 → 292/344 * 7200 + expect(scrollTopFromThumbPointer(input)).toBeCloseTo((292 / 344) * 7200) + }) }) diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 0a567ce1c5..5116d94f9e 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -1,4 +1,15 @@ -import { onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js" +import { + createEffect, + createMemo, + mergeProps, + onCleanup, + onMount, + Show, + splitProps, + type Accessor, + type ComponentProps, +} from "solid-js" +import { Portal } from "solid-js/web" import { createResizeObserver } from "@solid-primitives/resize-observer" import { createStore } from "solid-js/store" import { useI18n } from "../context/i18n" @@ -8,7 +19,17 @@ export type ScrollViewThumbVisibility = "hover" | "scroll" export interface ScrollViewProps extends ComponentProps<"div"> { viewportRef?: (el: HTMLDivElement) => void orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb + /** + * `hover`: show while hovered or scrolling. `scroll`: show only while scrolling. + * + * In most cases, scrolling a container = hovering over it, so this change has no effect. + * This is a special case to account for the home page scroll, where scrolling a container != hovering over it + * */ thumbVisibility?: ScrollViewThumbVisibility + /** Mount the thumb into an external track. Scroll metrics still come from this ScrollView. */ + thumbContainer?: HTMLElement | Accessor<HTMLElement | undefined> + /** Element whose hover reveals the thumb. Defaults to the ScrollView root when unset. */ + thumbHoverTarget?: HTMLElement | Accessor<HTMLElement | undefined> } export const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">) => { @@ -65,12 +86,14 @@ export function scrollTopFromThumbPointer(input: { clientHeight: number scrollHeight: number thumbHeight: number + /** Viewport height used for max scroll. Defaults to `clientHeight` (track == viewport). */ + scrollClientHeight?: number }) { const padding = 8 const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight if (maxThumbTop <= 0) return 0 const thumbTop = Math.max(0, Math.min(input.pointer - input.viewportTop - padding - input.grabOffset, maxThumbTop)) - return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - input.clientHeight) + return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - (input.scrollClientHeight ?? input.clientHeight)) } export function ScrollView(props: ScrollViewProps) { @@ -78,7 +101,16 @@ export function ScrollView(props: ScrollViewProps) { const merged = mergeProps({ orientation: "vertical", thumbVisibility: "hover" }, props) const [local, events, rest] = splitProps( merged, - ["class", "children", "viewportRef", "orientation", "thumbVisibility", "style"], + [ + "class", + "children", + "viewportRef", + "orientation", + "thumbVisibility", + "thumbContainer", + "thumbHoverTarget", + "style", + ], [ "onScroll", "onWheel", @@ -96,6 +128,15 @@ export function ScrollView(props: ScrollViewProps) { let viewportRef!: HTMLDivElement let thumbRef!: HTMLDivElement + const resolveEl = (value: HTMLElement | Accessor<HTMLElement | undefined> | undefined) => { + if (typeof value === "function") return value() + return value + } + + const thumbMount = createMemo(() => resolveEl(local.thumbContainer)) + const thumbHover = createMemo(() => resolveEl(local.thumbHoverTarget)) + const hoverRoot = () => !local.thumbHoverTarget && !local.thumbContainer + const [state, setState] = createStore({ isHovered: false, isDragging: false, @@ -114,7 +155,6 @@ export function ScrollView(props: ScrollViewProps) { let scrollIdleTimer: ReturnType<typeof setTimeout> | undefined const markScrolling = () => { - if (local.thumbVisibility !== "scroll") return setState("isScrolling", true) if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer) scrollIdleTimer = setTimeout(() => setState("isScrolling", false), 800) @@ -122,8 +162,8 @@ export function ScrollView(props: ScrollViewProps) { const thumbVisible = () => { if (isDragging()) return true - if (local.thumbVisibility === "scroll") return isScrolling() - return isHovered() + if (isScrolling()) return true + return local.thumbVisibility === "hover" && isHovered() } onCleanup(() => { @@ -141,7 +181,8 @@ export function ScrollView(props: ScrollViewProps) { setState("showThumb", true) const trackPadding = 8 - const trackHeight = clientHeight - trackPadding * 2 + const trackClientHeight = thumbMount()?.clientHeight || clientHeight + const trackHeight = trackClientHeight - trackPadding * 2 const minThumbHeight = 32 // Calculate raw thumb height based on ratio @@ -165,16 +206,40 @@ export function ScrollView(props: ScrollViewProps) { local.viewportRef(viewportRef) } - createResizeObserver([viewportRef, viewportRef.firstElementChild], updateThumb) + createResizeObserver( + () => [viewportRef, viewportRef.firstElementChild, thumbMount()].filter(Boolean) as HTMLElement[], + updateThumb, + ) updateThumb() }) + createEffect(() => { + thumbMount() + updateThumb() + }) + + createEffect(() => { + const target = thumbHover() + if (!target) return + + const enter = () => setState("isHovered", true) + const leave = () => setState("isHovered", false) + target.addEventListener("pointerenter", enter) + target.addEventListener("pointerleave", leave) + onCleanup(() => { + target.removeEventListener("pointerenter", enter) + target.removeEventListener("pointerleave", leave) + setState("isHovered", false) + }) + }) + const onThumbPointerDown = (e: PointerEvent) => { e.preventDefault() e.stopPropagation() setState("isDragging", true) const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top + const track = thumbMount() ?? viewportRef thumbRef.setPointerCapture(e.pointerId) @@ -182,9 +247,10 @@ export function ScrollView(props: ScrollViewProps) { const { scrollHeight, clientHeight } = viewportRef viewportRef.scrollTop = scrollTopFromThumbPointer({ pointer: e.clientY, - viewportTop: viewportRef.getBoundingClientRect().top, + viewportTop: track.getBoundingClientRect().top, grabOffset, - clientHeight, + clientHeight: track.clientHeight, + scrollClientHeight: clientHeight, scrollHeight, thumbHeight: thumbHeight(), }) @@ -203,6 +269,23 @@ export function ScrollView(props: ScrollViewProps) { thumbRef.addEventListener("pointercancel", done) } + const renderThumb = () => ( + <div + ref={(el) => { + thumbRef = el + }} + onPointerDown={onThumbPointerDown} + class="scroll-view__thumb" + data-visible={thumbVisible()} + data-dragging={isDragging()} + style={{ + height: `${thumbHeight()}px`, + transform: `translateY(${thumbTop()}px)`, + "z-index": 100, // ensure it displays over content + }} + /> + ) + // Keybinds implementation // We ensure the viewport has a tabindex so it can receive focus // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior, @@ -253,8 +336,12 @@ export function ScrollView(props: ScrollViewProps) { ref={rootRef} class={`scroll-view ${local.class || ""}`} style={local.style} - onPointerEnter={() => setState("isHovered", true)} - onPointerLeave={() => setState("isHovered", false)} + onPointerEnter={() => { + if (hoverRoot()) setState("isHovered", true) + }} + onPointerLeave={() => { + if (hoverRoot()) setState("isHovered", false) + }} {...rest} > {/* Viewport */} @@ -290,20 +377,11 @@ export function ScrollView(props: ScrollViewProps) { {local.children} </div> - {/* Thumb Overlay */} + {/* Thumb Overlay — optionally portaled into an external track */} <Show when={showThumb()}> - <div - ref={thumbRef} - onPointerDown={onThumbPointerDown} - class="scroll-view__thumb" - data-visible={thumbVisible()} - data-dragging={isDragging()} - style={{ - height: `${thumbHeight()}px`, - transform: `translateY(${thumbTop()}px)`, - "z-index": 100, // ensure it displays over content - }} - /> + <Show when={thumbMount()} fallback={renderThumb()}> + {(mount) => <Portal mount={mount()}>{renderThumb()}</Portal>} + </Show> </Show> </div> ) diff --git a/packages/web/package.json b/packages/web/package.json index 33929bac4d..6a9f1317dc 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.18.2-1", + "version": "1.18.3", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index d6d4058392..7edefd2af4 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "codev-code", "displayName": "CoDev Code", "description": "CoDev Code for VS Code", - "version": "1.18.2-1", + "version": "1.18.3", "publisher": "CoDev", "repository": { "type": "git",