From 8a43ba3928ed1f65890ecb65bd0bbe3cb1683e5d Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sat, 22 Aug 2026 11:25:36 +0700 Subject: [PATCH] chore(app): remove the retired legacy prompt input The fork hardcodes newLayoutDesigns to true, so the legacy PromptInput (session.tsx's Show fallback) could never render. Delete it and its orphaned submodules, mount the V2 composer unconditionally, retarget PromptInputState to contracts.ts where it is defined, and fill the todo-panel-motion story's input slot with a static placeholder. Co-Authored-By: Claude Fable 5 --- DIVERGENCES.md | 5 +- packages/app/src/components/prompt-input.tsx | 1780 ----------------- .../prompt-input/at-options.test.ts | 27 - .../src/components/prompt-input/at-options.ts | 23 - .../prompt-input/attachments.test.ts | 108 - .../components/prompt-input/attachments.ts | 228 --- .../components/prompt-input/context-items.tsx | 98 - .../components/prompt-input/drag-overlay.tsx | 25 - .../app/src/components/prompt-input/files.ts | 98 - .../prompt-input/image-attachments.css | 43 - .../prompt-input/image-attachments.tsx | 168 -- .../app/src/components/prompt-input/paste.ts | 24 - .../components/prompt-input/slash-popover.tsx | 376 ---- .../prompt-input/transient-state.ts | 46 - packages/app/src/pages/session.tsx | 90 +- .../session-composer-region-controller.ts | 2 +- .../composer/todo-panel-motion.stories.tsx | 43 +- .../test-browser/prompt-at-options.test.ts | 40 - .../test-browser/prompt-attachments.test.ts | 90 - .../prompt-transient-state.test.ts | 38 - 20 files changed, 49 insertions(+), 3303 deletions(-) delete mode 100644 packages/app/src/components/prompt-input.tsx delete mode 100644 packages/app/src/components/prompt-input/at-options.test.ts delete mode 100644 packages/app/src/components/prompt-input/at-options.ts delete mode 100644 packages/app/src/components/prompt-input/attachments.test.ts delete mode 100644 packages/app/src/components/prompt-input/attachments.ts delete mode 100644 packages/app/src/components/prompt-input/context-items.tsx delete mode 100644 packages/app/src/components/prompt-input/drag-overlay.tsx delete mode 100644 packages/app/src/components/prompt-input/files.ts delete mode 100644 packages/app/src/components/prompt-input/image-attachments.css delete mode 100644 packages/app/src/components/prompt-input/image-attachments.tsx delete mode 100644 packages/app/src/components/prompt-input/paste.ts delete mode 100644 packages/app/src/components/prompt-input/slash-popover.tsx delete mode 100644 packages/app/src/components/prompt-input/transient-state.ts delete mode 100644 packages/app/test-browser/prompt-at-options.test.ts delete mode 100644 packages/app/test-browser/prompt-attachments.test.ts delete mode 100644 packages/app/test-browser/prompt-transient-state.test.ts diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 131f7f4b41..4d9aa7f973 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -199,7 +199,7 @@ from the squash message first, or use a plain merge. | `packages/ui/src/context/dialog.tsx` | Per-dialog `persistent` flag (precedent: the TUI's persistent dialog, see the `packages/tui/src/ui/dialog.tsx` row): Escape, backdrop click, and Kobalte `onOpenChange` are no-ops for a dialog marked persistent; programmatic `close()` still works; `setPersistent` targets the top dialog so pushed sub-dialogs stay dismissable; `useDialog()` gains `persistent`/`setPersistent`. | The desktop configure gate needs a dialog the user cannot dismiss while no provider is configured. | Keep the flag and the three guards; take upstream's surrounding dialog changes. | | `packages/app/src/app.tsx` | `` (fork-only `components/gateway/`) mounted as the first child inside `ServerScopedProviders`' `ModelsProvider`. | The desktop configure gate; the placement is load-bearing — completing the flow seeds the live `useModels()` recents store (`buildGatewayConfig` deliberately writes no `config.model` pin). | Keep the mount inside `ModelsProvider`; take upstream's surrounding shell changes. | | `packages/app/src/context/platform.tsx` | Added optional `gatewaySso?: GatewaySsoPlatform` capability (wire types in fork-only `src/gateway/types.ts`, exported as `@opencode-ai/app/gateway/types` — see the `packages/app/package.json` row). | Desktop-only SSO login for the configure gate; absence of the capability is the web deployment's kill-switch. | Keep the field + import; take upstream's surrounding platform changes. | -| `packages/app/src/components/prompt-input-v2.tsx`, `packages/app/src/components/prompt-input.tsx` | `onUnpaidClick` opens `DialogConnectProvider` (lazy import) instead of the free-models "unpaid" dialogs; their now-unused imports dropped. | With the hosted providers hidden, "unpaid" means nothing is connected, and the free-models dialogs would render empty lists (this path is reachable on the web deployment; on desktop the gate covers it). | Keep the handler swap; take upstream's surrounding prompt-input changes. | +| `packages/app/src/components/prompt-input-v2.tsx` | `onUnpaidClick` opens `DialogConnectProvider` (lazy import) instead of the free-models "unpaid" dialogs; their now-unused imports dropped. (The legacy `prompt-input.tsx` carried the same swap until its deletion — see the legacy prompt-input removal row.) | With the hosted providers hidden, "unpaid" means nothing is connected, and the free-models dialogs would render empty lists (this path is reachable on the web deployment; on desktop the gate covers it). | Keep the handler swap; take upstream's surrounding prompt-input changes. | | `packages/desktop/src/renderer/index.tsx` | Calls `installConsoleFormatting()` (fork-only `./console-format`) before anything else logs. | `spyRendererConsole` receives Chromium's flattened console-message string, so any renderer `console.*` with an object argument reached the terminal and the exported debug logs as `[object Object]`; the wrapper serializes structured arguments (Error-aware, circular-safe, truncated) at the source. | Keep the import and call; take upstream's surrounding entry changes. | | Desktop gateway SSO wiring: `packages/desktop/src/main/index.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Main instantiates the fork-only `src/main/gateway-sso/` controller (wrapping `@codev/gateway` `login`/`silentSso` — node-only loopback OAuth — with `shell.openExternal` and a `net.fetch` wrapper) and registers its `gateway-sso-*` IPC channels next to the sandbox ones, passing an `onConfigured` callback that restarts the running sandboxes (`SandboxController.restartRunning`) because a guest is handed its codev config once at boot and cannot see credentials that appear afterwards; preload exposes the `gatewaySso` namespace (subscribe fan-out, sandbox pattern) with types re-exported from `@opencode-ai/app/gateway/types`; the renderer platform maps `gatewaySso: window.api.gatewaySso`. | SSO login for the desktop configure gate must run in the main process (`node:http` loopback server, `~/.codev/auth.json` writes); `net.fetch` so enterprise proxies/PAC apply (egress precedent). | Keep the wiring; take upstream's surrounding changes. Keep channel names `gateway-sso-*` stable. | | `packages/app/package.json` | Added `"./gateway/types": "./src/gateway/types.ts"` to `exports`. | The desktop preload aliases the app-owned gateway SSO wire types (sandbox/types pattern). | Keep the export; take upstream's other manifest changes. | @@ -266,7 +266,7 @@ from the squash message first, or use a plain merge. | Env-var rename, repo-wide (~230 files): both flag registries (`packages/core/src/flag/flag.ts`, `packages/opencode/src/effect/runtime-flags.ts`), every `process.env`/`Bun.env` read+set site across `packages/*`, `sdks/vscode`, `infra/enterprise.ts`, `nix/opencode.nix`, `turbo.json`, `.github/workflows/test.yml`, test preloads/fixtures/snapshots, docs (en + es), `SECURITY.md`, specs | `OPENCODE_*` env vars → `CODEV_*` (hard cutover, no legacy fallback), including the bundler define identifiers (`CODEV_VERSION`, `CODEV_CHANNEL`, `CODEV_MODELS_DEV`, `CODEV_LIBC`, `CODEV_CLI_NAME`, `CODEV_WORKER_PATH`), `VITE_OPENCODE_*` → `VITE_CODEV_*`, `import.meta.env.OPENCODE_CHANNEL` → `CODEV_CHANNEL`, the VSCode extension's `OPENCODE_CALLER`/`_EXTENSION_OPENCODE_PORT`, and the bare `OPENCODE=1` marker → `CODEV=1` | The fork's env surface uses the fork's name (reverses the earlier "internal names stay upstream" decision for env vars). NOT renamed: `__OPENCODE_PHOTON_WASM_PATH` and `__OPENCODE__` globals (not env vars; the photon name is baked into `patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch`), `patches/install-korean-ime-fix.sh` script-local shell vars, upstream secret names in workflow-deletion notes, lowercase identifiers (`@opencode-ai/*`, URLs, provider id) | Keep our names; rename any new upstream `OPENCODE_*` env var (or define/`VITE_` name) to `CODEV_*` when syncing. | | Locale dicts in `packages/{ui,app}/src/i18n/`, `packages/desktop/src/renderer/i18n/`, `packages/console/app/src/i18n/`, `packages/stats/app/src/i18n/`, `packages/web/src/content/i18n/`; every translated docs dir in `packages/web/src/content/docs/` | Deleted every locale except `en`/`es` (incl. `packages/app/src/i18n/parity.test.ts`) | Fork ships English + Spanish only. | Keep deleted (`git rm` the upstream side of modify/delete conflicts); also delete any locale files upstream adds. | | `packages/app/src/context/language.tsx`, `packages/app/src/entry.tsx`, `packages/desktop/src/renderer/i18n/index.ts`, `packages/console/app/src/i18n/index.ts`, `packages/console/app/src/lib/language.ts`, `packages/console/app/src/component/footer.tsx`, `packages/stats/app/src/i18n.ts`, `packages/enterprise/src/app.tsx`, `packages/app/e2e/regression/session-timeline-locale-projection.spec.ts` | Locale registries trimmed to `en` + `es`: `Locale` types, LOCALES/LABEL/TAG/DOCS maps, dict loaders, accept-language matchers; enterprise/entry `zh` special cases now use `es`; console footer always links Discord (Feishu was zh-only); e2e locale-projection spec tests `es` ("Explorado") instead of de/ar | Fork ships English + Spanish only. | Keep our trimmed lists; take upstream's structural changes and re-trim any new locale entries. | -| `packages/tui/src/component/prompt/autocomplete.tsx`, `packages/app/src/components/prompt-input.tsx`, `packages/opencode/src/cli/cmd/run/footer.prompt.tsx`, `packages/core/src/filesystem/search.ts`, `packages/tui/test/fixture/tui-sdk.ts`, `packages/opencode/test/cli/run/footer.view.test.tsx`, `packages/core/test/filesystem/search.test.ts` | `@` mention autocomplete lists file/directory results before references/agents/MCP resources; TUI and run-CLI menus cap the leading files so up to 3 non-file matches stay in the first visible page; web app group rank reordered to recent → file → reference → agent → resource (extracted into fork-only `prompt-input/at-options.ts`). TUI and run-CLI file resources track menu visibility so opening on an empty query fetches (previously nothing refetched until the query changed); core's ripgrep-fallback `find` returns its first entries for an empty query to mirror fff. Tests: tui-sdk fixture's `FetchHandler` gains an optional `method` arg, `renderFooter` accepts `findFiles`/`agents` overrides + a mention-menu ordering test, core search tests cover the fallback's empty query (fork-only test files: `packages/tui/test/prompt-autocomplete.test.tsx`, `packages/app/test-browser/prompt-at-options.test.ts`, `packages/app/src/components/prompt-input/at-options{,.test}.ts`) | Files are the dominant `@` use case; they should rank first and appear on a bare `@`. | Keep our ordering/reserve logic, resource source tracking, empty-query branch, fixture/test additions; take upstream's surrounding changes. | +| `packages/tui/src/component/prompt/autocomplete.tsx`, `packages/opencode/src/cli/cmd/run/footer.prompt.tsx`, `packages/core/src/filesystem/search.ts`, `packages/tui/test/fixture/tui-sdk.ts`, `packages/opencode/test/cli/run/footer.view.test.tsx`, `packages/core/test/filesystem/search.test.ts` | `@` mention autocomplete lists file/directory results before references/agents/MCP resources; TUI and run-CLI menus cap the leading files so up to 3 non-file matches stay in the first visible page. TUI and run-CLI file resources track menu visibility so opening on an empty query fetches (previously nothing refetched until the query changed); core's ripgrep-fallback `find` returns its first entries for an empty query to mirror fff. Tests: tui-sdk fixture's `FetchHandler` gains an optional `method` arg, `renderFooter` accepts `findFiles`/`agents` overrides + a mention-menu ordering test, core search tests cover the fallback's empty query (fork-only test file: `packages/tui/test/prompt-autocomplete.test.tsx`). The legacy web prompt originally carried the same reorder (fork-only `prompt-input/at-options.ts`); it was deleted with the legacy prompt input (see the removal row below), and the V2 composer's files-first ordering is tracked in its own `session-ui` row (PR #289). | Files are the dominant `@` use case; they should rank first and appear on a bare `@`. | Keep our ordering/reserve logic, resource source tracking, empty-query branch, fixture/test additions; take upstream's surrounding changes. | | `packages/slack/.gitignore`, `sdks/vscode/.gitignore` | Added the missing trailing newline (content unchanged) | Upstream's copies end without a newline; POSIX text files should end with one, and tools that concatenate or append to these files misbehave without it. | Whitespace-only: keep the trailing newline; take upstream's entries. | | `packages/tui/src/config/keybind.ts`, `packages/tui/src/component/prompt/index.tsx`, `packages/tui/src/app.tsx` | Permission-mode indicator + shift+tab cycle. keybind.ts: added `permission_mode_cycle: keybind("shift+tab", ...)` + its `permission.mode` CommandMap entry; `agent_cycle_reverse` default `"shift+tab"` → `"none"` (tab still cycles agents forward; previous-agent stays rebindable). prompt/index.tsx: the footer badge next to the agent name always renders in prompt mode — gray `manual` (`theme.textMuted`) in normal mode, red `auto` (`theme.error`) in auto mode (upstream only showed gray `auto`); the footer's shortcut hints gain a `shift+tab modes` text (via a `modeShortcut` `useCommandShortcut("permission.mode")` memo) left of `tab agents`, in the same `Show` gate (see the hints-and-usage row above for when that gate renders). app.tsx: `"permission.mode"` added to `appBindingCommands` so the binding is gathered. | The permission mode is always visible (manual/auto) with auto in red as a danger signal, and shift+tab toggles it without opening the palette. | Keep the `permission_mode_cycle` definition + mapping, the `agent_cycle_reverse` `"none"` default, the badge `Show` block, the `modeShortcut` memo + hint text, and the `appBindingCommands` entry; take upstream's surrounding changes. If upstream reworks the auto badge or rebinds shift+tab, re-apply on top and re-audit the shift+tab conflict. | | `packages/core/src/filesystem/search.ts`, `packages/core/src/ripgrep/binary.ts`, `packages/core/src/filesystem.ts`, `packages/core/src/tool/read-filesystem.ts`, `packages/core/test/filesystem/search.test.ts`, `packages/core/test/location-filesystem.test.ts`, `packages/core/test/ripgrep-binary.test.ts`, `packages/core/test/preload.ts` | Windows file-search fallback robustness. search.ts: the background ripgrep index walk no longer dies silently (`Effect.orDie` in a forked fiber) — each attempt resets the accumulated file/directory state, failures log a warning ("file index scan failed, file search may be empty"), and the walk retries forever on a 1s exponential backoff capped at 30s spacing, so installing rg mid-session self-heals; directory entries are suffixed and detected with `"/"` instead of `path.sep` (both ripgrep and fff layers), and fallback `find` queries normalize `\` to `/` and collapse doubled slashes (directory tab-expansion appends `/` to an already-`/`-suffixed entry). binary.ts: `Effect.cached` on ripgrep binary resolution (which memoizes failure exits for the process lifetime) replaced with a success-only cache behind a semaphore, so a failed rg download (offline, blocked proxy) retries on the next use instead of bricking search until restart; the download tail tries GitHub first (each attempt bounded to 30s) and falls back to the CoDev landing-page mirror (the base64-encoded `MIRROR` constant plus `/rg---[.exe]`, a raw executable — no archive/extract — staged in a scoped temp dir then copied into place), via `Effect.catchCause` so a proxy block page (200 + HTML) that dies in `extract` as a defect still reaches the mirror. filesystem.ts `list` + read-filesystem.ts `list`: directory suffix `path.sep` → `"/"`; `fs.list` also `/`-normalizes `path.relative` output. Tests: search.test.ts pins `"/"`-suffixed directories, adds query-normalization coverage, and a stub-Ripgrep scan-retry test asserting the partial first walk is discarded; location-filesystem.test.ts pins `"src/"`; ripgrep-binary.test.ts adds mocked-HTTP coverage of the fallback order, success caching, and failure non-caching; preload.ts redirects `XDG_CACHE_HOME` to a fresh temp dir so no test can read or clobber the developer's real `~/.cache/codev`. | On Windows fff is disabled by default (upstream `e4300e9b74`), so the `@` mention popup and file search depend entirely on the ripgrep fallback; a missing `rg.exe` plus one failed download left the index permanently empty with no user-visible signal (the CI-workflows row's choco preinstall papered over the same swallowed failure). `\`-suffixed directory entries also broke `endsWith("/")` directory-mime detection in the run CLI and poisoned drill-down queries with backslashes that can never match the `/`-normalized index. | Keep our retry/reset scan, success-only binary cache, `"/"` suffixes, and query normalization; take upstream's surrounding changes. If upstream reworks the fallback or re-enables fff on win32, preserve the invariants: entry paths are `/`-normalized with `/`-suffixed directories on every platform, index-scan failures are logged and retried, a failed binary download is never cached, and the GitHub download keeps its CoDev-mirror fallback — the mirror filename scheme must match what codev-landing-page hosts under `public/docs/code/downloads` (and codevhub's `RG_VERSION` pin in its `src/lib/ripgrep.ts`); bump all three together when changing `VERSION`. | @@ -281,6 +281,7 @@ from the squash message first, or use a plain merge. | Sandbox/WSL delete cleanup + confirmation: `packages/app/src/context/server.tsx`, `packages/app/src/components/dialog-select-server.tsx`, `packages/app/src/i18n/{en,es}.ts` | `server.tsx` gains `forget(key)` beside `remove(key)`: it deletes the persisted per-server `projects`/`lastProject`/`recentlyClosed` map entries for one key. `dialog-select-server.tsx` `handleRemove` (already diverged, see the renderer-slice row) additionally: for `sandbox:` and `wsl:` keys, awaits a confirmation through the fork-only `packages/app/src/sandbox/dialog-delete.tsx` / `packages/app/src/wsl/dialog-delete.tsx` (lazy-imported, pushed with `dialog.push` so it stacks on whatever dialog opened the menu) and returns without touching anything when declined; and calls `server.forget(key)` right after `server.remove(key)` for both key kinds. The controller gains a `useDialog()` binding for this. i18n gains the `sandbox.delete.*` and `wsl.delete.*` families. The two dialogs state different consequences on purpose: deleting a sandbox erases its disk (sessions, terminals, guest files — permanently; mounted host folders survive), while removing a WSL server destroys nothing — the app never unregisters distributions — it only forgets the app-side project state. | `remove()` only edits the stored HTTP server list; the project maps are keyed by server key and outlive the server, which is right for an HTTP server that still exists remotely but wrong for a deleted sandbox or removed distro — a recreated sandbox with the same name inherited the old projects (`server.projects["sandbox:"]`) and, via the leftover `recentlyClosed`, silently refused to seed folders closed in the previous sandbox's life; a re-added distro likewise inherited its previous project list. And deleting a sandbox erases guest data permanently, so it must not be a one-click menu action; the confirmations live in `handleRemove` because the settings rows and the generic server list all funnel through it. | Keep the confirm-and-forget block at the top of `handleRemove` and the `forget` calls between `server.remove(key)` and the awaited platform removals; take upstream's surrounding changes. `forget` is fork-only API — if upstream reworks the persisted `server` record shape, re-point the three deletes at the new maps. Do not gate `forget` behind the platform removal succeeding: the tab/workspace cleanup ahead of it already treats the server as gone. HTTP removals deliberately keep their maps — the server still exists remotely and re-adding it should restore its projects. If the app ever gains real distro unregistration, the WSL dialog's "nothing is deleted" wording must change with it. | | Vanished-stream retry + visibility + persisted retry parts: `packages/opencode/src/provider/error.ts`, `packages/opencode/src/session/{processor,retry,prompt,message-v2}.ts`, `packages/session-ui/src/components/message-part.tsx`, `packages/tui/src/routes/session/index.tsx`, `packages/ui/src/i18n/{en,es}.ts`, tests `packages/opencode/test/session/{retry,message-v2,processor-effect}.test.ts`, `packages/opencode/test/cli/run/run-process.test.ts` | error.ts: new `ProviderError.StreamVanishedError`. processor.ts: `ProcessorContext.produced` (set on `text-delta`/`tool-input-start`/`tool-call`, reset per attempt); after the stream drains, a turn with no output, no error, and finish `undefined`/`"unknown"` fails with `StreamVanishedError` so the existing `SessionRetry.policy` retries it (visible "retrying… attempt #N" status). retry.ts: `retryable()` gains an optional `attempt` param (policy passes `meta.attempt`); `STREAM_VANISHED_CODE` matches `metadata.code` and stops after `STREAM_VANISHED_MAX_RETRIES` (3) so a deterministic provider quirk cannot re-bill turns forever. message-v2.ts: maps `StreamVanishedError` → `APIError{isRetryable:true, metadata.code}`; new `transportInterruptCode()` walks the cause chain for `ECONNRESET`/`EPIPE`/`ETIMEDOUT`/`UND_ERR_SOCKET` so undici's mid-stream `TypeError("terminated")` (cause `SocketError "other side closed"`) becomes a retryable `APIError` instead of an unretryable `Unknown` (AbortedError when `ctx.aborted`). prompt.ts: the run-loop exit records a visible `APIError` ("the reply may be incomplete") on a message whose finish is `"unknown"` with no error, instead of going idle silently — mirrors upstream's own content-filter precedent. Persisted retry parts: the schema's dormant `RetryPart` (already in `@opencode-ai/schema/session-v1` and the generated SDKs, previously never written or rendered) is now written per retry attempt — `SessionRetry.policy`'s `set` callback gains the parsed `error`, and processor.ts persists `{type:"retry", attempt, error, time}` via `session.updatePart` before publishing the live retry status — and rendered by a new `PART_MAPPING["retry"]` component in session-ui's message-part.tsx (error-card style, message + "retried - attempt #N" line; new i18n key `ui.messagePart.retry.retried` en/es) and by a `PART_MAPPING["retry"]` entry in the TUI's routes/session/index.tsx (a muted one-liner aligned with the transcript — error-colored `Retried` label, `(attempt #N) · `, mirroring the footer's live `Retrying (attempt N) · message` wording; fork-only view test `packages/tui/test/retry-part.view.test.tsx`). Upstream's two `codev run` subprocess tests that pin the old contract ("unknown stream finish preserves partial output and exits 0" — renamed here to "…retries and preserves partial output" — and "--format json records partial output for an unknown stream finish") are updated to the retrying behaviour: each queues an explicit reply after `llm.fail(...)` so the retry consumes it instead of the test server's auto-"ok", stdout carries both turns, and the json test asserts the extra `step_start,text,step_finish` trio with the vanished step still finishing `"unknown"` and the retried one `"stop"`. What they lock in is unchanged: an SSE error item is not fatal — exit stays 0, pre-failure output survives, and the provider message never reaches stderr. | A provider (or corporate proxy) that drops the SSE mid-turn ended the run with no retry, no error card, no banner — the agent "just stopped" and only a user-typed "continue" resumed it. Empty turns are safely retried (nothing to duplicate); turns with output are not rerun but now say why they ended. | Keep our `produced` tracking, the post-drain fail, the classifier cap, the transport-code walk, the loop-exit error, and the two retry-part renderers (session-ui and TUI `PART_MAPPING["retry"]` entries); take upstream's surrounding changes. If upstream ships its own abnormal-finish handling, prefer theirs and drop ours where they overlap. Good upstream-PR candidate. The two `run-process.test.ts` expectations are ours only because the retry is: if upstream's version of that file conflicts, re-apply the queued-reply shape on top of their test body; if their behaviour is adopted instead, revert these two tests to upstream wholesale. | | `packages/session-ui/src/v2/components/prompt-input/interaction.ts` | The `@` mention popup's `contextList` grouping delegates to the fork-only `at-options.ts` beside it (`atSuggestionCategory` + `compareAtCategories`, unit-tested in the fork-only `at-options.test.ts`): suggestion groups rank recent → file → reference → agent → resource instead of upstream's reference → agent → resource → recent → file, so file results lead in the V2 composer — the prompt the desktop and web apps render now that the legacy layout is retired. The grouped menu scrolls, so no row-reserve logic is needed (unlike the TUI/run-footer variants), and default selection stays index 0, so Enter accepts the top file match. | Files are the dominant `@` use case; the earlier files-first reorder (see the `@` autocomplete row above) covered the TUI, the legacy web prompt, and the `codev run` footer but missed the V2 composer. | Keep the two-line delegation to `at-options.ts`; take upstream's surrounding changes. If upstream restructures the suggestion list, re-apply the ranking wherever context suggestions are grouped. | +| Legacy prompt input removed: deleted `packages/app/src/components/prompt-input.tsx` and its now-orphaned submodules `packages/app/src/components/prompt-input/{attachments,files,paste,transient-state}.ts`, `{context-items,drag-overlay,image-attachments,slash-popover}.tsx`, `image-attachments.css`, `attachments.test.ts`, `packages/app/test-browser/{prompt-attachments,prompt-transient-state,prompt-at-options}.test.ts` (plus the fork-only `at-options{,.test}.ts`); edits in `packages/app/src/pages/session.tsx`, `packages/app/src/pages/session/composer/{session-composer-region-controller.ts,todo-panel-motion.stories.tsx}` | The fork hardcodes `newLayoutDesigns` to `true` (see the settings.tsx row), so the legacy `PromptInput` — the `fallback` of session.tsx's `` — could never render. session.tsx now creates the V2 controller unconditionally and always mounts `PromptInputV2Composer` (no `Show`); `session-composer-region-controller.ts` imports `PromptInputState` from `prompt-input/contracts` (where it is defined) instead of the deleted barrel; the todo-panel-motion story fills its composer-region `promptInput` slot with a static styled placeholder instead of the legacy component (its unused `controls` mock removed). Kept: `prompt-input/{contracts,history,history-store,placeholder,submit,editor-dom,build-request-parts,submission-state}.ts` and their tests — the V2 composer path still uses them. | Dead code: retired with the legacy layout, kept compiling for months, and PR #56-style fixes had to be applied to it for nothing. | Keep deleted — `git rm` the upstream side of modify/delete conflicts on these files, and delete any new module upstream adds under `prompt-input/` that only the legacy `PromptInput` imports. Take upstream changes to the kept shared modules. If upstream restructures session.tsx's composer block, re-apply "always render the V2 composer" and keep the legacy fallback out; if upstream rewires the story's input slot, keep our placeholder. If the fork ever wants the legacy layout back, restore from upstream, not from history. | ## Known rebrand gaps diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx deleted file mode 100644 index 15229bc472..0000000000 --- a/packages/app/src/components/prompt-input.tsx +++ /dev/null @@ -1,1780 +0,0 @@ -import { useFilteredList } from "@opencode-ai/ui/hooks" -import { useSpring } from "@opencode-ai/ui/motion-spring" -import { - createEffect, - on, - Component, - Show, - onCleanup, - createMemo, - createSignal, - createResource, - Switch, - Match, - type JSX, -} from "solid-js" -import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" -import { - ContentPart, - DEFAULT_PROMPT, - isCommentItem, - isPromptEqual, - Prompt, - usePrompt, - ImageAttachmentPart, - AgentPart, - FileAttachmentPart, -} from "@/context/prompt" -import { useLayout } from "@/context/layout" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" -import { useComments } from "@/context/comments" -import { Button } from "@opencode-ai/ui/button" -import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" -import { Icon } from "@opencode-ai/ui/icon" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" -import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" -import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { Select } from "@opencode-ai/ui/select" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model" -import { useCommand } from "@/context/command" -import { usePermission } from "@/context/permission" -import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" -import { createSessionTabs } from "@/pages/session/helpers" -import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" -import { createPromptAttachments } from "./prompt-input/attachments" -import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files" -import { - canNavigateHistoryAtCursor, - navigatePromptHistory, - type PromptHistoryComment, - type PromptHistoryEntry, - promptLength, -} from "./prompt-input/history" -import { - createPersistedPromptInputHistory, - createPromptInputHistory, - type PromptInputHistory, -} from "./prompt-input/history-store" -import { - type PromptInputControls, - type PromptInputProps, - type PromptInputState, - type PromptInputSubmission, -} from "./prompt-input/contracts" -import { createPromptSubmit } from "./prompt-input/submit" -import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" -import { atOptionCategory, compareAtCategories } from "./prompt-input/at-options" -import { PromptContextItems } from "./prompt-input/context-items" -import { PromptImageAttachments } from "./prompt-input/image-attachments" -import { PromptDragOverlay } from "./prompt-input/drag-overlay" -import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder" -import { createPromptInputTransientState } from "./prompt-input/transient-state" -import { showToast } from "@/utils/toast" -import { ImagePreview } from "@opencode-ai/ui/image-preview" -import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" - -export { createPromptInputHistory } -export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission } - -const EXAMPLES = [ - "prompt.example.1", - "prompt.example.2", - "prompt.example.3", - "prompt.example.4", - "prompt.example.5", - "prompt.example.6", - "prompt.example.7", - "prompt.example.8", - "prompt.example.9", - "prompt.example.10", - "prompt.example.11", - "prompt.example.12", - "prompt.example.13", - "prompt.example.14", - "prompt.example.15", - "prompt.example.16", - "prompt.example.17", - "prompt.example.18", - "prompt.example.19", - "prompt.example.20", - "prompt.example.21", - "prompt.example.22", - "prompt.example.23", - "prompt.example.24", - "prompt.example.25", -] as const - -export const PromptInput: Component = (props) => { - const sdk = useSDK() - - const sync = useSync() - const files = useFile() - const prompt = props.state ?? usePrompt() - const layout = useLayout() - const comments = useComments() - const dialog = useDialog() - const command = useCommand() - const permission = usePermission() - const language = useLanguage() - const platform = usePlatform() - const tabs = () => props.controls.session.tabs - let editorRef!: HTMLDivElement - let fileInputRef: HTMLInputElement | undefined - let scrollRef!: HTMLDivElement - let slashPopoverRef!: HTMLDivElement - let restoreEndOnFocus = true - let savedCursor: number | null = null - - const mirror = { input: false } - const inset = 56 - const space = `${inset}px` - - const scrollCursorIntoView = () => { - const container = scrollRef - const selection = window.getSelection() - if (!container || !selection || selection.rangeCount === 0) return - - const range = selection.getRangeAt(0) - if (!editorRef.contains(range.startContainer)) return - - const cursor = getCursorPosition(editorRef) - const length = promptLength(prompt.current().filter((part) => part.type !== "image")) - if (cursor >= length) { - container.scrollTop = container.scrollHeight - return - } - - const rect = range.getClientRects().item(0) ?? range.getBoundingClientRect() - if (!rect.height) return - - const containerRect = container.getBoundingClientRect() - const top = rect.top - containerRect.top + container.scrollTop - const bottom = rect.bottom - containerRect.top + container.scrollTop - const padding = 12 - - if (top < container.scrollTop + padding) { - container.scrollTop = Math.max(0, top - padding) - return - } - - if (bottom > container.scrollTop + container.clientHeight - inset) { - container.scrollTop = bottom - container.clientHeight + inset - } - } - - const queueScroll = (count = 2) => { - requestAnimationFrame(() => { - scrollCursorIntoView() - if (count > 1) queueScroll(count - 1) - }) - } - - const activeFileTab = createSessionTabs({ - tabs, - pathFromTab: files.pathFromTab, - normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab), - }).activeFileTab - - const commentInReview = (path: string) => { - const sessionID = props.controls.session.id - if (!sessionID) return false - - const diffs = sync().data.session_diff[sessionID] - if (!diffs) return false - return diffs.some((diff) => diff.file === path) - } - - const openComment = (item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }) => { - if (!item.commentID) return - - const focus = { file: item.path, id: item.commentID } - comments.setActive(focus) - - const queueCommentFocus = (attempts = 6) => { - const schedule = (left: number) => { - requestAnimationFrame(() => { - comments.setFocus({ ...focus }) - if (left <= 0) return - requestAnimationFrame(() => { - const current = comments.focus() - if (!current) return - if (current.file !== focus.file || current.id !== focus.id) return - schedule(left - 1) - }) - }) - } - - schedule(attempts) - } - - const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) - if (wantsReview) { - if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() - layout.fileTree.setTab("changes") - tabs().setActive("review") - queueCommentFocus() - return - } - - if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() - layout.fileTree.setTab("all") - const tab = files.tab(item.path) - void tabs().open(tab) - tabs().setActive(tab) - void Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus()) - } - - const recent = createMemo(() => { - const all = tabs().all() - const active = activeFileTab() - const order = active ? [active, ...all.filter((x) => x !== active)] : all - const seen = new Set() - const paths: string[] = [] - - for (const tab of order) { - const path = files.pathFromTab(tab) - if (!path) continue - if (seen.has(path)) continue - seen.add(path) - paths.push(path) - } - - return paths - }) - const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) - const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) - const imageAttachments = createMemo(() => - prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), - ) - - const [store, setStore] = createPromptInputTransientState( - () => prompt.capture(), - Math.floor(Math.random() * EXAMPLES.length), - ) - const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) - const motion = (value: number) => ({ - opacity: value, - transform: `scale(${0.98 + value * 0.02})`, - filter: `blur(${(1 - value) * 2}px)`, - "pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const), - }) - const buttons = createMemo(() => motion(buttonsSpring())) - const shell = createMemo(() => motion(1 - buttonsSpring())) - const control = createMemo(() => ({ height: "28px", ...buttons() })) - - const commentCount = createMemo(() => { - if (store.mode === "shell") return 0 - return prompt.context.items().filter((item) => !!item.comment?.trim()).length - }) - const blank = createMemo(() => { - const text = prompt - .current() - .map((part) => ("content" in part ? part.content : "")) - .join("") - return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0 - }) - const stopping = createMemo(() => working() && blank()) - const tip = () => { - if (stopping()) { - return ( -
- {language.t("prompt.action.stop")} - {language.t("common.key.esc")} -
- ) - } - - return ( -
- {language.t("prompt.action.send")} - -
- ) - } - - const contextItems = createMemo(() => { - const items = prompt.context.items() - if (store.mode !== "shell") return items - return items.filter((item) => !item.comment?.trim()) - }) - - const hasUserPrompt = createMemo(() => { - const sessionID = props.controls.session.id - if (!sessionID) return false - const messages = sync().data.message[sessionID] - if (!messages) return false - return messages.some((m) => m.role === "user") - }) - - const history = props.history ?? createPersistedPromptInputHistory() - - const suggest = createMemo(() => !hasUserPrompt()) - - const placeholder = createMemo(() => - promptPlaceholder({ - mode: store.mode, - commentCount: commentCount(), - example: suggest() ? (store.mode === "shell" ? "git status" : language.t(EXAMPLES[store.placeholder])) : "", - suggest: suggest(), - t: (key, params) => language.t(key as Parameters[0], params as never), - }), - ) - - const historyComments = () => { - const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) - return prompt.context.items().flatMap((item) => { - if (item.type !== "file") return [] - const comment = item.comment?.trim() - if (!comment) return [] - - const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined - const nextSelection = - selection ?? - (item.selection - ? ({ - start: item.selection.startLine, - end: item.selection.endLine, - } satisfies SelectedLineRange) - : undefined) - if (!nextSelection) return [] - - return [ - { - id: item.commentID ?? item.key, - path: item.path, - selection: { ...nextSelection }, - comment, - time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), - origin: item.commentOrigin, - preview: item.preview, - } satisfies PromptHistoryComment, - ] - }) - } - - const applyHistoryComments = (items: PromptHistoryComment[]) => { - comments.replace( - items.map((item) => ({ - id: item.id, - file: item.path, - selection: { ...item.selection }, - comment: item.comment, - time: item.time, - })), - ) - prompt.context.replaceComments( - items.map((item) => ({ - type: "file" as const, - path: item.path, - selection: selectionFromLines(item.selection), - comment: item.comment, - commentID: item.id, - commentOrigin: item.origin, - preview: item.preview, - })), - ) - } - - const applyHistoryPrompt = (entry: PromptHistoryEntry, position: "start" | "end") => { - const p = entry.prompt - const length = position === "start" ? 0 : promptLength(p) - setStore("applyingHistory", true) - applyHistoryComments(entry.comments) - prompt.set(p, length) - requestAnimationFrame(() => { - editorRef.focus() - setCursorPosition(editorRef, length) - setStore("applyingHistory", false) - queueScroll() - }) - } - - const getCaretState = () => { - const selection = window.getSelection() - const textLength = promptLength(prompt.current()) - if (!selection || selection.rangeCount === 0) { - return { collapsed: false, cursorPosition: 0, textLength } - } - const anchorNode = selection.anchorNode - if (!anchorNode || !editorRef.contains(anchorNode)) { - return { collapsed: false, cursorPosition: 0, textLength } - } - return { - collapsed: selection.isCollapsed, - cursorPosition: getCursorPosition(editorRef), - textLength, - } - } - - const escBlur = () => platform.platform === "desktop" && platform.os === "macos" - - const pick = () => { - pickAttachmentFiles({ - picker: platform.openAttachmentPickerDialog, - directory: () => sdk().directory, - fallback: () => fileInputRef?.click(), - onFile: addAttachment, - onError: (error) => - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: error instanceof Error ? error.message : String(error), - }), - }) - } - - const setMode = (mode: "normal" | "shell") => { - setStore("mode", mode) - setStore({ popover: null, slashMenu: false, slashMenuQuery: "" }) - requestAnimationFrame(() => editorRef?.focus()) - } - - const shellModeKey = "mod+shift+x" - const normalModeKey = "mod+shift+e" - - command.register("prompt-input", () => [ - { - id: "file.attach", - title: language.t("prompt.action.attachFile"), - category: language.t("command.category.file"), - keybind: "mod+u", - disabled: store.mode !== "normal", - onSelect: pick, - }, - { - id: "prompt.mode.shell", - title: language.t("command.prompt.mode.shell"), - category: language.t("command.category.session"), - keybind: shellModeKey, - disabled: store.mode === "shell", - onSelect: () => setMode("shell"), - }, - { - id: "prompt.mode.normal", - title: language.t("command.prompt.mode.normal"), - category: language.t("command.category.session"), - keybind: normalModeKey, - disabled: store.mode === "normal", - onSelect: () => setMode("normal"), - }, - ]) - - const closePopover = () => setStore({ popover: null, slashMenu: false, slashMenuQuery: "" }) - - const resetHistoryNavigation = (force = false) => { - if (!force && (store.historyIndex < 0 || store.applyingHistory)) return - setStore("historyIndex", -1) - setStore("savedPrompt", null) - } - - const clearEditor = () => { - editorRef.innerHTML = "" - } - - const setEditorText = (text: string) => { - clearEditor() - editorRef.textContent = text - } - - const focusEditorEnd = () => { - requestAnimationFrame(() => { - editorRef.focus() - const range = document.createRange() - const selection = window.getSelection() - range.selectNodeContents(editorRef) - range.collapse(false) - selection?.removeAllRanges() - selection?.addRange(range) - }) - } - - const currentCursor = () => { - const selection = window.getSelection() - if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null - return getCursorPosition(editorRef) - } - - const restoreFocus = () => { - requestAnimationFrame(() => { - const cursor = savedCursor ?? prompt.cursor() ?? promptLength(prompt.current()) - editorRef.focus() - setCursorPosition(editorRef, cursor) - queueScroll() - }) - } - - const handleFocus = () => { - if (!restoreEndOnFocus) return - restoreEndOnFocus = false - requestAnimationFrame(() => { - if (document.activeElement !== editorRef) return - setCursorPosition(editorRef, prompt.cursor() ?? promptLength(prompt.current())) - queueScroll() - }) - } - - const renderEditorWithCursor = (parts: Prompt) => { - const cursor = currentCursor() - renderEditor(parts) - if (cursor !== null) setCursorPosition(editorRef, cursor) - } - - createEffect(() => { - props.controls.session.id - if (props.controls.session.id) return - if (!suggest()) return - const interval = setInterval(() => { - setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) - }, 6500) - onCleanup(() => clearInterval(interval)) - }) - - const [composing, setComposing] = createSignal(false) - const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 - - const handleBlur = () => { - const cursor = currentCursor() - savedCursor = cursor - if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor) - closePopover() - setComposing(false) - } - - const handleCompositionStart = () => { - setComposing(true) - } - - const handleCompositionEnd = () => { - setComposing(false) - requestAnimationFrame(() => { - if (composing()) return - reconcile(prompt.current().filter((part) => part.type !== "image")) - }) - } - - const referenceDescription = (reference: ReferenceInfo) => - reference.source.type === "git" ? reference.source.repository : reference.source.path - - const referenceList = createMemo(() => - sync() - .data.reference.filter((reference) => !reference.hidden) - .map( - (reference): AtOption => ({ - type: "reference", - name: reference.name, - path: reference.path, - display: reference.name, - description: reference.description ?? referenceDescription(reference), - }), - ), - ) - - const agentList = createMemo(() => - props.controls.agents.available - .filter((agent) => !agent.hidden && agent.mode !== "primary") - .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), - ) - - const mcpResourceList = createMemo(() => - Object.values(sync().data.mcp_resource).map( - (resource): AtOption => ({ - type: "resource", - name: resource.name, - uri: resource.uri, - client: resource.client, - display: resource.name, - description: resource.description, - mime: resource.mimeType, - }), - ), - ) - - const handleAtSelect = (option: AtOption | undefined) => { - if (!option) return - if (option.type === "agent") { - addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) - return - } - if (option.type === "reference") { - addPart({ - type: "file", - path: option.path, - content: "@" + option.name, - start: 0, - end: 0, - mime: "application/x-directory", - filename: option.name, - }) - return - } - if (option.type === "resource") { - addPart({ - type: "file", - path: option.uri, - content: "@" + option.name, - start: 0, - end: 0, - mime: option.mime ?? "text/plain", - filename: option.name, - url: option.uri, - source: { - type: "resource", - text: { value: "@" + option.name, start: 0, end: 0 }, - clientName: option.client, - uri: option.uri, - }, - }) - return - } - addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) - } - - const atKey = (x: AtOption | undefined) => { - if (!x) return "" - if (x.type === "agent") return `agent:${x.name}` - if (x.type === "reference") return `reference:${x.name}` - if (x.type === "resource") return `resource:${x.client}:${x.uri}` - return `file:${x.path}` - } - - const { - flat: atFlat, - active: atActive, - setActive: setAtActive, - onInput: atOnInput, - onKeyDown: atOnKeyDown, - } = useFilteredList({ - items: async (query) => { - const references = referenceList() - const agents = agentList() - const mcpResources = mcpResourceList() - const open = recent() - const seen = new Set(open) - const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) - if (!query.trim()) return [...references, ...agents, ...mcpResources, ...pinned] - const paths = await files.searchFilesAndDirectories(query) - const fileOptions: AtOption[] = paths - .filter((path) => !seen.has(path)) - .map((path) => ({ type: "file", path, display: path })) - return [...references, ...agents, ...mcpResources, ...pinned, ...fileOptions] - }, - key: atKey, - filterKeys: ["display"], - skipFilter: (item) => item.type === "file" && !item.recent, - groupBy: atOptionCategory, - sortGroupsBy: compareAtCategories, - onSelect: handleAtSelect, - }) - - const slashCommands = createMemo(() => { - const builtin = command.options - .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) - .map((opt) => ({ - id: opt.id, - trigger: opt.slash!, - title: opt.title, - description: opt.description, - keybind: opt.keybind, - type: "builtin" as const, - })) - - const custom = sync().data.command.map((cmd) => ({ - id: `custom.${cmd.name}`, - trigger: cmd.name, - title: cmd.name, - description: cmd.description, - type: "custom" as const, - source: cmd.source, - })) - - return [...custom, ...builtin] - }) - - const handleSlashSelect = (cmd: SlashCommand | undefined) => { - if (!cmd) return - const menu = store.slashMenu - closePopover() - const images = imageAttachments() - - if (cmd.type === "custom") { - const text = `/${cmd.trigger} ` - if (menu) { - editorRef.focus() - setCursorPosition(editorRef, 0) - addPart({ type: "text", content: text, start: 0, end: text.length }) - focusEditorEnd() - return - } - setEditorText(text) - prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length) - focusEditorEnd() - return - } - - if (menu) { - command.trigger(cmd.id, "slash") - return - } - - clearEditor() - prompt.set([...DEFAULT_PROMPT, ...images], 0) - command.trigger(cmd.id, "slash") - } - - const { - flat: slashFlat, - active: slashActive, - setActive: setSlashActive, - onInput: slashOnInput, - onKeyDown: slashOnKeyDown, - } = useFilteredList({ - items: slashCommands, - key: (x) => x?.id, - filterKeys: ["trigger", "title"], - onSelect: handleSlashSelect, - }) - - const createPill = (part: FileAttachmentPart | AgentPart) => { - const pill = document.createElement("span") - pill.textContent = part.content - pill.setAttribute("data-type", part.type) - if (part.type === "file") { - pill.setAttribute("data-path", part.path) - if (part.mime) pill.setAttribute("data-mime", part.mime) - if (part.filename) pill.setAttribute("data-filename", part.filename) - if (part.url) pill.setAttribute("data-url", part.url) - if (part.source?.type === "resource") { - pill.setAttribute("data-source-type", part.source.type) - pill.setAttribute("data-source-client-name", part.source.clientName) - pill.setAttribute("data-source-uri", part.source.uri) - } - } - if (part.type === "agent") pill.setAttribute("data-name", part.name) - pill.setAttribute("contenteditable", "false") - pill.style.userSelect = "text" - pill.style.cursor = "default" - return pill - } - - const isNormalizedEditor = () => - Array.from(editorRef.childNodes).every((node) => { - if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent ?? "" - if (!text.includes("\u200B")) return true - if (text !== "\u200B") return false - - const prev = node.previousSibling - const next = node.nextSibling - const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" - return !!prevIsBr && !next - } - if (node.nodeType !== Node.ELEMENT_NODE) return false - const el = node as HTMLElement - if (el.dataset.type === "file") return true - if (el.dataset.type === "agent") return true - return el.tagName === "BR" - }) - - const renderEditor = (parts: Prompt) => { - clearEditor() - for (const part of parts) { - if (part.type === "text") { - editorRef.appendChild(createTextFragment(part.content)) - continue - } - if (part.type === "file" || part.type === "agent") { - editorRef.appendChild(createPill(part)) - } - } - - const last = editorRef.lastChild - if (last?.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR") { - editorRef.appendChild(document.createTextNode("\u200B")) - } - } - - const scrollSlashActiveIntoView = () => { - const activeId = slashActive() - if (!activeId || !slashPopoverRef) return - - requestAnimationFrame(() => { - const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) - element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) - }) - } - const selectPopoverActive = () => { - if (store.popover === "at") { - const items = atFlat() - if (items.length === 0) return - const active = atActive() - const item = items.find((entry) => atKey(entry) === active) ?? items[0] - handleAtSelect(item) - return - } - - if (store.popover === "slash") { - const items = slashFlat() - if (items.length === 0) return - const active = slashActive() - const item = items.find((entry) => entry.id === active) ?? items[0] - handleSlashSelect(item) - } - } - - const reconcile = (input: Prompt) => { - if (mirror.input) { - mirror.input = false - if (isNormalizedEditor()) return - - renderEditorWithCursor(input) - return - } - - const dom = parseFromDOM() - if (isNormalizedEditor() && isPromptEqual(input, dom)) return - - renderEditorWithCursor(input) - } - - createEffect( - on( - () => prompt.current(), - (parts) => { - if (composing()) return - reconcile(parts.filter((part) => part.type !== "image")) - }, - ), - ) - - const parseFromDOM = (): Prompt => { - const parts: Prompt = [] - let position = 0 - let buffer = "" - - const flushText = () => { - let content = buffer - if (content.includes("\r")) content = content.replace(/\r\n?/g, "\n") - if (content.includes("\u200B")) content = content.replace(/\u200B/g, "") - buffer = "" - if (!content) return - parts.push({ type: "text", content, start: position, end: position + content.length }) - position += content.length - } - - const pushFile = (file: HTMLElement) => { - const content = file.textContent ?? "" - const source = - file.dataset.sourceType === "resource" && file.dataset.sourceClientName && file.dataset.sourceUri - ? { - type: "resource" as const, - text: { - value: content, - start: position, - end: position + content.length, - }, - clientName: file.dataset.sourceClientName, - uri: file.dataset.sourceUri, - } - : undefined - parts.push({ - type: "file", - path: file.dataset.path!, - content, - start: position, - end: position + content.length, - ...(file.dataset.mime ? { mime: file.dataset.mime } : {}), - ...(file.dataset.filename ? { filename: file.dataset.filename } : {}), - ...(file.dataset.url ? { url: file.dataset.url } : {}), - ...(source ? { source } : {}), - }) - position += content.length - } - - const pushAgent = (agent: HTMLElement) => { - const content = agent.textContent ?? "" - parts.push({ - type: "agent", - name: agent.dataset.name!, - content, - start: position, - end: position + content.length, - }) - position += content.length - } - - const visit = (node: Node) => { - if (node.nodeType === Node.TEXT_NODE) { - buffer += node.textContent ?? "" - return - } - if (node.nodeType !== Node.ELEMENT_NODE) return - - const el = node as HTMLElement - if (el.dataset.type === "file") { - flushText() - pushFile(el) - return - } - if (el.dataset.type === "agent") { - flushText() - pushAgent(el) - return - } - if (el.tagName === "BR") { - buffer += "\n" - return - } - - for (const child of Array.from(el.childNodes)) { - visit(child) - } - } - - const children = Array.from(editorRef.childNodes) - children.forEach((child, index) => { - const isBlock = child.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((child as HTMLElement).tagName) - visit(child) - if (isBlock && index < children.length - 1) { - buffer += "\n" - } - }) - - flushText() - - if (parts.length === 0) parts.push(...DEFAULT_PROMPT) - return parts - } - - const handleInput = () => { - const rawParts = parseFromDOM() - const images = imageAttachments() - const cursorPosition = getCursorPosition(editorRef) - const rawText = - rawParts.length === 1 && rawParts[0]?.type === "text" - ? rawParts[0].content - : rawParts.map((p) => ("content" in p ? p.content : "")).join("") - const hasNonText = rawParts.some((part) => part.type !== "text") - const textContent = (editorRef.textContent ?? "").replace(/\u200B/g, "") - const shouldReset = - textContent.length === 0 && rawText.replace(/\n/g, "").length === 0 && !hasNonText && images.length === 0 - - if (shouldReset) { - closePopover() - resetHistoryNavigation() - if (prompt.dirty()) { - mirror.input = true - prompt.set(DEFAULT_PROMPT, 0) - } - queueScroll() - return - } - - const shellMode = store.mode === "shell" - - if (!shellMode) { - const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) - const slashMatch = rawText.match(/^\/(\S*)$/) - - if (atMatch) { - atOnInput(atMatch[1]) - setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" }) - } else if (slashMatch) { - slashOnInput(slashMatch[1]) - setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" }) - } else { - closePopover() - } - } else { - closePopover() - } - - resetHistoryNavigation() - - mirror.input = true - prompt.set([...rawParts, ...images], cursorPosition) - queueScroll() - } - - const addPart = (part: ContentPart) => { - if (part.type === "image") return false - - const selection = window.getSelection() - if (!selection) return false - - if (selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) { - editorRef.focus() - const cursor = prompt.cursor() ?? promptLength(prompt.current()) - setCursorPosition(editorRef, cursor) - } - - if (selection.rangeCount === 0) return false - const range = selection.getRangeAt(0) - if (!editorRef.contains(range.startContainer)) return false - - if (part.type === "file" || part.type === "agent") { - const cursorPosition = getCursorPosition(editorRef) - const rawText = prompt - .current() - .map((p) => ("content" in p ? p.content : "")) - .join("") - const textBeforeCursor = rawText.substring(0, cursorPosition) - const atMatch = textBeforeCursor.match(/@(\S*)$/) - const pill = createPill(part) - const gap = document.createTextNode(" ") - - if (atMatch) { - const start = atMatch.index ?? cursorPosition - atMatch[0].length - setRangeEdge(editorRef, range, "start", start) - setRangeEdge(editorRef, range, "end", cursorPosition) - } - - range.deleteContents() - range.insertNode(gap) - range.insertNode(pill) - range.setStartAfter(gap) - range.collapse(true) - selection.removeAllRanges() - selection.addRange(range) - } - - if (part.type === "text") { - const fragment = createTextFragment(part.content) - const last = fragment.lastChild - range.deleteContents() - range.insertNode(fragment) - if (last) { - if (last.nodeType === Node.TEXT_NODE) { - const text = last.textContent ?? "" - if (text === "\u200B") { - range.setStart(last, 0) - } - if (text !== "\u200B") { - range.setStart(last, text.length) - } - } - if (last.nodeType !== Node.TEXT_NODE) { - const isBreak = last.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR" - const next = last.nextSibling - const emptyText = next?.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === "" - if (isBreak && (!next || emptyText)) { - const placeholder = next && emptyText ? next : document.createTextNode("\u200B") - if (!next) last.parentNode?.insertBefore(placeholder, null) - placeholder.textContent = "\u200B" - range.setStart(placeholder, 0) - } else { - range.setStartAfter(last) - } - } - } - range.collapse(true) - selection.removeAllRanges() - selection.addRange(range) - } - - handleInput() - closePopover() - return true - } - - const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { - history.add(prompt, mode, mode === "shell" ? [] : historyComments()) - } - - createEffect( - on( - () => props.edit?.id, - (id) => { - const edit = props.edit - if (!id || !edit) return - - for (const item of prompt.context.items()) { - prompt.context.remove(item.key) - } - - for (const item of edit.context) { - prompt.context.add({ - type: item.type, - path: item.path, - selection: item.selection, - comment: item.comment, - commentID: item.commentID, - commentOrigin: item.commentOrigin, - preview: item.preview, - }) - } - - setStore("mode", "normal") - closePopover() - setStore("historyIndex", -1) - setStore("savedPrompt", null) - prompt.set(edit.prompt, promptLength(edit.prompt)) - requestAnimationFrame(() => { - editorRef.focus() - setCursorPosition(editorRef, promptLength(edit.prompt)) - queueScroll() - }) - props.onEditLoaded?.() - }, - { defer: true }, - ), - ) - - const navigateHistory = (direction: "up" | "down") => { - const result = navigatePromptHistory({ - direction, - entries: history.entries(store.mode), - historyIndex: store.historyIndex, - currentPrompt: prompt.current(), - currentComments: historyComments(), - savedPrompt: store.savedPrompt, - }) - if (!result.handled) return false - setStore("historyIndex", result.historyIndex) - setStore("savedPrompt", result.savedPrompt) - applyHistoryPrompt(result.entry, result.cursor) - return true - } - - const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({ - prompt, - editor: () => editorRef, - isDialogActive: () => !!dialog.active, - setDraggingType: (type) => setStore("draggingType", type), - focusEditor: () => { - editorRef.focus() - setCursorPosition(editorRef, promptLength(prompt.current())) - }, - addPart, - readClipboardImage: platform.readClipboardImage, - getPathForFile: platform.getPathForFile, - }) - - const fileAttachmentInput = () => ( - (fileInputRef = el)} - type="file" - multiple - accept={ACCEPTED_FILE_TYPES.join(",")} - class="hidden" - onChange={(e) => { - const list = e.currentTarget.files - if (list) void addAttachments(Array.from(list)) - e.currentTarget.value = "" - }} - /> - ) - - const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) - // Check provider variants directly: `variants` also includes the UI-only default option. - const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0) - const accepting = createMemo(() => { - const id = props.controls.session.id - if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) - return permission.isAutoAccepting(id, sdk().directory) - }) - - const { abort, handleSubmit } = - props.submission ?? - createPromptSubmit({ - prompt, - info, - imageAttachments, - commentCount, - autoAccept: () => accepting(), - mode: () => store.mode, - working, - editor: () => editorRef, - queueScroll, - promptLength, - addToHistory, - resetHistoryNavigation: () => { - resetHistoryNavigation(true) - }, - setMode: (mode) => setStore("mode", mode), - setPopover: (popover) => { - if (!popover) return closePopover() - setStore({ popover, slashMenu: false, slashMenuQuery: "" }) - }, - newSessionWorktree: () => props.newSessionWorktree, - onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, - shouldQueue: props.shouldQueue, - onQueue: props.onQueue, - onAbort: props.onAbort, - onSubmit: props.onSubmit, - model: props.controls.model.selection, - }) - - const handleKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { - event.preventDefault() - if (store.mode !== "normal") return - pick() - return - } - - if (event.key === "Backspace") { - const selection = window.getSelection() - if (selection && selection.isCollapsed) { - const node = selection.anchorNode - const offset = selection.anchorOffset - if (node && node.nodeType === Node.TEXT_NODE) { - const text = node.textContent ?? "" - if (/^\u200B+$/.test(text) && offset > 0) { - const range = document.createRange() - range.setStart(node, 0) - range.collapse(true) - selection.removeAllRanges() - selection.addRange(range) - } - } - } - } - - if (event.key === "!" && store.mode === "normal") { - const cursorPosition = getCursorPosition(editorRef) - if (cursorPosition === 0) { - setStore("mode", "shell") - closePopover() - event.preventDefault() - return - } - } - - if (event.key === "Escape") { - if (store.popover) { - closePopover() - event.preventDefault() - event.stopPropagation() - return - } - - if (store.mode === "shell") { - setStore("mode", "normal") - event.preventDefault() - event.stopPropagation() - return - } - - if (working()) { - void abort() - event.preventDefault() - event.stopPropagation() - return - } - - if (escBlur()) { - editorRef.blur() - event.preventDefault() - event.stopPropagation() - return - } - } - - if (store.mode === "shell") { - const { collapsed, cursorPosition, textLength } = getCaretState() - if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) { - setStore("mode", "normal") - event.preventDefault() - return - } - } - - // Handle Shift+Enter BEFORE IME check - Shift+Enter is never used for IME input - // and should always insert a newline regardless of composition state - if (event.key === "Enter" && event.shiftKey) { - addPart({ type: "text", content: "\n", start: 0, end: 0 }) - event.preventDefault() - return - } - - if (event.key === "Enter" && isImeComposing(event)) { - return - } - - const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey - - if (store.popover) { - if (event.key === "Tab") { - selectPopoverActive() - event.preventDefault() - return - } - const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter" - const ctrlNav = ctrl && (event.key === "n" || event.key === "p") - if (nav || ctrlNav) { - if (store.popover === "at") { - atOnKeyDown(event) - event.preventDefault() - return - } - if (store.popover === "slash") { - slashOnKeyDown(event) - if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) { - scrollSlashActiveIntoView() - } - } - event.preventDefault() - return - } - } - - if (ctrl && event.code === "KeyG") { - if (store.popover) { - closePopover() - event.preventDefault() - return - } - if (working()) { - void abort() - event.preventDefault() - } - return - } - - if (event.key === "ArrowUp" || event.key === "ArrowDown") { - if (event.altKey || event.ctrlKey || event.metaKey) return - const { collapsed } = getCaretState() - if (!collapsed) return - - const cursorPosition = getCursorPosition(editorRef) - const textContent = prompt - .current() - .map((part) => ("content" in part ? part.content : "")) - .join("") - const direction = event.key === "ArrowUp" ? "up" : "down" - if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition, store.historyIndex >= 0)) return - if (navigateHistory(direction)) { - event.preventDefault() - } - return - } - - // Note: Shift+Enter is handled earlier, before IME check - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault() - if (event.repeat) return - if ( - working() && - prompt - .current() - .map((part) => ("content" in part ? part.content : "")) - .join("") - .trim().length === 0 && - imageAttachments().length === 0 && - commentCount() === 0 - ) { - return - } - void handleSubmit(event) - } - } - - const handleSlashMenuKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - closePopover() - requestAnimationFrame(() => editorRef.focus()) - event.preventDefault() - return - } - - if (event.key === "Tab") { - selectPopoverActive() - event.preventDefault() - return - } - - const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey - const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter" - const ctrlNav = ctrl && (event.key === "n" || event.key === "p") - if (!nav && !ctrlNav) return - slashOnKeyDown(event) - if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) scrollSlashActiveIntoView() - event.preventDefault() - } - - const agentsLoading = () => props.controls.agents.loading - const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) - const providersLoading = () => props.controls.model.loading - const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) - - const [promptReady] = createResource( - () => prompt.ready.promise, - (p) => p, - ) - - const bindEditorRef = (el: HTMLDivElement) => { - editorRef = el - restoreEndOnFocus = true - props.ref?.(el) - } - return ( -
- {(promptReady(), null)} - (slashPopoverRef = el)} - atFlat={atFlat()} - atActive={atActive() ?? undefined} - atKey={atKey} - setAtActive={setAtActive} - onAtSelect={handleAtSelect} - slashFlat={slashFlat()} - slashActive={slashActive() ?? undefined} - setSlashActive={setSlashActive} - onSlashSelect={handleSlashSelect} - slashMenu={store.slashMenu} - slashMenuQuery={store.slashMenuQuery} - onSlashMenuInput={(value) => { - setStore("slashMenuQuery", value) - slashOnInput(value) - }} - onSlashMenuKeyDown={handleSlashMenuKeyDown} - commandKeybind={command.keybind} - commandKeybindParts={command.keybindParts} - newLayoutDesigns={false} - t={(key) => language.t(key as Parameters[0])} - /> - - - { - const active = comments.active() - return !!item.commentID && item.commentID === active?.id && item.path === active?.file - }} - openComment={openComment} - remove={(item) => { - if (item.commentID) comments.remove(item.path, item.commentID) - prompt.context.remove(item.key) - }} - newLayoutDesigns={false} - t={(key) => language.t(key as Parameters[0])} - /> - - dialog.show(() => ) - } - onRemove={removeAttachment} - removeLabel={language.t("prompt.attachment.remove")} - newLayoutDesigns={false} - /> -
{ - const target = e.target - if (!(target instanceof HTMLElement)) return - if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { - return - } - editorRef?.focus() - }} - > -
(scrollRef = el)} - style={{ "scroll-padding-bottom": space }} - > -
-
- {placeholder()} -
-
- - - - - -
-
-
- - {language.t("prompt.mode.shell")} -
- -
-
- -
- - (x === "default" ? language.t("common.default") : x)} - onSelect={(value) => { - props.controls.model.selection.variant.set(value === "default" ? undefined : value) - restoreFocus() - }} - class="capitalize max-w-[160px] text-text-base" - valueClass="truncate text-13-regular text-text-base" - triggerStyle={control()} - triggerProps={{ "data-action": "prompt-model-variant" }} - variant="ghost" - /> - -
-
- - -
-
-
- - -
- ) -} diff --git a/packages/app/src/components/prompt-input/at-options.test.ts b/packages/app/src/components/prompt-input/at-options.test.ts deleted file mode 100644 index 7f8dcfbe6e..0000000000 --- a/packages/app/src/components/prompt-input/at-options.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { AtOption } from "./slash-popover" -import { atOptionCategory, compareAtCategories } from "./at-options" - -const options: AtOption[] = [ - { type: "reference", name: "effect", path: "/refs/effect", display: "effect", description: "" }, - { type: "agent", name: "general", display: "general" }, - { type: "resource", name: "docs", uri: "mcp://docs", client: "mcp", display: "docs" }, - { type: "file", path: "src/open.ts", display: "src/open.ts", recent: true }, - { type: "file", path: "src/found.ts", display: "src/found.ts" }, -] - -describe("atOptionCategory", () => { - test("categorizes every option shape", () => { - expect(options.map(atOptionCategory)).toEqual(["reference", "agent", "resource", "recent", "file"]) - }) -}) - -describe("compareAtCategories", () => { - test("ranks file groups before references, agents, and resources", () => { - const sorted = ["reference", "agent", "resource", "recent", "file"] - .map((category) => ({ category })) - .sort(compareAtCategories) - .map((group) => group.category) - expect(sorted).toEqual(["recent", "file", "reference", "agent", "resource"]) - }) -}) diff --git a/packages/app/src/components/prompt-input/at-options.ts b/packages/app/src/components/prompt-input/at-options.ts deleted file mode 100644 index c0771335b4..0000000000 --- a/packages/app/src/components/prompt-input/at-options.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { AtOption } from "./slash-popover" - -export function atOptionCategory(item: AtOption) { - if (item.type === "reference") return "reference" - if (item.type === "agent") return "agent" - if (item.type === "resource") return "resource" - if (item.recent) return "recent" - return "file" -} - -// Files are the dominant @ use case, so file groups (recent open tabs, then -// search results) rank ahead of references, agents, and MCP resources. -const rank = (category: string) => { - if (category === "recent") return 0 - if (category === "file") return 1 - if (category === "reference") return 2 - if (category === "agent") return 3 - return 4 -} - -export function compareAtCategories(a: { category: string }, b: { category: string }) { - return rank(a.category) - rank(b.category) -} diff --git a/packages/app/src/components/prompt-input/attachments.test.ts b/packages/app/src/components/prompt-input/attachments.test.ts deleted file mode 100644 index 104921697d..0000000000 --- a/packages/app/src/components/prompt-input/attachments.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { attachmentMime, pickAttachmentFiles } from "./files" -import { pasteMode } from "./paste" - -describe("attachmentMime", () => { - test("keeps PDFs when the browser reports the mime", async () => { - const file = new File(["%PDF-1.7"], "guide.pdf", { type: "application/pdf" }) - expect(await attachmentMime(file)).toBe("application/pdf") - }) - - test("normalizes structured text types to text/plain", async () => { - const file = new File(['{"ok":true}\n'], "data.json", { type: "application/json" }) - expect(await attachmentMime(file)).toBe("text/plain") - }) - - test("accepts text files even with a misleading browser mime", async () => { - const file = new File(["export const x = 1\n"], "main.ts", { type: "video/mp2t" }) - expect(await attachmentMime(file)).toBe("text/plain") - }) - - test("rejects binary files", async () => { - const file = new File([Uint8Array.of(0, 255, 1, 2)], "blob.bin", { type: "application/octet-stream" }) - expect(await attachmentMime(file)).toBeUndefined() - }) -}) - -describe("pickAttachmentFiles", () => { - test("reads the current project directory for every native picker invocation", async () => { - const paths: string[] = [] - const files: File[] = [] - const file = new File(["hello"], "hello.txt", { type: "text/plain" }) - let directory = "C:\\Projects\\LoremIpsum" - const picker = async (options?: { defaultPath?: string }, onFile?: (file: File) => Promise) => { - paths.push(options?.defaultPath ?? "") - await onFile?.(file) - } - - pickAttachmentFiles({ - picker, - directory: () => directory, - fallback: () => undefined, - onFile: async (selected) => files.push(selected), - onError: () => undefined, - }) - await Promise.resolve() - directory = "C:\\Projects\\DolorSit" - pickAttachmentFiles({ - picker, - directory: () => directory, - fallback: () => undefined, - onFile: async (selected) => files.push(selected), - onError: () => undefined, - }) - await Promise.resolve() - expect(files).toEqual([file, file]) - expect(paths).toEqual(["C:\\Projects\\LoremIpsum", "C:\\Projects\\DolorSit"]) - }) - - test("uses the browser file input when no native picker exists", async () => { - let fallback = 0 - pickAttachmentFiles({ - directory: () => "/projects/consectetur-adipiscing", - fallback: () => { - fallback += 1 - }, - onFile: async () => undefined, - onError: () => undefined, - }) - expect(fallback).toBe(1) - }) - - test("reports native picker failures without rejecting", async () => { - const error = new Error("picker unavailable") - const errors: unknown[] = [] - const handled = Promise.withResolvers() - pickAttachmentFiles({ - picker: async () => Promise.reject(error), - directory: () => "C:\\Projects\\LoremIpsum", - fallback: () => undefined, - onFile: async () => undefined, - onError: (cause) => { - errors.push(cause) - handled.resolve() - }, - }) - await handled.promise - expect(errors).toEqual([error]) - }) -}) - -describe("pasteMode", () => { - test("uses native paste for short single-line text", () => { - expect(pasteMode("hello world")).toBe("native") - }) - - test("uses manual paste for multiline text", () => { - expect( - pasteMode(`{ - "ok": true -}`), - ).toBe("manual") - expect(pasteMode("a\r\nb")).toBe("manual") - }) - - test("uses manual paste for large text", () => { - expect(pasteMode("x".repeat(8000))).toBe("manual") - }) -}) diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts deleted file mode 100644 index 9568b17d19..0000000000 --- a/packages/app/src/components/prompt-input/attachments.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { onMount } from "solid-js" -import { makeEventListener } from "@solid-primitives/event-listener" -import { showToast } from "@/utils/toast" -import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt" -import { useLanguage } from "@/context/language" -import { uuid } from "@/utils/uuid" -import { getCursorPosition } from "./editor-dom" -import { attachmentMime } from "./files" -import { normalizePaste, pasteMode } from "./paste" - -function dataUrl(file: File, mime: string) { - return new Promise((resolve) => { - const reader = new FileReader() - reader.addEventListener("error", () => resolve("")) - reader.addEventListener("load", () => { - const value = typeof reader.result === "string" ? reader.result : "" - const idx = value.indexOf(",") - if (idx === -1) { - resolve(value) - return - } - resolve(`data:${mime};base64,${value.slice(idx + 1)}`) - }) - reader.readAsDataURL(file) - }) -} - -type PromptTarget = Pick["capture"]>, "current" | "cursor" | "set"> -type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined } - -type PromptAttachmentsCoreInput = { - capture: () => PromptTarget - editor: () => HTMLDivElement | undefined - focusEditor?: () => void - addPart?: (part: ContentPart) => boolean - warn?: () => void - readClipboardImage?: () => Promise - getPathForFile?: (file: File) => string -} - -export type PromptAttachmentsInput = { - prompt: ReturnType - editor: () => HTMLDivElement | undefined - isDialogActive: () => boolean - setDraggingType: (type: "image" | "@mention" | null) => void - focusEditor: () => void - addPart: (part: ContentPart) => boolean - readClipboardImage?: () => Promise - getPathForFile?: (file: File) => string -} - -export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) { - const capture = (): AttachmentTarget | undefined => { - const prompt = input.capture() - const editor = input.editor() - if (!editor) return - return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) } - } - - const add = async (file: File, toast = true, target = capture()) => { - if (!target) return false - const mime = await attachmentMime(file) - if (!mime) { - if (toast) input.warn?.() - return false - } - - const url = await dataUrl(file, mime) - if (!url) return false - - const attachment: ImageAttachmentPart = { - type: "image", - id: uuid(), - filename: file.name, - sourcePath: input.getPathForFile?.(file) || undefined, - mime, - dataUrl: url, - } - target.prompt.set([...target.prompt.current(), attachment], target.cursor) - return true - } - - const addAttachment = (file: File) => add(file) - - const addAttachments = async (files: File[], toast = true, target = capture()) => { - let found = false - - for (const file of files) { - const ok = await add(file, false, target) - if (ok) found = true - } - - if (!found && files.length > 0 && toast) input.warn?.() - return found - } - - const addClipboardAttachment = async (pending: Promise, target = capture()) => { - const file = await pending - if (!file) return false - return add(file, true, target) - } - - const removeAttachment = (id: string) => { - const target = input.capture() - const current = target.current() - const next = current.filter((part) => part.type !== "image" || part.id !== id) - target.set(next, target.cursor()) - } - - const handlePaste = async (event: ClipboardEvent) => { - const clipboardData = event.clipboardData - if (!clipboardData) return - const target = capture() - if (!target) return - - event.preventDefault() - event.stopPropagation() - - const files = Array.from(clipboardData.items).flatMap((item) => { - if (item.kind !== "file") return [] - const file = item.getAsFile() - return file ? [file] : [] - }) - - if (files.length > 0) { - await addAttachments(files, true, target) - return - } - - const plainText = clipboardData.getData("text/plain") ?? "" - - // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images - if (input.readClipboardImage && !plainText) { - if (await addClipboardAttachment(input.readClipboardImage(), target)) return - } - - if (!plainText) return - - const text = normalizePaste(plainText) - - const put = () => { - if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true - input.focusEditor?.() - return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false - } - - if (pasteMode(text) === "manual") { - put() - return - } - - const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text) - if (inserted) return - - put() - } - - return { - addAttachment, - addAttachments, - addClipboardAttachment, - removeAttachment, - handlePaste, - } -} - -export function createPromptAttachments(input: PromptAttachmentsInput) { - const language = useLanguage() - const attachments = createPromptAttachmentsCore({ - ...input, - capture: input.prompt.capture, - warn: () => { - showToast({ - title: language.t("prompt.toast.pasteUnsupported.title"), - description: language.t("prompt.toast.pasteUnsupported.description"), - }) - }, - }) - - const handleGlobalDragOver = (event: DragEvent) => { - if (input.isDialogActive()) return - - event.preventDefault() - const hasFiles = event.dataTransfer?.types.includes("Files") - const hasText = event.dataTransfer?.types.includes("text/plain") - if (hasFiles) { - input.setDraggingType("image") - } else if (hasText) { - input.setDraggingType("@mention") - } - } - - const handleGlobalDragLeave = (event: DragEvent) => { - if (input.isDialogActive()) return - if (!event.relatedTarget) { - input.setDraggingType(null) - } - } - - const handleGlobalDrop = async (event: DragEvent) => { - if (input.isDialogActive()) return - - event.preventDefault() - input.setDraggingType(null) - - const plainText = event.dataTransfer?.getData("text/plain") - const filePrefix = "file:" - if (plainText?.startsWith(filePrefix)) { - const filePath = plainText.slice(filePrefix.length) - input.focusEditor() - input.addPart({ type: "file", path: filePath, content: "@" + filePath, start: 0, end: 0 }) - return - } - - const dropped = event.dataTransfer?.files - if (!dropped) return - - await attachments.addAttachments(Array.from(dropped)) - } - - onMount(() => { - makeEventListener(document, "dragover", handleGlobalDragOver) - makeEventListener(document, "dragleave", handleGlobalDragLeave) - makeEventListener(document, "drop", handleGlobalDrop) - }) - - return attachments -} diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx deleted file mode 100644 index 1f03f199ab..0000000000 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { Component, For, Show } from "solid-js" -import { Dynamic } from "solid-js/web" -import { FileIcon } from "@opencode-ai/ui/file-icon" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { Tooltip } from "@opencode-ai/ui/tooltip" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path" -import type { ContextItem } from "@/context/prompt" - -type PromptContextItem = ContextItem & { key: string } - -type ContextItemsProps = { - items: PromptContextItem[] - active: (item: PromptContextItem) => boolean - openComment: (item: PromptContextItem) => void - remove: (item: PromptContextItem) => void - newLayoutDesigns: boolean - t: (key: string) => string -} - -export const PromptContextItems: Component = (props) => { - return ( - 0}> -
- - {(item) => { - const directory = getDirectory(item.path) - const filename = getFilename(item.path) - const label = getFilenameTruncated(item.path, 14) - const selected = props.active(item) - - return ( - - - {directory} - - {filename} - - } - placement="top" - openDelay={800} - > -
props.openComment(item)} - > -
- -
- {label} - - {(sel) => ( - - {sel().startLine === sel().endLine - ? `:${sel().startLine}` - : `:${sel().startLine}-${sel().endLine}`} - - )} - -
- { - e.stopPropagation() - props.remove(item) - }} - aria-label={props.t("prompt.context.removeFile")} - /> -
- - {(comment) =>
{comment()}
} -
-
-
- ) - }} -
-
-
- ) -} diff --git a/packages/app/src/components/prompt-input/drag-overlay.tsx b/packages/app/src/components/prompt-input/drag-overlay.tsx deleted file mode 100644 index 41962ce536..0000000000 --- a/packages/app/src/components/prompt-input/drag-overlay.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Component, Show } from "solid-js" -import { Icon } from "@opencode-ai/ui/icon" - -type PromptDragOverlayProps = { - type: "image" | "@mention" | null - label: string -} - -const kindToIcon = { - image: "photo", - "@mention": "link", -} as const - -export const PromptDragOverlay: Component = (props) => { - return ( - -
-
- - {props.label} -
-
-
- ) -} diff --git a/packages/app/src/components/prompt-input/files.ts b/packages/app/src/components/prompt-input/files.ts deleted file mode 100644 index 3c6ad1ff08..0000000000 --- a/packages/app/src/components/prompt-input/files.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker" - -export { ACCEPTED_FILE_TYPES } - -type AttachmentPicker = ( - options: { - defaultPath?: string - multiple?: boolean - accept?: string[] - }, - onFile: (file: File) => Promise, -) => Promise - -export function pickAttachmentFiles(input: { - picker?: AttachmentPicker - directory: () => string - fallback: () => void - onFile: (file: File) => Promise - onError: (error: unknown) => void -}) { - if (!input.picker) { - input.fallback() - return - } - void input - .picker( - { - defaultPath: input.directory(), - multiple: true, - accept: ACCEPTED_FILE_TYPES, - }, - input.onFile, - ) - .catch(input.onError) -} - -const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES) -const IMAGE_EXTS = new Map([ - ["gif", "image/gif"], - ["jpeg", "image/jpeg"], - ["jpg", "image/jpeg"], - ["png", "image/png"], - ["webp", "image/webp"], -]) -const TEXT_MIMES = new Set([ - "application/json", - "application/ld+json", - "application/toml", - "application/x-toml", - "application/x-yaml", - "application/xml", - "application/yaml", -]) - -const SAMPLE = 4096 - -function kind(type: string) { - return type.split(";", 1)[0]?.trim().toLowerCase() ?? "" -} - -function ext(name: string) { - const idx = name.lastIndexOf(".") - if (idx === -1) return "" - return name.slice(idx + 1).toLowerCase() -} - -function textMime(type: string) { - if (!type) return false - if (type.startsWith("text/")) return true - if (TEXT_MIMES.has(type)) return true - if (type.endsWith("+json")) return true - return type.endsWith("+xml") -} - -function textBytes(bytes: Uint8Array) { - if (bytes.length === 0) return true - let count = 0 - for (const byte of bytes) { - if (byte === 0) return false - if (byte < 9 || (byte > 13 && byte < 32)) count += 1 - } - return count / bytes.length <= 0.3 -} - -export async function attachmentMime(file: File) { - const type = kind(file.type) - if (IMAGE_MIMES.has(type)) return type - if (type === "application/pdf") return type - - const suffix = ext(file.name) - const fallback = IMAGE_EXTS.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined) - if ((!type || type === "application/octet-stream") && fallback) return fallback - - if (textMime(type)) return "text/plain" - const bytes = new Uint8Array(await file.slice(0, SAMPLE).arrayBuffer()) - if (!textBytes(bytes)) return - return "text/plain" -} diff --git a/packages/app/src/components/prompt-input/image-attachments.css b/packages/app/src/components/prompt-input/image-attachments.css deleted file mode 100644 index 2aaf494ebf..0000000000 --- a/packages/app/src/components/prompt-input/image-attachments.css +++ /dev/null @@ -1,43 +0,0 @@ -@keyframes prompt-attachments-fade-left { - from { - visibility: hidden; - } - to { - visibility: visible; - } -} - -@keyframes prompt-attachments-fade-right { - from { - visibility: visible; - } - to { - visibility: hidden; - } -} - -[data-slot="prompt-attachments"] { - timeline-scope: --prompt-attachments-scroll; - - [data-slot^="prompt-attachments-fade-"] { - visibility: hidden; - } -} - -@supports (animation-timeline: --prompt-attachments-scroll) and (timeline-scope: --prompt-attachments-scroll) { - [data-slot="prompt-attachments-scroll"] { - scroll-timeline: --prompt-attachments-scroll x; - } - - [data-slot="prompt-attachments-fade-left"] { - animation: prompt-attachments-fade-left linear both; - animation-timeline: --prompt-attachments-scroll; - animation-range: 0 0.1px; - } - - [data-slot="prompt-attachments-fade-right"] { - animation: prompt-attachments-fade-right linear both; - animation-timeline: --prompt-attachments-scroll; - animation-range: calc(100% - 1.1px) calc(100% - 1px); - } -} diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx deleted file mode 100644 index 3a0cc3e4dd..0000000000 --- a/packages/app/src/components/prompt-input/image-attachments.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { Component, For, Show } from "solid-js" -import { Icon } from "@opencode-ai/ui/icon" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { Tooltip } from "@opencode-ai/ui/tooltip" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { AttachmentCardV2 } from "@opencode-ai/session-ui/v2/attachment-card-v2" -import { CommentCardV2 } from "@opencode-ai/session-ui/v2/comment-card-v2" -import { typeLabel } from "@opencode-ai/session-ui/message-file" -import type { ContextItem, ImageAttachmentPart } from "@/context/prompt" -import "./image-attachments.css" - -type PromptCommentItem = ContextItem & { key: string } - -type PromptImageAttachmentsProps = { - attachments: ImageAttachmentPart[] - onOpen: (attachment: ImageAttachmentPart) => void - onRemove: (id: string) => void - removeLabel: string - newLayoutDesigns: boolean - comments?: PromptCommentItem[] - commentActive?: (item: PromptCommentItem) => boolean - onOpenComment?: (item: PromptCommentItem) => void - onRemoveComment?: (item: PromptCommentItem) => void -} - -const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base" -const imageClass = - "size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors" -const imageClassV2 = "w-[58px] h-[46px] rounded-[6px] object-cover" -// inset box-shadows do not paint over content, so the hairline is a separate overlay -const imageHairlineClassV2 = - "absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" -const removeClass = - "absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover" -const removeClassV2 = - "absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" -const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md" - -export const PromptImageAttachments: Component = (props) => { - return ( - 0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}> -
-
- - - {(item) => ( -
- - props.onOpenComment?.(item)} - /> - - -
- )} -
-
- - {(attachment) => { - const image = attachment.mime.startsWith("image/") - const media = () => ( - - -
- } - > - - {typeLabel(attachment.filename, attachment.mime)} - - - } - > - {attachment.filename} props.onOpen(attachment)} - /> - - ) - const name = () => ( -
- {attachment.filename} -
- ) - const remove = () => ( - - ) - // v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip - return ( - -
- {media()} - {name()} - {remove()} -
- - } - > -
- - {media()} - -
- - - {remove()} -
-
- ) - }} - -
- -
-
- -
- - ) -} diff --git a/packages/app/src/components/prompt-input/paste.ts b/packages/app/src/components/prompt-input/paste.ts deleted file mode 100644 index 6787d50309..0000000000 --- a/packages/app/src/components/prompt-input/paste.ts +++ /dev/null @@ -1,24 +0,0 @@ -const LARGE_PASTE_CHARS = 8000 -const LARGE_PASTE_BREAKS = 120 - -function largePaste(text: string) { - if (text.length >= LARGE_PASTE_CHARS) return true - let breaks = 0 - for (const char of text) { - if (char !== "\n") continue - breaks += 1 - if (breaks >= LARGE_PASTE_BREAKS) return true - } - return false -} - -export function normalizePaste(text: string) { - if (!text.includes("\r")) return text - return text.replace(/\r\n?/g, "\n") -} - -export function pasteMode(text: string) { - if (largePaste(text)) return "manual" - if (text.includes("\n") || text.includes("\r")) return "manual" - return "native" -} diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx deleted file mode 100644 index b9a2dc7afa..0000000000 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import { Component, For, Match, Show, Switch } from "solid-js" -import { FileIcon } from "@opencode-ai/ui/file-icon" -import { Icon } from "@opencode-ai/ui/icon" -import { Tag } from "@opencode-ai/ui/v2/badge-v2" -import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" -import { getDirectory, getFilename } from "@opencode-ai/core/util/path" - -export type AtOption = - | { type: "agent"; name: string; display: string } - | { - type: "resource" - name: string - uri: string - client: string - display: string - description?: string - mime?: string - } - | { type: "reference"; name: string; path: string; display: string; description: string } - | { type: "file"; path: string; display: string; recent?: boolean } - -export interface SlashCommand { - id: string - trigger: string - title: string - description?: string - keybind?: string - type: "builtin" | "custom" - source?: "command" | "mcp" | "skill" -} - -type PromptPopoverProps = { - popover: "at" | "slash" | null - setSlashPopoverRef: (el: HTMLDivElement) => void - atFlat: AtOption[] - atActive?: string - atKey: (item: AtOption) => string - setAtActive: (id: string) => void - onAtSelect: (item: AtOption) => void - slashFlat: SlashCommand[] - slashActive?: string - setSlashActive: (id: string) => void - onSlashSelect: (item: SlashCommand) => void - slashMenu: boolean - slashMenuQuery: string - onSlashMenuInput: (value: string) => void - onSlashMenuKeyDown: (event: KeyboardEvent) => void - commandKeybind: (id: string) => string | undefined - commandKeybindParts: (id: string) => string[] - newLayoutDesigns: boolean - t: (key: string) => string -} - -export const PromptPopover: Component = (props) => { - return ( - -
{ - if (props.popover === "slash") props.setSlashPopoverRef(el) - }} - class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10 - overflow-auto no-scrollbar flex flex-col p-2" - classList={{ - "z-[70] rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": props.newLayoutDesigns, - "rounded-[12px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]": - !props.newLayoutDesigns, - }} - onMouseDown={(e) => e.preventDefault()} - > - - - 0} - fallback={ -
- {props.t("prompt.popover.emptyResults")} -
- } - > - - {(item) => { - const key = props.atKey(item) - - if (item.type === "agent") { - return ( - - ) - } - - if (item.type === "resource") { - return ( - - ) - } - - if (item.type === "reference") { - return ( - - ) - } - - const isDirectory = item.path.endsWith("/") - const directory = isDirectory ? item.path : getDirectory(item.path) - const filename = isDirectory ? "" : getFilename(item.path) - - return ( - - ) - }} - -
-
- - -
- requestAnimationFrame(() => el.focus())} - value={props.slashMenuQuery} - onInput={(event) => props.onSlashMenuInput(event.currentTarget.value)} - onKeyDown={props.onSlashMenuKeyDown} - onMouseDown={(event) => event.stopPropagation()} - aria-label={props.t("prompt.menu.commands")} - placeholder="/" - class="w-full bg-transparent outline-none text-[13px] leading-5 text-v2-text-text-base placeholder:text-v2-text-text-faint" - /> -
-
- 0} - fallback={ -
- {props.t("prompt.popover.emptyCommands")} -
- } - > - - {(cmd) => { - const keybind = () => props.commandKeybind(cmd.id) - const keybindParts = () => props.commandKeybindParts(cmd.id) - return ( - - ) - }} - -
-
-
-
-
- ) -} diff --git a/packages/app/src/components/prompt-input/transient-state.ts b/packages/app/src/components/prompt-input/transient-state.ts deleted file mode 100644 index 29f2f182b3..0000000000 --- a/packages/app/src/components/prompt-input/transient-state.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { createComputed, on, type Accessor } from "solid-js" -import { createStore, type SetStoreFunction } from "solid-js/store" -import type { PromptHistoryEntry } from "./history" - -export type PromptInputTransientState = { - popover: "at" | "slash" | null - slashMenu: boolean - slashMenuQuery: string - historyIndex: number - savedPrompt: PromptHistoryEntry | null - placeholder: number - draggingType: "image" | "@mention" | null - mode: "normal" | "shell" - applyingHistory: boolean -} - -function resetPromptInputTransientState(setStore: SetStoreFunction) { - setStore({ - popover: null, - slashMenu: false, - slashMenuQuery: "", - historyIndex: -1, - savedPrompt: null, - draggingType: null, - mode: "normal", - applyingHistory: false, - }) -} - -export function createPromptInputTransientState(identity: Accessor, placeholder: number) { - const [store, setStore] = createStore({ - popover: null, - slashMenu: false, - slashMenuQuery: "", - historyIndex: -1, - savedPrompt: null, - placeholder, - draggingType: null, - mode: "normal", - applyingHistory: false, - }) - - createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true })) - - return [store, setStore] as const -} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 3ce2841676..b545cfefa8 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -57,7 +57,6 @@ import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTabs } from "@/context/tabs" import { TerminalProvider, useTerminal } from "@/context/terminal" -import { PromptInput } from "@/components/prompt-input" import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2" import { useSettingsCommand } from "@/components/settings-dialog" import { setCursorPosition } from "@/components/prompt-input/editor-dom" @@ -2209,70 +2208,39 @@ export default function Page() { promptDock = el }, }) + const promptController = usePromptInputV2Controller({ + get controls() { + return inputController() + }, + ref: (el) => { + inputRef = el + }, + get newSessionWorktree() { + return newSessionWorktree() + }, + onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"), + onSubmit: () => { + comments.clear() + resumeScroll() + }, + shouldQueue: queueEnabled, + onQueue: queueFollowup, + onAbort: () => { + const id = params.id + if (!id) return + setFollowup("paused", id, true) + }, + }) return ( { - inputRef = el - }} - newSessionWorktree={newSessionWorktree()} - onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} - onSubmit={() => { - comments.clear() - resumeScroll() - }} - edit={editingFollowup()} - onEditLoaded={clearFollowupEdit} - shouldQueue={queueEnabled} - onQueue={queueFollowup} - onAbort={() => { - const id = params.id - if (!id) return - setFollowup("paused", id, true) - }} - /> - } - > - {(_) => { - const controller = usePromptInputV2Controller({ - get controls() { - return inputController() - }, - ref: (el) => { - inputRef = el - }, - get newSessionWorktree() { - return newSessionWorktree() - }, - onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"), - onSubmit: () => { - comments.clear() - resumeScroll() - }, - shouldQueue: queueEnabled, - onQueue: queueFollowup, - onAbort: () => { - const id = params.id - if (!id) return - setFollowup("paused", id, true) - }, - }) - return ( - - ) - }} - + } /> ) diff --git a/packages/app/src/pages/session/composer/session-composer-region-controller.ts b/packages/app/src/pages/session/composer/session-composer-region-controller.ts index 4073f1c191..a0794505ce 100644 --- a/packages/app/src/pages/session/composer/session-composer-region-controller.ts +++ b/packages/app/src/pages/session/composer/session-composer-region-controller.ts @@ -2,7 +2,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" import { useSpring } from "@opencode-ai/ui/motion-spring" import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js" import { createStore } from "solid-js/store" -import type { PromptInputState } from "@/components/prompt-input" +import type { PromptInputState } from "@/components/prompt-input/contracts" import { useSync } from "@/context/sync" import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import type { SessionComposerController } from "./session-composer-state" diff --git a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx index 41d1f984bd..e8f5553c5a 100644 --- a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx +++ b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx @@ -3,7 +3,6 @@ import { createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import type { Todo } from "@opencode-ai/sdk/v2" import { useServerSync } from "@/context/global-sync" -import { PromptInput } from "@/components/prompt-input" import { usePrompt } from "@/context/prompt" import { SessionComposerRegion, @@ -26,9 +25,9 @@ This playground renders the real session composer region from app code. ### Includes - \`SessionTodoDock\` (real) -- \`PromptInput\` (real) +- a static prompt-input placeholder (the retired legacy \`PromptInput\` used to fill this slot) -No visual reimplementation layer is used for the dock/input stack.`, +The dock renders from real app code; only the input below it is a stand-in.`, }, }, }, @@ -56,23 +55,6 @@ const btn = (accent?: boolean) => "font-size": "13px", }) as const -const controls = { - agents: { available: [], options: ["build"], current: "build", loading: false, visible: true, select: () => {} }, - model: { - selection: { - current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }), - variant: { list: () => [], current: () => undefined, set: () => {} }, - }, - paid: true, - loading: false, - }, - session: { - id: "story-session", - tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} }, - reviewPanel: { opened: () => false, open: () => {} }, - }, -} - const css = ` [data-component="todo-stage"] { display: grid; @@ -301,13 +283,20 @@ export const Playground = { setDockRef: () => {}, })} promptInput={ - {}, handleSubmit: (event) => event.preventDefault() }} - ref={() => {}} - newSessionWorktree="" - onNewSessionWorktreeReset={() => {}} - /> +
+ Ask anything... +
} />
diff --git a/packages/app/test-browser/prompt-at-options.test.ts b/packages/app/test-browser/prompt-at-options.test.ts deleted file mode 100644 index 77ca3bb494..0000000000 --- a/packages/app/test-browser/prompt-at-options.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { createRoot } from "solid-js" -import { useFilteredList } from "@opencode-ai/ui/hooks" -import type { AtOption } from "@/components/prompt-input/slash-popover" -import { atOptionCategory, compareAtCategories } from "@/components/prompt-input/at-options" - -const options: AtOption[] = [ - { type: "reference", name: "effect", path: "/refs/effect", display: "effect", description: "" }, - { type: "agent", name: "general", display: "general" }, - { type: "resource", name: "docs", uri: "mcp://docs", client: "mcp", display: "docs" }, - { type: "file", path: "src/open.ts", display: "src/open.ts", recent: true }, - { type: "file", path: "src/found.ts", display: "src/found.ts" }, -] - -describe("@ menu ordering through useFilteredList", () => { - test("flattens with files first on an empty query", async () => { - await createRoot(async (dispose) => { - const list = useFilteredList({ - items: options, - key: (item) => `${item.type}:${item.display}`, - filterKeys: ["display"], - skipFilter: (item) => item.type === "file" && !item.recent, - groupBy: atOptionCategory, - sortGroupsBy: compareAtCategories, - }) - // grouped is a resource; wait for it to settle. - for (let i = 0; i < 100 && list.flat().length === 0; i++) { - await new Promise((resolve) => setTimeout(resolve, 10)) - } - expect(list.flat().map((item) => item.display)).toEqual([ - "src/open.ts", - "src/found.ts", - "effect", - "general", - "docs", - ]) - dispose() - }) - }) -}) diff --git a/packages/app/test-browser/prompt-attachments.test.ts b/packages/app/test-browser/prompt-attachments.test.ts deleted file mode 100644 index ca7686598e..0000000000 --- a/packages/app/test-browser/prompt-attachments.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { createRoot } from "solid-js" -import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments" -import { createPromptState } from "@/context/prompt" - -describe("prompt attachment session ownership", () => { - test("adds an asynchronously read image to the session where the read started", async () => { - await createRoot(async (dispose) => { - const sessions = { A: createPromptState(), B: createPromptState() } - let active: "A" | "B" = "A" - const attachments = createPromptAttachmentsCore({ - capture: () => sessions[active].capture(), - editor: () => document.createElement("div"), - }) - const pending = attachments.addAttachment(new File([new Uint8Array(1024 * 1024)], "a.png", { type: "image/png" })) - - active = "B" - await pending - - expect(images(sessions.A)).toHaveLength(1) - expect(images(sessions.B)).toHaveLength(0) - dispose() - }) - }) - - test("finishes the captured attachment after the active editor is removed", async () => { - await createRoot(async (dispose) => { - const prompt = createPromptState() - let editor: HTMLDivElement | undefined = document.createElement("div") - const attachments = createPromptAttachmentsCore({ - capture: prompt.capture, - editor: () => editor, - }) - const pending = attachments.addAttachment(new File([new Uint8Array(1024 * 1024)], "a.png", { type: "image/png" })) - - editor = undefined - await pending - - expect(images(prompt)).toHaveLength(1) - dispose() - }) - }) - - test("keeps every file in a batch on the session where the batch started", async () => { - await createRoot(async (dispose) => { - const sessions = { A: createPromptState(), B: createPromptState() } - let active: "A" | "B" = "A" - const attachments = createPromptAttachmentsCore({ - capture: () => sessions[active].capture(), - editor: () => document.createElement("div"), - }) - const pending = attachments.addAttachments([ - new File([new Uint8Array(1024 * 1024)], "first.png", { type: "image/png" }), - new File([new Uint8Array(1024 * 1024)], "second.png", { type: "image/png" }), - ]) - - active = "B" - await pending - - expect(images(sessions.A)).toHaveLength(2) - expect(images(sessions.B)).toHaveLength(0) - dispose() - }) - }) - - test("keeps a delayed native clipboard image on the session where paste started", async () => { - await createRoot(async (dispose) => { - const sessions = { A: createPromptState(), B: createPromptState() } - const read = Promise.withResolvers() - let active: "A" | "B" = "A" - const attachments = createPromptAttachmentsCore({ - capture: () => sessions[active].capture(), - editor: () => document.createElement("div"), - }) - const pending = attachments.addClipboardAttachment(read.promise) - - active = "B" - read.resolve(new File([new Uint8Array(1024 * 1024)], "clipboard.png", { type: "image/png" })) - await pending - - expect(images(sessions.A)).toHaveLength(1) - expect(images(sessions.B)).toHaveLength(0) - dispose() - }) - }) -}) - -function images(prompt: ReturnType) { - return prompt.current().filter((part) => part.type === "image") -} diff --git a/packages/app/test-browser/prompt-transient-state.test.ts b/packages/app/test-browser/prompt-transient-state.test.ts deleted file mode 100644 index 5777c9c392..0000000000 --- a/packages/app/test-browser/prompt-transient-state.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { expect, test } from "bun:test" -import { createRoot, createSignal } from "solid-js" -import { createPromptInputTransientState } from "@/components/prompt-input/transient-state" - -test("resets transient prompt input state when the prompt session changes", () => { - createRoot((dispose) => { - const [identity, setIdentity] = createSignal("A") - const [state, setState] = createPromptInputTransientState(identity, 3) - setState({ - popover: "slash", - slashMenu: true, - slashMenuQuery: "compact", - historyIndex: 2, - savedPrompt: { - prompt: [{ type: "text", content: "draft-A", start: 0, end: 7 }], - comments: [], - }, - draggingType: "image", - mode: "shell", - applyingHistory: true, - }) - - setIdentity("B") - - expect(state).toMatchObject({ - popover: null, - slashMenu: false, - slashMenuQuery: "", - historyIndex: -1, - savedPrompt: null, - placeholder: 3, - draggingType: null, - mode: "normal", - applyingHistory: false, - }) - dispose() - }) -})