Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ff83952
feat: add session bookmarking to group important sessions at the top
ariane-emory Jan 12, 2026
9a407e5
chore: remove unused session_pin config keybind (hardcoded in dialog)
ariane-emory Jan 12, 2026
af03998
chore: regenerate SDK types after removing unused config key
ariane-emory Jan 12, 2026
2f31c16
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 13, 2026
26da5fa
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 13, 2026
082faa7
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 13, 2026
bdd45c6
fix: improve session bookmark selection behavior
ariane-emory Jan 13, 2026
5887682
fix: remember last session when navigating to home with /new
ariane-emory Jan 13, 2026
cc43228
fix: remove duplicate useKV() hook call in session.new command
ariane-emory Jan 14, 2026
79c6530
fix: keep selection visible when bookmarking/unbookmarking sessions
ariane-emory Jan 14, 2026
0a65d99
fix: scroll to selected session on dialog open and after bookmark toggle
ariane-emory Jan 14, 2026
e8610f2
fix: scroll viewport to selected item when dialog opens
ariane-emory Jan 14, 2026
059ca29
fix: defer initial scroll to next tick to ensure component is rendered
ariane-emory Jan 14, 2026
ca4373c
feat: center selected item in viewport when dialog opens or after boo…
ariane-emory Jan 14, 2026
1fbe08a
refactor: remove viewport centering fix from bookmark branch
ariane-emory Jan 15, 2026
22e1d1d
fix: make last_session_id ephemeral and per-process
ariane-emory Jan 15, 2026
f4875af
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 15, 2026
836ed90
Merge dev into feat/session-bookmarks
ariane-emory Jan 16, 2026
402ffb5
Merge dev into feat/session-bookmarks
ariane-emory Jan 17, 2026
4ce6789
Fix: add server-side support for session pinned/bookmark field
ariane-emory Jan 17, 2026
86d462b
Fix: add server-side support for session pinned/bookmark field
ariane-emory Jan 17, 2026
251ee95
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 17, 2026
b9b3bbe
tui: show date with time for bookmarked sessions
ariane-emory Jan 18, 2026
958f88a
tui: fix extra empty line for long session titles in dialog
ariane-emory Jan 18, 2026
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
7 changes: 7 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,13 @@ function App() {
const current = promptRef.current
// Don't require focus - if there's any text, preserve it
const currentPrompt = current?.current?.input ? current.current : undefined

const currentSessionID = route.data.type === "session" ? route.data.sessionID : undefined

// Store the last session ID so we can return to it easily (ephemeral, per-process)
if (currentSessionID) {
kv.setEphemeral("last_session_id", currentSessionID)
}
route.navigate({
type: "home",
initialPrompt: currentPrompt,
Expand Down
108 changes: 81 additions & 27 deletions packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
import { Locale } from "@/util/locale"
import { useKeybind } from "../context/keybind"
import { Keybind } from "@/util/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { DialogSessionRename } from "./dialog-session-rename"
Expand All @@ -23,46 +24,84 @@ export function DialogSessionList() {

const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const [selectRef, setSelectRef] = createSignal<DialogSelectRef<string>>()

const [searchResults] = createResource(search, async (query) => {
if (!query) return undefined
const result = await sdk.client.session.list({ search: query, limit: 30 })
return result.data ?? []
})

const deleteKeybind = "ctrl+d"
const pinKeybind = "ctrl+b"
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))

const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]

const sessions = createMemo(() => searchResults() ?? sync.data.session)
const sessions = createMemo(() => {
const results = searchResults()
if (!results) return sync.data.session
return results.map((result) => {
const live = sync.data.session.find((s) => s.id === result.id)
return live ?? result
})
})

const defaultSessionID = createMemo(() => {
const lastSessionID = kv.getEphemeral("last_session_id")

// First try last session we were in (ephemeral, per-process)
if (lastSessionID) {
const session = sessions().find((s) => s.id === lastSessionID)
if (session) return session.id
}

// Fallback to most recently updated session
const allSessions = sessions().filter((x) => x.parentID === undefined)
const sorted = allSessions.toSorted((a, b) => b.time.updated - a.time.updated)
return sorted[0]?.id
})


const options = createMemo(() => {
const today = new Date().toDateString()
return sessions()
.filter((x) => x.parentID === undefined)
const allSessions = sessions().filter((x) => x.parentID === undefined)

const pinned = allSessions
.filter((x) => x.time.pinned !== undefined)
.toSorted((a, b) => (b.time.pinned ?? 0) - (a.time.pinned ?? 0))

const unpinned = allSessions
.filter((x) => x.time.pinned === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => {
const date = new Date(x.time.updated)
let category = date.toDateString()
if (category === today) {
category = "Today"
}
const isDeleting = toDelete() === x.id
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy"
return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
footer: Locale.time(x.time.updated),
gutter: isWorking ? (
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
<spinner frames={spinnerFrames} interval={80} color={theme.primary} />
</Show>
) : undefined,
}
})

const mapSession = (session: typeof allSessions[number], category: string, showDate: boolean) => {
const isDeleting = toDelete() === session.id
const status = sync.data.session_status?.[session.id]
const isWorking = status?.type === "busy"
return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : session.title,
bg: isDeleting ? theme.error : undefined,
value: session.id,
category,
footer: showDate ? Locale.shortDateTime(session.time.updated) : Locale.time(session.time.updated),
gutter: isWorking ? (
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
<spinner frames={spinnerFrames} interval={80} color={theme.primary} />
</Show>
) : undefined,
}
}

const pinnedOptions = pinned.map((x) => mapSession(x, "Bookmarks", true))

const unpinnedOptions = unpinned.map((x) => {
const date = new Date(x.time.updated)
const category = date.toDateString() === today ? "Today" : date.toDateString()
return mapSession(x, category, false)
})

return [...pinnedOptions, ...unpinnedOptions]
})

