diff --git a/src/App.tsx b/src/App.tsx index 81951b74..571b89dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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) => ( <> 0} fallback={}> - setActiveSession(instance().id, id)} - onClose={(id) => handleCloseSession(instance().id, id)} - onNew={() => handleNewSession(instance().id)} - /> + + {/* Session List Sidebar */} + setActiveSession(instance().id, id)} + onClose={(id) => handleCloseSession(instance().id, id)} + onNew={() => handleNewSession(instance().id)} + /> - - - - No session selected - Select a session to view messages + {/* Main Content Area */} + + + + No session selected + Select a session to view messages + - - } - > - - - } - > - - + } + > + + + } + > + + + > diff --git a/src/components/session-list.tsx b/src/components/session-list.tsx new file mode 100644 index 00000000..f67f8ad5 --- /dev/null +++ b/src/components/session-list.tsx @@ -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 + 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 = (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 ( + + + + + + Sessions + + + + + + + + Parent & Info + + + {(item) => ( + + + }> + + + + {item.title} + + + { + event.stopPropagation() + item.onClose?.() + }} + role="button" + tabIndex={0} + aria-label="Close session" + > + + + + + + )} + + + + 0}> + + + Child Sessions + + + {(item) => ( + + + + + {item.title} + + + { + event.stopPropagation() + item.onClose?.() + }} + role="button" + tabIndex={0} + aria-label="Close session" + > + + + + + + )} + + + + + + + + ) +} + +export default SessionList diff --git a/src/components/session-tab.tsx b/src/components/session-tab.tsx deleted file mode 100644 index 8ee0e54b..00000000 --- a/src/components/session-tab.tsx +++ /dev/null @@ -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 = (props) => { - const label = () => { - if (props.special === "info") return "Info" - return props.session?.title || "Untitled" - } - - return ( - - - }> - - - {label()} - - { - e.stopPropagation() - props.onClose?.() - }} - role="button" - tabIndex={0} - aria-label="Close session" - > - - - - - - ) -} - -export default SessionTab diff --git a/src/components/session-tabs.tsx b/src/components/session-tabs.tsx deleted file mode 100644 index fca7c0ea..00000000 --- a/src/components/session-tabs.tsx +++ /dev/null @@ -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 - activeSessionId: string | null - onSelect: (sessionId: string) => void - onClose: (sessionId: string) => void - onNew: () => void -} - -const SessionTabs: Component = (props) => { - const sessionsList = () => Array.from(props.sessions.entries()) - const totalTabs = () => sessionsList().length + 1 - - return ( - - - - - {([id, session]) => ( - props.onSelect(id)} - onClose={session.parentId === null ? () => props.onClose(id) : undefined} - /> - )} - - props.onSelect("info")} - /> - - - - - 1}> - - - - - - - ) -} - -export default SessionTabs diff --git a/src/lib/shortcuts/navigation.ts b/src/lib/shortcuts/navigation.ts index 471bfdb3..987b2418 100644 --- a/src/lib/shortcuts/navigation.ts +++ b/src/lib/shortcuts/navigation.ts @@ -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", diff --git a/src/styles/components.css b/src/styles/components.css index 5bea0156..2175973f 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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; + } } \ No newline at end of file
No session selected
Select a session to view messages