Skip to content
47 changes: 47 additions & 0 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? []
Expand All @@ -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[] = [
{
Expand Down Expand Up @@ -1880,8 +1922,10 @@ export default function Layout(props: ParentProps) {
clearHoverProjectSoon,
prefetchSession,
archiveSession,
deleteSession,
workspaceName,
renameWorkspace,
renameSession,
editorOpen,
openEditor,
closeEditor,
Expand Down Expand Up @@ -1926,6 +1970,9 @@ export default function Layout(props: ParentProps) {
clearHoverProjectSoon,
prefetchSession,
archiveSession,
deleteSession,
renameSession,
InlineEditor,
},
setHoverSession,
}
Expand Down
24 changes: 17 additions & 7 deletions packages/app/src/pages/layout/helpers.ts
Original file line number Diff line number Diff line change
@@ -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]:)[\\/]+$/)
Expand All @@ -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
Expand All @@ -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<T>(
request: Record<string, T[] | undefined>,
Expand Down
106 changes: 71 additions & 35 deletions packages/app/src/pages/layout/sidebar-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -82,6 +83,9 @@ export type SessionItemProps = {
clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void>
deleteSession: (session: Session) => Promise<void>
renameSession: (session: Session, next: string) => Promise<void>
InlineEditor: typeof import("../layout/inline-editor").createInlineEditorController extends (...args: any[]) => { InlineEditor: infer T } ? T : never
}

const SessionRow = (props: {
Expand All @@ -94,20 +98,23 @@ const SessionRow = (props: {
hasPermissions: Accessor<boolean>
hasError: Accessor<boolean>
unseenCount: Accessor<number>
isPinned: Accessor<boolean>
setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void
sidebarOpened: Accessor<boolean>
warmHover: () => void
warmPress: () => void
warmFocus: () => void
cancelHoverPrefetch: () => void
renameSession: (session: Session, next: string) => Promise<void>
InlineEditor: typeof import("../layout/inline-editor").createInlineEditorController extends (...args: any[]) => { InlineEditor: infer T } ? T : never
}): JSX.Element => (
<A
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 transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
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)
Expand All @@ -116,28 +123,38 @@ const SessionRow = (props: {
}}
>
<div class="flex items-center gap-1 w-full">
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
</span>
<Show when={props.isWorking() || props.hasPermissions() || props.hasError() || props.unseenCount() > 0 || props.isPinned()}>
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<span />}>
<Match when={props.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
<Match when={props.isPinned()}>
<Icon name="pin-filled" size="small" class="text-icon-weak opacity-80" />
</Match>
</Switch>
</div>
</Show>
<props.InlineEditor
id={`session:${props.session.id}`}
value={() => 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
/>
</div>
</A>
)
Expand Down Expand Up @@ -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?.()
Expand Down Expand Up @@ -285,29 +303,27 @@ 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}
warmHover={scheduleHoverPrefetch}
warmPress={() => warm(2, "high")}
warmFocus={() => warm(2, "high")}
cancelHoverPrefetch={cancelHoverPrefetch}
renameSession={props.renameSession}
InlineEditor={props.InlineEditor}
/>
)

return (
<div
data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default pl-2 pr-3 transition-colors
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
class="group/session relative w-full rounded-md cursor-default pl-2 pr-3 transition-colors hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
>
<Show
when={hoverEnabled()}
fallback={
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
{item}
</Tooltip>
}
fallback={item}
>
<SessionHoverPreview
mobile={props.mobile}
Expand All @@ -333,15 +349,25 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
</Show>

<div
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5`}
classList={{
"opacity-100 pointer-events-auto": !!props.mobile,
"opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}}
>
<Tooltip value={language.t("common.archive")} placement="top">
<IconButton
icon={isPinned() ? "pin-filled" : "pin"}
variant="ghost"
class={`size-6 rounded-md ${isPinned() ? "text-icon-brand-base hover:text-icon-brand-strong" : ""}`}
aria-label={isPinned() ? language.t("common.unpin") || "Unpin" : language.t("common.pin") || "Pin"}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleSessionPinned(props.session.id)
}}
/>
<IconButton
icon="archive"
variant="ghost"
Expand All @@ -353,7 +379,17 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
void props.archiveSession(props.session)
}}
/>
</Tooltip>
<IconButton
icon="trash"
variant="ghost"
class="size-6 rounded-md text-text-diff-delete-base hover:text-text-diff-delete-strong"
aria-label={language.t("common.delete")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.deleteSession(props.session)
}}
/>
</div>
</div>
)
Expand Down
9 changes: 8 additions & 1 deletion packages/app/src/pages/layout/sidebar-project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -188,6 +189,8 @@ const ProjectPreviewPanel = (props: {
workspaces: Accessor<string[]>
label: (directory: string) => string
projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
projectPinnedSessions: Accessor<ReturnType<typeof sortedRootSessions>>
projectUnpinnedSessions: Accessor<ReturnType<typeof sortedRootSessions>>
projectChildren: Accessor<Map<string, string[]>>
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
workspaceChildren: (directory: string) => Map<string, string[]>
Expand All @@ -204,7 +207,7 @@ const ProjectPreviewPanel = (props: {
<Show
when={props.workspaceEnabled()}
fallback={
<For each={props.projectSessions().slice(0, 2)}>
<For each={[...props.projectPinnedSessions(), ...props.projectUnpinnedSessions()].slice(0, 2)}>
{(session) => (
<SessionItem
{...props.ctx.sessionProps}
Expand Down Expand Up @@ -320,6 +323,8 @@ export const SortableProject = (props: {

const projectStore = createMemo(() => 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 })
Expand Down Expand Up @@ -381,6 +386,8 @@ export const SortableProject = (props: {
workspaces={workspaces}
label={label}
projectSessions={projectSessions}
projectPinnedSessions={projectPinnedSessions}
projectUnpinnedSessions={projectUnpinnedSessions}
projectChildren={projectChildren}
workspaceSessions={workspaceSessions}
workspaceChildren={workspaceChildren}
Expand Down
Loading
Loading