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 { Instance } from "../../types/instance"
|
||||||
import type { Command } from "../../lib/commands"
|
import type { Command } from "../../lib/commands"
|
||||||
import type { BackgroundProcess } from "../../../../server/src/api-types"
|
import type { BackgroundProcess } from "../../../../server/src/api-types"
|
||||||
|
import type { Session } from "../../types/session"
|
||||||
import {
|
import {
|
||||||
activeParentSessionId,
|
activeParentSessionId,
|
||||||
activeSessionId as activeSessionMap,
|
activeSessionId as activeSessionMap,
|
||||||
getSessionFamily,
|
getSessionFamily,
|
||||||
getSessionInfo,
|
getSessionInfo,
|
||||||
|
getSessionThreads,
|
||||||
|
sessions,
|
||||||
|
setActiveParentSession,
|
||||||
setActiveSession,
|
setActiveSession,
|
||||||
} from "../../stores/sessions"
|
} from "../../stores/sessions"
|
||||||
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
||||||
@@ -87,7 +91,7 @@ const MAX_SESSION_SIDEBAR_WIDTH = 360
|
|||||||
const RIGHT_DRAWER_WIDTH = 260
|
const RIGHT_DRAWER_WIDTH = 260
|
||||||
const MIN_RIGHT_DRAWER_WIDTH = 200
|
const MIN_RIGHT_DRAWER_WIDTH = 200
|
||||||
const MAX_RIGHT_DRAWER_WIDTH = 380
|
const MAX_RIGHT_DRAWER_WIDTH = 380
|
||||||
const SESSION_CACHE_LIMIT = 2
|
const SESSION_CACHE_LIMIT = 5
|
||||||
const APP_BAR_HEIGHT = 56
|
const APP_BAR_HEIGHT = 56
|
||||||
const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
|
const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
|
||||||
const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
|
const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
|
||||||
@@ -268,6 +272,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
requestAnimationFrame(() => measureDrawerHost())
|
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 activeSessions = createMemo(() => {
|
||||||
const parentId = activeParentSessionId().get(props.instance.id)
|
const parentId = activeParentSessionId().get(props.instance.id)
|
||||||
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
|
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
|
||||||
@@ -477,7 +487,26 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleSessionSelect = (sessionId: string) => {
|
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(() => {
|
createEffect(() => {
|
||||||
const sessionsMap = activeSessions()
|
const instanceSessions = allInstanceSessions()
|
||||||
const parentId = parentSessionIdForInstance()
|
|
||||||
const activeId = activeSessionIdForInstance()
|
const activeId = activeSessionIdForInstance()
|
||||||
|
|
||||||
setCachedSessionIds((current) => {
|
setCachedSessionIds((current) => {
|
||||||
const next: string[] = []
|
const next = current.filter((id) => id !== "info" && instanceSessions.has(id))
|
||||||
const append = (id: string | null) => {
|
|
||||||
|
const touch = (id: string | null) => {
|
||||||
if (!id || id === "info") return
|
if (!id || id === "info") return
|
||||||
if (!sessionsMap.has(id)) return
|
if (!instanceSessions.has(id)) return
|
||||||
if (next.includes(id)) return
|
|
||||||
next.push(id)
|
const index = next.indexOf(id)
|
||||||
|
if (index !== -1) {
|
||||||
|
next.splice(index, 1)
|
||||||
|
}
|
||||||
|
next.unshift(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
append(parentId)
|
touch(activeId)
|
||||||
append(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 trimmedSet = new Set(trimmed)
|
||||||
const removed = current.filter((id) => !trimmedSet.has(id))
|
const removed = current.filter((id) => !trimmedSet.has(id))
|
||||||
if (removed.length) {
|
if (removed.length) {
|
||||||
@@ -832,7 +865,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||||
<SessionList
|
<SessionList
|
||||||
instanceId={props.instance.id}
|
instanceId={props.instance.id}
|
||||||
sessions={activeSessions()}
|
sessions={allInstanceSessions()}
|
||||||
|
threads={sessionThreads()}
|
||||||
activeSessionId={activeSessionIdForInstance()}
|
activeSessionId={activeSessionIdForInstance()}
|
||||||
onSelect={handleSessionSelect}
|
onSelect={handleSessionSelect}
|
||||||
onClose={(id) => {
|
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 { Session, SessionStatus } from "../types/session"
|
||||||
|
import type { SessionThread } from "../stores/session-state"
|
||||||
import { getSessionStatus } from "../stores/session-status"
|
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 KeyboardHint from "./keyboard-hint"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import SessionRenameDialog from "./session-rename-dialog"
|
import SessionRenameDialog from "./session-rename-dialog"
|
||||||
@@ -18,6 +19,7 @@ const log = getLogger("session")
|
|||||||
interface SessionListProps {
|
interface SessionListProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessions: Map<string, Session>
|
sessions: Map<string, Session>
|
||||||
|
threads: SessionThread[]
|
||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
onSelect: (sessionId: string) => void
|
onSelect: (sessionId: string) => void
|
||||||
onClose: (sessionId: string) => void
|
onClose: (sessionId: string) => void
|
||||||
@@ -67,7 +69,38 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
return deleting ? deleting.has(sessionId) : false
|
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) => {
|
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)
|
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)
|
const session = () => props.sessions.get(rowProps.sessionId)
|
||||||
if (!session()) {
|
if (!session()) {
|
||||||
return <></>
|
return <></>
|
||||||
@@ -144,16 +185,21 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
<div class="session-list-item group">
|
<div class="session-list-item group">
|
||||||
|
|
||||||
<button
|
<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)}
|
onClick={() => selectSession(rowProps.sessionId)}
|
||||||
title={title()}
|
title={title()}
|
||||||
role="button"
|
role="button"
|
||||||
aria-selected={isActive()}
|
aria-selected={isActive()}
|
||||||
|
aria-expanded={rowProps.hasChildren ? Boolean(rowProps.expanded) : undefined}
|
||||||
>
|
>
|
||||||
<div class="session-item-row session-item-header">
|
<div class="session-item-row session-item-header">
|
||||||
<div class="session-item-title-row">
|
<div class="session-item-title-row">
|
||||||
<MessageSquare class="w-4 h-4 flex-shrink-0" />
|
{rowProps.isChild ? (
|
||||||
<span class="session-item-title truncate">{title()}</span>
|
<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>
|
</div>
|
||||||
<Show when={rowProps.canClose}>
|
<Show when={rowProps.canClose}>
|
||||||
<span
|
<span
|
||||||
@@ -171,14 +217,36 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-item-row session-item-meta">
|
<div class="session-item-row session-item-meta">
|
||||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
{pendingPermission() ? (
|
<Show
|
||||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
when={rowProps.hasChildren && !rowProps.isChild}
|
||||||
) : (
|
fallback={
|
||||||
<span class="status-dot" />
|
rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />
|
||||||
)}
|
}
|
||||||
{statusText()}
|
>
|
||||||
</span>
|
<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">
|
<div class="session-item-actions">
|
||||||
<span
|
<span
|
||||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
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 activeParentId = createMemo(() => {
|
||||||
() => {
|
const activeId = props.activeSessionId
|
||||||
const ids: string[] = []
|
if (!activeId || activeId === "info") return null
|
||||||
for (const session of props.sessions.values()) {
|
|
||||||
if (session.parentId === null) {
|
const activeSession = props.sessions.get(activeId)
|
||||||
ids.push(session.id)
|
if (!activeSession) return null
|
||||||
}
|
|
||||||
}
|
return activeSession.parentId ?? activeSession.id
|
||||||
return ids
|
})
|
||||||
},
|
|
||||||
undefined,
|
createEffect(() => {
|
||||||
{ equals: arraysEqual },
|
const parentId = activeParentId()
|
||||||
)
|
if (!parentId) return
|
||||||
|
ensureParentExpanded(parentId)
|
||||||
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 },
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -299,7 +351,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
<div class="session-item-row session-item-header">
|
<div class="session-item-row session-item-header">
|
||||||
<div class="session-item-title-row">
|
<div class="session-item-title-row">
|
||||||
<Info class="w-4 h-4 flex-shrink-0" />
|
<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>
|
</div>
|
||||||
{infoShortcut && <Kbd shortcut={formatShortcut(infoShortcut)} class="ml-2 not-italic" />}
|
{infoShortcut && <Kbd shortcut={formatShortcut(infoShortcut)} class="ml-2 not-italic" />}
|
||||||
</div>
|
</div>
|
||||||
@@ -308,21 +360,34 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<Show when={userSessionIds().length > 0}>
|
<Show when={props.threads.length > 0}>
|
||||||
<div class="session-section">
|
<div class="session-section">
|
||||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
||||||
User Session
|
Sessions
|
||||||
</div>
|
</div>
|
||||||
<For each={userSessionIds()}>{(id) => <SessionRow sessionId={id} canClose />}</For>
|
<For each={props.threads}>
|
||||||
</div>
|
{(thread) => {
|
||||||
</Show>
|
const expanded = () => expandedParents().has(thread.parent.id)
|
||||||
|
return (
|
||||||
<Show when={childSessionIds().length > 0}>
|
<>
|
||||||
<div class="session-section">
|
<SessionRow
|
||||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
sessionId={thread.parent.id}
|
||||||
Agent Sessions
|
canClose
|
||||||
</div>
|
hasChildren={thread.children.length > 0}
|
||||||
<For each={childSessionIds()}>{(id) => <SessionRow sessionId={id} />}</For>
|
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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ export interface SessionInfo {
|
|||||||
contextAvailableTokens: number | null
|
contextAvailableTokens: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionThread = {
|
||||||
|
parent: Session
|
||||||
|
children: Session[]
|
||||||
|
latestUpdated: number
|
||||||
|
}
|
||||||
|
|
||||||
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
|
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
|
||||||
const [activeSessionId, setActiveSessionId] = createSignal<Map<string, string>>(new Map())
|
const [activeSessionId, setActiveSessionId] = createSignal<Map<string, string>>(new Map())
|
||||||
const [activeParentSessionId, setActiveParentSessionId] = createSignal<Map<string, string>>(new Map())
|
const [activeParentSessionId, setActiveParentSessionId] = createSignal<Map<string, string>>(new Map())
|
||||||
@@ -375,6 +381,55 @@ function getSessionFamily(instanceId: string, parentId: string): Session[] {
|
|||||||
return [parent, ...children]
|
return [parent, ...children]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSessionThreads(instanceId: string): SessionThread[] {
|
||||||
|
const instanceSessions = sessions().get(instanceId)
|
||||||
|
if (!instanceSessions || instanceSessions.size === 0) return []
|
||||||
|
|
||||||
|
const parents: Session[] = []
|
||||||
|
const childrenByParent = new Map<string, Session[]>()
|
||||||
|
|
||||||
|
for (const session of instanceSessions.values()) {
|
||||||
|
if (session.parentId === null) {
|
||||||
|
parents.push(session)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentId = session.parentId
|
||||||
|
if (!parentId) continue
|
||||||
|
const children = childrenByParent.get(parentId)
|
||||||
|
if (children) {
|
||||||
|
children.push(session)
|
||||||
|
} else {
|
||||||
|
childrenByParent.set(parentId, [session])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const threads: SessionThread[] = []
|
||||||
|
|
||||||
|
for (const parent of parents) {
|
||||||
|
const children = childrenByParent.get(parent.id) ?? []
|
||||||
|
if (children.length > 1) {
|
||||||
|
children.sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentUpdated = parent.time.updated ?? 0
|
||||||
|
const latestChild = children[0]?.time.updated ?? 0
|
||||||
|
const latestUpdated = Math.max(parentUpdated, latestChild)
|
||||||
|
|
||||||
|
threads.push({ parent, children, latestUpdated })
|
||||||
|
}
|
||||||
|
|
||||||
|
threads.sort((a, b) => {
|
||||||
|
if (b.latestUpdated !== a.latestUpdated) return b.latestUpdated - a.latestUpdated
|
||||||
|
const bParentUpdated = b.parent.time.updated ?? 0
|
||||||
|
const aParentUpdated = a.parent.time.updated ?? 0
|
||||||
|
if (bParentUpdated !== aParentUpdated) return bParentUpdated - aParentUpdated
|
||||||
|
return b.parent.id.localeCompare(a.parent.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
return threads
|
||||||
|
}
|
||||||
|
|
||||||
function isSessionBusy(instanceId: string, sessionId: string): boolean {
|
function isSessionBusy(instanceId: string, sessionId: string): boolean {
|
||||||
const instanceSessions = sessions().get(instanceId)
|
const instanceSessions = sessions().get(instanceId)
|
||||||
if (!instanceSessions) return false
|
if (!instanceSessions) return false
|
||||||
@@ -530,6 +585,7 @@ export {
|
|||||||
getParentSessions,
|
getParentSessions,
|
||||||
getChildSessions,
|
getChildSessions,
|
||||||
getSessionFamily,
|
getSessionFamily,
|
||||||
|
getSessionThreads,
|
||||||
isSessionBusy,
|
isSessionBusy,
|
||||||
isSessionMessagesLoading,
|
isSessionMessagesLoading,
|
||||||
getSessionInfo,
|
getSessionInfo,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
getSessionDraftPrompt,
|
getSessionDraftPrompt,
|
||||||
getSessionFamily,
|
getSessionFamily,
|
||||||
getSessionInfo,
|
getSessionInfo,
|
||||||
|
getSessionThreads,
|
||||||
getSessions,
|
getSessions,
|
||||||
isSessionBusy,
|
isSessionBusy,
|
||||||
isSessionMessagesLoading,
|
isSessionMessagesLoading,
|
||||||
@@ -100,6 +101,7 @@ export {
|
|||||||
getSessionDraftPrompt,
|
getSessionDraftPrompt,
|
||||||
getSessionFamily,
|
getSessionFamily,
|
||||||
getSessionInfo,
|
getSessionInfo,
|
||||||
|
getSessionThreads,
|
||||||
getSessions,
|
getSessions,
|
||||||
isSessionBusy,
|
isSessionBusy,
|
||||||
isSessionMessagesLoading,
|
isSessionMessagesLoading,
|
||||||
|
|||||||
@@ -483,214 +483,3 @@
|
|||||||
background-color: var(--surface-secondary);
|
background-color: var(--surface-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Session view utility */
|
|
||||||
.session-view {
|
|
||||||
@apply flex flex-1 min-h-0 flex-col;
|
|
||||||
background-color: var(--surface-base);
|
|
||||||
color: inherit;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Session list component */
|
|
||||||
.session-list-container {
|
|
||||||
@apply flex flex-col flex-1 min-h-0 relative;
|
|
||||||
background-color: var(--surface-secondary);
|
|
||||||
min-width: 200px;
|
|
||||||
max-width: 500px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-sidebar {
|
|
||||||
@apply flex flex-col min-h-0;
|
|
||||||
background-color: var(--surface-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-sidebar-header {
|
|
||||||
@apply flex flex-col gap-2 w-full;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-sidebar-title {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-sidebar-shortcuts {
|
|
||||||
@apply flex flex-col gap-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-sidebar-new {
|
|
||||||
@apply w-full;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-sidebar-controls {
|
|
||||||
@apply flex flex-col gap-3;
|
|
||||||
background-color: var(--surface-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-sidebar-controls > * {
|
|
||||||
@apply w-full;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-sidebar-separator {
|
|
||||||
background-color: var(--border-base);
|
|
||||||
height: 1px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-resize-handle {
|
|
||||||
@apply absolute top-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-resize-handle--left {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-resize-handle--right {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-resize-handle:hover {
|
|
||||||
background-color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-resize-handle::before {
|
|
||||||
content: "";
|
|
||||||
@apply absolute top-0 h-full w-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-resize-handle--left::before {
|
|
||||||
right: 0;
|
|
||||||
transform: translateX(50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-resize-handle--right::before {
|
|
||||||
left: 0;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-list-header {
|
|
||||||
@apply border-b relative;
|
|
||||||
border-color: var(--border-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-list-header h3 {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-list {
|
|
||||||
@apply flex-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-list-item {
|
|
||||||
@apply border-b last:border-b-0;
|
|
||||||
border-color: var(--border-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item-base {
|
|
||||||
@apply w-full flex flex-col gap-1 px-3 py-2.5 text-left transition-colors outline-none;
|
|
||||||
font-family: var(--font-family-sans);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item-base:focus-visible {
|
|
||||||
@apply ring-2 ring-offset-1;
|
|
||||||
ring-color: var(--accent-primary);
|
|
||||||
ring-offset-color: var(--surface-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item-row {
|
|
||||||
@apply flex items-center gap-2 w-full;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item-header {
|
|
||||||
@apply justify-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item-title-row {
|
|
||||||
@apply flex items-center gap-2 min-w-0 flex-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item-meta {
|
|
||||||
@apply justify-between items-center;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item-active .session-item-meta {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item-actions {
|
|
||||||
@apply flex items-center gap-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item-active {
|
|
||||||
background-color: var(--list-item-highlight-bg);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item-inactive {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item-inactive:hover {
|
|
||||||
background-color: var(--surface-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item-active .session-item-close:hover {
|
|
||||||
background-color: var(--surface-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item-title {
|
|
||||||
@apply flex-1 min-w-0;
|
|
||||||
font-weight: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item-close {
|
|
||||||
@apply flex-shrink-0 p-0.5 rounded transition-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item-close:focus-visible {
|
|
||||||
@apply ring-2 ring-offset-1;
|
|
||||||
ring-color: var(--accent-primary);
|
|
||||||
ring-offset-color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-list-footer {
|
|
||||||
@apply border-t;
|
|
||||||
border-color: var(--border-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-new-button {
|
|
||||||
background-color: var(--surface-base);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-new-button:hover {
|
|
||||||
background-color: var(--surface-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-new-button:focus-visible {
|
|
||||||
@apply ring-2 ring-offset-1;
|
|
||||||
ring-color: var(--accent-primary);
|
|
||||||
ring-offset-color: var(--surface-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive behavior for session list */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.session-list-container {
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item-base {
|
|
||||||
@apply px-2 py-2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -206,6 +206,58 @@ session-sidebar-controls .selector-trigger-primary {
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.session-item-base.session-item-child {
|
||||||
|
padding-left: 2.25rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item-base.session-item-child::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 1.125rem;
|
||||||
|
width: 1px;
|
||||||
|
background-color: var(--text-secondary);
|
||||||
|
opacity: 0.95;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item-base.session-item-child.session-item-child-last::before {
|
||||||
|
bottom: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item-base.session-item-child::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 1.125rem;
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--text-secondary);
|
||||||
|
opacity: 0.95;
|
||||||
|
transform: translateY(-0.5px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item-base.session-item-border-user {
|
||||||
|
border-left: 4px solid var(--message-user-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item-base.session-item-border-assistant {
|
||||||
|
border-left: 4px solid var(--message-assistant-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item-expander {
|
||||||
|
@apply flex-shrink-0 p-0.5 rounded transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item-expander--spacer {
|
||||||
|
width: calc(0.875rem + 0.25rem);
|
||||||
|
height: calc(0.875rem + 0.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
.session-item-base:focus-visible {
|
.session-item-base:focus-visible {
|
||||||
@apply ring-2 ring-offset-1;
|
@apply ring-2 ring-offset-1;
|
||||||
ring-color: var(--accent-primary);
|
ring-color: var(--accent-primary);
|
||||||
@@ -221,7 +273,7 @@ session-sidebar-controls .selector-trigger-primary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.session-item-title-row {
|
.session-item-title-row {
|
||||||
@apply flex items-center gap-2 min-w-0 flex-1;
|
@apply flex items-start gap-2 min-w-0 flex-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-item-meta {
|
.session-item-meta {
|
||||||
@@ -247,6 +299,16 @@ session-sidebar-controls .selector-trigger-primary {
|
|||||||
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
|
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-item-base.session-item-kind-user.session-item-active {
|
||||||
|
background-color: var(--session-user-active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item-base.session-item-kind-assistant.session-item-active {
|
||||||
|
background-color: var(--session-assistant-active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.session-item-inactive {
|
.session-item-inactive {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@@ -256,6 +318,8 @@ session-sidebar-controls .selector-trigger-primary {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.session-item-active .session-item-close:hover {
|
.session-item-active .session-item-close:hover {
|
||||||
background-color: var(--surface-hover);
|
background-color: var(--surface-hover);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -266,6 +330,14 @@ session-sidebar-controls .selector-trigger-primary {
|
|||||||
font-weight: inherit;
|
font-weight: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-item-title--clamp {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.session-item-close {
|
.session-item-close {
|
||||||
@apply flex-shrink-0 p-0.5 rounded transition-all;
|
@apply flex-shrink-0 p-0.5 rounded transition-all;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@
|
|||||||
--message-tool-bg: #f8f9fa;
|
--message-tool-bg: #f8f9fa;
|
||||||
--message-tool-border: #6c757d;
|
--message-tool-border: #6c757d;
|
||||||
|
|
||||||
|
/* Session list selection tints */
|
||||||
|
--session-user-active-bg: color-mix(in oklab, var(--surface-secondary) 85%, var(--message-user-border));
|
||||||
|
--session-assistant-active-bg: color-mix(in oklab, var(--surface-secondary) 85%, var(--message-assistant-border));
|
||||||
|
|
||||||
/* Semantic component colors */
|
/* Semantic component colors */
|
||||||
--session-status-working-fg: #b45309;
|
--session-status-working-fg: #b45309;
|
||||||
--session-status-working-bg: rgba(245, 158, 11, 0.16);
|
--session-status-working-bg: rgba(245, 158, 11, 0.16);
|
||||||
|
|||||||
Reference in New Issue
Block a user