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

### Desktop / Web UI

- **The ⋮ menu on a session row opens on the first click** — it used to ignore
one click and open on the next, both in the sidebar's Recents and on the
**View all** list. The rows were being rebuilt from scratch whenever anything
the session list depends on changed, which could happen mid-click and threw
the click away; they are now updated in place. The same rebuild could also
close the menu under you while it was open, or drop a rename you were
part-way through typing. The ⋯ menu beside a session title was never
affected.

- **Recents no longer draws a half-cut View all** — in a window short enough
that the list has no room for a whole row (roughly under 380px tall), **View
all** was still drawn and clipped by the edge of the list. It is now left out
at those sizes instead.

- **Delete reads as destructive** — the **Delete** item in a session's ⋮ / ⋯
menu is now red, in both the sidebar rows and the menu beside a session
title, and it has lost its trailing ellipsis. It still asks for confirmation
Expand Down
1 change: 1 addition & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ from the squash message first, or use a plain merge.
| `packages/ui/src/theme/context.tsx`, `packages/app/public/oc-theme-preload.js`, `packages/app/src/theme-preload.test.ts`, `packages/app/src/pages/layout.tsx`, `packages/app/src/components/settings-general.tsx`, `packages/app/src/components/settings-v2/general.tsx` | **One theme in the web/desktop UI; light and dark are the only axis.** Upstream ships 36 selectable palettes with a Settings → Appearance **Theme** picker and command-palette entries. The fork exposes none of them: OC-2 is the theme, `colorScheme` (system/light/dark) is the only appearance choice. The lock lives at `normalize()` in `theme/context.tsx`, renamed to the exported `normalizeThemeID()` and returning the exported `SHIPPED_THEME_ID` unconditionally — that one function is already on every path (provider init, the `onMount` migration, `load`, the cross-tab `storage` listener, `setTheme`, `previewTheme`), so a stored id from before the picker was removed, or one written by another tab, resolves to OC-2 without a second guard. It is exported (and the six call sites renamed) so `theme/context.test.ts` can cover the invariant directly instead of only through its call sites — the module imports cleanly under `bun test` because `import.meta.glob` is called lazily inside `getFiles()`. It also gives the migration for free: `onMount` already rewrites `opencode-theme-id` and calls `clear()` when the raw value differs from the normalized one, which now drops the stale `opencode-theme-css-{light,dark}` cache. `ids()` returns `["oc-2"]` and `loadThemes()` loads only OC-2 instead of all 36 (it was `Promise.all(themeIDs().map(load))`). The bundled theme JSON, the `names` map and `registerTheme` are **left alone** — unreferenced data costs nothing and keeps upstream's theme edits applying cleanly. `oc-theme-preload.js` does the same rewrite *before* mount, so the first launch after upgrading does not paint the user's old palette for a frame; with no other theme reachable its cached-CSS `<style>` injection is gone and it only sets the mode attributes. Removed surfaces: the **Theme** `SettingsRow`/`SettingsRowV2` in both Settings screens (with the `…/themes` docs "Learn more" link, `themeOptions`, the `ThemeOption` type and the `onMount(loadThemes)` that fed them), and in `pages/layout.tsx` the `theme.cycle` command with its `mod+shift+t` keybind, the `cycleTheme` helper and the `theme.set.<id>` command loop (with `availableThemeEntries`). The **colour-scheme** commands stay: `theme.scheme.cycle` (`mod+shift+s`), the three `theme.scheme.<scheme>` entries and their preview/commit wiring. i18n keys (`settings.general.row.theme.*`, `command.theme.cycle`, `command.theme.set`, `toast.theme.title`) are left in the dicts — unused keys are harmless and removing them only creates conflicts. | Every fork surface — the brand, the icons, the screenshots, the docs — is drawn against OC-2. A palette picker means 36 combinations nobody verifies, and a user on Synthwave '84 files bug reports about a UI the fork never shipped. Light/dark still covers the real need. | Keep ours. Take upstream's surrounding changes; re-apply the `normalize()` constant, the `ids()`/`loadThemes()` narrowing, the preload rewrite, and the removed rows/commands. If upstream adds a new theme-selection surface, remove it rather than wiring it up. Do not "fix" `normalize()` back to the `oc-1 → oc-2` mapping — that mapping is a subset of what it does now. Leave the bundled theme files and `names` map in place; deleting them is a much bigger conflict surface for no gain. |
| `packages/app/src/pages/session/timeline/message-timeline.tsx` | **Session actions menu extracted.** The ⋯ menu beside the session title — both renderings, the legacy `DropdownMenu` and the v2 `MenuV2`, ~145 lines — and the `DialogDeleteSession` closure move to the fork-only `packages/app/src/components/session-actions-menu.tsx` (verbatim at extraction; the v2 Delete item has since dropped its ellipsis and taken the danger colour, so do not diff the two expecting a match), and this file renders `<SessionActionsMenu>` instead, passing its existing `openTitleEditor` / share / `archiveSession` / `deleteSession` as callbacks and its trigger element out through `triggerRef` (the share popover still anchors to it). The extracted dialog takes a resolved `title` and an `onConfirm` rather than a `sessionID`, so the caller supplies `sessionTitle(sync().session.get(id)?.title)`. The `title` store loses `pendingRename`/`pendingShare`: deferring Rename and Share until the menu's `onCloseAutoFocus` is now the component's job, since every caller needs it (a list row's inline editor is blurred by the returning focus exactly as the header's was). Also gains one fork-only `createEffect` that consumes `consumeSessionShareRequest(sessionID())` and opens the share popover — the session rows' Share routes through it. | The sidebar's Recents rows and the home session list open the same menu; three copies of four items would drift. Only this file rendered the menu, so extraction is the smallest way to share it. | Keep the extraction. On conflict, take upstream's `message-timeline.tsx` wholesale and re-apply: delete both inline menu blocks and the local `DialogDeleteSession`, mount `<SessionActionsMenu>` with the same callbacks, drop the two `pending*` store fields, and re-add the share-request effect. If upstream adds a menu item, add it to `session-actions-menu.tsx` (both variants) rather than back here. Do not "simplify" the component's deferred rename/share into a plain `onSelect` — focus return is what it exists to survive. |
| `packages/app/src/pages/home.tsx` | `HomeSessionRow` gains the same `<SessionActionsMenu>` (hover/focus/menu-open revealed, vertical dots) and inline rename: while renaming, the row renders a `div` with `InlineEditor` instead of the `button`, because an input inside a button is invalid markup and swallows keystrokes into the open-session handler. `NewHome` holds one `createInlineEditorController()` for the list and passes it plus the model down. Its local `archiveSession` is deleted — `createHomeModel()` owns `archiveSession`/`renameSession`/`deleteSession` now, so the sidebar and this screen run one implementation (extends the "state both screens need moves to home-model.tsx" split in the persistent-sidebar row above); the call site becomes `model.archiveSession`. Upstream's `SHOW_HOME_SESSION_ARCHIVE` block is left exactly as-is — the flag is `false`, so it renders nothing, and touching it would widen the diff for no gain. `data-component="home-session-row"` and every other selector are untouched (`packages/app/e2e` binds to them); the menu adds `data-action="session-menu"`. | The Recents rows and "View all" are the same list at two sizes; a menu on one and not the other makes "View all" a dead end. | Keep our row structure. On conflict take upstream's row and re-apply the menu, the editing branch, and the `model.*` action wiring. Never let a `data-component`/`data-action` hook drift. If upstream re-enables `SHOW_HOME_SESSION_ARCHIVE`, drop that block instead — its absolutely-positioned archive button would sit on top of the menu. |
| `packages/app/src/pages/home.tsx` (again) | **Session lists keyed by id.** The "View all" list iterates `groups().map((group) => group.id)` and, within each group, `group.sessions.map((record) => record.session.id)`, resolving each back through a `createMemo` + `<Show>` rather than handing the objects to `<For>`. `groupSessions` mints new group objects on every call and `createHomeModel`'s records memo mints a new `{session, project, projectName}` wrapper per session on every recompute — the `projects` memo alone returns a fresh array on a pointerdown — and `<For>` keys on reference, so the rows were torn down and rebuilt on recomputes that changed nothing visible. When that landed between a pointerdown and its mouseup the browser had no surviving common ancestor to fire `click` on, so a row's ⋮ menu ignored its first click; the same rebuild could close an open menu or drop an in-progress rename. `data-component="home-session-row"`, `data-action="session-menu"` and the group header refs / opacity keys are unchanged. The fork-only `packages/app/src/components/app-sidebar.tsx` carries the same shape for the Recents list. | A menu that swallows the first click reads as broken, and the cause is structural rather than incidental: any refresh of the session index rebuilt every row. | Keep the id keying. On conflict take upstream's list body and re-apply both `<For>` wrappers. Do not "simplify" back to `<For each={groups()}>` / `<For each={group.sessions}>` — that is the bug. If upstream ever makes groups or records referentially stable this can go, but prove it with `packages/app/e2e/regression/home-session-menu-first-click.spec.ts` before removing it. |
| `packages/desktop/icons/{dev,beta,prod}/` (all PNGs, `icon.icns`, `icon.ico`, `dock.png`), `packages/desktop/icons/README.md` | Regenerated from the fork-only `packages/desktop/icons/icon.svg` by the fork-only `packages/desktop/scripts/generate-icons.ts`: the brand-red `#ee0033` CoDev "C" — the `--mini` splash badge glyph, at the VS Code extension icon's proportions (unit = 120/1024, mark 480x600 centered) — on a white tile, replacing upstream's black/blueprint "o" mark. All three channels are byte-identical for now (chosen deliberately over upstream's per-channel treatments); the generator still writes them separately so a channel can diverge later. `icon.icns` members and `dock.png` use the macOS Big Sur grid (824/1024 inset + drop shadow); Windows, Linux and Store assets stay full-bleed. README rewritten from the Tauri-CLI + Image2Icon recipe to the generator. The stale Tauri-era `android/` and `ios/` subfolders are left untouched — nothing in the Electron build reads them. | Closes the "desktop app icons need design assets" rebrand gap: the Dock/taskbar icon was still upstream's mark. | Keep our assets and README (binary: take ours wholesale). Regenerate via the script rather than hand-editing; if upstream adds an icon size, add it to `FLAT_PNGS`/`ICONSET` and rerun. |
| `packages/tui/src/theme/assets/codev.json` (new file), `packages/tui/src/theme/index.ts`, `packages/tui/src/context/theme.tsx` | Added the `codev` theme — upstream's `opencode.json` palette with `darkSecondary` → `#0891b2` (brand teal) — registered it in the theme index, and made it the default/fallback theme (`"opencode"` → `"codev"` in the store default, kv fallback, and load-failure fallbacks). `theme/assets/opencode.json` itself matches upstream again. | Brand accent lives in a fork-owned theme instead of edits to upstream's palette; the default theme carries the brand. | Keep our theme file, its index registration, and the fallback strings; when upstream changes the opencode palette, mirror it into `codev.json` (keeping our `darkSecondary`). |
| `packages/opencode/src/cli/ui.ts` | `wordmark`/`logo()` render the lowercase codev wordmark via `ansiWordmark` (bold, no shadow, "dev" in brand red on TTY) | Help banner matches the TUI home banner. | Keep our wordmark + logo(); take upstream's surrounding changes. |
Expand Down
69 changes: 69 additions & 0 deletions packages/app/e2e/regression/home-session-menu-first-click.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { expect, test, type Page } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"

// The "View all" screen — the same session rows as the sidebar, grouped by day,
// rendered by home.tsx rather than app-sidebar.tsx. It carried the same
// first-click defect for the same reason: reference-keyed lists over objects
// that get rebuilt on unrelated recomputes.
const main = (page: Page) => page.locator("main")
const rows = (page: Page) => main(page).locator('[data-component="home-session-row"]')
const content = (page: Page) => page.locator('[data-component="menu-v2-content"]')

async function boot(page: Page) {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
await expectAppVisible(rows(page).first())
await expect(rows(page)).toHaveCount(fixture.sessions.length)
}

async function revealActions(page: Page) {
await rows(page).first().hover()
await expect(main(page).locator('[data-action="session-menu"]').first()).toBeVisible()
}

test("the View all row menu opens on the first click", async ({ page }) => {
await boot(page)
await revealActions(page)

await main(page).locator('[data-action="session-menu"]').first().click()
await expect(content(page)).toHaveCount(1)
})

test("a record rebuild leaves the View all rows and their groups alone", async ({ page }) => {
await boot(page)
await revealActions(page)
await main(page).locator('[data-action="session-menu"]').first().click()
await expectAppVisible(content(page))

const row = await rows(page).first().elementHandle()

// Re-runs the records memo, which hands both the group list and the row list
// a fresh set of objects. Keyed by id, neither level may rebuild.
await page.evaluate(() => window.dispatchEvent(new Event("focus")))
await page.evaluate(() => window.dispatchEvent(new Event("online")))
await page.waitForTimeout(600)

await expect(content(page)).toHaveCount(1)
expect(
await rows(page)
.first()
.evaluate((el, previous) => el === previous, row),
).toBe(true)
})
21 changes: 12 additions & 9 deletions packages/app/e2e/regression/sidebar-section-collapse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,22 +100,25 @@ test("View all is never pushed out of the list as the window shortens", async ({
// silently cuts View all off. Compare against the list box rather than the
// viewport, and sweep heights so the assertion does not depend on landing in
// a lucky band.
// Stops at 400. Below that the flexible area itself drops under one row
// (12px at a 380px window, 0 at 360) and View all is drawn anyway, so it is
// clipped — capacity clamps the session rows to zero but nothing withholds
// View all when there is no room for it either. That is pre-existing, not
// something the collapse work introduced, and it wants its own fix; this
// sweep guards the range where the reservation is supposed to hold.
for (const height of [900, 700, 560, 480, 420, 400]) {
// Swept down past 400, where the flexible area drops under one row (12px at
// a 380px window, none at 360). View all is withheld entirely at those sizes
// rather than drawn into space that clips it, so the invariant holds all the
// way down: it is either whole or absent, never cut in half.
for (const height of [900, 700, 560, 480, 420, 400, 380, 360, 320]) {
await page.setViewportSize({ width: 1280, height })
// The row count is driven by a ResizeObserver, so the list is briefly still
// filled for the old box after a resize. Poll rather than measure the frame
// straight after: a genuine clip never resolves, so this waits out the
// reflow without waiting away the failure.
await expect(async () => {
const list = await recents(page).locator('[data-slot="sidebar-recents-list"]').boundingBox()
const all = await viewAll(page).boundingBox()
if (!all) return // too short to draw the section at all
// Withheld outright once the list cannot hold a whole row — absent is a
// pass, clipped is not.
if ((await viewAll(page).count()) === 0) {
expect({ height, listFits: (list?.height ?? 0) >= 28 }).toEqual({ height, listFits: false })
return
}
const all = (await viewAll(page).boundingBox())!
expect(list).not.toBeNull()
expect(
{ height, bottomOverflow: Math.max(0, Math.round(all.y + all.height - (list!.y + list!.height))) },
Expand Down
Loading
Loading