refactor: replace session tabs with sectioned list
This commit is contained in:
74
src/App.tsx
74
src/App.tsx
@@ -5,7 +5,7 @@ import FolderSelectionView from "./components/folder-selection-view"
|
|||||||
import InstanceWelcomeView from "./components/instance-welcome-view"
|
import InstanceWelcomeView from "./components/instance-welcome-view"
|
||||||
import CommandPalette from "./components/command-palette"
|
import CommandPalette from "./components/command-palette"
|
||||||
import InstanceTabs from "./components/instance-tabs"
|
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 MessageStream from "./components/message-stream"
|
||||||
import PromptInput from "./components/prompt-input"
|
import PromptInput from "./components/prompt-input"
|
||||||
import InfoView from "./components/info-view"
|
import InfoView from "./components/info-view"
|
||||||
@@ -813,42 +813,46 @@ const App: Component = () => {
|
|||||||
{(instance) => (
|
{(instance) => (
|
||||||
<>
|
<>
|
||||||
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={instance()} />}>
|
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={instance()} />}>
|
||||||
<SessionTabs
|
<div class="flex h-full">
|
||||||
instanceId={instance().id}
|
{/* Session List Sidebar */}
|
||||||
sessions={activeSessions()}
|
<SessionList
|
||||||
activeSessionId={activeSessionIdForInstance()}
|
instanceId={instance().id}
|
||||||
onSelect={(id) => setActiveSession(instance().id, id)}
|
sessions={activeSessions()}
|
||||||
onClose={(id) => handleCloseSession(instance().id, id)}
|
activeSessionId={activeSessionIdForInstance()}
|
||||||
onNew={() => handleNewSession(instance().id)}
|
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">
|
{/* Main Content Area */}
|
||||||
<Show
|
<div class="content-area flex-1 overflow-hidden flex flex-col">
|
||||||
when={activeSessionIdForInstance() === "info"}
|
<Show
|
||||||
fallback={
|
when={activeSessionIdForInstance() === "info"}
|
||||||
<Show
|
fallback={
|
||||||
when={activeSessionIdForInstance()}
|
<Show
|
||||||
fallback={
|
when={activeSessionIdForInstance()}
|
||||||
<div class="flex items-center justify-center h-full">
|
fallback={
|
||||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
<div class="flex items-center justify-center h-full">
|
||||||
<p class="mb-2">No session selected</p>
|
<div class="text-center text-gray-500 dark:text-gray-400">
|
||||||
<p class="text-sm">Select a session to view messages</p>
|
<p class="mb-2">No session selected</p>
|
||||||
|
<p class="text-sm">Select a session to view messages</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
>
|
||||||
>
|
<SessionView
|
||||||
<SessionView
|
sessionId={activeSessionIdForInstance()!}
|
||||||
sessionId={activeSessionIdForInstance()!}
|
activeSessions={activeSessions()}
|
||||||
activeSessions={activeSessions()}
|
instanceId={activeInstance()!.id}
|
||||||
instanceId={activeInstance()!.id}
|
instanceFolder={activeInstance()!.folder}
|
||||||
instanceFolder={activeInstance()!.folder}
|
escapeInDebounce={escapeInDebounce()}
|
||||||
escapeInDebounce={escapeInDebounce()}
|
/>
|
||||||
/>
|
</Show>
|
||||||
</Show>
|
}
|
||||||
}
|
>
|
||||||
>
|
<InfoView instanceId={instance().id} />
|
||||||
<InfoView instanceId={instance().id} />
|
</Show>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
|
|||||||
307
src/components/session-list.tsx
Normal file
307
src/components/session-list.tsx
Normal 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
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -5,6 +5,21 @@ import { getSessionFamily, activeSessionId, setActiveSession, activeParentSessio
|
|||||||
export function registerNavigationShortcuts() {
|
export function registerNavigationShortcuts() {
|
||||||
const isMac = () => navigator.platform.toLowerCase().includes("mac")
|
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({
|
keyboardRegistry.register({
|
||||||
id: "instance-prev",
|
id: "instance-prev",
|
||||||
key: "[",
|
key: "[",
|
||||||
@@ -43,16 +58,20 @@ export function registerNavigationShortcuts() {
|
|||||||
const instanceId = activeInstanceId()
|
const instanceId = activeInstanceId()
|
||||||
if (!instanceId) return
|
if (!instanceId) return
|
||||||
|
|
||||||
const parentId = activeParentSessionId().get(instanceId)
|
const navigationIds = buildNavigationOrder(instanceId)
|
||||||
if (!parentId) return
|
if (navigationIds.length === 0) return
|
||||||
|
|
||||||
const familySessions = getSessionFamily(instanceId, parentId)
|
const currentActiveId = activeSessionId().get(instanceId)
|
||||||
const ids = familySessions.map((s) => s.id).concat(["info"])
|
let currentIndex = navigationIds.indexOf(currentActiveId || "")
|
||||||
if (ids.length <= 1) return
|
|
||||||
|
|
||||||
const current = ids.indexOf(activeSessionId().get(instanceId) || "")
|
if (currentIndex === -1) {
|
||||||
const prev = current <= 0 ? ids.length - 1 : current - 1
|
currentIndex = navigationIds.length - 1
|
||||||
if (ids[prev]) setActiveSession(instanceId, ids[prev])
|
}
|
||||||
|
|
||||||
|
const targetIndex = currentIndex <= 0 ? navigationIds.length - 1 : currentIndex - 1
|
||||||
|
const targetSessionId = navigationIds[targetIndex]
|
||||||
|
|
||||||
|
setActiveSession(instanceId, targetSessionId)
|
||||||
},
|
},
|
||||||
description: "previous session",
|
description: "previous session",
|
||||||
context: "global",
|
context: "global",
|
||||||
@@ -66,16 +85,20 @@ export function registerNavigationShortcuts() {
|
|||||||
const instanceId = activeInstanceId()
|
const instanceId = activeInstanceId()
|
||||||
if (!instanceId) return
|
if (!instanceId) return
|
||||||
|
|
||||||
const parentId = activeParentSessionId().get(instanceId)
|
const navigationIds = buildNavigationOrder(instanceId)
|
||||||
if (!parentId) return
|
if (navigationIds.length === 0) return
|
||||||
|
|
||||||
const familySessions = getSessionFamily(instanceId, parentId)
|
const currentActiveId = activeSessionId().get(instanceId)
|
||||||
const ids = familySessions.map((s) => s.id).concat(["info"])
|
let currentIndex = navigationIds.indexOf(currentActiveId || "")
|
||||||
if (ids.length <= 1) return
|
|
||||||
|
|
||||||
const current = ids.indexOf(activeSessionId().get(instanceId) || "")
|
if (currentIndex === -1) {
|
||||||
const next = (current + 1) % ids.length
|
currentIndex = 0
|
||||||
if (ids[next]) setActiveSession(instanceId, ids[next])
|
}
|
||||||
|
|
||||||
|
const targetIndex = (currentIndex + 1) % navigationIds.length
|
||||||
|
const targetSessionId = navigationIds[targetIndex]
|
||||||
|
|
||||||
|
setActiveSession(instanceId, targetSessionId)
|
||||||
},
|
},
|
||||||
description: "next session",
|
description: "next session",
|
||||||
context: "global",
|
context: "global",
|
||||||
|
|||||||
@@ -1599,4 +1599,142 @@ button.button-primary {
|
|||||||
@apply flex flex-col h-full;
|
@apply flex flex-col h-full;
|
||||||
background-color: var(--surface-base);
|
background-color: var(--surface-base);
|
||||||
color: inherit;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user