feat(ui): thread sessions in sidebar list
Show sessions as parent/child threads with expand/collapse and improved agent row styling. Keep a 5-session cache to avoid refetching messages when switching between recently visited sessions.
This commit is contained in:
@@ -29,11 +29,15 @@ import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
|
||||
import type { Instance } from "../../types/instance"
|
||||
import type { Command } from "../../lib/commands"
|
||||
import type { BackgroundProcess } from "../../../../server/src/api-types"
|
||||
import type { Session } from "../../types/session"
|
||||
import {
|
||||
activeParentSessionId,
|
||||
activeSessionId as activeSessionMap,
|
||||
getSessionFamily,
|
||||
getSessionInfo,
|
||||
getSessionThreads,
|
||||
sessions,
|
||||
setActiveParentSession,
|
||||
setActiveSession,
|
||||
} from "../../stores/sessions"
|
||||
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
||||
@@ -87,7 +91,7 @@ const MAX_SESSION_SIDEBAR_WIDTH = 360
|
||||
const RIGHT_DRAWER_WIDTH = 260
|
||||
const MIN_RIGHT_DRAWER_WIDTH = 200
|
||||
const MAX_RIGHT_DRAWER_WIDTH = 380
|
||||
const SESSION_CACHE_LIMIT = 2
|
||||
const SESSION_CACHE_LIMIT = 5
|
||||
const APP_BAR_HEIGHT = 56
|
||||
const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
|
||||
const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
|
||||
@@ -268,6 +272,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
requestAnimationFrame(() => measureDrawerHost())
|
||||
})
|
||||
|
||||
const allInstanceSessions = createMemo<Map<string, Session>>(() => {
|
||||
return sessions().get(props.instance.id) ?? new Map()
|
||||
})
|
||||
|
||||
const sessionThreads = createMemo(() => getSessionThreads(props.instance.id))
|
||||
|
||||
const activeSessions = createMemo(() => {
|
||||
const parentId = activeParentSessionId().get(props.instance.id)
|
||||
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
|
||||
@@ -477,7 +487,26 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
})
|
||||
|
||||
const handleSessionSelect = (sessionId: string) => {
|
||||
setActiveSession(props.instance.id, sessionId)
|
||||
if (sessionId === "info") {
|
||||
setActiveSession(props.instance.id, sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
const session = allInstanceSessions().get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
if (session.parentId === null) {
|
||||
setActiveParentSession(props.instance.id, sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
const parentId = session.parentId
|
||||
if (!parentId) return
|
||||
|
||||
batch(() => {
|
||||
setActiveParentSession(props.instance.id, parentId)
|
||||
setActiveSession(props.instance.id, sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -522,23 +551,27 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const sessionsMap = activeSessions()
|
||||
const parentId = parentSessionIdForInstance()
|
||||
const instanceSessions = allInstanceSessions()
|
||||
const activeId = activeSessionIdForInstance()
|
||||
|
||||
setCachedSessionIds((current) => {
|
||||
const next: string[] = []
|
||||
const append = (id: string | null) => {
|
||||
const next = current.filter((id) => id !== "info" && instanceSessions.has(id))
|
||||
|
||||
const touch = (id: string | null) => {
|
||||
if (!id || id === "info") return
|
||||
if (!sessionsMap.has(id)) return
|
||||
if (next.includes(id)) return
|
||||
next.push(id)
|
||||
if (!instanceSessions.has(id)) return
|
||||
|
||||
const index = next.indexOf(id)
|
||||
if (index !== -1) {
|
||||
next.splice(index, 1)
|
||||
}
|
||||
next.unshift(id)
|
||||
}
|
||||
|
||||
append(parentId)
|
||||
append(activeId)
|
||||
touch(activeId)
|
||||
|
||||
const trimmed = next.length > SESSION_CACHE_LIMIT ? next.slice(0, SESSION_CACHE_LIMIT) : next
|
||||
|
||||
const limit = parentId ? SESSION_CACHE_LIMIT + 1 : SESSION_CACHE_LIMIT
|
||||
const trimmed = next.length > limit ? next.slice(0, limit) : next
|
||||
const trimmedSet = new Set(trimmed)
|
||||
const removed = current.filter((id) => !trimmedSet.has(id))
|
||||
if (removed.length) {
|
||||
@@ -832,7 +865,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||
<SessionList
|
||||
instanceId={props.instance.id}
|
||||
sessions={activeSessions()}
|
||||
sessions={allInstanceSessions()}
|
||||
threads={sessionThreads()}
|
||||
activeSessionId={activeSessionIdForInstance()}
|
||||
onSelect={handleSessionSelect}
|
||||
onClose={(id) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, For, Show, createSignal, createMemo, JSX } from "solid-js"
|
||||
import { Component, For, Show, createSignal, createMemo, createEffect, JSX } from "solid-js"
|
||||
import type { Session, SessionStatus } from "../types/session"
|
||||
import type { SessionThread } from "../stores/session-state"
|
||||
import { getSessionStatus } from "../stores/session-status"
|
||||
import { MessageSquare, Info, X, Copy, Trash2, Pencil, ShieldAlert } from "lucide-solid"
|
||||
import { Bot, User, Info, X, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import Kbd from "./kbd"
|
||||
import SessionRenameDialog from "./session-rename-dialog"
|
||||
@@ -18,6 +19,7 @@ const log = getLogger("session")
|
||||
interface SessionListProps {
|
||||
instanceId: string
|
||||
sessions: Map<string, Session>
|
||||
threads: SessionThread[]
|
||||
activeSessionId: string | null
|
||||
onSelect: (sessionId: string) => void
|
||||
onClose: (sessionId: string) => void
|
||||
@@ -67,7 +69,38 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
return deleting ? deleting.has(sessionId) : false
|
||||
}
|
||||
|
||||
const [expandedParents, setExpandedParents] = createSignal<Set<string>>(new Set())
|
||||
|
||||
const toggleParentExpanded = (parentId: string) => {
|
||||
setExpandedParents((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(parentId)) {
|
||||
next.delete(parentId)
|
||||
} else {
|
||||
next.add(parentId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const ensureParentExpanded = (parentId: string) => {
|
||||
setExpandedParents((prev) => {
|
||||
if (prev.has(parentId)) return prev
|
||||
const next = new Set(prev)
|
||||
next.add(parentId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectSession = (sessionId: string) => {
|
||||
if (sessionId !== "info") {
|
||||
const session = props.sessions.get(sessionId)
|
||||
const parentId = session?.parentId ?? session?.id
|
||||
if (parentId) {
|
||||
ensureParentExpanded(parentId)
|
||||
}
|
||||
}
|
||||
|
||||
props.onSelect(sessionId)
|
||||
}
|
||||
|
||||
@@ -127,7 +160,15 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
}
|
||||
|
||||
|
||||
const SessionRow: Component<{ sessionId: string; canClose?: boolean }> = (rowProps) => {
|
||||
const SessionRow: Component<{
|
||||
sessionId: string
|
||||
canClose?: boolean
|
||||
isChild?: boolean
|
||||
isLastChild?: boolean
|
||||
hasChildren?: boolean
|
||||
expanded?: boolean
|
||||
onToggleExpand?: () => void
|
||||
}> = (rowProps) => {
|
||||
const session = () => props.sessions.get(rowProps.sessionId)
|
||||
if (!session()) {
|
||||
return <></>
|
||||
@@ -144,16 +185,21 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
<div class="session-list-item group">
|
||||
|
||||
<button
|
||||
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
||||
class={`session-item-base ${rowProps.isChild ? `session-item-child${rowProps.isLastChild ? " session-item-child-last" : ""} session-item-border-assistant session-item-kind-assistant` : "session-item-border-user session-item-kind-user"} ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
||||
onClick={() => selectSession(rowProps.sessionId)}
|
||||
title={title()}
|
||||
role="button"
|
||||
aria-selected={isActive()}
|
||||
aria-expanded={rowProps.hasChildren ? Boolean(rowProps.expanded) : undefined}
|
||||
>
|
||||
<div class="session-item-row session-item-header">
|
||||
<div class="session-item-title-row">
|
||||
<MessageSquare class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="session-item-title truncate">{title()}</span>
|
||||
{rowProps.isChild ? (
|
||||
<Bot class="w-4 h-4 flex-shrink-0" />
|
||||
) : (
|
||||
<User class="w-4 h-4 flex-shrink-0" />
|
||||
)}
|
||||
<span class="session-item-title session-item-title--clamp">{title()}</span>
|
||||
</div>
|
||||
<Show when={rowProps.canClose}>
|
||||
<span
|
||||
@@ -171,14 +217,36 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
<div class="session-item-row session-item-meta">
|
||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||
{pendingPermission() ? (
|
||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<span class="status-dot" />
|
||||
)}
|
||||
{statusText()}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Show
|
||||
when={rowProps.hasChildren && !rowProps.isChild}
|
||||
fallback={
|
||||
rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />
|
||||
}
|
||||
>
|
||||
<span
|
||||
class={`session-item-expander opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
rowProps.onToggleExpand?.()
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={rowProps.expanded ? "Collapse session" : "Expand session"}
|
||||
title={rowProps.expanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
|
||||
</span>
|
||||
</Show>
|
||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||
{pendingPermission() ? (
|
||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<span class="status-dot" />
|
||||
)}
|
||||
{statusText()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="session-item-actions">
|
||||
<span
|
||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
@@ -234,37 +302,21 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
const userSessionIds = createMemo(
|
||||
() => {
|
||||
const ids: string[] = []
|
||||
for (const session of props.sessions.values()) {
|
||||
if (session.parentId === null) {
|
||||
ids.push(session.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
},
|
||||
undefined,
|
||||
{ equals: arraysEqual },
|
||||
)
|
||||
|
||||
const childSessionIds = createMemo(
|
||||
() => {
|
||||
const children: { id: string; updated: number }[] = []
|
||||
for (const session of props.sessions.values()) {
|
||||
if (session.parentId !== null) {
|
||||
children.push({ id: session.id, updated: session.time.updated ?? 0 })
|
||||
}
|
||||
}
|
||||
if (children.length <= 1) {
|
||||
return children.map((entry) => entry.id)
|
||||
}
|
||||
children.sort((a, b) => b.updated - a.updated)
|
||||
return children.map((entry) => entry.id)
|
||||
},
|
||||
undefined,
|
||||
{ equals: arraysEqual },
|
||||
)
|
||||
const activeParentId = createMemo(() => {
|
||||
const activeId = props.activeSessionId
|
||||
if (!activeId || activeId === "info") return null
|
||||
|
||||
const activeSession = props.sessions.get(activeId)
|
||||
if (!activeSession) return null
|
||||
|
||||
return activeSession.parentId ?? activeSession.id
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const parentId = activeParentId()
|
||||
if (!parentId) return
|
||||
ensureParentExpanded(parentId)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -299,7 +351,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
<div class="session-item-row session-item-header">
|
||||
<div class="session-item-title-row">
|
||||
<Info class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="session-item-title truncate">Instance Info</span>
|
||||
<span class="session-item-title session-item-title--clamp">Instance Info</span>
|
||||
</div>
|
||||
{infoShortcut && <Kbd shortcut={formatShortcut(infoShortcut)} class="ml-2 not-italic" />}
|
||||
</div>
|
||||
@@ -308,21 +360,34 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
</div>
|
||||
|
||||
|
||||
<Show when={userSessionIds().length > 0}>
|
||||
<Show when={props.threads.length > 0}>
|
||||
<div class="session-section">
|
||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
||||
User Session
|
||||
Sessions
|
||||
</div>
|
||||
<For each={userSessionIds()}>{(id) => <SessionRow sessionId={id} canClose />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={childSessionIds().length > 0}>
|
||||
<div class="session-section">
|
||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
||||
Agent Sessions
|
||||
</div>
|
||||
<For each={childSessionIds()}>{(id) => <SessionRow sessionId={id} />}</For>
|
||||
<For each={props.threads}>
|
||||
{(thread) => {
|
||||
const expanded = () => expandedParents().has(thread.parent.id)
|
||||
return (
|
||||
<>
|
||||
<SessionRow
|
||||
sessionId={thread.parent.id}
|
||||
canClose
|
||||
hasChildren={thread.children.length > 0}
|
||||
expanded={expanded()}
|
||||
onToggleExpand={() => toggleParentExpanded(thread.parent.id)}
|
||||
/>
|
||||
<Show when={expanded() && thread.children.length > 0}>
|
||||
<For each={thread.children}>
|
||||
{(child, index) => (
|
||||
<SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} />
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user