Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>"]`) 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

Expand Down
Original file line number Diff line number Diff line change
@@ -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"])
})
})
23 changes: 23 additions & 0 deletions packages/session-ui/src/v2/components/prompt-input/at-options.ts
Original file line number Diff line number Diff line change
@@ -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)
}
14 changes: 3 additions & 11 deletions packages/session-ui/src/v2/components/prompt-input/interaction.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<PromptInputV2Suggestion>({
items: () => input.commands(),
Expand Down
Loading