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

### Desktop / Web UI

- **Code and Work** — the sidebar now opens on two tabs. **Code** is your
machine: the local server, plus any WSL, remote or SSH servers you have
added. **Work** is your sandboxes. Each side keeps its own projects, pinned
and recent sessions, and its own open session tabs, so a sandbox session
never appears while you are working locally and vice versa. Clicking a tab
returns you to the session you were last in on that side rather than opening
a blank new session; with no history it falls back to a new session, and to
the home screen when that side has nothing to open yet. Switching to Work
with no sandbox yet offers to create one — cancelling leaves you on Code —
and deleting your last sandbox returns you to Code rather than leaving you
on an empty Work side.
The **Home** button has been removed from the titlebar: it opened a new
session rather than a home screen, which is what the tabs now decide. The
recent-sessions screen is still one click away behind the search button and
the sidebar's **View all**.
- **The desktop app now identifies itself as CoDev to your operating system** —
its application id changed from `ai.opencode.desktop` to `ai.codev.desktop`,
its deep links from `opencode://` to `codev://`, and its Linux packages from
Expand Down Expand Up @@ -48,7 +63,9 @@ file is the human-readable summary of what changed and why.
had a session tab open took the whole window down with `Notification server
not found`. Tabs belonging to a removed server are now closed before its
disk is deleted, and a tab whose server has gone away (removed, restarting,
or crashed) simply stops showing an unread marker instead of failing.
or crashed) stops showing its unread and attention markers instead of
failing — including a tab that is drawn for the first time after its server
is already gone.
- **Sandboxes run the agent as an unprivileged user** — the server inside a
sandbox no longer runs as root: it drops to the image's ordinary `codev`
account once the guest has finished mounting, so a sandbox escape has one
Expand Down
4 changes: 3 additions & 1 deletion DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,11 @@ from the squash message first, or use a plain merge.

| Desktop identity: `packages/desktop/src/main/{index,store}.ts`, `packages/desktop/electron-builder.config{,.test}.ts`, `packages/desktop/scripts/copy-metainfo.ts`, `packages/desktop/resources/linux/opencode-desktop.desktop` (deleted), `packages/app/src/pages/layout/{deep-links.ts,helpers.test.ts}`, `packages/codev-brand/test/brand.test.ts` | appId `ai.opencode.desktop{,.beta,.dev}` → `ai.codev.desktop{,.beta,.dev}` (both copies: `APP_IDS` in main and in the builder config, plus the unpackaged fallback); deep-link scheme `opencode://` → `codev://` in all three places that must agree — `protocols.schemes`, `app.setAsDefaultProtocolClient`, and the renderer's `parseUrl` — plus the `argv.startsWith` filter the second-instance handler uses; `rpm.packageName` `opencode`/`opencode-beta`/`opencode-dev` → `codev`/`codev-beta`/`codev-dev`; the hidden `opencode-desktop.desktop` legacy launcher and its `deb`/`rpm` fpm wiring deleted. `src/main/migrate.ts` keeps the old ids — they name the *Tauri* app's directories, not ours. | The appId is the whole desktop identity: `app.setPath("userData", join(appData, appId))`, so it also names the data directory, and it drives the macOS bundle id, the Windows AppUserModelId and NSIS keys, the Linux `.desktop` filename, `executableName` and `StartupWMClass`, and the AppStream metainfo id. Leaving it as opencode meant the fork's app identified itself as upstream to the OS. Renamed as a clean cut with no data migration and no back-compat shim, on the explicit basis that there is no installed base: existing profiles under `appData/ai.opencode.desktop*` are simply orphaned (settings, session history, sandboxes and the downloaded guest image), and a packaged update across the rename would install beside the old app rather than replace it. The legacy launcher went with it — it existed to rescue GNOME/KDE pins from a superseded packaging id, and its `Exec=/opt/OpenCode/...` no longer matched any install layout. | Keep our ids and scheme; take upstream's other config. Never rename the `TAURI_APP_IDS` in `migrate.ts` to match — a blind find-and-replace there points the Tauri migration at a directory that never existed. All three deep-link sites have to move together or links open nothing; `electron-builder.config.test.ts` covers the ids, the package names and the scheme per channel. |

| Code/Work workspace split: `packages/app/src/context/layout.tsx`, `packages/app/src/components/{app-sidebar,titlebar}.tsx`, `packages/app/src/i18n/{en,es}.ts` | The sidebar gains two tabs above Projects — **Code** (every non-sandbox server: the local sidecar, WSL, remote, SSH) and **Work** (the sandboxes) — backed by the fork-only `packages/app/src/workspace/{side.ts,tabs.tsx}`. `layout.tsx` stores `home.workspace` and `home.lastSession` (per side) and records the latter from an effect on `route()`; `app-sidebar.tsx` renders `<WorkspaceTabs/>`, swaps `global.servers.list()` for a side-filtered memo in all six places it indexed it, and adds an empty-state branch because a side can now have no servers at all; `titlebar.tsx` loses the Home button and its `home.toggle` command (`mod+b`) and filters the tab strip, its reorder and `tab.prev`/`tab.next` to the visible side; `effectiveSide` falls back to Code only where the platform has no sandbox support — deliberately not on an empty sandbox list, which is indistinguishable from one that has not loaded; `layout.tsx` also holds an effect keeping `home.selection.server` on the showing side, because a sandbox joins the server list only once it has finished starting, so the one-shot correction inside a switch cannot cover a sandbox created moments earlier; the fork-only `DialogAddSandbox` gains an `onCreated` callback so the Work gate switches on the create's own result rather than on the asynchronous sandbox list; `dialog-select-server.tsx` `handleRemove` calls `workspace.removedServer(key)` beside the tab cleanup, which steps back to Code when the sandbox being deleted is the last one; i18n gains `workspace.{code,work,empty.none,empty.starting}`. | Sandboxes were just another row in a list of servers, so the isolated environment and the local machine were one undifferentiated pile. Splitting them makes "am I running on my machine or in a sandbox?" a property of where you are rather than something to infer from a server name. The Home button went because it opened a draft rather than a home screen, which is the behaviour the tabs replace: a tab returns you to that side's last session, falling back to a draft and then to the home screen. All the logic lives in the fork-only module; the upstream files hold wiring only, and every section below the tabs is the same component reading a filtered list. | Keep the wiring and take upstream's surrounding changes. The six `sideServers()` call sites in `app-sidebar.tsx` were `global.servers.list()` — if upstream adds a seventh, filter it too or that branch will show sandboxes under Code. Read `useWorkspaceSide()` rather than `layout.home.workspace()`: it collapses to Code where the platform has no sandbox support, which is what keeps the web build coherent. The tab strip filter and `reorderWithinSide` go together — `tabs.reorder()` rejects a partial key list, so a filtered strip without the merge silently ignores drags. |

