Skip to content
Closed
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
23 changes: 23 additions & 0 deletions packages/app/e2e/regression/command-palette.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,29 @@ test("lists recent sessions before a query is entered", async ({ page }) => {
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
})

test("pins the open session from the palette", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page)
await input.pressSequentially("pin session")
const pin = dialog.getByRole("option", { name: /^Pin session/ })
await expect(pin).toHaveAttribute("aria-selected", "true")
await input.press("Enter")
await expect(dialog).toHaveCount(0)

await page.keyboard.press("ControlOrMeta+Shift+P")
const reopened = page.getByRole("dialog").getByRole("textbox")
await reopened.pressSequentially("pin session")
await expect(page.getByRole("option", { name: /^Unpin session/ })).toHaveAttribute("aria-selected", "true")
await page.keyboard.press("Escape")

await page.getByRole("button", { name: "Home", exact: true }).click()
const list = page.getByRole("region", { name: "Recent sessions" })
await expect(list.getByText("Pinned", { exact: true })).toBeVisible()
await expect(list.locator('[data-component="home-session-row-container"]').first()).toHaveAttribute(
"data-session-id",
paletteSession.id,
)
})

test("reopens from its shortcut as soon as it closes", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page)
await input.press("Escape")
Expand Down
34 changes: 31 additions & 3 deletions packages/app/e2e/regression/home.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@ test("the session context menu renames, exports, and deletes a Home session", as

await target.focus()
await target.press("Shift+F10")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Pin" })).toBeVisible()
await page.keyboard.press("Escape")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeHidden()
await expect(page.getByRole("menuitem", { name: "Pin" })).toBeHidden()
await expect(target).toBeFocused()

const box = await target.boundingBox()
await target.click({ button: "right", position: { x: 48, y: 12 } })
await expect(page).toHaveURL("/")
await expect(page.getByRole("menuitem")).toHaveText(["Rename", "Export…", "Delete…"])
await expect(page.getByRole("menuitem")).toHaveText(["Pin", "Rename", "Export…", "Delete…"])
const menu = await page.locator('[data-component="menu-v2-content"]').boundingBox()
expect(Math.abs((menu?.x ?? 0) - (box?.x ?? 0) - 48)).toBeLessThan(4)

Expand Down Expand Up @@ -77,6 +77,34 @@ test("the session context menu renames, exports, and deletes a Home session", as
await expect(renamedRow).toBeHidden()
})

test("pinning a Home session keeps it above the other sessions", async ({ page }) => {
await openHome(page)
const list = page.getByRole("region", { name: "Recent sessions" })
const target = row(page, fixture.expected.targetTitle)
await expect(target).toBeVisible()
await expect(list.getByText("Pinned", { exact: true })).toHaveCount(0)

await target.click({ button: "right" })
await page.getByRole("menuitem", { name: "Pin", exact: true }).click()

await expect(list.getByText("Pinned", { exact: true })).toBeVisible()
await expect(list.locator('[data-component="home-session-row-container"]').first()).toHaveAttribute(
"data-session-id",
fixture.targetID,
)

await page.reload()
await expect(list.getByText("Pinned", { exact: true })).toBeVisible()
await expect(list.locator('[data-component="home-session-row-container"]').first()).toHaveAttribute(
"data-session-id",
fixture.targetID,
)

await target.click({ button: "right" })
await page.getByRole("menuitem", { name: "Unpin", exact: true }).click()
await expect(list.getByText("Pinned", { exact: true })).toHaveCount(0)
})