onMount(() => {
Expand All @@ -71,10 +110,11 @@ export function DialogSessionList() {

return (
<DialogSelect
ref={setSelectRef}
title="Sessions"
options={options()}
skipFilter={true}
current={currentSessionID()}
current={currentSessionID() ?? defaultSessionID()}
onFilter={setSearch}
onMove={() => {
setToDelete(undefined)
Expand Down Expand Up @@ -108,6 +148,20 @@ export function DialogSessionList() {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
{
keybind: Keybind.parse(pinKeybind)[0],
title: "bookmark",
onTrigger: async (option) => {
const session = sessions().find((s) => s.id === option.value)
if (!session) return
const isPinned = session.time.pinned !== undefined
await sdk.client.session.update({
sessionID: option.value,
time: { pinned: isPinned ? null : Date.now() },
})
selectRef()?.scrollToValue(option.value)
},
},
]}
/>
)
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/kv.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
init: () => {
const [ready, setReady] = createSignal(false)
const [store, setStore] = createStore<Record<string, any>>()
const ephemeral: Record<string, any> = {}
const file = Bun.file(path.join(Global.Path.state, "kv.json"))

file
Expand Down Expand Up @@ -46,6 +47,12 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
setStore(key, value)
Bun.write(file, JSON.stringify(store, null, 2))
},
getEphemeral(key: string, defaultValue?: any) {
return ephemeral[key] ?? defaultValue
},
setEphemeral(key: string, value: any) {
ephemeral[key] = value
},
}
return result
},
Expand Down
22 changes: 8 additions & 14 deletions packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface DialogSelectOption<T = any> {
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
scrollToValue: (value: T) => void
}

export function DialogSelect<T>(props: DialogSelectProps<T>) {
Expand All @@ -54,20 +55,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
filter: "",
})

createEffect(
on(
() => props.current,
(current) => {
if (current) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex >= 0) {
setStore("selected", currentIndex)
}
}
},
),
)

let input: InputRenderable

const filtered = createMemo(() => {
Expand Down Expand Up @@ -193,6 +180,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
get filtered() {
return filtered()
},
scrollToValue(value: T) {
const index = flat().findIndex((opt) => isDeepEqual(opt.value, value))
if (index >= 0) {
moveTo(index)
}
},
}
props.ref?.(ref)

Expand Down Expand Up @@ -337,6 +330,7 @@ function Option(props: {
fg={props.active ? fg : props.current ? theme.primary : theme.text}
attributes={props.active ? TextAttributes.BOLD : undefined}
overflow="hidden"
wrapMode="none"
paddingLeft={3}
>
{Locale.truncate(props.title, 61)}
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/server/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ export const SessionRoutes = lazy(() =>
time: z
.object({
archived: z.number().optional(),
pinned: z.number().nullable().optional(),
})
.optional(),
}),
Expand All @@ -281,6 +282,7 @@ export const SessionRoutes = lazy(() =>
session.title = updates.title
}
if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived
if (updates.time?.pinned !== undefined) session.time.pinned = updates.time.pinned ?? undefined
})

return c.json(updatedSession)
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export namespace Session {
updated: z.number(),
compacting: z.number().optional(),
archived: z.number().optional(),
pinned: z.number().optional(),
}),
permission: PermissionNext.Ruleset.optional(),
revert: z
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/util/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ export namespace Locale {
}
}

export function shortDateTime(input: number): string {
const date = new Date(input)
const dateStr = date.toLocaleDateString(undefined, { month: "short", day: "numeric" })
return `${dateStr}, ${time(input)}`
}

export function number(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M"
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,7 @@ export class Session extends HeyApiClient {
title?: string
time?: {
archived?: number
pinned?: number | null
}
},
options?: Options<never, ThrowOnError>,
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,7 @@ export type Session = {
updated: number
compacting?: number
archived?: number
pinned?: number
}
permission?: PermissionRuleset
revert?: {
Expand Down Expand Up @@ -2778,6 +2779,7 @@ export type SessionUpdateData = {
title?: string
time?: {
archived?: number
pinned?: number | null
}
}
path: {
Expand Down