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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@ file is the human-readable summary of what changed and why.

### Desktop / Web UI

- **Adding a folder to a sandbox no longer freezes the app** — mounting a new
folder on a sandbox you had a session open on locked the window up entirely:
no clicks, no typing, force quit the only way out. Applying a mount restarts
the sandbox, and while it was down the app kept closing the session's tab and
immediately reopening it, thousands of times a second, which left it too busy
to notice the sandbox had come back. Any sandbox restart was affected, not
only mounting a folder.
- **Your sandbox tabs survive a sandbox restart** — a sandbox that restarts —
to apply a folder you just mounted, or because you stopped and started it —
is away for a second or two, and every open tab belonging to it was closed
and its state thrown out: the prompt you had typed, your terminal, where you
were scrolled. Tabs now belong to a sandbox for as long as the sandbox
exists, not for as long as it is running, so they sit through the restart
with everything you had in them and pick up where they were. Only deleting
the sandbox closes its tabs. The session itself says **Waiting for this
server** while the sandbox is away — including when you open one of its tabs
with the sandbox stopped, which used to claim the session could not be found
— and returns on its own, with no click needed, once the sandbox is back.
- **Switching to Work with no sandbox explains itself** — clicking **Work**
before you have a sandbox opens the Add sandbox dialog, which was a dialog
you did not ask for with nothing to say about why it appeared. It now leads
Expand Down
2 changes: 2 additions & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ from the squash message first, or use a plain merge.
| `packages/app/src/context/global.tsx` | Two lines: imports `seedSandboxProjects` from the fork-only `packages/app/src/sandbox/projects.ts` and calls it with `server.list` + `ensureServerCtx`, beside the memo that already creates a ctx per connection. All the logic lives in the fork-only module. | A sandbox exposes exactly one directory (`/workspace` on vz/bwrap, the wslpath translation on WSL) and the guest registers it as its own project, but the app's per-server project list starts empty and nothing ever fills it. With no project, every "New session" entry point — `home-model.openNewSession`, the titlebar's `openNewTab` fallback chain — finds nothing for that server and falls back to another one, so the sandbox looks selected while the session runs on the local server. The seed reads `sync.data.path.directory`, which every server ctx already fetches, so it stays backend-agnostic. The same module also names that project after the sandbox's host folder (via `client.project.update`, the call behind rename-project), because the guest sees only its mount point and every sandbox would otherwise read "workspace" in the sidebar, tabs and palette. | Keep both lines; take upstream's other changes to the file. The call must stay inside the Global context init (it needs `ensureServerCtx`) — `SandboxesProvider` is mounted above `GlobalProvider` in `app.tsx`, so it cannot live there. |

