diff --git a/CHANGELOG.md b/CHANGELOG.md index e4dc7c6442..080b92faaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,17 @@ file is the human-readable summary of what changed and why. from today, now framed by the sidebar. The **Recents** header is itself a toggle — clicking it folds the section down to just its title, the chevron beside it shows which way it is, and the choice is remembered for next launch; - **New session** stays one click away in the titlebar while it is folded. Below - a narrow window width the sidebar hides and the home screen keeps its previous - stacked layout. Two buttons join the titlebar: the leftmost hides and restores + **New session** stays one click away in the titlebar while it is folded. Above + Recents sits a **Pinned** section for the sessions you keep coming back to: + pinning one moves it out of the chronological list into Pinned, where it stays + put. Pinned is exactly as tall as its rows so Recents keeps its space, scrolls + rather than growing once you pass five pins, and folds and remembers that + independently of Recents; its header is there from the start, with nothing + under it until you pin something. Pins are remembered per server, and deleting + a session drops its pin. The **View all** screen grows a matching **Pinned** + group above Today. Below a narrow + window width the sidebar hides and the home screen keeps its previous stacked + layout. Two buttons join the titlebar: the leftmost hides and restores the sidebar, so a session can have the full window width, and it remembers the choice — note that Projects live only in the sidebar, so switching project means opening it again — while the second, next to Home, opens the @@ -37,20 +45,22 @@ file is the human-readable summary of what changed and why. command it dispatches was only ever registered by the old one. Every session row, in Recents and on **View all**, carries a vertical three-dot button at its right edge, appearing on hover, which opens the same menu the ⋯ button - beside a session title opens: **Rename**, **Share…** (only where sharing is - enabled), **Archive** and **Delete** — the last in red, and without a trailing - ellipsis in either menu. Renaming edits the row's label in place, Enter saves - and Esc cancels, instead of taking you into the session first; deleting still - asks for confirmation and takes the session's children with it; sharing from a - row opens the session and its share popover, because the share state is not - carried by the list. Previously the only way to rename, archive or delete was - to open the session and use the menu next to its title. + beside a session title opens: **Pin** (**Unpin** once set), **Rename**, + **Share…** (only where sharing is enabled), **Archive** and **Delete** — the + last in red, and without a trailing ellipsis in either menu. Renaming edits + the row's label in place, Enter saves and Esc cancels, instead of taking you + into the session first; deleting still asks for confirmation and takes the + session's children with it; sharing from a row opens the session and its share + popover, because the share state is not carried by the list. Previously the + only way to rename, archive or delete was to open the session and use the menu + next to its title. ([#173](https://github.com/quickbeard/codev-code/pull/173), [#175](https://github.com/quickbeard/codev-code/pull/175), [#179](https://github.com/quickbeard/codev-code/pull/179), [#183](https://github.com/quickbeard/codev-code/pull/183), [#184](https://github.com/quickbeard/codev-code/pull/184), - [#185](https://github.com/quickbeard/codev-code/pull/185)) + [#185](https://github.com/quickbeard/codev-code/pull/185), + [#189](https://github.com/quickbeard/codev-code/pull/189)) ### Agent diff --git a/DIVERGENCES.md b/DIVERGENCES.md index e7e26d01a6..ff4382af0a 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -125,6 +125,7 @@ from the squash message first, or use a plain merge. | `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 `` 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 `` 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 `` (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` + `` rather than handing the objects to ``. `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 `` 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 `` wrappers. Do not "simplify" back to `` / `` — 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. | +| Pinned sessions: `packages/app/src/pages/home.tsx`, `packages/app/src/pages/session/timeline/message-timeline.tsx`, `packages/app/src/pages/layout-new.tsx`, `packages/app/src/i18n/{en,es}.ts` | **Pin a session to a sidebar section of its own.** The fork-only `src/context/pinned-sessions.tsx` holds the pinned ids — one `Persist.global("sidebar.pinned")` record keyed by `ServerConnection.Key`, newest pin first — and `layout-new.tsx` mounts its provider inside `AppSidebarProvider`; that is the nearest point covering all three surfaces, since `NewAppLayout` is the router root. It has to be context rather than state in `createHomeModel()`: the sidebar and the home screen each hold their own model, so two `persisted` stores over one key would write the same storage without sharing memory and a pin would not cross between them until a reload — the same reason the project selection lives in `useLayout()`. `home-model.tsx` (fork-only) derives `pinnedRecords()` / `recentRecords()` from it and `deleteSession` drops the pins of the whole deleted subtree. In `home.tsx`, `groupSessions` takes the pinned records and emits a leading `pinned` group, and pinned sessions are filtered out of the today/yesterday/older groups so they appear once; `HomeSessionRow` passes `pinned`/`onPin` into ``. `message-timeline.tsx` passes the same pair, resolving the server the pin belongs to as `params.serverKey ? requireServerKey(params.serverKey) : useServer().key` — it must match what the sidebar's selection resolves to or the header would toggle a pin the sidebar cannot see. New i18n keys `common.pin`, `common.unpin`, `sidebar.pinned`, `home.sessions.group.pinned` in `en` + `es`. Every `data-component`/`data-action` hook is untouched; the new ones are `data-section="pinned"` and `data-slot="sidebar-pinned-list"`. The Pinned list is keyed by session id for the same reason the row above keys the View all lists — it renders the same rebuilt record wrappers. | A purely chronological Recents buries the handful of sessions someone actually returns to, and "View all" is the only way back. Pinning is the smallest fix, and it has to be offered wherever a session's menu is or it is undiscoverable. | Keep our structure. On conflict take upstream's `home.tsx` / `message-timeline.tsx` wholesale and re-apply: the `pinned` group id and its filter in `groupSessions`, the `pinnedRecords` argument at the `groups` memo, and the two `pinned`/`onPin` props. If upstream adds its own grouping ahead of Today, fold the pinned group in above it rather than replacing it. Never resolve the header's server key differently from the sidebar's — mismatched keys silently split one user's pins in two. | | `packages/app/e2e/regression/remote-session-settings.spec.ts` | Both tests pick the settings-shortcut modifier by asking the page — `/(Mac|iPod|iPhone|iPad)/.test(navigator.platform)` — through a local `openSettingsDialog(page)` helper, instead of branching on the Node-side `process.platform`. | Upstream's version pressed `Meta+,` whenever the *host* was macOS, but the app resolves `mod` from `navigator.platform` (`IS_MAC` in `src/context/command.tsx`) and Playwright's `devices["Desktop Chrome"]` profile ships a Windows user agent, so the page always reports `Win32`. On a macOS machine the suite therefore pressed Meta while the app listened for Control: the settings dialog never opened and both tests failed, while Linux CI stayed green because there the host branch happened to pick Control anyway. A real macOS browser reports `MacIntel`, so the app is right and only the test was wrong. | Keep our helper. On conflict take upstream's spec and re-apply it. If upstream fixes this itself, drop the row. Worth submitting upstream — it breaks the suite for every macOS developer. | | `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`). | diff --git a/packages/app/e2e/regression/sidebar-pinned-sessions.spec.ts b/packages/app/e2e/regression/sidebar-pinned-sessions.spec.ts new file mode 100644 index 0000000000..00582c05a7 --- /dev/null +++ b/packages/app/e2e/regression/sidebar-pinned-sessions.spec.ts @@ -0,0 +1,147 @@ +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" + +const sidebar = (page: Page) => page.locator('[data-component="app-sidebar"]') +const section = (page: Page, name: string) => sidebar(page).locator(`[data-section="${name}"]`) +const rowsIn = (page: Page, name: string) => section(page, name).locator('[data-component="sidebar-session-row"]') +const pinnedToggle = (page: Page) => section(page, "pinned").locator('[data-action="sidebar-section-toggle"]') +const menuContent = (page: Page) => page.locator('[data-component="menu-v2-content"]') +// Exact text: "Unpin" contains "Pin", so a substring match cannot tell the two +// labels apart — which is the whole thing the toggle test is checking. +const menuItem = (page: Page, label: string) => + menuContent(page) + .locator('[data-component="menu-v2-item"]') + .filter({ hasText: new RegExp(`^${label}$`) }) + +const TARGET = "Example Game: sample jump movement & sample physics analysis" +const SOURCE = "Uncommitted changes inquiry" + +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 expect(rowsIn(page, "recents").first()).toBeVisible({ timeout: 30_000 }) +} + +// Addressed by position rather than by walking up from the row, because the +// trigger is a sibling of the row button with no shared hook. Every caller acts +// on the first row of its section and asserts the title separately. +// +// Retried, because the row trigger swallows its first click and opens on the +// second — a pre-existing defect fixed separately in #185, not something this +// feature introduced or should assert into place. +async function openFirstRowMenu(page: Page, name: string) { + await rowsIn(page, name).first().hover() + const trigger = section(page, name).locator('[data-action="session-menu"]').first() + await expect(async () => { + await trigger.click() + await expect(menuContent(page)).toHaveCount(1, { timeout: 1000 }) + }).toPass({ timeout: 15_000 }) +} + +async function pinFirstRecent(page: Page) { + await expect(rowsIn(page, "recents").first()).toContainText(TARGET) + await openFirstRowMenu(page, "recents") + await menuItem(page, "Pin").click() + await expect(rowsIn(page, "pinned")).toHaveCount(1) +} + +test("pinning moves a session out of Recents and into Pinned, and it sticks", async ({ page }) => { + await boot(page) + + // The header is present before the first pin, so the feature advertises + // itself, but the body stays empty rather than spending a row on saying so. + await expect(section(page, "pinned")).toBeVisible() + await expect(pinnedToggle(page)).toBeVisible() + await expect(rowsIn(page, "pinned")).toHaveCount(0) + await expect(rowsIn(page, "recents")).toHaveCount(fixture.sessions.length) + + await pinFirstRecent(page) + + await expect(rowsIn(page, "pinned").first()).toContainText(TARGET) + await expect(rowsIn(page, "recents")).toHaveCount(fixture.sessions.length - 1) + await expect(section(page, "recents")).not.toContainText(TARGET) + + // Stored, not per mount. + await page.reload() + await expect(rowsIn(page, "pinned")).toHaveCount(1) + await expect(rowsIn(page, "pinned").first()).toContainText(TARGET) + await expect(rowsIn(page, "recents")).toHaveCount(fixture.sessions.length - 1) +}) + +test("the item reads Unpin once pinned, and unpinning returns the row to Recents", async ({ page }) => { + await boot(page) + await pinFirstRecent(page) + + await openFirstRowMenu(page, "pinned") + await expect(menuItem(page, "Unpin")).toHaveCount(1) + await expect(menuItem(page, "Pin")).toHaveCount(0) + await menuItem(page, "Unpin").click() + + await expect(rowsIn(page, "pinned")).toHaveCount(0) + await expect(rowsIn(page, "recents")).toHaveCount(fixture.sessions.length) + await expect(section(page, "recents")).toContainText(TARGET) +}) + +test("Pin leads the menu, above the edit actions", async ({ page }) => { + await boot(page) + await openFirstRowMenu(page, "recents") + + const labels = await menuContent(page).locator('[data-component="menu-v2-item"]').allInnerTexts() + expect(labels).toEqual(["Pin", "Rename", "Share...", "Archive", "Delete"]) +}) + +test("Pinned hugs its rows instead of halving the column", async ({ page }) => { + await boot(page) + const recentsBefore = (await section(page, "recents").boundingBox())! + expect(recentsBefore).not.toBeNull() + + await pinFirstRecent(page) + + const pinned = (await section(page, "pinned").boundingBox())! + const recentsAfter = (await section(page, "recents").boundingBox())! + expect(pinned).not.toBeNull() + expect(recentsAfter).not.toBeNull() + + // Header (28) + gap (4) + one row (28). A flexing Pinned would be hundreds of + // pixels tall here instead. + expect(Math.round(pinned.height)).toBe(60) + // And Recents gives up only what Pinned actually took, not half the column. + // The 4px is the column's gap-4... which is 16px; allow the row gap plus + // rounding on top of the section height. + expect(recentsAfter.height).toBeGreaterThan(recentsBefore.height - pinned.height - 20) +}) + +test("the View all list grows a Pinned group above the dated ones", async ({ page }) => { + await boot(page) + await pinFirstRecent(page) + + const main = page.locator("main") + await expectAppVisible(main.locator('[data-component="home-session-row"]').first()) + + // The pinned session moves into the Pinned group rather than appearing in + // both it and its date group. + await expect(main.locator('[data-component="home-session-row"]').filter({ hasText: TARGET })).toHaveCount(1) + + const text = (await main.innerText()).replace(/\s+/g, " ") + expect(text).toContain("Pinned") + // Pinned heading, then its session, then the dated group below it. + expect(text.indexOf("Pinned")).toBeLessThan(text.indexOf(TARGET)) + expect(text.indexOf(TARGET)).toBeLessThan(text.indexOf(SOURCE)) +}) diff --git a/packages/app/e2e/regression/sidebar-section-collapse.spec.ts b/packages/app/e2e/regression/sidebar-section-collapse.spec.ts index 6055985c42..290d58d41a 100644 --- a/packages/app/e2e/regression/sidebar-section-collapse.spec.ts +++ b/packages/app/e2e/regression/sidebar-section-collapse.spec.ts @@ -5,7 +5,9 @@ import { expectAppVisible } from "../utils/waits" const sidebar = (page: Page) => page.locator('[data-component="app-sidebar"]') const recents = (page: Page) => sidebar(page).locator('[data-section="recents"]') +const pinned = (page: Page) => sidebar(page).locator('[data-section="pinned"]') const toggle = (page: Page) => recents(page).locator('[data-action="sidebar-section-toggle"]') +const pinnedToggle = (page: Page) => pinned(page).locator('[data-action="sidebar-section-toggle"]') const viewAll = (page: Page) => recents(page).locator('[data-action="sidebar-view-all"]') const sessionRows = (page: Page) => recents(page).locator('[data-component="sidebar-session-row"]') const settings = (page: Page) => sidebar(page).getByRole("button", { name: "Settings" }) @@ -71,6 +73,50 @@ test("collapsing Recents moves neither its own header nor the bottom nav", async expect(Math.abs(after.settings!.y - before.settings!.y)).toBeLessThan(2) }) +test("Pinned and Recents fold independently and each remembers its own state", async ({ page }) => { + await boot(page) + + // Both are the same SidebarSection over one `sidebar.sections` record keyed by + // name, so the risk worth pinning is that they share a key and move together. + await expect(pinnedToggle(page)).toHaveAttribute("aria-expanded", "true") + await expect(toggle(page)).toHaveAttribute("aria-expanded", "true") + + await pinnedToggle(page).click() + await expect(pinnedToggle(page)).toHaveAttribute("aria-expanded", "false") + await expect(toggle(page)).toHaveAttribute("aria-expanded", "true") + await expectAppVisible(viewAll(page)) + + await page.reload() + await expectAppVisible(toggle(page)) + await expect(pinnedToggle(page)).toHaveAttribute("aria-expanded", "false") + await expect(toggle(page)).toHaveAttribute("aria-expanded", "true") + + // And the reverse pairing, so neither is merely following the other. + await toggle(page).click() + await expect(toggle(page)).toHaveAttribute("aria-expanded", "false") + await expect(pinnedToggle(page)).toHaveAttribute("aria-expanded", "false") + await pinnedToggle(page).click() + await expect(pinnedToggle(page)).toHaveAttribute("aria-expanded", "true") + await expect(toggle(page)).toHaveAttribute("aria-expanded", "false") +}) + +test("collapsing Pinned leaves Recents and the bottom nav where they are", async ({ page }) => { + await boot(page) + await expectAppVisible(settings(page)) + const before = { recents: await recents(page).boundingBox(), settings: await settings(page).boundingBox() } + + // Pinned is content-sized, so folding it frees only its rows' worth of space. + // Recents is the flexible child and absorbs that; Settings must not move. + await pinnedToggle(page).click() + await expect(pinnedToggle(page)).toHaveAttribute("aria-expanded", "false") + const after = { recents: await recents(page).boundingBox(), settings: await settings(page).boundingBox() } + + expect(before.settings).not.toBeNull() + expect(after.settings).not.toBeNull() + expect(Math.abs(after.settings!.y - before.settings!.y)).toBeLessThan(2) + expect(after.recents!.height).toBeGreaterThanOrEqual(before.recents!.height) +}) + test("the header toggles from the keyboard", async ({ page }) => { await boot(page) @@ -100,11 +146,12 @@ 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. - // 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]) { + // Swept down past 400, where the flexible area drops under one row. View all + // is withheld entirely at those sizes rather than drawn into space that clips + // it, so the invariant holds all the way down: whole or absent, never cut in + // half. 440 is in the sweep because the Pinned section takes a fixed 60px off + // the column, which lifts the threshold that far up while it is expanded. + for (const height of [900, 700, 560, 480, 440, 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 diff --git a/packages/app/src/components/app-sidebar-rows.ts b/packages/app/src/components/app-sidebar-rows.ts index 135744919c..0e5318c0e7 100644 --- a/packages/app/src/components/app-sidebar-rows.ts +++ b/packages/app/src/components/app-sidebar-rows.ts @@ -17,6 +17,9 @@ export function recentsRowCapacity(height: number) { // area keeps shrinking past that — under one row at a ~380px window, to nothing // at ~360 — and the list clips with overflow-hidden, so drawing the row anyway // just cut it in half. One row is the floor for showing it. +// +// The Pinned section takes a fixed 60px off the column, so those thresholds sit +// ~60px higher whenever it is expanded. export function fitsRecentsRow(height: number) { return height >= HOME_NAV_ROW_PITCH - HOME_NAV_ROW_GAP } diff --git a/packages/app/src/components/app-sidebar.tsx b/packages/app/src/components/app-sidebar.tsx index 4357d85836..58a1cf4e90 100644 --- a/packages/app/src/components/app-sidebar.tsx +++ b/packages/app/src/components/app-sidebar.tsx @@ -9,8 +9,9 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { fitsRecentsRow, recentsRowCapacity } from "@/components/app-sidebar-rows" +import { fitsRecentsRow, HOME_NAV_ROW_GAP, HOME_NAV_ROW_PITCH, recentsRowCapacity } from "@/components/app-sidebar-rows" import { useServerManagementController } from "@/components/dialog-select-server" +import { PINNED_VISIBLE_ROWS } from "@/components/pinned-sessions-list" import { DialogDeleteSession, SessionActionsMenu } from "@/components/session-actions-menu" import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" import { useSettingsCommand } from "@/components/settings-dialog" @@ -184,8 +185,17 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool + setSections("collapsed", "pinned", !open)} + /> + void + model: ReturnType + open: boolean + onOpenChange: (open: boolean) => void +}) { + const language = useLanguage() + const editor = createInlineEditorController() + + return ( + + {/* Nothing under the header until something is pinned. The header itself + still renders, so the section is discoverable without spending a row + on telling you it is empty. */} + 0}> + +
+ {/* Iterated by session id for the same reason Recents is, below: the + records memo rebuilds its wrappers on unrelated recomputes and + `` keys on reference, which tore rows down mid-click. */} + record.session.id)}> + {(id) => { + const record = createMemo(() => props.records.find((item) => item.session.id === id)) + return ( + + + + ) + }} + +
+
+
+
+ ) +} + +// Fills the space between Pinned and the bottom Settings/Help while open. The // row list renders only as many sessions as measure into the free height so // "View all" — which sits outside the flexible area — is always reachable // without scrolling, at any window height. @@ -395,6 +466,8 @@ function SidebarSessionRow(props: { vertical dataAction="session-menu" shareEnabled={props.model.shareEnabled(props.record.session)} + pinned={props.model.isPinned(props.record.session)} + onPin={() => props.model.togglePin(props.record.session)} onRename={() => props.editor.openEditor(editorId(), title())} onShare={() => { props.openSession(props.record.session) diff --git a/packages/app/src/components/pinned-sessions-list.test.ts b/packages/app/src/components/pinned-sessions-list.test.ts new file mode 100644 index 0000000000..be5191a568 --- /dev/null +++ b/packages/app/src/components/pinned-sessions-list.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import { orderPinnedRecords, togglePinnedIDs } from "./pinned-sessions-list" + +const record = (id: string) => ({ session: { id } }) +const ids = (records: { session: { id: string } }[]) => records.map((item) => item.session.id) + +describe("orderPinnedRecords", () => { + test("follows pin order, not the order records arrive in", () => { + // records() is sorted by recency; the pinned list must not be, or a pinned + // row would jump position every time its session was touched. + const records = [record("a"), record("b"), record("c")] + expect(ids(orderPinnedRecords(records, ["c", "a"]))).toEqual(["c", "a"]) + }) + + test("drops ids with no matching record", () => { + // Pins outlive their sessions: deleted elsewhere, archived, or belonging to + // a project that is no longer open. Resolving through records is what keeps + // the stored list self-healing. + expect(ids(orderPinnedRecords([record("a")], ["gone", "a", "also-gone"]))).toEqual(["a"]) + }) + + test("is empty when nothing is pinned", () => { + expect(orderPinnedRecords([record("a")], [])).toEqual([]) + }) + + test("is empty when no pin resolves", () => { + expect(orderPinnedRecords([record("a")], ["b"])).toEqual([]) + }) +}) + +describe("togglePinnedIDs", () => { + test("adds to the front so the newest pin leads", () => { + expect(togglePinnedIDs(["a"], "b")).toEqual(["b", "a"]) + }) + + test("removes on a second toggle", () => { + expect(togglePinnedIDs(["b", "a"], "b")).toEqual(["a"]) + expect(togglePinnedIDs(["b", "a"], "a")).toEqual(["b"]) + }) + + test("never duplicates an id", () => { + expect(togglePinnedIDs(togglePinnedIDs(["a"], "b"), "b")).toEqual(["a"]) + }) + + test("does not mutate the list it was given", () => { + const before = ["a"] + togglePinnedIDs(before, "b") + expect(before).toEqual(["a"]) + }) +}) diff --git a/packages/app/src/components/pinned-sessions-list.ts b/packages/app/src/components/pinned-sessions-list.ts new file mode 100644 index 0000000000..2e09f5d74d --- /dev/null +++ b/packages/app/src/components/pinned-sessions-list.ts @@ -0,0 +1,28 @@ +// How many pinned rows the sidebar shows before the section scrolls. Pinned is +// content-sized rather than flexible, so without a cap a long pin list would +// push Recents out of the column; past this it scrolls inside itself instead. +export const PINNED_VISIBLE_ROWS = 5 + +// Pinned rows follow pin order, newest pin first — deliberately not the +// recency order Recents uses. A pinned row that reshuffled every time its +// session was touched would defeat the point of pinning it. +// +// Ids outlive the sessions they name: a session can be deleted on another +// device, archived, or belong to a project that is no longer open, and its id +// sits in storage regardless. Resolving through the records list drops those +// silently, which is also what keeps the stored list self-healing. +// Generic over the record rather than tied to HomeSessionRecord: the id is all +// it reads, and staying structural keeps this module free of a dependency back +// on pages/home-model. +export function orderPinnedRecords(records: T[], ids: string[]) { + const byID = new Map(records.map((record) => [record.session.id, record] as const)) + return ids.flatMap((id) => { + const record = byID.get(id) + return record ? [record] : [] + }) +} + +export function togglePinnedIDs(ids: string[], id: string) { + if (ids.includes(id)) return ids.filter((item) => item !== id) + return [id, ...ids] +} diff --git a/packages/app/src/components/session-actions-menu.tsx b/packages/app/src/components/session-actions-menu.tsx index 3198ecaa26..68c047adb6 100644 --- a/packages/app/src/components/session-actions-menu.tsx +++ b/packages/app/src/components/session-actions-menu.tsx @@ -27,6 +27,10 @@ type SessionActionsMenuProps = { onRename: () => void onArchive: () => void onDelete: () => void + // Pin is opt-in: the item only renders where a caller supplies the handler, + // so a surface with no notion of pinning does not have to pass a no-op. + pinned?: boolean + onPin?: () => void // Share is hidden when the server disables sharing. The header passes its // shareEnabled() memo; list rows pass the same value from their server ctx. shareEnabled?: boolean @@ -129,6 +133,21 @@ export function SessionActionsMenu(props: SessionActionsMenuProps) { /> + {/* Pin leads the menu and sits above a separator: it is the only + item that moves the row somewhere else, so it does not belong in + the run of edit actions below it. Unlike Rename and Share it can + run straight from onSelect — it takes no focus, so it has + nothing to lose to the closing menu. */} + + {(onPin) => ( + <> + onPin()()}> + {props.pinned ? language.t("common.unpin") : language.t("common.pin")} + + + + )} + defer("rename")}>{language.t("common.rename")} defer("share")}>{language.t("session.share.action.share")}... diff --git a/packages/app/src/components/sidebar-section.tsx b/packages/app/src/components/sidebar-section.tsx index 141e6c48ea..fe3b0db247 100644 --- a/packages/app/src/components/sidebar-section.tsx +++ b/packages/app/src/components/sidebar-section.tsx @@ -17,14 +17,20 @@ export function SidebarSection(props: { label: string open: boolean onOpenChange: (open: boolean) => void + // Whether an open section claims the column's free height. Only one section + // should: with two of them flexing, they split the column in half and the + // shorter one is left holding empty space. Recents takes the default; Pinned + // opts out and sizes itself to its rows. + grow?: boolean class?: string children: JSX.Element }) { + const flexible = () => props.open && props.grow !== false return (
{/* The whole header row is the toggle, and it carries the label text, so diff --git a/packages/app/src/context/pinned-sessions.tsx b/packages/app/src/context/pinned-sessions.tsx new file mode 100644 index 0000000000..3dc35b030d --- /dev/null +++ b/packages/app/src/context/pinned-sessions.tsx @@ -0,0 +1,54 @@ +import { createContext, useContext, type ParentProps } from "solid-js" +import { createStore } from "solid-js/store" +import { togglePinnedIDs } from "@/components/pinned-sessions-list" +import type { ServerConnection } from "@/context/server" +import { Persist, persisted } from "@/utils/persist" + +type PinnedSessionsState = { + ids: (server: ServerConnection.Key) => string[] + isPinned: (server: ServerConnection.Key, sessionID: string) => boolean + toggle: (server: ServerConnection.Key, sessionID: string) => void + remove: (server: ServerConnection.Key, sessionID: string) => void +} + +const PinnedSessionsContext = createContext() + +const EMPTY: string[] = [] + +// Which sessions are pinned, per server. This is context rather than state +// inside createHomeModel because the sidebar and the home screen each hold +// their own model: two `persisted` stores over one key write the same storage +// but do not share memory, so pinning in the sidebar would not reach the View +// all list until a reload. Same reason the project selection lives in +// useLayout() (see the note atop pages/home-model.tsx). +// +// Keyed by server: session ids are only unique within a server, so a pin made +// against one must not follow the id onto another. +export function PinnedSessionsProvider(props: ParentProps) { + const [store, setStore] = persisted( + Persist.global("sidebar.pinned"), + createStore({ sessions: {} as Record }), + ) + + return ( + store.sessions[server] ?? EMPTY, + isPinned: (server, sessionID) => (store.sessions[server] ?? EMPTY).includes(sessionID), + toggle: (server, sessionID) => setStore("sessions", server, (ids) => togglePinnedIDs(ids ?? EMPTY, sessionID)), + remove: (server, sessionID) => + setStore("sessions", server, (ids) => (ids ?? EMPTY).filter((item) => item !== sessionID)), + }} + > + {props.children} + + ) +} + +// Returns undefined outside a provider rather than throwing, matching +// useAppSidebar(). Callers treat that as "nothing is pinned", which keeps the +// legacy shell — where this provider is not mounted — rendering a plain +// chronological list instead of crashing. +export function usePinnedSessions() { + return useContext(PinnedSessionsContext) +} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 6c41dd7346..99471d7e03 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -627,6 +627,7 @@ export const dict = { "home.sessions.search.noResults": "No sessions found for {{query}}", "home.sessions.empty": "Nothing here yet", "home.sessions.empty.description": "Create a session to get started", + "home.sessions.group.pinned": "Pinned", "home.sessions.group.today": "Today", "home.sessions.group.yesterday": "Yesterday", "home.sessions.group.older": "Older", @@ -774,6 +775,8 @@ export const dict = { "common.rename": "Rename", "common.reset": "Reset", "common.archive": "Archive", + "common.pin": "Pin", + "common.unpin": "Unpin", "common.delete": "Delete", "common.close": "Close", "common.edit": "Edit", @@ -804,6 +807,7 @@ export const dict = { "sidebar.nav.projectsAndSessions": "Projects and sessions", "sidebar.settings": "Settings", "sidebar.help": "Help", + "sidebar.pinned": "Pinned", "sidebar.recents": "Recents", "sidebar.recents.new": "New session", "sidebar.recents.viewAll": "View all", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 8221e76282..4170a31ba8 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -607,6 +607,7 @@ export const dict = { "home.sessions.search.noResults": "No se encontraron sesiones para {{query}}", "home.sessions.empty": "Aún no hay nada", "home.sessions.empty.description": "Crea una sesión para empezar", + "home.sessions.group.pinned": "Fijadas", "home.sessions.group.today": "Hoy", "home.sessions.group.yesterday": "Ayer", "home.sessions.group.older": "Anteriores", @@ -729,6 +730,8 @@ export const dict = { "common.rename": "Renombrar", "common.reset": "Restablecer", "common.archive": "Archivar", + "common.pin": "Fijar", + "common.unpin": "Dejar de fijar", "common.delete": "Eliminar", "common.close": "Cerrar", "common.edit": "Editar", @@ -739,6 +742,7 @@ export const dict = { "sidebar.nav.projectsAndSessions": "Proyectos y sesiones", "sidebar.settings": "Ajustes", "sidebar.help": "Ayuda", + "sidebar.pinned": "Fijadas", "sidebar.recents": "Recientes", "sidebar.recents.new": "Sesión nueva", "sidebar.recents.viewAll": "Ver todo", diff --git a/packages/app/src/pages/home-model.tsx b/packages/app/src/pages/home-model.tsx index 45906b4ea1..aead0695ac 100644 --- a/packages/app/src/pages/home-model.tsx +++ b/packages/app/src/pages/home-model.tsx @@ -14,6 +14,8 @@ import { import { useLanguage } from "@/context/language" import { useLayout, type HomeProjectSelection, type LocalProject } from "@/context/layout" import { useNotification } from "@/context/notification" +import { usePinnedSessions } from "@/context/pinned-sessions" +import { orderPinnedRecords } from "@/components/pinned-sessions-list" import { ServerConnection, useServer } from "@/context/server" import { useServerSync } from "@/context/server-sync" import { useTabs } from "@/context/tabs" @@ -103,6 +105,7 @@ export function createHomeModel() { const language = useLanguage() const notification = useNotification() const pickDirectory = useDirectoryPicker() + const pinned = usePinnedSessions() const selection = layout.home.selection @@ -192,6 +195,17 @@ export function createHomeModel() { }), ) const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT)) + // `records()` stays the whole list — the search field and anything else + // counting sessions still wants it. The two splits below are what the + // sidebar's Pinned / Recents sections and the View all grouping render. + const pinnedIDs = createMemo(() => pinned?.ids(selection().server) ?? []) + const pinnedRecords = createMemo(() => orderPinnedRecords(records(), pinnedIDs())) + const recentRecords = createMemo(() => { + const ids = new Set(pinnedIDs()) + return records().filter((record) => !ids.has(record.session.id)) + }) + const isPinned = (session: Session) => pinnedIDs().includes(session.id) + const togglePin = (session: Session) => pinned?.toggle(selection().server, session.id) function setSelection(next: HomeProjectSelection) { layout.home.setSelection(next) @@ -371,6 +385,11 @@ export function createHomeModel() { const known = store.session ?? [] const removed = descendantIDs(known, session.id) setStore(produce((draft) => (draft.session = draft.session.filter((item) => !removed.has(item.id))))) + // A deleted session takes its pin with it. `orderPinnedRecords` would drop + // the id from the rendered list anyway, but leaving it stored means the pin + // silently returns if the id is ever reused, and the subtree's children may + // be pinned too. + removed.forEach((id) => pinned?.remove(ServerConnection.key(conn), id)) refreshIndex() notifySessionTabsRemoved({ server: ServerConnection.key(conn), @@ -406,6 +425,11 @@ export function createHomeModel() { projectByID, allRecords, records, + pinnedIDs, + pinnedRecords, + recentRecords, + isPinned, + togglePin, setSelection, focusServer, selectProject, diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index f658e9604c..04c0a2f339 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -90,7 +90,7 @@ export const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-w export const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]" type HomeSessionGroup = { - id: "today" | "yesterday" | "older" + id: "pinned" | "today" | "yesterday" | "older" title: string sessions: HomeSessionRecord[] } @@ -280,7 +280,7 @@ export function NewHome() { return model.allRecords().filter((record) => matchesHomeSessionSearch(record, query)) }) const searchOpen = createMemo(() => state.searchFocused && search().length > 0) - const groups = createMemo(() => groupSessions(records(), language)) + const groups = createMemo(() => groupSessions(records(), language, model.pinnedRecords())) const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups) const prefetched = new Set() @@ -1276,6 +1276,8 @@ function HomeSessionRow(props: { vertical dataAction="session-menu" shareEnabled={props.model.shareEnabled(props.record.session)} + pinned={props.model.isPinned(props.record.session)} + onPin={() => props.model.togglePin(props.record.session)} onRename={() => props.editor.openEditor(editorId(), title())} onShare={() => { props.openSession(props.record.session) @@ -1348,16 +1350,26 @@ function HomeSessionSkeleton(props: { label: string }) { ) } -function groupSessions(records: HomeSessionRecord[], language: ReturnType): HomeSessionGroup[] { +// Pinned sessions lead the list and leave their date group, mirroring the +// sidebar — a pinned session appearing in both places would read as a duplicate. +// `pinnedRecords` arrives already in pin order, so the group is not re-sorted by +// date the way the others are. +function groupSessions( + records: HomeSessionRecord[], + language: ReturnType, + pinnedRecords: HomeSessionRecord[], +): HomeSessionGroup[] { const now = DateTime.local() const yesterday = now.minus({ days: 1 }) - const todaySessions = records.filter((record) => + const pinnedIDs = new Set(pinnedRecords.map((record) => record.session.id)) + const dated = records.filter((record) => !pinnedIDs.has(record.session.id)) + const todaySessions = dated.filter((record) => DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"), ) - const yesterdaySessions = records.filter((record) => + const yesterdaySessions = dated.filter((record) => DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"), ) - const olderSessions = records.filter((record) => { + const olderSessions = dated.filter((record) => { const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created) return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day") }) @@ -1367,6 +1379,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType - {props.children} + {/* Above the shell, not inside it: the sidebar, the View all list and the + session header all read pinned state, and this is the nearest point + that covers all three. */} + + {props.children} + ) } diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index f7fff56745..e5e559b267 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -59,6 +59,8 @@ import { DialogDeleteSession, SessionActionsMenu } from "@/components/session-ac import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" import { useSessionKey } from "@/pages/session/session-layout" +import { usePinnedSessions } from "@/context/pinned-sessions" +import { useServer } from "@/context/server" import { useServerSDK } from "@/context/server-sdk" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" @@ -267,6 +269,12 @@ export function MessageTimeline(props: { const dialog = useDialog() const language = useLanguage() const { params, sessionKey } = useSessionKey() + const pinnedSessions = usePinnedSessions() + const server = useServer() + // Pins are stored per server, so the header has to name the same one the + // sidebar does: the route's server on a /server/:serverKey route, otherwise + // the current connection, which is what the sidebar's selection falls back to. + const pinnedServer = createMemo(() => (params.serverKey ? requireServerKey(params.serverKey) : server.key)) const ownerSessionKey = sessionKey() const cached = timelineCache.get(ownerSessionKey) const initialMeasurements = cached?.measurements @@ -1451,6 +1459,8 @@ export function MessageTimeline(props: { onOpenChange={(open) => setTitle("menuOpen", open)} pressed={share.open} shareEnabled={shareEnabled()} + pinned={pinnedSessions?.isPinned(pinnedServer(), id)} + onPin={pinnedSessions ? () => pinnedSessions.toggle(pinnedServer(), id) : undefined} onRename={openTitleEditor} onShare={() => requestAnimationFrame(() => setShare({ open: true, dismiss: null }))} onArchive={() => void archiveSession(id)}