test("the Home shortcut focuses session search, and the Home button leaves focus alone", async ({ page }) => {
await mockStressTimeline(page)
await seed(page, {
Expand Down
23 changes: 13 additions & 10 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { type Component, createRenderEffect, ErrorBoundary, type JSX, type Paren
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/shell/commands/command"
import { DesktopCommands } from "@/shell/commands/desktop"
import { SessionPinsProvider } from "@/home/sessions/pins"
import { GlobalProvider } from "@/runtime/server/runtime"
import { LanguageProvider, UiI18nBridge, type Locale } from "@/runtime/i18n/language"
import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
Expand Down Expand Up @@ -94,16 +95,18 @@ export function AppInterface(props: {
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const Root = (rootProps: ParentProps) => (
<TabsProvider>
<GlobalProvider>
<BodyTypography />
<CommandProvider>
<DesktopCommands />
{props.children}
{rootProps.children}
</CommandProvider>
</GlobalProvider>
</TabsProvider>
<SessionPinsProvider>
<TabsProvider>
<GlobalProvider>
<BodyTypography />
<CommandProvider>
<DesktopCommands />
{props.children}
{rootProps.children}
</CommandProvider>
</GlobalProvider>
</TabsProvider>
</SessionPinsProvider>
)

return (
Expand Down
101 changes: 89 additions & 12 deletions packages/app/src/home/sessions/controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Button } from "@opencode/ui/button"
import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode/ui/dialog"
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import { useCommand } from "@/shell/commands/command"
import {
Expand All @@ -27,14 +28,15 @@ import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
import type { HomeController } from "../model"
import { buildHomeSessionRecords, homeProjectForSession, homeSessionLocation, type HomeSessionRecord } from "./records"
import { useSessionPins } from "./pins"

export type { HomeSessionRecord } from "./records"

// Keep the immutable result opaque so Solid Query does not recursively unwrap every session on mount.
const selectSessions = (sessions: SessionInfo[]) => () => sessions

export type HomeSessionGroup = {
id: "today" | "yesterday" | "older"
id: "pinned" | "today" | "yesterday" | "older"
title: string
sessions: HomeSessionRecord[]
}
Expand All @@ -48,6 +50,7 @@ export function createHomeSessionsController(home: HomeController) {
const language = useLanguage()
const platform = usePlatform()
const queryClient = useQueryClient()
const pins = useSessionPins()

const projectDirectories = createMemo(() => {
const selected = home.selection.value().directory
Expand Down Expand Up @@ -91,17 +94,70 @@ export function createHomeSessionsController(home: HomeController) {
)
})

const allRecords = createMemo(() =>
buildHomeSessionRecords({
sessions: indexedSessions,
const serverKey = createMemo(() => {
const conn = home.server.focused()

return conn ? ServerConnection.key(conn) : undefined
})

const pinnedIDs = createMemo(() => {
const server = serverKey()

return server ? pins.ids(server) : []
})

// Pinned sessions can outlive the fetched index, so resolve the ones it does not hold.
const resolvedPins = new Set<string>()
const [resolved, setResolved] = createStore<{ sessions: Record<string, SessionInfo | undefined> }>({ sessions: {} })

createEffect(() => {
const ctx = home.server.focusedContext()
const server = serverKey()

if (!ctx || !server) return
const indexed = new Set(indexedSessions().map((session) => session.id))

pinnedIDs()
.filter((sessionID) => !indexed.has(sessionID))
.forEach((sessionID) => {
const key = `${server}\0${sessionID}`

if (resolvedPins.has(key)) return
resolvedPins.add(key)
void untrack(() => ctx.sdk.api.session.get({ sessionID })).then(
(session) => setResolved("sessions", key, session),
() => undefined,
)
})
})

const pinnedSessions = createMemo(() => {
const server = serverKey()

if (!server) return []
const indexed = new Map(indexedSessions().map((session) => [session.id, session] as const))

return pinnedIDs().flatMap((sessionID) => {
const session = indexed.get(sessionID) ?? resolved.sessions[`${server}\0${sessionID}`]

return session && !session.parentID && session.time.archived === undefined ? [session] : []
})
})

const allRecords = createMemo(() => {
const pinned = new Map(pinnedIDs().map((id, index) => [id, index] as const))
const rank = (sessionID: string) => pinned.get(sessionID) ?? Number.MAX_SAFE_INTEGER

return buildHomeSessionRecords({
sessions: () => [...pinnedSessions(), ...indexedSessions()],
projectDirectories,
projects: home.project.list,
resolveProject: (session) => home.server.focusedContext()?.projects.forSession(session),
}),
)
}).toSorted((a, b) => rank(a.session.id) - rank(b.session.id))
})

const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
const groups = createMemo(() => groupSessions(records(), language))
const groups = createMemo(() => groupSessions(records(), language, new Set(pinnedIDs())))
const prefetched = new Set<string>()

const location = (record: HomeSessionRecord) => {
Expand Down Expand Up @@ -249,10 +305,11 @@ export function createHomeSessionsController(home: HomeController) {
.remove(session.id)
.then(() => {
notifySessionTabsRemoved({
server: ServerConnection.key(conn),
server,
directory: session.location.directory,
sessionIDs: ids,
})
pins.forget(server, ids)

return true
})
Expand Down Expand Up @@ -388,6 +445,19 @@ export function createHomeSessionsController(home: HomeController) {
return !!server && sessionHasOpenTab(tabs.store, server, record.session)
},
},
pin: {
is: (record: HomeSessionRecord) => {
const server = serverKey()

return !!server && pins.pinned(server, record.session.id)
},
toggle: (record: HomeSessionRecord) => {
const server = serverKey()

if (!server) return
pins.toggle(server, record.session.id)
},
},
}
}

Expand All @@ -404,21 +474,28 @@ function localDay(date: Date) {
return date.getFullYear() * 10_000 + date.getMonth() * 100 + date.getDate()
}

function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
function groupSessions(
records: HomeSessionRecord[],
language: ReturnType<typeof useLanguage>,
pinned: Set<string>,
): HomeSessionGroup[] {
const now = new Date()
const today = localDay(now)
const yesterday = localDay(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
const day = (record: HomeSessionRecord) => localDay(new Date(record.session.time.updated ?? record.session.time.created))
const todaySessions = records.filter((record) => day(record) === today)
const yesterdaySessions = records.filter((record) => day(record) === yesterday)
const olderSessions = records.filter((record) => day(record) !== today && day(record) !== yesterday)
const pinnedRecords = records.filter((record) => pinned.has(record.session.id))
const recent = records.filter((record) => !pinned.has(record.session.id))
const todaySessions = recent.filter((record) => day(record) === today)
const yesterdaySessions = recent.filter((record) => day(record) === yesterday)
const olderSessions = recent.filter((record) => day(record) !== today && day(record) !== yesterday)

const olderTitle =
todaySessions.length === 0 && yesterdaySessions.length === 0
? language.t("sidebar.project.recentSessions")
: language.t("home.sessions.group.older")

return [
{ id: "pinned" as const, title: language.t("home.sessions.group.pinned"), sessions: pinnedRecords },
{ id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions },
{ id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions },
{ id: "older" as const, title: olderTitle, sessions: olderSessions },
Expand Down
22 changes: 22 additions & 0 deletions packages/app/src/home/sessions/pins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be dropped. The new home.spec.ts case already checks pin order and that unpinning removes the group, and packages/app/AGENTS.md asks not to unit-test what an e2e keeper already proves. Without this file, withPin, withoutPins and pinnedIDs would not need to be exported from pins.tsx either.

import { ServerConnection } from "@/runtime/server/registry"
import { pinnedIDs, withPin, withoutPins } from "./pins"

const server = ServerConnection.Key.make("sidecar")

describe("session pins", () => {
test("keeps the order pins were added in and ignores a repeat", () => {
const pins = withPin(withPin({}, server, "ses_a"), server, "ses_b")

expect(pinnedIDs(pins, server)).toEqual(["ses_a", "ses_b"])
expect(withPin(pins, server, "ses_a")).toBe(pins)
})

test("keeps the remaining order when unpinning and drops the server once empty", () => {
const pins = withPin(withPin({}, server, "ses_a"), server, "ses_b")

expect(pinnedIDs(withoutPins(pins, server, ["ses_a"]), server)).toEqual(["ses_b"])
expect(withoutPins(pins, server, ["ses_a", "ses_b"])).toEqual({})
expect(withoutPins(pins, server, ["ses_missing"])).toBe(pins)
})
})
67 changes: 67 additions & 0 deletions packages/app/src/home/sessions/pins.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { createSimpleContext } from "@opencode/ui/context"
import { Schema } from "effect"
import { reconcile } from "solid-js/store"
import { Persistence } from "@/runtime/persistence/schema"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { ServerConnection } from "@/runtime/server/registry"

// Session IDs per server, in the order they were pinned.
export const PinnedSessions = Persistence.record(Persistence.array(Schema.String))

export type PinnedSessions = typeof PinnedSessions.Type

export function pinnedIDs(pins: PinnedSessions, server: ServerConnection.Key) {
return pins[server] ?? []
}

export function isPinned(pins: PinnedSessions, server: ServerConnection.Key, sessionID: string) {
return pinnedIDs(pins, server).includes(sessionID)
}

export function withPin(pins: PinnedSessions, server: ServerConnection.Key, sessionID: string) {
const ids = pinnedIDs(pins, server)

if (ids.includes(sessionID)) return pins

return { ...pins, [server]: [...ids, sessionID] }
}

export function withoutPins(pins: PinnedSessions, server: ServerConnection.Key, sessionIDs: readonly string[]) {
const current = pinnedIDs(pins, server)
const ids = current.filter((id) => !sessionIDs.includes(id))

if (ids.length === current.length) return pins

if (ids.length > 0) return { ...pins, [server]: ids }

const next = { ...pins }

delete next[server]

return next
}

export const { use: useSessionPins, provider: SessionPinsProvider } = createSimpleContext({
name: "SessionPins",
gate: false,
init: () => {
const [store, setStore] = persisted(Persist.global("home.sessions.pinned"), PinnedSessions, {})

return {
ids: (server: ServerConnection.Key) => pinnedIDs(store, server),
pinned: (server: ServerConnection.Key, sessionID: string) => isPinned(store, server, sessionID),
toggle(server: ServerConnection.Key, sessionID: string) {
setStore(
reconcile(
isPinned(store, server, sessionID)
? withoutPins(store, server, [sessionID])
: withPin(store, server, sessionID),
),
)
},
forget(server: ServerConnection.Key, sessionIDs: readonly string[]) {
setStore(reconcile(withoutPins(store, server, sessionIDs)))
},
}
},
})
2 changes: 2 additions & 0 deletions packages/app/src/home/sessions/region.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ export function HomeSessions(props: {
searchNoResultsLabel={props.search.result.noResultsLabel()}
titleOpacity={props.scroll.header.titleOpacity}
isOpenTab={props.sessions.tab.isOpen}
isPinned={props.sessions.pin.is}
onCreateSession={props.sessions.session.create}
onOpenSession={props.sessions.session.open}
onTogglePin={props.sessions.pin.toggle}
onArchiveSession={props.sessions.session.archive}
onRenameSession={props.sessions.session.rename}
onExportSession={props.sessions.session.export}
Expand Down
Loading
Loading