| `packages/app/src/pages/layout/project-avatar-state.ts` | Both `ensureServerState` lookups are gated on `connection()`: `unread` before `notification.ensureServerState`, and the `permissionState` memo before `permission.ensureServerState` (which also moves below `connection`, and its consumer in `hasPermissions` handles the undefined state). Matches the guard `hasPermissions`/`hasQuestions`/`loading` already apply to `sync()`. | `ensureServerState` throws on a key absent from the connection list, and the throw propagates out of a render-time memo, killing the whole renderer. Sidecar connections are listed only while healthy — `readySandboxConnections`/`readyWslConnections` publish a sandbox or WSL server only in the `ready` state — so any removal, restart, or crash of one drops its key while its session tabs are still mounted. Reproduced by removing a sandbox that had an open session tab: `Error: Notification server not found: sandbox:<name>`. The permission lookup is the sharper of the two because `createMemo` computes eagerly: for a tab mounted after its server disappeared it throws during construction, before any downstream guard runs — `Error: Permission server not found: sandbox:<name>`, reproduced by deleting a sandbox from Settings while the Work side was open. | Keep the guard; take upstream's other changes to the hook. This is an upstream bug (WSL servers hit the identical path), so if upstream fixes it their way, drop ours in favour of theirs. |
| `packages/app/src/components/titlebar.tsx` | The effect that opens a tab for the session the route points at now skips servers that are not in `global.servers.list()`. | The tab store prunes tabs whose server left the list (`context/tabs.tsx`), and this effect adds one back for whatever the route names — so a route pointing at a server that is momentarily absent puts the two in a loop: prune, re-add, prune, at about a thousand rounds a second. The renderer never yields, so it also never processes the IPC event that would bring the server back, and the window stays wedged until it is killed. Sidecar connections are published only while healthy (`readySandboxConnections`/`readyWslConnections`), so any restart drops one: reproduced by adding a folder to a sandbox while one of its sessions was open, which restarts the guest to apply the mount. The route is deliberately left alone — the tab returns on its own when the server comes back. This is an upstream bug (WSL servers hit the identical path). | Keep the guard; take upstream's other changes to the effect. If upstream fixes it their way, drop ours in favour of theirs. Do not "simplify" the guard away — an unguarded add against the pruner is the hang. |
| Tabs that outlive a sandbox restart: `packages/app/src/context/{server,tabs}.tsx`, `packages/app/src/app.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/renderer/index.tsx` | `server.tsx` takes a `retainedServers` prop and exposes it as `retained` — servers that exist but are not connected; `tabs.tsx` reconciles both of its prune effects (open tabs and the closed-tab stack) against `list` **plus** `retained` through a shared `knownServers()`; `app.tsx` threads `retainedServers` from `AppInterface` into `ServerProvider`, and `TargetServerRoute` renders the fork-only `packages/app/src/sandbox/server-away.tsx` instead of mounting its providers when the route's server is retained but absent; i18n gains `session.error.disconnected{,.description}`; `renderer/index.tsx` passes `retainedSandboxServers(...)` (fork-only, in `renderer/sandbox/connections.ts`) — every sandbox that exists, whatever its runtime state. | A sandbox is published as a connection only while it is ready, so applying a mount — which restarts the guest — takes it out of `server.list` for a second or two. The tab pruner read that as "this server is gone" and deleted every tab belonging to it, discarding each tab's memory: the typed prompt, terminal and scroll state. Retention draws the line where the user does — the sandbox still exists, so its tabs do — and explicit removal is unaffected because `dialog-select-server` already calls `tabs.removeServer(key)` and the sandbox then leaves `retained` as well. The route guard is the other half: a retained server has no connection, and mounting `ServerSDKProvider`/`ServerSyncProvider` with `undefined` sends the session's requests to whichever server is selected instead, which answered "this session cannot be found" about a healthy session and bounced the route home through a burst of ~250 navigations. WSL servers are deliberately not retained — same latent problem, but the WSL vertical has no equivalent restart in normal use. | Keep all five edits together — retention without the route guard just moves the confusing state, and the guard without retention is unreachable. Take upstream's other changes to these files. `retained` must stay a superset relationship with `list` (a server can be in both mid-transition); if upstream reworks the pruner, re-apply the union. The web build passes no `retainedServers`, so `retained` is empty there and every one of these paths collapses to upstream behaviour. |
| `script/publish.ts` | The `if (Script.release)` block calling `packages/desktop/scripts/finalize-latest-{json,yml}.ts` removed, replaced by a comment. | Both finalizers existed to assemble update metadata into a GitHub release, which the fork no longer publishes to. electron-builder already writes a correct per-platform `latest*.yml` next to each installer, and the fork builds one arch per platform (macOS is universal — see the universal-builds row), so there is nothing left to merge. | Keep removed. If upstream adds further desktop release steps here, drop them unless they target the bucket. |
| `packages/desktop/scripts/finalize-latest-json.ts`, `packages/desktop/scripts/finalize-latest-yml.ts` | Deleted. | Both `gh release upload` into a GitHub release the fork does not use. `finalize-latest-yml.ts` merged per-arch `latest*.yml` from an upstream build matrix the fork does not run; `finalize-latest-json.ts` additionally built a Tauri-style `latest.json` signed with `@tauri-apps/cli signer` that nothing in the Electron updater path ever reads, and had gone stale besides — it hardcoded `opencode-desktop-mac-{x64,arm64}.app.tar.gz`, an artifact name and format this fork never produces. Leaving them in place is a trap: they look like the release path and upload to the wrong place. | Keep deleted (`git rm` the upstream side of modify/delete conflicts). If a future build matrix produces two `latest.yml` files for one platform (Windows x64 + arm64), write a bucket-targeted merge step rather than restoring these. |
| `packages/desktop/src/renderer/index.tsx` | Desktop notification icon `https://opencode.ai/favicon-96x96-v3.png` → the relative `./favicon-96x96-v3.png`. The renderer's `publicDir` is `packages/app/public`, whose `favicon-96x96-v3.png` symlinks to the rebranded `packages/ui/src/assets/favicon/` mark, so the file is emitted next to `index.html` and resolves against the renderer origin (`oc://renderer/` packaged, the vite dev server otherwise) — the same asset and the same relative form `src/renderer/index.html` already uses for `<link rel="icon">`. | A desktop app should not reach out to a remote host to draw its own notification icon (it fails offline and leaks a request per notification), and the remote file is upstream-branded while the bundled one is the codev mark. | Keep our relative path. If upstream changes the URL, re-point it at the bundled asset rather than taking a new remote host. `packages/app/src/entry.tsx` and `pages/layout/helpers.ts` keep their remote `opencode.ai/favicon*` sources — same bug class, not yet fixed. |
Expand Down
23 changes: 20 additions & 3 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { SettingsProvider, useSettings } from "@/context/settings"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context"
import { ServerAway } from "@/sandbox/server-away"
import { SandboxesProvider } from "@/sandbox/context"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import LegacyLayout from "@/pages/layout"
Expand Down Expand Up @@ -111,6 +112,7 @@ const SessionRoute = () => {
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string; id: string }>()
const global = useGlobal()
const server = useServer()
// Absent while this route is being torn down: removing a server navigates
// away, and the param clears before this subtree unmounts. Rendering nothing
// is the right answer — throwing from a memo kills the whole renderer.
Expand All @@ -129,9 +131,18 @@ function TargetServerRoute(props: ParentProps) {
// sit on a route whose providers cannot mount, which leaves the shell in a
// half-built state that fails further down.
<Show when={key()} keyed fallback={<Navigate href="/" />}>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
{(current) => (
// Fork: a retained server is one that exists but is away — a sandbox
// restarting to apply a mount. Its tabs stay open, so this route stays
// reachable, and mounting the providers with no connection would send
// this session's requests to whatever server is selected instead. Wait
// for it: the route swaps back the moment it is connected again.
<Show when={conn() || !server.retained.includes(current)} fallback={<ServerAway name={current} />}>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)}
</Show>
)
}
Expand Down Expand Up @@ -542,6 +553,11 @@ export function AppInterface(props: {
defaultServer: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
// Fork: servers that exist but are not connected right now (a sandbox
// restarting to apply new mounts). They are not in `servers` — nothing can
// talk to them — but their tabs and tab state are kept until they are gone
// for good. See context/server.tsx `retained`.
retainedServers?: Array<ServerConnection.Key>
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
startup?: Promise<void>
Expand Down Expand Up @@ -570,6 +586,7 @@ export function AppInterface(props: {
defaultServer={props.defaultServer}
canonicalLocalServer={props.canonicalLocalServer}
servers={props.servers}
retainedServers={props.retainedServers}
>
<GlobalProvider>
<SettingsProvider>
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/components/titlebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,13 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
if (!s) return
const sessionId = s.parentID ?? s.id
const next = { server: route.server ?? server.key, sessionId }
// A tab for a server that is not connected is dropped again the
// moment it lands (context/tabs.tsx prunes tabs whose server
// left the list), and this effect would add it straight back.
// Sidecar servers leave the list whenever they restart — a
// sandbox applying new mounts does exactly that — so without
// this the two spin against each other and lock the renderer.
if (!global.servers.list().some((conn) => ServerConnection.key(conn) === next.server)) return
tabsStoreActions.addSessionTab(next)
}
})
Expand Down
10 changes: 10 additions & 0 deletions packages/app/src/context/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
defaultServer: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
retainedServers?: Array<ServerConnection.Key>
}) => {
const [store, setStore, _, ready] = persisted(
{
Expand Down Expand Up @@ -357,6 +358,15 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
get list() {
return allServers()
},
// Servers that exist but are not connected at the moment. Sidecar
// servers are published only while they are healthy, so a sandbox
// restarting to apply new mounts leaves `list` for a second or two —
// long enough for anything reconciling against `list` to decide it is
// gone. Everything keyed by server that should outlive a restart (tabs
// and their state) reconciles against `list` plus these.
get retained() {
return props.retainedServers ?? []
},
get current() {
return current()
},
Expand Down
11 changes: 9 additions & 2 deletions packages/app/src/context/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,16 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({

onCleanup(memory.dispose)

// Tabs belong to servers that exist, not to servers that answer right now:
// a sandbox restarting to apply new mounts drops out of `server.list` for
// a second or two, and pruning against that alone closed the user's
// session tabs — and discarded their prompt and terminal state — on every
// restart. `server.retained` carries the ones that are merely away.
const knownServers = () => new Set([...server.list.map(ServerConnection.key), ...server.retained])

createEffect(() => {
if (!ready() || !recentReady()) return
const servers = new Set(server.list.map(ServerConnection.key))
const servers = knownServers()
const next = store.filter((tab) => servers.has(tab.server))
if (next.length !== store.length) {
for (const tab of store) {
Expand All @@ -143,7 +150,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({

createEffect(() => {
if (!closedReady()) return
const servers = new Set(server.list.map(ServerConnection.key))
const servers = knownServers()
const next = closed.filter((entry) => servers.has(entry.tab.server))
if (next.length !== closed.length) setClosed(() => next)
})
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,12 @@ export const dict = {
"session.error.notFound": "This session cannot be found",
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab",
// Fork: a sandbox is a server that comes and goes — it leaves while it
// restarts to apply a new mounted folder — and its tabs stay open across
// that gap, so its sessions need something truer to say than "not found".
"session.error.disconnected": "Waiting for this server",
"session.error.disconnected.description":
"This session is on a server that is not running right now. It comes back on its own once the server does.",
"session.error.serverConnection": "Can't connect to this server",
"session.review.filesChanged": "Files Changed {{count}}",
"session.review.change.one": "Change",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,9 @@ export const dict = {
"session.error.notFound": "No se encuentra esta sesión",
"session.error.notFound.description": "Esta pestaña apunta a una sesión que ya no existe en este servidor.",
"session.error.notFound.closeTab": "Cerrar pestaña",
"session.error.disconnected": "Esperando a este servidor",
"session.error.disconnected.description":
"Esta sesión está en un servidor que no está en ejecución ahora mismo. Vuelve por sí sola cuando el servidor lo haga.",
"session.error.serverConnection": "No se puede conectar a este servidor",
"session.review.filesChanged": "{{count}} Archivos Cambiados",
"session.review.change.one": "Cambio",
Expand Down
25 changes: 25 additions & 0 deletions packages/app/src/sandbox/server-away.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useLanguage } from "@/context/language"

// Stands in for a server-scoped route whose server exists but is not connected
// right now — a sandbox restarting to apply a new mounted folder is gone for a
// second or two, and its tabs stay open across that gap (context/tabs.tsx).
//
// The alternative is what the route did before: mount its providers with no
// connection, which sends the session's requests to whichever server is
// selected instead, and answers "this session cannot be found" about a session
// that is fine. This says what is actually happening, and the route swaps back
// to the real thing on its own the moment the server answers again.
export function ServerAway(props: { name: string }) {
const language = useLanguage()
return (
<div class="flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-2">
<div class="text-16-medium text-text max-w-md">{language.t("session.error.disconnected")}</div>
<div class="text-13-regular text-text-weak max-w-md">
{language.t("session.error.disconnected.description")}
</div>
<div class="max-w-full text-11-regular text-text-faint break-all">{props.name}</div>
</div>
</div>
)
}
Loading
Loading