diff --git a/CHANGELOG.md b/CHANGELOG.md index 60673338e9..b60a96185d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -256,6 +256,13 @@ already in use` and no way forward short of finding the file in a temporary ### Desktop / Web UI +- **Files first in the `@` mention menu** — the composer's `@` popup listed + references, agents, and MCP resources ahead of files, so the top match Enter + would accept was rarely the file being typed. File results (recent open tabs + first, then search matches) now lead, as they already do in the TUI and the + `codev run` footer ([#56](https://github.com/quickbeard/codev-code/pull/56) + reordered those, but missed the composer the desktop and web apps now use). + ([#289](https://github.com/quickbeard/codev-code/pull/289)) - **No more "Could not reach Local Server" on corporate proxies** — on networks with a proxy auto-configuration script, the app often opened onto "Could not reach Local Server / Retrying automatically…" and then connected diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 5b6d6f6680..3496432d0e 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -280,6 +280,7 @@ from the squash message first, or use a plain merge. | Sandbox project label fallback: `packages/app/src/context/global.tsx` | `enrich()` (the per-server project-list decorator) gains one fork line: when the merged project record has no `name` yet, it fills it from the fork-only `sandboxProjectHostName(conn, worktree, primary)` (`packages/app/src/sandbox/projects.ts`), which maps a sandbox connection's guest paths back to host folder basenames — `conn.projectDir` for the primary (matched by the synced primary directory, or the `/workspace` literal before sync loads) and `conn.mounts[].path` for extras. Non-sandbox connections and named projects are untouched (the helper returns `undefined` / the branch is skipped). | A fresh sandbox project renders `displayName()` = name-or-worktree-basename, and inside the guest the primary worktree is `/workspace`, so every new sandbox flashed the label "workspace" until `adoptFolderName`'s server-side rename round-tripped (guest reports the project → `project.update` → `project.updated` event). The fallback is display-only; the durable rename through the server is unchanged and takes precedence the moment it lands. | Keep the one `if (!base.name)` line after upstream's `base` construction and take upstream's surrounding `enrich` changes. If upstream renames or restructures `enrich`, re-apply the fallback wherever project records are decorated for the sidebar list. The helper stays in the fork-only sandbox module — do not inline it. | | 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/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). The TUI filters to known part types and ignores retry parts. 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, and the loop-exit error; 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. | ## Known rebrand gaps diff --git a/packages/session-ui/src/v2/components/prompt-input/at-options.test.ts b/packages/session-ui/src/v2/components/prompt-input/at-options.test.ts new file mode 100644 index 0000000000..7682436908 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/at-options.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test" +import type { PromptInputV2Suggestion } from "./types" +import { atSuggestionCategory, compareAtCategories } from "./at-options" + +const suggestions: PromptInputV2Suggestion[] = [ + { id: "reference:effect", kind: "reference", label: "@effect", path: "/refs/effect" }, + { id: "agent:general", kind: "agent", label: "@general" }, + { id: "resource:mcp:docs", kind: "resource", label: "@docs", path: "mcp://docs" }, + { id: "file:src/open.ts", kind: "file", label: "src/open.ts", path: "src/open.ts", recent: true }, + { id: "file:src/found.ts", kind: "file", label: "src/found.ts", path: "src/found.ts" }, +] + +describe("atSuggestionCategory", () => { + test("categorizes every suggestion shape", () => { + expect(suggestions.map(atSuggestionCategory)).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/session-ui/src/v2/components/prompt-input/at-options.ts b/packages/session-ui/src/v2/components/prompt-input/at-options.ts new file mode 100644 index 0000000000..173bb85436 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/at-options.ts @@ -0,0 +1,23 @@ +import type { PromptInputV2Suggestion } from "./types" + +export function atSuggestionCategory(item: PromptInputV2Suggestion) { + if (item.kind === "reference") return "reference" + if (item.kind === "agent") return "agent" + if (item.kind === "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/session-ui/src/v2/components/prompt-input/interaction.ts b/packages/session-ui/src/v2/components/prompt-input/interaction.ts index f4a9fa74b7..2be48a459f 100644 --- a/packages/session-ui/src/v2/components/prompt-input/interaction.ts +++ b/packages/session-ui/src/v2/components/prompt-input/interaction.ts @@ -1,6 +1,7 @@ import { createEffect, on, type Accessor } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { useFilteredList } from "@opencode-ai/ui/hooks" +import { atSuggestionCategory, compareAtCategories } from "./at-options" import { createPromptInputV2Attachments, type PromptInputV2AttachmentConfig } from "./attachments" import { createPromptInputV2Store, type PromptInputV2StoreInput } from "./store" import type { @@ -118,17 +119,8 @@ export function createPromptInputV2Controller(input: { key: (item) => item.id, filterKeys: ["label"], skipFilter: (item) => item.kind === "file" && !item.recent, - groupBy: (item) => { - if (item.kind === "reference") return "reference" - if (item.kind === "agent") return "agent" - if (item.kind === "resource") return "resource" - if (item.recent) return "recent" - return "file" - }, - sortGroupsBy: (a, b) => { - const order = ["reference", "agent", "resource", "recent", "file"] - return order.indexOf(a.category) - order.indexOf(b.category) - }, + groupBy: atSuggestionCategory, + sortGroupsBy: compareAtCategories, }) const commandList = useFilteredList({ items: () => input.commands(),