diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c84c7272d67..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 ?? [] @@ -970,6 +987,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,8 +1922,10 @@ export default function Layout(props: ParentProps) { clearHoverProjectSoon, prefetchSession, archiveSession, + deleteSession, workspaceName, renameWorkspace, + renameSession, editorOpen, openEditor, closeEditor, @@ -1926,6 +1970,9 @@ export default function Layout(props: ParentProps) { clearHoverProjectSoon, prefetchSession, archiveSession, + deleteSession, + renameSession, + InlineEditor, }, setHoverSession, } diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index be4ce9f5742..fec46a77db9 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,14 +9,19 @@ 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 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 @@ -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..5e566d44786 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,9 @@ export type SessionItemProps = { clearHoverProjectSoon: () => void 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: { @@ -94,6 +98,7 @@ const SessionRow = (props: { hasPermissions: Accessor hasError: Accessor unseenCount: Accessor + isPinned: Accessor setHoverSession: (id: string | undefined) => void clearHoverProjectSoon: () => void sidebarOpened: Accessor @@ -101,13 +106,15 @@ 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.setHoverSession(undefined) @@ -116,28 +123,38 @@ const SessionRow = (props: { }} >
-
- }> - - - - -
- - -
- - 0}> -
- - -
- - {props.session.title} - + 0 || props.isPinned()}> +
+ }> + + + + +
+ + +
+ + 0}> +
+ + + + + +
+ + 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" + openOnDblClick + />
) @@ -229,6 +246,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 +303,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} @@ -292,22 +311,19 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { warmPress={() => warm(2, "high")} warmFocus={() => warm(2, "high")} cancelHoverPrefetch={cancelHoverPrefetch} + renameSession={props.renameSession} + InlineEditor={props.InlineEditor} /> ) return (
- {item} - - } + fallback={item} > {
{ "group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true, }} > - + { + event.preventDefault() + event.stopPropagation() + toggleSessionPinned(props.session.id) + }} + /> { void props.archiveSession(props.session) }} /> - + { + 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..6fda9dfbdf8 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -41,6 +41,8 @@ export type WorkspaceSidebarContext = { clearHoverProjectSoon: () => void 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 @@ -236,6 +238,8 @@ const WorkspaceActions = (props: {
) +import { getPinnedSessions, isSessionPinned } from "@/utils/pinned-sessions" + const WorkspaceSessionList = (props: { slug: Accessor mobile?: boolean @@ -248,58 +252,100 @@ 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": ``,