| `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` | `unread` gains a `connection()` guard before `notification.ensureServerState(server())`, matching 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>`. | 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/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. |
| `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
28 changes: 21 additions & 7 deletions packages/app/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { useSettingsCommand } from "@/components/settings-dialog"
import { ChannelIndicator } from "@/components/titlebar"
import { useAppSidebar } from "@/context/app-sidebar"
import { useGlobal } from "@/context/global"
import { serversForSide } from "@/workspace/side"
import { useWorkspaceSide, WorkspaceEmpty, WorkspaceTabs } from "@/workspace/tabs"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/server"
Expand Down Expand Up @@ -75,6 +77,10 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
Persist.global("sidebar.sections"),
createStore({ collapsed: {} as Record<string, boolean> }),
)
// Fork: Code shows every non-sandbox server, Work shows the sandboxes. The
// sections below are unchanged; they just read this instead of the full list.
const side = useWorkspaceSide()
const sideServers = createMemo(() => serversForSide(global.servers.list(), side()))

// Collapsed hides the column outright rather than narrowing it to a rail, so
// the content pane takes the full width. The titlebar toggle is the way back.
Expand All @@ -85,11 +91,14 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
class={`${props.class ?? ""} w-[280px] min-h-0 min-w-0 shrink-0 flex-col gap-4 overflow-hidden px-3 pb-4 pt-6`}
aria-label={language.t("home.projects")}
>
{/* Fork: the Code/Work split. Everything below is unchanged — it just
sees the servers of the selected side. */}
<WorkspaceTabs />
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5">
<div class="text-v2-text-text-muted [font-weight:530]">{language.t("home.projects")}</div>
<Show
when={
global.servers.list().length === 1 &&
sideServers().length === 1 &&
!(model.projects().length === 0 && model.recentlyClosed().length > 0)
}
>
Expand All @@ -100,22 +109,26 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<IconV2 name="folder-add-left" />}
disabled={global.servers.health[ServerConnection.key(global.servers.list()[0]!)]?.healthy === false}
onClick={() => model.chooseProject(global.servers.list()[0]!)}
disabled={global.servers.health[ServerConnection.key(sideServers()[0]!)]?.healthy === false}
onClick={() => model.chooseProject(sideServers()[0]!)}
aria-label={language.t("home.project.add")}
/>
</TooltipV2>
</Show>
</div>
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
{/* Fork: a side can be empty — Work while its only sandbox is still
starting, or after the last one was removed. Every branch below
indexes servers[0], so the empty case has to be caught first. */}
<Show when={sideServers().length > 0} fallback={<WorkspaceEmpty side={side()} />}>
<Show
when={global.servers.list().length > 1}
when={sideServers().length > 1}
fallback={
<Show
when={model.projects().length > 0}
fallback={
<HomeProjectEmpty
server={global.servers.list()[0]!}
server={sideServers()[0]!}
recentlyClosed={model.recentlyClosed()}
homedir={model.homedir()}
chooseProject={model.chooseProject}
Expand All @@ -125,7 +138,7 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
}
>
<HomeProjectList
server={global.servers.list()[0]!}
server={sideServers()[0]!}
projects={model.projects()}
selected={model.selection()}
selectProject={model.selectProject}
Expand All @@ -140,7 +153,7 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
}
>
<div class="flex min-w-0 flex-col gap-4">
<For each={global.servers.list()}>
<For each={sideServers()}>
{(item) => {
const key = ServerConnection.key(item)
const healthy = () => !!global.servers.health[key]?.healthy
Expand Down Expand Up @@ -183,6 +196,7 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
</For>
</div>
</Show>
</Show>
</ScrollView>

<SidebarPinned
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/components/dialog-select-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { normalizeServerUrl, ServerConnection, useServer } from "@/context/serve
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { useWorkspaceSwitch } from "@/workspace/tabs"

const DEFAULT_USERNAME = "opencode"

Expand Down Expand Up @@ -193,6 +194,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
const navigate = useNavigate()
const server = useServer()
const tabs = useTabs()
const workspace = useWorkspaceSwitch()
const global = useGlobal()
const platform = usePlatform()
const language = useLanguage()
Expand Down Expand Up @@ -517,6 +519,9 @@ export function useServerManagementController(options: { onSelect?: () => void;
// server that is already gone, and the tab-pruning effect that catches
// them does not discard their draft state the way removeServer does.
tabs.removeServer(key)
// Fork: before the awaits, so the switch lands with the tab cleanup
// rather than after a delete that takes as long as erasing a disk.
workspace.removedServer(key)
server.remove(key)
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
if (key.startsWith("sandbox:")) await platform.sandboxes?.remove(key)
Expand Down
Loading
Loading