refactor: restructure session sidebar layout

This commit is contained in:
Shantur Rathore
2025-10-29 22:01:17 +00:00
parent c3504266fa
commit 7542110120
6 changed files with 227 additions and 100 deletions

View File

@@ -9,6 +9,9 @@ import SessionList from "./components/session-list"
import MessageStream from "./components/message-stream"
import PromptInput from "./components/prompt-input"
import InfoView from "./components/info-view"
import AgentSelector from "./components/agent-selector"
import ModelSelector from "./components/model-selector"
import KeyboardHint from "./components/keyboard-hint"
import { initMarkdown } from "./lib/markdown"
import { useTheme } from "./lib/theme"
import { createCommandRegistry } from "./lib/commands"
@@ -78,14 +81,6 @@ const SessionView: Component<{
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
}
async function handleAgentChange(agent: string) {
await updateSessionAgent(props.instanceId, props.sessionId, agent)
}
async function handleModelChange(model: { providerId: string; modelId: string }) {
await updateSessionModel(props.instanceId, props.sessionId, model)
}
async function handleRevert(messageId: string) {
const instance = instances().get(props.instanceId)
if (!instance || !instance.client) return
@@ -148,10 +143,6 @@ const SessionView: Component<{
instanceFolder={props.instanceFolder}
sessionId={s().id}
onSend={handleSendMessage}
agent={s().agent}
model={s().model}
onAgentChange={handleAgentChange}
onModelChange={handleModelChange}
escapeInDebounce={props.escapeInDebounce}
/>
</div>
@@ -189,6 +180,29 @@ const App: Component = () => {
return activeSessionId().get(instance.id) || null
})
const activeSessionForInstance = createMemo(() => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return null
return activeSessions().get(sessionId) ?? null
})
const handleSidebarAgentChange = async (agent: string) => {
const instance = activeInstance()
const sessionId = activeSessionIdForInstance()
if (!instance || !sessionId || sessionId === "info") return
await updateSessionAgent(instance.id, sessionId, agent)
}
const handleSidebarModelChange = async (model: { providerId: string; modelId: string }) => {
const instance = activeInstance()
const sessionId = activeSessionIdForInstance()
if (!instance || !sessionId || sessionId === "info") return
await updateSessionModel(instance.id, sessionId, model)
}
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
async function handleSelectFolder(folderPath?: string, binaryPath?: string) {
setIsSelectingFolder(true)
try {
@@ -813,24 +827,76 @@ const App: Component = () => {
{(instance) => (
<>
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={instance()} />}>
<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="flex flex-1 min-h-0">
{/* Session Sidebar */}
<div
class="session-sidebar flex flex-col bg-surface-secondary"
style={{ width: `${sessionSidebarWidth()}px` }}
>
<SessionList
instanceId={instance().id}
sessions={activeSessions()}
activeSessionId={activeSessionIdForInstance()}
onSelect={(id) => setActiveSession(instance().id, id)}
onClose={(id) => handleCloseSession(instance().id, id)}
onNew={() => handleNewSession(instance().id)}
showHeader
showFooter={false}
headerContent={
<div class="session-sidebar-header">
<span class="session-sidebar-title text-sm font-semibold text-primary">Sessions</span>
<div class="session-sidebar-shortcuts">
{(() => {
const shortcut = keyboardRegistry.get("session-prev")
return shortcut ? <KeyboardHint shortcuts={[shortcut]} separator="" /> : null
})()}
{(() => {
const shortcut = keyboardRegistry.get("session-next")
return shortcut ? <KeyboardHint shortcuts={[shortcut]} separator="" /> : null
})()}
</div>
<button
class="session-sidebar-new inline-flex items-center justify-center gap-1.5 rounded-md border border-base px-3 py-2 text-xs font-semibold transition-colors hover:border-accent-primary hover:text-accent-primary"
onClick={() => handleNewSession(instance().id)}
type="button"
aria-label="Create new session"
title="New session (Cmd/Ctrl+Shift+N)"
>
<span class="leading-none">New Session</span>
</button>
</div>
}
onWidthChange={setSessionSidebarWidth}
/>
<div class="session-sidebar-separator border-t border-base" />
<Show when={activeSessionForInstance()}>
{(activeSession) => (
<div class="session-sidebar-controls px-3 py-3 border-r border-base flex flex-col gap-3">
<AgentSelector
instanceId={instance().id}
sessionId={activeSession().id}
currentAgent={activeSession().agent}
onAgentChange={handleSidebarAgentChange}
/>
<ModelSelector
instanceId={instance().id}
sessionId={activeSession().id}
currentModel={activeSession().model}
onModelChange={handleSidebarModelChange}
/>
</div>
)}
</Show>
</div>
{/* Main Content Area */}
<div class="content-area flex-1 overflow-hidden flex flex-col">
<div class="content-area flex-1 min-h-0 overflow-hidden flex flex-col">
<Show
when={activeSessionIdForInstance() === "info"}
fallback={
<Show
when={activeSessionIdForInstance()}
keyed
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500 dark:text-gray-400">
@@ -840,13 +906,15 @@ const App: Component = () => {
</div>
}
>
<SessionView
sessionId={activeSessionIdForInstance()!}
activeSessions={activeSessions()}
instanceId={activeInstance()!.id}
instanceFolder={activeInstance()!.folder}
escapeInDebounce={escapeInDebounce()}
/>
{(sessionId) => (
<SessionView
sessionId={sessionId}
activeSessions={activeSessions()}
instanceId={activeInstance()!.id}
instanceFolder={activeInstance()!.folder}
escapeInDebounce={escapeInDebounce()}
/>
)}
</Show>
}
>

View File

@@ -53,7 +53,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
}
return (
<div class="flex items-center gap-2">
<div class="sidebar-selector">
<Select
value={availableAgents().find((a) => a.name === props.currentAgent)}
onChange={handleChange}
@@ -108,7 +108,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
</Select.Content>
</Select.Portal>
</Select>
<span class="hint">
<span class="hint sidebar-selector-hint">
<Kbd shortcut="cmd+shift+a" />
</span>
</div>

View File

@@ -63,7 +63,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
})
return (
<div class="flex items-center gap-2">
<div class="sidebar-selector">
<Combobox<FlatModel>
value={currentModelValue()}
onChange={handleChange}
@@ -97,14 +97,14 @@ export default function ModelSelector(props: ModelSelectorProps) {
</Combobox.Item>
)}
>
<Combobox.Control class="relative" data-model-selector-control>
<Combobox.Control class="relative w-full" data-model-selector-control>
<Combobox.Input class="sr-only" data-model-selector />
<Combobox.Trigger
ref={triggerRef}
class="selector-trigger"
>
<div class="selector-trigger-label">
<span class="selector-trigger-primary">
<div class="selector-trigger-label selector-trigger-label--stacked">
<span class="selector-trigger-primary selector-trigger-primary--align-left">
Model: {currentModelValue()?.name ?? "None"}
</span>
{currentModelValue() && (
@@ -132,7 +132,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
</Combobox.Content>
</Combobox.Portal>
</Combobox>
<span class="hint">
<span class="hint sidebar-selector-hint">
<Kbd shortcut="cmd+shift+m" />
</span>
</div>

View File

@@ -1,6 +1,4 @@
import { createSignal, Show, onMount, For, onCleanup } from "solid-js"
import AgentSelector from "./agent-selector"
import ModelSelector from "./model-selector"
import UnifiedPicker from "./unified-picker"
import { addToHistory, getHistory } from "../stores/message-history"
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
@@ -17,10 +15,6 @@ interface PromptInputProps {
sessionId: string
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
disabled?: boolean
agent: string
model: { providerId: string; modelId: string }
onAgentChange: (agent: string) => Promise<void>
onModelChange: (model: { providerId: string; modelId: string }) => Promise<void>
escapeInDebounce?: boolean
}
@@ -730,37 +724,25 @@ export default function PromptInput(props: PromptInputProps) {
</button>
</div>
<div class="prompt-input-hints">
<HintRow>
<Show
when={props.escapeInDebounce}
fallback={
<>
<Kbd>Enter</Kbd> to send <Kbd>Shift+Enter</Kbd> for new line <Kbd>@</Kbd> for files/agents {" "}
<Kbd></Kbd> for history
<Show when={attachments().length > 0}>
<span class="ml-2 text-xs" style="color: var(--text-muted);"> {attachments().length} file(s) attached</span>
</Show>
</>
}
>
<span class="font-medium" style="color: var(--status-warning);">
Press <Kbd>Esc</Kbd> again to abort session
</span>
</Show>
</HintRow>
<div class="flex items-center gap-2">
<AgentSelector
instanceId={props.instanceId}
sessionId={props.sessionId}
currentAgent={props.agent}
onAgentChange={props.onAgentChange}
/>
<ModelSelector
instanceId={props.instanceId}
sessionId={props.sessionId}
currentModel={props.model}
onModelChange={props.onModelChange}
/>
<div class="flex justify-end">
<HintRow>
<Show
when={props.escapeInDebounce}
fallback={
<>
<Kbd>Enter</Kbd> to send <Kbd>Shift+Enter</Kbd> for new line <Kbd>@</Kbd> for files/agents {" "}
<Kbd></Kbd> for history
<Show when={attachments().length > 0}>
<span class="ml-2 text-xs" style="color: var(--text-muted);"> {attachments().length} file(s) attached</span>
</Show>
</>
}
>
<span class="font-medium" style="color: var(--status-warning);">
Press <Kbd>Esc</Kbd> again to abort session
</span>
</Show>
</HintRow>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { Component, For, Show, createSignal, createEffect, onCleanup, onMount, createMemo } from "solid-js"
import { Component, For, Show, createSignal, createEffect, onCleanup, onMount, createMemo, JSX } from "solid-js"
import type { Session } from "../types/session"
import { MessageSquare, Info, Plus, X } from "lucide-solid"
import KeyboardHint from "./keyboard-hint"
@@ -21,6 +21,11 @@ interface SessionListProps {
onSelect: (sessionId: string) => void
onClose: (sessionId: string) => void
onNew: () => void
showHeader?: boolean
showFooter?: boolean
headerContent?: JSX.Element
footerContent?: JSX.Element
onWidthChange?: (width: number) => void
}
const MIN_WIDTH = 200
@@ -57,6 +62,10 @@ const SessionList: Component<SessionListProps> = (props) => {
window.localStorage.setItem(STORAGE_KEY, width.toString())
})
createEffect(() => {
props.onWidthChange?.(sidebarWidth())
})
const clampWidth = (width: number) => Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width))
const removeMouseListeners = () => {
@@ -194,14 +203,18 @@ const SessionList: Component<SessionListProps> = (props) => {
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)}
/>
<Show when={props.showHeader !== false}>
<div class="session-list-header p-3 border-b border-base">
{props.headerContent ?? (
<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>
</Show>
<div class="session-list flex-1 overflow-y-auto">
<div class="session-section">
@@ -289,17 +302,21 @@ const SessionList: Component<SessionListProps> = (props) => {
</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>
<Show when={props.showFooter !== false}>
<div class="session-list-footer p-3 border-t border-base">
{props.footerContent ?? (
<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>
</Show>
</div>
)
}

View File

@@ -491,7 +491,7 @@ button.button-primary {
/* Message stream component utilities */
.message-stream-container {
@apply relative flex-1 flex flex-col overflow-hidden;
@apply relative flex-1 min-h-0 flex flex-col overflow-hidden;
}
.connection-status {
@@ -527,7 +527,7 @@ button.button-primary {
}
.message-stream {
@apply flex-1 overflow-y-auto p-4 flex flex-col gap-4;
@apply flex-1 min-h-0 overflow-y-auto p-4 flex flex-col gap-4;
background-color: var(--surface-base);
color: inherit;
}
@@ -1060,17 +1060,25 @@ button.button-primary {
}
.selector-trigger-label {
@apply flex flex-col items-start min-w-0;
@apply flex flex-col min-w-0;
}
.selector-trigger-label--stacked {
@apply items-start;
}
.selector-trigger-primary {
@apply text-sm font-medium truncate;
color: var(--text-primary);
font-weight: var(--font-weight-medium);
}
.selector-trigger-primary--align-left {
@apply text-left w-full;
}
.selector-trigger-secondary {
@apply text-xs text-left truncate;
color: var(--text-muted);
font-size: 10px;
}
.selector-trigger-icon {
@@ -1622,19 +1630,71 @@ button.button-primary {
/* Session view utility */
.session-view {
@apply flex flex-col h-full;
@apply flex flex-1 min-h-0 flex-col;
background-color: var(--surface-base);
color: inherit;
}
/* Session list component */
.session-list-container {
@apply flex flex-col h-full relative;
@apply flex flex-col flex-1 min-h-0 relative;
background-color: var(--surface-secondary);
min-width: 200px;
max-width: 500px;
}
.session-sidebar {
@apply flex flex-col min-h-0;
background-color: var(--surface-secondary);
}
.session-sidebar-header {
@apply flex flex-col gap-2 w-full;
}
.session-sidebar-title {
color: var(--text-primary);
}
.session-sidebar-shortcuts {
@apply flex flex-col gap-1;
}
.session-sidebar-new {
@apply w-full;
}
.session-sidebar-controls {
@apply flex flex-col gap-3;
background-color: var(--surface-secondary);
}
.session-sidebar-controls > * {
@apply w-full;
}
.session-sidebar-controls .selector-trigger,
.session-sidebar-controls [data-model-selector-control],
.session-sidebar-controls .selector-trigger-label,
.session-sidebar-controls .selector-trigger-primary {
@apply w-full;
}
.sidebar-selector {
@apply flex flex-col gap-1 w-full;
}
.sidebar-selector-hint {
@apply flex justify-center text-xs w-full;
color: var(--text-muted);
}
.session-sidebar-separator {
background-color: var(--border-base);
height: 1px;
width: 100%;
}
.session-resize-handle {
@apply absolute top-0 right-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
z-index: 10;