refactor: replace session tabs with sectioned list

This commit is contained in:
Shantur Rathore
2025-10-29 11:46:59 +00:00
parent 30992fbf48
commit e406c9f76d
6 changed files with 523 additions and 169 deletions

View File

@@ -0,0 +1,307 @@
import { Component, For, Show, createSignal, createEffect, onCleanup, onMount, createMemo } from "solid-js"
import type { Session } from "../types/session"
import { MessageSquare, Info, Plus, X } from "lucide-solid"
import KeyboardHint from "./keyboard-hint"
import { keyboardRegistry } from "../lib/keyboard-registry"
interface SessionListItem {
id: string
title: string
isSpecial?: boolean
isActive: boolean
isParent?: boolean
onSelect: () => void
onClose?: () => void
}
interface SessionListProps {
instanceId: string
sessions: Map<string, Session>
activeSessionId: string | null
onSelect: (sessionId: string) => void
onClose: (sessionId: string) => void
onNew: () => void
}
const MIN_WIDTH = 200
const MAX_WIDTH = 500
const DEFAULT_WIDTH = 280
const STORAGE_KEY = "opencode-session-sidebar-width"
const SessionList: Component<SessionListProps> = (props) => {
const [sidebarWidth, setSidebarWidth] = createSignal(DEFAULT_WIDTH)
const [isResizing, setIsResizing] = createSignal(false)
const [startX, setStartX] = createSignal(0)
const [startWidth, setStartWidth] = createSignal(DEFAULT_WIDTH)
let mouseMoveHandler: ((event: MouseEvent) => void) | null = null
let mouseUpHandler: (() => void) | null = null
let touchMoveHandler: ((event: TouchEvent) => void) | null = null
let touchEndHandler: (() => void) | null = null
onMount(() => {
if (typeof window === "undefined") return
const saved = window.localStorage.getItem(STORAGE_KEY)
if (!saved) return
const width = Number.parseInt(saved, 10)
if (Number.isFinite(width) && width >= MIN_WIDTH && width <= MAX_WIDTH) {
setSidebarWidth(width)
setStartWidth(width)
}
})
createEffect(() => {
if (typeof window === "undefined") return
const width = sidebarWidth()
window.localStorage.setItem(STORAGE_KEY, width.toString())
})
const clampWidth = (width: number) => Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width))
const removeMouseListeners = () => {
if (mouseMoveHandler) {
document.removeEventListener("mousemove", mouseMoveHandler)
mouseMoveHandler = null
}
if (mouseUpHandler) {
document.removeEventListener("mouseup", mouseUpHandler)
mouseUpHandler = null
}
}
const removeTouchListeners = () => {
if (touchMoveHandler) {
document.removeEventListener("touchmove", touchMoveHandler)
touchMoveHandler = null
}
if (touchEndHandler) {
document.removeEventListener("touchend", touchEndHandler)
touchEndHandler = null
}
}
const stopResizing = () => {
setIsResizing(false)
removeMouseListeners()
removeTouchListeners()
}
const handleMouseMove = (event: MouseEvent) => {
if (!isResizing()) return
const diff = event.clientX - startX()
const newWidth = clampWidth(startWidth() + diff)
setSidebarWidth(newWidth)
}
const handleMouseUp = () => {
stopResizing()
}
const handleTouchMove = (event: TouchEvent) => {
if (!isResizing()) return
const touch = event.touches[0]
const diff = touch.clientX - startX()
const newWidth = clampWidth(startWidth() + diff)
setSidebarWidth(newWidth)
}
const handleTouchEnd = () => {
stopResizing()
}
const handleMouseDown = (event: MouseEvent) => {
event.preventDefault()
setIsResizing(true)
setStartX(event.clientX)
setStartWidth(sidebarWidth())
mouseMoveHandler = handleMouseMove
mouseUpHandler = handleMouseUp
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
}
const handleTouchStart = (event: TouchEvent) => {
event.preventDefault()
const touch = event.touches[0]
setIsResizing(true)
setStartX(touch.clientX)
setStartWidth(sidebarWidth())
touchMoveHandler = handleTouchMove
touchEndHandler = handleTouchEnd
document.addEventListener("touchmove", handleTouchMove)
document.addEventListener("touchend", handleTouchEnd)
}
onCleanup(() => {
removeMouseListeners()
removeTouchListeners()
})
const sessionSections = createMemo(() => {
const parentItems: SessionListItem[] = []
const childItems: SessionListItem[] = []
for (const [id, session] of props.sessions.entries()) {
const item: SessionListItem = {
id,
title: session.title || "Untitled",
isActive: id === props.activeSessionId,
isParent: session.parentId === null,
onSelect: () => props.onSelect(id),
onClose: session.parentId === null ? () => props.onClose(id) : undefined,
}
if (session.parentId === null) {
parentItems.push(item)
} else {
childItems.push(item)
}
}
childItems.sort((a, b) => {
const sessionA = props.sessions.get(a.id)
const sessionB = props.sessions.get(b.id)
if (!sessionA || !sessionB) return 0
return sessionB.time.updated - sessionA.time.updated
})
parentItems.push({
id: "info",
title: "Info",
isSpecial: true,
isActive: props.activeSessionId === "info",
onSelect: () => props.onSelect("info"),
})
return { parentItems, childItems }
})
return (
<div
class="session-list-container bg-surface-secondary border-r border-base flex flex-col"
style={{ width: `${sidebarWidth()}px` }}
>
<div
class="session-resize-handle"
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
role="presentation"
aria-hidden="true"
/>
<div class="session-list-header p-3 border-b border-base">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-primary">Sessions</h3>
<KeyboardHint
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
/>
</div>
</div>
<div class="session-list flex-1 overflow-y-auto">
<div class="session-section">
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
Parent & Info
</div>
<For each={sessionSections().parentItems}>
{(item) => (
<div class="session-list-item group">
<button
class={`session-item-base ${
item.isActive ? "session-item-active" : "session-item-inactive"
} ${item.isSpecial ? "session-item-special" : ""}`}
onClick={item.onSelect}
title={item.title}
role="button"
aria-selected={item.isActive}
>
<Show when={item.isSpecial} fallback={<MessageSquare class="w-4 h-4 flex-shrink-0" />}>
<Info class="w-4 h-4 flex-shrink-0" />
</Show>
<span class="session-item-title truncate">{item.title}</span>
<Show when={!item.isSpecial && item.onClose}>
<span
class="session-item-close opacity-0 group-hover:opacity-100 hover:bg-status-error hover:text-white rounded p-0.5 transition-all"
onClick={(event) => {
event.stopPropagation()
item.onClose?.()
}}
role="button"
tabIndex={0}
aria-label="Close session"
>
<X class="w-3 h-3" />
</span>
</Show>
</button>
</div>
)}
</For>
</div>
<Show when={sessionSections().childItems.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">
Child Sessions
</div>
<For each={sessionSections().childItems}>
{(item) => (
<div class="session-list-item group">
<button
class={`session-item-base ${
item.isActive ? "session-item-active" : "session-item-inactive"
} ${item.isSpecial ? "session-item-special" : ""}`}
onClick={item.onSelect}
title={item.title}
role="button"
aria-selected={item.isActive}
>
<MessageSquare class="w-4 h-4 flex-shrink-0" />
<span class="session-item-title truncate">{item.title}</span>
<Show when={!item.isSpecial && item.onClose}>
<span
class="session-item-close opacity-0 group-hover:opacity-100 hover:bg-status-error hover:text-white rounded p-0.5 transition-all"
onClick={(event) => {
event.stopPropagation()
item.onClose?.()
}}
role="button"
tabIndex={0}
aria-label="Close session"
>
<X class="w-3 h-3" />
</span>
</Show>
</button>
</div>
)}
</For>
</div>
</Show>
</div>
<div class="session-list-footer p-3 border-t border-base">
<button
class="session-new-button w-full flex items-center gap-2 px-3 py-2 rounded-md transition-colors text-sm font-medium"
onClick={props.onNew}
title="New session (Cmd/Ctrl+T)"
aria-label="New session"
>
<Plus class="w-4 h-4" />
<span>New Session</span>
</button>
</div>
</div>
)
}
export default SessionList

