add responsive session sidebar
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Show, createMemo, createSignal, type Component } from "solid-js"
|
||||
import { Show, createMemo, createSignal, onCleanup, onMount, type Component } from "solid-js"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { Instance } from "../../types/instance"
|
||||
import type { Command } from "../../lib/commands"
|
||||
@@ -30,9 +30,38 @@ interface InstanceShellProps {
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_SIDEBAR_WIDTH = 350
|
||||
const MOBILE_SIDEBAR_BREAKPOINT = 1024
|
||||
|
||||
const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||
const [isCompactLayout, setIsCompactLayout] = createSignal(false)
|
||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
|
||||
const sidebarId = `session-sidebar-${props.instance.id}`
|
||||
let previousIsCompact = false
|
||||
|
||||
const shouldShowSidebarToggle = () => isCompactLayout() && !isSidebarOpen()
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
const handleResize = () => {
|
||||
const compact = window.innerWidth < MOBILE_SIDEBAR_BREAKPOINT
|
||||
setIsCompactLayout(compact)
|
||||
if (!compact) {
|
||||
setIsSidebarOpen(true)
|
||||
} else if (!previousIsCompact && compact) {
|
||||
setIsSidebarOpen(false)
|
||||
}
|
||||
previousIsCompact = compact
|
||||
}
|
||||
|
||||
handleResize()
|
||||
window.addEventListener("resize", handleResize)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("resize", handleResize)
|
||||
})
|
||||
})
|
||||
|
||||
const activeSessions = createMemo(() => {
|
||||
const parentId = activeParentSessionId().get(props.instance.id)
|
||||
@@ -68,8 +97,20 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<div class="session-sidebar flex flex-col bg-surface-secondary" style={{ width: `${sessionSidebarWidth()}px` }}>
|
||||
<div
|
||||
class="flex flex-1 min-h-0 relative"
|
||||
classList={{ "session-layout-compact": isCompactLayout() }}
|
||||
>
|
||||
<div
|
||||
id={sidebarId}
|
||||
class="session-sidebar flex flex-col bg-surface-secondary"
|
||||
classList={{
|
||||
"session-sidebar-overlay": isCompactLayout(),
|
||||
"session-sidebar-collapsed": isCompactLayout() && !isSidebarOpen(),
|
||||
}}
|
||||
style={!isCompactLayout() ? { width: `${sessionSidebarWidth()}px` } : undefined}
|
||||
aria-hidden={isCompactLayout() && !isSidebarOpen()}
|
||||
>
|
||||
<SessionList
|
||||
instanceId={props.instance.id}
|
||||
sessions={activeSessions()}
|
||||
@@ -91,7 +132,19 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
showFooter={false}
|
||||
headerContent={
|
||||
<div class="session-sidebar-header">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
|
||||
<div class="session-sidebar-header-row">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
|
||||
<Show when={isCompactLayout()}>
|
||||
<button
|
||||
type="button"
|
||||
class="session-sidebar-close"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
aria-label="Close session sidebar"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
{keyboardShortcuts().length ? (
|
||||
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
@@ -138,6 +191,20 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<div class="content-area flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
<Show
|
||||
when={shouldShowSidebarToggle() && (!activeSessionIdForInstance() || activeSessionIdForInstance() === "info")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="session-sidebar-menu-button session-sidebar-menu-button--floating"
|
||||
onClick={() => setIsSidebarOpen(true)}
|
||||
aria-controls={sidebarId}
|
||||
aria-expanded={isSidebarOpen()}
|
||||
aria-label="Open session list"
|
||||
>
|
||||
<span aria-hidden="true" class="session-sidebar-menu-icon">☰</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show
|
||||
when={activeSessionIdForInstance() === "info"}
|
||||
fallback={
|
||||
@@ -160,6 +227,8 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
instanceId={props.instance.id}
|
||||
instanceFolder={props.instance.folder}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
showSidebarToggle={shouldShowSidebarToggle()}
|
||||
onSidebarToggle={() => setIsSidebarOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
@@ -168,6 +237,15 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
<InfoView instanceId={props.instance.id} />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={isCompactLayout() && isSidebarOpen()}>
|
||||
<button
|
||||
type="button"
|
||||
class="session-sidebar-backdrop"
|
||||
aria-label="Close session sidebar"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Show } from "solid-js"
|
||||
import Kbd from "./kbd"
|
||||
|
||||
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
||||
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-primary/70"
|
||||
|
||||
interface MessageListHeaderProps {
|
||||
usedTokens: number
|
||||
|
||||
availableTokens?: number | null
|
||||
connectionStatus: "connected" | "connecting" | "error" | "disconnected" | "unknown" | null
|
||||
onCommandPalette: () => void
|
||||
formatTokens: (value: number) => string
|
||||
showSidebarToggle?: boolean
|
||||
onSidebarToggle?: () => void
|
||||
}
|
||||
|
||||
export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
@@ -15,14 +21,26 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
|
||||
return (
|
||||
<div class="connection-status">
|
||||
<div class="connection-status-text connection-status-info flex flex-wrap items-center gap-2 text-sm font-medium">
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
|
||||
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
|
||||
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
|
||||
<div class="connection-status-text connection-status-info flex flex-wrap items-center gap-3 text-sm font-medium">
|
||||
<Show when={props.showSidebarToggle}>
|
||||
<button
|
||||
type="button"
|
||||
class="session-sidebar-menu-button"
|
||||
onClick={() => props.onSidebarToggle?.()}
|
||||
aria-label="Open session list"
|
||||
>
|
||||
<span aria-hidden="true" class="session-sidebar-menu-icon">☰</span>
|
||||
</button>
|
||||
</Show>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||
<div class={METRIC_CHIP_CLASS}>
|
||||
<span class={METRIC_LABEL_CLASS}>Used</span>
|
||||
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
|
||||
</div>
|
||||
<div class={METRIC_CHIP_CLASS}>
|
||||
<span class={METRIC_LABEL_CLASS}>Avail</span>
|
||||
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,19 +59,19 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
<Show when={props.connectionStatus === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
<span class="status-dot" />
|
||||
Connected
|
||||
<span class="status-text">Connected</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.connectionStatus === "connecting"}>
|
||||
<span class="status-indicator connecting">
|
||||
<span class="status-dot" />
|
||||
Connecting...
|
||||
<span class="status-text">Connecting...</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.connectionStatus === "error" || props.connectionStatus === "disconnected"}>
|
||||
<span class="status-indicator disconnected">
|
||||
<span class="status-dot" />
|
||||
Disconnected
|
||||
<span class="status-text">Disconnected</span>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface MessageSectionProps {
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
registerScrollToBottom?: (fn: () => void) => void
|
||||
showSidebarToggle?: boolean
|
||||
onSidebarToggle?: () => void
|
||||
}
|
||||
|
||||
export default function MessageSection(props: MessageSectionProps) {
|
||||
@@ -336,6 +338,8 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
connectionStatus={connectionStatus()}
|
||||
onCommandPalette={handleCommandPaletteClick}
|
||||
formatTokens={formatTokens}
|
||||
showSidebarToggle={props.showSidebarToggle}
|
||||
onSidebarToggle={props.onSidebarToggle}
|
||||
/>
|
||||
|
||||
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll}>
|
||||
|
||||
@@ -19,6 +19,8 @@ interface SessionViewProps {
|
||||
instanceId: string
|
||||
instanceFolder: string
|
||||
escapeInDebounce: boolean
|
||||
showSidebarToggle?: boolean
|
||||
onSidebarToggle?: () => void
|
||||
}
|
||||
|
||||
export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
@@ -150,6 +152,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
registerScrollToBottom={(fn) => {
|
||||
scrollToBottomHandle = fn
|
||||
}}
|
||||
showSidebarToggle={props.showSidebarToggle}
|
||||
onSidebarToggle={props.onSidebarToggle}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,48 @@
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.session-sidebar-menu-button {
|
||||
@apply inline-flex items-center justify-center border rounded-md px-2 py-1 text-sm font-medium;
|
||||
border-color: var(--border-base);
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
transition: color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.session-sidebar-menu-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.session-sidebar-menu-button:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-sidebar-menu-icon {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
@apply flex items-center gap-1.5 text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-indicator .status-dot {
|
||||
@apply w-2 h-2 rounded-full;
|
||||
}
|
||||
|
||||
.status-indicator .status-text {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.status-indicator .status-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.connection-status-info {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
@@ -17,10 +17,71 @@
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-layout-compact {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.session-sidebar-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: min(90vw, 360px);
|
||||
max-width: 360px;
|
||||
border-right: 1px solid var(--border-base);
|
||||
box-shadow: var(--folder-card-shadow);
|
||||
transform: translateX(0);
|
||||
transition: transform 0.25s ease, opacity 0.2s ease;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.session-sidebar-collapsed {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.session-sidebar-backdrop {
|
||||
@apply absolute inset-0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background-color: var(--overlay-scrim);
|
||||
cursor: pointer;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.session-sidebar-menu-button--floating {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.session-sidebar-header {
|
||||
@apply flex flex-col gap-2 w-full;
|
||||
}
|
||||
|
||||
.session-sidebar-header-row {
|
||||
@apply flex items-center justify-between gap-2;
|
||||
}
|
||||
|
||||
.session-sidebar-close {
|
||||
@apply inline-flex items-center gap-1 text-xs font-medium px-2 py-1 rounded-md border transition-colors;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-sidebar-close:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.session-sidebar-close:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-sidebar-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user