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

@@ -5,7 +5,7 @@ import FolderSelectionView from "./components/folder-selection-view"
import InstanceWelcomeView from "./components/instance-welcome-view"
import CommandPalette from "./components/command-palette"
import InstanceTabs from "./components/instance-tabs"
import SessionTabs from "./components/session-tabs"
import SessionList from "./components/session-list"
import MessageStream from "./components/message-stream"
import PromptInput from "./components/prompt-input"
import InfoView from "./components/info-view"
@@ -813,42 +813,46 @@ const App: Component = () => {
{(instance) => (
<>
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={instance()} />}>
<SessionTabs
instanceId={instance().id}
sessions={activeSessions()}
activeSessionId={activeSessionIdForInstance()}
onSelect={(id) => setActiveSession(instance().id, id)}
onClose={(id) => handleCloseSession(instance().id, id)}
onNew={() => handleNewSession(instance().id)}
/>
<div class="flex h-full">
{/* Session List Sidebar */}
<SessionList
instanceId={instance().id}
sessions={activeSessions()}
activeSessionId={activeSessionIdForInstance()}
onSelect={(id) => setActiveSession(instance().id, id)}
onClose={(id) => handleCloseSession(instance().id, id)}
onNew={() => handleNewSession(instance().id)}
/>
<div class="content-area flex-1 overflow-hidden flex flex-col">
<Show
when={activeSessionIdForInstance() === "info"}
fallback={
<Show
when={activeSessionIdForInstance()}
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500 dark:text-gray-400">
<p class="mb-2">No session selected</p>
<p class="text-sm">Select a session to view messages</p>
{/* Main Content Area */}
<div class="content-area flex-1 overflow-hidden flex flex-col">
<Show
when={activeSessionIdForInstance() === "info"}
fallback={
<Show
when={activeSessionIdForInstance()}
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500 dark:text-gray-400">
<p class="mb-2">No session selected</p>
<p class="text-sm">Select a session to view messages</p>
</div>
</div>
</div>
}
>
<SessionView
sessionId={activeSessionIdForInstance()!}
activeSessions={activeSessions()}
instanceId={activeInstance()!.id}
instanceFolder={activeInstance()!.folder}
escapeInDebounce={escapeInDebounce()}
/>
</Show>
}
>
<InfoView instanceId={instance().id} />
</Show>
}
>
<SessionView
sessionId={activeSessionIdForInstance()!}
activeSessions={activeSessions()}
instanceId={activeInstance()!.id}
instanceFolder={activeInstance()!.folder}
escapeInDebounce={escapeInDebounce()}
/>
</Show>
}
>
<InfoView instanceId={instance().id} />
</Show>
</div>
</div>
</Show>
</>

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

View File

@@ -5,6 +5,21 @@ import { getSessionFamily, activeSessionId, setActiveSession, activeParentSessio
export function registerNavigationShortcuts() {
const isMac = () => navigator.platform.toLowerCase().includes("mac")
const buildNavigationOrder = (instanceId: string): string[] => {
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return []
const familySessions = getSessionFamily(instanceId, parentId)
if (familySessions.length === 0) return []
const [parentSession, ...childSessions] = familySessions
if (!parentSession) return []
const sortedChildren = childSessions.slice().sort((a, b) => b.time.updated - a.time.updated)
return [parentSession.id, "info", ...sortedChildren.map((session) => session.id)]
}
keyboardRegistry.register({
id: "instance-prev",
key: "[",
@@ -43,16 +58,20 @@ export function registerNavigationShortcuts() {
const instanceId = activeInstanceId()
if (!instanceId) return
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return
const navigationIds = buildNavigationOrder(instanceId)
if (navigationIds.length === 0) return
const familySessions = getSessionFamily(instanceId, parentId)
const ids = familySessions.map((s) => s.id).concat(["info"])
if (ids.length <= 1) return
const currentActiveId = activeSessionId().get(instanceId)
let currentIndex = navigationIds.indexOf(currentActiveId || "")
const current = ids.indexOf(activeSessionId().get(instanceId) || "")
const prev = current <= 0 ? ids.length - 1 : current - 1
if (ids[prev]) setActiveSession(instanceId, ids[prev])
if (currentIndex === -1) {
currentIndex = navigationIds.length - 1
}
const targetIndex = currentIndex <= 0 ? navigationIds.length - 1 : currentIndex - 1
const targetSessionId = navigationIds[targetIndex]
setActiveSession(instanceId, targetSessionId)
},
description: "previous session",
context: "global",
@@ -66,16 +85,20 @@ export function registerNavigationShortcuts() {
const instanceId = activeInstanceId()
if (!instanceId) return
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return
const navigationIds = buildNavigationOrder(instanceId)
if (navigationIds.length === 0) return
const familySessions = getSessionFamily(instanceId, parentId)
const ids = familySessions.map((s) => s.id).concat(["info"])
if (ids.length <= 1) return
const currentActiveId = activeSessionId().get(instanceId)
let currentIndex = navigationIds.indexOf(currentActiveId || "")
const current = ids.indexOf(activeSessionId().get(instanceId) || "")
const next = (current + 1) % ids.length
if (ids[next]) setActiveSession(instanceId, ids[next])
if (currentIndex === -1) {
currentIndex = 0
}
const targetIndex = (currentIndex + 1) % navigationIds.length
const targetSessionId = navigationIds[targetIndex]
setActiveSession(instanceId, targetSessionId)
},
description: "next session",
context: "global",

View File

@@ -1599,4 +1599,142 @@ button.button-primary {
@apply flex flex-col h-full;
background-color: var(--surface-base);
color: inherit;
}
/* Session list component */
.session-list-container {
@apply flex flex-col h-full relative;
background-color: var(--surface-secondary);
min-width: 200px;
max-width: 500px;
}
.session-resize-handle {
@apply absolute top-0 right-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
z-index: 10;
}
.session-resize-handle:hover {
background-color: var(--accent-primary);
}
.session-resize-handle::before {
content: "";
@apply absolute top-0 left-0 w-2 h-full -translate-x-1/2;
}
.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-header-hints {
@apply flex-shrink-0;
}
.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 items-center gap-3 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-active {
background-color: var(--accent-primary);
color: var(--text-inverted);
font-weight: var(--font-weight-medium);
}
.session-item-inactive {
color: var(--text-secondary);
}
.session-item-inactive:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.session-item-special {
color: var(--text-muted);
font-style: italic;
}
.session-item-active .session-item-close:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.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);
}
[data-theme="dark"] .session-new-button {
background-color: var(--surface-base);
color: var(--text-primary);
}
[data-theme="dark"] .session-new-button:hover {
background-color: var(--surface-hover);
}
/* Responsive behavior for session list */
@media (max-width: 768px) {
.session-list-container {
min-width: 200px;
}
.session-item-base {
@apply px-2 py-2;
}
}