From 90992e08ccfc7cf9b89f25791fd281d0a1cec444 Mon Sep 17 00:00:00 2001 From: Rohan Guliani Date: Mon, 16 Mar 2026 18:04:39 -0400 Subject: [PATCH 1/7] feat(app): add pinned sessions functionality to sidebar with instant hover actions --- packages/app/src/pages/layout.tsx | 27 ++++ packages/app/src/pages/layout/helpers.ts | 22 ++- .../app/src/pages/layout/sidebar-items.tsx | 83 ++++++++--- .../app/src/pages/layout/sidebar-project.tsx | 9 +- .../src/pages/layout/sidebar-workspace.tsx | 135 ++++++++++++------ packages/app/src/utils/pinned-sessions.ts | 32 +++++ packages/ui/src/components/icon.tsx | 2 + 7 files changed, 233 insertions(+), 77 deletions(-) create mode 100644 packages/app/src/utils/pinned-sessions.ts diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c84c7272d67..7324bac9004 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -970,6 +970,31 @@ export default function Layout(props: ParentProps) { } } + async function deleteSession(session: Session) { + const [store, setStore] = globalSync.child(session.directory) + const sessions = store.session ?? [] + const index = sessions.findIndex((s) => s.id === session.id) + const nextSession = sessions[index + 1] ?? sessions[index - 1] + + await globalSDK.client.session.delete({ + directory: session.directory, + sessionID: session.id, + }) + setStore( + produce((draft) => { + const match = Binary.search(draft.session, session.id, (s) => s.id) + if (match.found) draft.session.splice(match.index, 1) + }), + ) + if (session.id === params.id) { + if (nextSession) { + navigate(`/${params.dir}/session/${nextSession.id}`) + } else { + navigate(`/${params.dir}/session`) + } + } + } + command.register("layout", () => { const commands: CommandOption[] = [ { @@ -1880,6 +1905,7 @@ export default function Layout(props: ParentProps) { clearHoverProjectSoon, prefetchSession, archiveSession, + deleteSession, workspaceName, renameWorkspace, editorOpen, @@ -1926,6 +1952,7 @@ export default function Layout(props: ParentProps) { clearHoverProjectSoon, prefetchSession, archiveSession, + deleteSession, }, setHoverSession, } diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index be4ce9f5742..c73496a16a6 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,5 +1,6 @@ import { getFilename } from "@opencode-ai/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" +import { getPinnedSessions } from "../../utils/pinned-sessions" export const workspaceKey = (directory: string) => { const drive = directory.match(/^([A-Za-z]:)[\\/]+$/) @@ -8,9 +9,14 @@ export const workspaceKey = (directory: string) => { return directory.replace(/[\\/]+$/, "") } -function sortSessions(now: number) { +function sortSessions(now: number, pinned: string[]) { const oneMinuteAgo = now - 60 * 1000 return (a: Session, b: Session) => { + const aPinned = pinned.includes(a.id) + const bPinned = pinned.includes(b.id) + if (aPinned && !bPinned) return -1 + if (!aPinned && bPinned) return 1 + const aUpdated = a.time.updated ?? a.time.created const bUpdated = b.time.updated ?? b.time.created const aRecent = aUpdated > oneMinuteAgo @@ -25,13 +31,17 @@ function sortSessions(now: number) { const isRootVisibleSession = (session: Session, directory: string) => workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived -export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => - store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now)) +export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => { + const pinned = getPinnedSessions() + return store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now, pinned)) +} -export const latestRootSession = (stores: { session: Session[]; path: { directory: string } }[], now: number) => - stores +export const latestRootSession = (stores: { session: Session[]; path: { directory: string } }[], now: number) => { + const pinned = getPinnedSessions() + return stores .flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory))) - .sort(sortSessions(now))[0] + .sort(sortSessions(now, pinned))[0] +} export function hasProjectPermissions( request: Record, diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index f8e16f3e122..ee7236405ed 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -16,6 +16,7 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { messageAgentColor } from "@/utils/agent" +import { isSessionPinned, toggleSessionPinned } from "@/utils/pinned-sessions" import { sessionPermissionRequest } from "../session/composer/session-request-tree" import { hasProjectPermissions } from "./helpers" @@ -82,6 +83,7 @@ export type SessionItemProps = { clearHoverProjectSoon: () => void prefetchSession: (session: Session, priority?: "high" | "low") => void archiveSession: (session: Session) => Promise + deleteSession: (session: Session) => Promise } const SessionRow = (props: { @@ -94,6 +96,7 @@ const SessionRow = (props: { hasPermissions: Accessor hasError: Accessor unseenCount: Accessor + isPinned: Accessor setHoverSession: (id: string | undefined) => void clearHoverProjectSoon: () => void sidebarOpened: Accessor @@ -104,7 +107,7 @@ const SessionRow = (props: { }): JSX.Element => (
-
- }> - - - - -
- - -
- - 0}> -
- - -
+ 0 || props.isPinned()}> +
+ }> + + + + +
+ + +
+ + 0}> +
+ + + + + +
+ {props.session.title} @@ -229,6 +237,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded()) const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const isActive = createMemo(() => props.session.id === params.id) + const isPinned = createMemo(() => isSessionPinned(props.session.id)) const warm = (span: number, priority: "high" | "low") => { const nav = props.navList?.() @@ -285,6 +294,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { hasPermissions={hasPermissions} hasError={hasError} unseenCount={unseenCount} + isPinned={isPinned} setHoverSession={props.setHoverSession} clearHoverProjectSoon={props.clearHoverProjectSoon} sidebarOpened={layout.sidebar.opened} @@ -298,8 +308,9 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { return (
{
{ "group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true, }} > + + { + event.preventDefault() + event.stopPropagation() + toggleSessionPinned(props.session.id) + }} + /> + { }} /> + + { + event.preventDefault() + event.stopPropagation() + void props.deleteSession(props.session) + }} + /> +
) diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index a26bc183118..e2bb0284bf3 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -10,6 +10,7 @@ import { useLayout, type LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { useNotification } from "@/context/notification" +import { getPinnedSessions, isSessionPinned } from "@/utils/pinned-sessions" import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items" import { childMapByParent, displayName, sortedRootSessions } from "./helpers" @@ -188,6 +189,8 @@ const ProjectPreviewPanel = (props: { workspaces: Accessor label: (directory: string) => string projectSessions: Accessor> + projectPinnedSessions: Accessor> + projectUnpinnedSessions: Accessor> projectChildren: Accessor> workspaceSessions: (directory: string) => ReturnType workspaceChildren: (directory: string) => Map @@ -204,7 +207,7 @@ const ProjectPreviewPanel = (props: { + {(session) => ( globalSync.child(props.project.worktree, { bootstrap: false })[0]) const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) + const projectPinnedSessions = createMemo(() => projectSessions().filter(s => isSessionPinned(s.id))) + const projectUnpinnedSessions = createMemo(() => projectSessions().filter(s => !isSessionPinned(s.id))) const projectChildren = createMemo(() => childMapByParent(projectStore().session)) const workspaceSessions = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) @@ -381,6 +386,8 @@ export const SortableProject = (props: { workspaces={workspaces} label={label} projectSessions={projectSessions} + projectPinnedSessions={projectPinnedSessions} + projectUnpinnedSessions={projectUnpinnedSessions} projectChildren={projectChildren} workspaceSessions={workspaceSessions} workspaceChildren={workspaceChildren} diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 86ede774e63..c3158775e6a 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -41,6 +41,7 @@ export type WorkspaceSidebarContext = { clearHoverProjectSoon: () => void prefetchSession: (session: Session, priority?: "high" | "low") => void archiveSession: (session: Session) => Promise + deleteSession: (session: Session) => Promise workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void editorOpen: (id: string) => boolean @@ -236,6 +237,8 @@ const WorkspaceActions = (props: {
) +import { getPinnedSessions, isSessionPinned } from "@/utils/pinned-sessions" + const WorkspaceSessionList = (props: { slug: Accessor mobile?: boolean @@ -248,58 +251,96 @@ const WorkspaceSessionList = (props: { hasMore: Accessor loadMore: () => Promise language: ReturnType -}): JSX.Element => ( - + ) +} export const SortableWorkspace = (props: { ctx: WorkspaceSidebarContext diff --git a/packages/app/src/utils/pinned-sessions.ts b/packages/app/src/utils/pinned-sessions.ts new file mode 100644 index 00000000000..3fe92314cef --- /dev/null +++ b/packages/app/src/utils/pinned-sessions.ts @@ -0,0 +1,32 @@ +import { createSignal, createEffect } from "solid-js" + +const KEY = "opencode:pinned-sessions" + +function loadPinned(): string[] { + try { + const data = localStorage.getItem(KEY) + if (data) return JSON.parse(data) + } catch (err) {} + return [] +} + +const [pinnedSessions, setPinnedSessions] = createSignal(loadPinned()) + +createEffect(() => { + localStorage.setItem(KEY, JSON.stringify(pinnedSessions())) +}) + +export function isSessionPinned(sessionId: string) { + return pinnedSessions().includes(sessionId) +} + +export function toggleSessionPinned(sessionId: string) { + setPinnedSessions((prev) => { + if (prev.includes(sessionId)) return prev.filter((id) => id !== sessionId) + return [...prev, sessionId] + }) +} + +export function getPinnedSessions() { + return pinnedSessions() +} diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index e2eaf107a67..3481cb089d6 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -1,6 +1,8 @@ import { splitProps, type ComponentProps } from "solid-js" const icons = { + "pin-filled": ``, + pin: ``, "align-right": ``, "arrow-up": ``, "arrow-left": ``, From 934b3d6d2ecd268f90b5e1fd7c54d61703a55b03 Mon Sep 17 00:00:00 2001 From: Rohan Guliani Date: Mon, 16 Mar 2026 19:03:59 -0400 Subject: [PATCH 2/7] fix(app): change pointer events to mouse events to fix hovercard interception lag --- packages/app/src/pages/layout/sidebar-items.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index ee7236405ed..e4a352bca34 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -109,8 +109,8 @@ const SessionRow = (props: { href={`/${props.slug}/session/${props.session.id}`} class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.mobile ? "pr-[76px]" : ""} group-hover/session:pr-[76px] group-focus-within/session:pr-[76px] group-active/session:pr-[76px] ${props.dense ? "py-0.5" : "py-1"}`} onPointerDown={props.warmPress} - onPointerEnter={props.warmHover} - onPointerLeave={props.cancelHoverPrefetch} + onMouseEnter={props.warmHover} + onMouseLeave={props.cancelHoverPrefetch} onFocus={props.warmFocus} onClick={() => { props.setHoverSession(undefined) From 8c0cb1c9f0298eeae194304340029d53a36a7083 Mon Sep 17 00:00:00 2001 From: Rohan Guliani Date: Mon, 16 Mar 2026 19:20:50 -0400 Subject: [PATCH 3/7] feat(app): restore inline renaming for session titles on double click --- packages/app/src/pages/layout.tsx | 20 +++++++++++++++++++ .../app/src/pages/layout/sidebar-items.tsx | 18 ++++++++++++++--- .../src/pages/layout/sidebar-workspace.tsx | 5 +++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 7324bac9004..7403cd8ffa4 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -944,6 +944,23 @@ export default function Layout(props: ParentProps) { } } + async function renameSession(session: Session, next: string) { + if (next === session.title) return + const [store, setStore] = globalSync.child(session.directory) + + await globalSDK.client.session.update({ + directory: session.directory, + sessionID: session.id, + title: next, + }) + setStore( + produce((draft) => { + const match = Binary.search(draft.session, session.id, (s) => s.id) + if (match.found) draft.session[match.index].title = next + }), + ) + } + async function archiveSession(session: Session) { const [store, setStore] = globalSync.child(session.directory) const sessions = store.session ?? [] @@ -1908,6 +1925,7 @@ export default function Layout(props: ParentProps) { deleteSession, workspaceName, renameWorkspace, + renameSession, editorOpen, openEditor, closeEditor, @@ -1953,6 +1971,8 @@ export default function Layout(props: ParentProps) { prefetchSession, archiveSession, deleteSession, + renameSession, + InlineEditor, }, setHoverSession, } diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index e4a352bca34..a9786023066 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -84,6 +84,8 @@ export type SessionItemProps = { prefetchSession: (session: Session, priority?: "high" | "low") => void archiveSession: (session: Session) => Promise deleteSession: (session: Session) => Promise + renameSession: (session: Session, next: string) => Promise + InlineEditor: typeof import("../layout/inline-editor").createInlineEditorController extends (...args: any[]) => { InlineEditor: infer T } ? T : never } const SessionRow = (props: { @@ -104,6 +106,8 @@ const SessionRow = (props: { warmPress: () => void warmFocus: () => void cancelHoverPrefetch: () => void + renameSession: (session: Session, next: string) => Promise + InlineEditor: typeof import("../layout/inline-editor").createInlineEditorController extends (...args: any[]) => { InlineEditor: infer T } ? T : never }): JSX.Element => (
- - {props.session.title} - + props.session.title} + onSave={(next) => props.renameSession(props.session, next)} + class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" + displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" + stopPropagation + openOnDblClick + />
) @@ -302,6 +312,8 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { warmPress={() => warm(2, "high")} warmFocus={() => warm(2, "high")} cancelHoverPrefetch={cancelHoverPrefetch} + renameSession={props.renameSession} + InlineEditor={props.InlineEditor} /> ) diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index c3158775e6a..6fda9dfbdf8 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -42,6 +42,7 @@ export type WorkspaceSidebarContext = { prefetchSession: (session: Session, priority?: "high" | "low") => void archiveSession: (session: Session) => Promise deleteSession: (session: Session) => Promise + renameSession: (session: Session, next: string) => Promise workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void editorOpen: (id: string) => boolean @@ -293,6 +294,8 @@ const WorkspaceSessionList = (props: { prefetchSession={props.ctx.prefetchSession} archiveSession={props.ctx.archiveSession} deleteSession={props.ctx.deleteSession} + renameSession={props.ctx.renameSession} + InlineEditor={props.ctx.InlineEditor} /> )} @@ -320,6 +323,8 @@ const WorkspaceSessionList = (props: { prefetchSession={props.ctx.prefetchSession} archiveSession={props.ctx.archiveSession} deleteSession={props.ctx.deleteSession} + renameSession={props.ctx.renameSession} + InlineEditor={props.ctx.InlineEditor} /> )} From ca643310db674f5e7d0fcc7b79307bd7f211d2d7 Mon Sep 17 00:00:00 2001 From: Rohan Guliani Date: Wed, 18 Mar 2026 12:42:21 -0400 Subject: [PATCH 4/7] style(app): remove outline and shadow border from pinned session items --- packages/app/src/pages/layout/sidebar-items.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index a9786023066..4d389817e95 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -320,9 +320,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { return (
Date: Wed, 18 Mar 2026 12:47:30 -0400 Subject: [PATCH 5/7] style(app): remove all tooltips from sidebar session item hover states --- packages/app/src/pages/layout/sidebar-items.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 4d389817e95..075274f2b89 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -324,11 +324,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { > - {item} - - } + fallback={item} > { "group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true, }} > - { toggleSessionPinned(props.session.id) }} /> - - { void props.archiveSession(props.session) }} /> - - { void props.deleteSession(props.session) }} /> -
) From b5192fc9a58a1b13824c2e03cbdd147727c11347 Mon Sep 17 00:00:00 2001 From: Rohan Guliani Date: Wed, 18 Mar 2026 13:20:10 -0400 Subject: [PATCH 6/7] fix(app): allow click propagation through inline editor so session links navigate properly --- packages/app/src/pages/layout/sidebar-items.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 075274f2b89..5e566d44786 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -153,7 +153,6 @@ const SessionRow = (props: { onSave={(next) => props.renameSession(props.session, next)} class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" - stopPropagation openOnDblClick />
From df55cd2e27c4070f1b6f17c6e437b5530e3fbb1b Mon Sep 17 00:00:00 2001 From: Rohan Guliani Date: Wed, 18 Mar 2026 14:30:24 -0400 Subject: [PATCH 7/7] Revert "fix(app): restore oldest-first sorting for highly active sessions to pass E2E tests" This reverts commit 25c843faa18c7ba6af29b6828554e30b2f9809ea. --- packages/app/src/pages/layout/helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index c73496a16a6..fec46a77db9 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -21,7 +21,7 @@ function sortSessions(now: number, pinned: string[]) { const bUpdated = b.time.updated ?? b.time.created const aRecent = aUpdated > oneMinuteAgo const bRecent = bUpdated > oneMinuteAgo - if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + if (aRecent && bRecent) return a.id > b.id ? -1 : a.id < b.id ? 1 : 0 if (aRecent && !bRecent) return -1 if (!aRecent && bRecent) return 1 return bUpdated - aUpdated