View File

@@ -1,56 +0,0 @@
import { Component, Show } from "solid-js"
import type { Session } from "../types/session"
import { MessageSquare, Info, X } from "lucide-solid"
interface SessionTabProps {
session?: Session
special?: "info"
active: boolean
isParent?: boolean
onSelect: () => void
onClose?: () => void
}
const SessionTab: Component<SessionTabProps> = (props) => {
const label = () => {
if (props.special === "info") return "Info"
return props.session?.title || "Untitled"
}
return (
<div class="group">
<button
class={`session-tab-base ${
props.active
? "session-tab-active"
: "session-tab-inactive"
} ${props.special === "info" ? "session-tab-special" : ""} ${props.isParent && !props.active ? "font-semibold" : ""}`}
onClick={props.onSelect}
title={label()}
role="tab"
aria-selected={props.active}
>
<Show when={props.special === "info"} fallback={<MessageSquare class="w-3.5 h-3.5 flex-shrink-0" />}>
<Info class="w-3.5 h-3.5 flex-shrink-0" />
</Show>
<span class="tab-label">{label()}</span>
<Show when={!props.special && props.onClose}>
<span
class="tab-close"
onClick={(e) => {
e.stopPropagation()
props.onClose?.()
}}
role="button"
tabIndex={0}
aria-label="Close session"
>
<X class="w-3 h-3" />
</span>
</Show>
</button>
</div>
)
}
export default SessionTab

View File

@@ -1,62 +0,0 @@
import { Component, For, Show } from "solid-js"
import type { Session } from "../types/session"
import SessionTab from "./session-tab"
import KeyboardHint from "./keyboard-hint"
import { Plus } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry"
interface SessionTabsProps {
instanceId: string
sessions: Map<string, Session>
activeSessionId: string | null
onSelect: (sessionId: string) => void
onClose: (sessionId: string) => void
onNew: () => void
}
const SessionTabs: Component<SessionTabsProps> = (props) => {
const sessionsList = () => Array.from(props.sessions.entries())
const totalTabs = () => sessionsList().length + 1
return (
<div class="tab-bar tab-bar-session">
<div class="tab-container" role="tablist">
<div class="flex items-center gap-1 overflow-x-auto">
<For each={sessionsList()}>
{([id, session]) => (
<SessionTab
session={session}
active={id === props.activeSessionId}
isParent={session.parentId === null}
onSelect={() => props.onSelect(id)}
onClose={session.parentId === null ? () => props.onClose(id) : undefined}
/>
)}
</For>
<SessionTab
special="info"
active={props.activeSessionId === "info"}
onSelect={() => props.onSelect("info")}
/>
<button
class="new-tab-button"
onClick={props.onNew}
title="New parent session (Cmd/Ctrl+T)"
aria-label="New parent session"
>
<Plus class="w-4 h-4" />
</button>
</div>
<Show when={totalTabs() > 1}>
<div class="flex-shrink-0 ml-4">
<KeyboardHint
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
/>
</div>
</Show>
</div>
</div>
)
}
export default SessionTabs