sidebar: collapsible session tree, show cwd in header, remove auto-focus

- Replace card layout with compact tree nodes
- Nest sub-agents by __ separator with collapsible chevrons
- Display cwd as dim badge in chat header
- Remove PromptBar auto-focus that interfered with rename
This commit is contained in:
Levi Neely 2026-04-29 18:21:46 +02:00
parent 854390b826
commit 906d86686e
4 changed files with 151 additions and 100 deletions

View File

@ -222,6 +222,7 @@ export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, b
<div class={`state-dot ${stateClass(state)}`} style="margin-right: 2px;" />
<span class="badge accent">{cfg.backend ?? session.backend}</span>
<span class="badge">{truncate(cfg.model ?? session.model, 24)}</span>
<span class="badge dim" title={session.cwd}>{session.cwd}</span>
{cfg.agent && cfg.agent !== 'default' && (
<span class="badge warn">{cfg.agent}</span>
)}

View File

@ -10,7 +10,6 @@ export function PromptBar({ running, onSend, onStop }: Props) {
const [value, setValue] = useState('')
const ref = useRef<HTMLTextAreaElement>(null)
useEffect(() => { ref.current?.focus() })
const submit = () => {
const text = value.trim()

View File

@ -1,7 +1,6 @@
import { useState } from 'preact/hooks'
import type { IdxEntry } from '../lib/api'
import type { Theme } from '../lib/theme'
import { truncate } from '../lib/chat'
interface Props {
sessions: IdxEntry[]
@ -18,12 +17,44 @@ interface Props {
function stateClass(state: string) {
if (state === 'idle') return 'idle'
if (state === 'stopped') return 'stopped'
return 'running' // thinking, calling: <tool>, etc.
return 'running'
}
interface SessionNode {
session: IdxEntry | null
children: Map<string, SessionNode>
}
function buildTree(sessions: IdxEntry[]): SessionNode {
const root: SessionNode = { session: null, children: new Map() }
for (const s of sessions) {
const parts = s.id.split('__')
let node = root
for (let i = 0; i < parts.length - 1; i++) {
const key = parts.slice(0, i + 1).join('__')
if (!node.children.has(key)) {
node.children.set(key, { session: null, children: new Map() })
}
node = node.children.get(key)!
}
if (node.children.has(s.id)) {
node.children.get(s.id)!.session = s
} else {
node.children.set(s.id, { session: s, children: new Map() })
}
}
return root
}
function leafName(id: string): string {
const parts = id.split('__')
return parts[parts.length - 1]
}
export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill, onRename, themes, currentTheme, onThemeChange }: Props) {
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const startRename = (e: MouseEvent, id: string) => {
e.stopPropagation()
@ -36,6 +67,70 @@ export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill, onRenam
setEditingId(null)
if (name && name !== oldId) onRename(oldId, name)
}
const toggle = (id: string, e: MouseEvent) => {
e.stopPropagation()
setCollapsed(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const tree = buildTree(sessions)
function renderNode(node: SessionNode, depth: number): any {
const items: any[] = []
for (const [id, child] of node.children) {
const s = child.session
const hasChildren = child.children.size > 0
const isCollapsed = collapsed.has(id)
if (s) {
items.push(
<div key={id} class="stree-node" style={{ marginLeft: (depth * 14) + 'px' }}>
<div
class={`stree-row${s.id === selectedId ? ' selected' : ''}`}
onClick={() => onSelect(s.id === selectedId ? null : s.id)}
>
{hasChildren && (
<span class={`stree-chevron${isCollapsed ? ' collapsed' : ''}`} onClick={(e: MouseEvent) => toggle(id, e)}>&#9662;</span>
)}
<span class={`state-dot ${stateClass(s.state)}`} />
{editingId === s.id ? (
<input
class="session-rename-input"
value={editValue}
onInput={e => setEditValue((e.target as HTMLInputElement).value)}
onBlur={() => commitRename(s.id)}
onKeyDown={e => { if (e.key === 'Enter') commitRename(s.id); if (e.key === 'Escape') setEditingId(null) }}
onClick={e => e.stopPropagation()}
autoFocus
/>
) : (
<span class="stree-label" onDblClick={(e: MouseEvent) => startRename(e, s.id)}>{leafName(s.id)}</span>
)}
<span class="stree-state">{s.state}</span>
<button
class="btn-kill"
title="Kill session"
onClick={(e: MouseEvent) => { e.stopPropagation(); onKill(s.id) }}
>&#10005;</button>
</div>
{hasChildren && !isCollapsed && renderNode(child, depth + 1)}
</div>
)
} else if (hasChildren) {
items.push(
<div key={id}>
{renderNode(child, depth + 1)}
</div>
)
}
}
return items
}
return (
<aside class="sidebar">
<div class="sidebar-header">
@ -57,41 +152,7 @@ export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill, onRenam
{sessions.length === 0 && (
<div class="session-empty">no sessions</div>
)}
{sessions.map(s => (
<div
key={s.id}
class={`session-card${s.id === selectedId ? ' selected' : ''}`}
onClick={() => onSelect(s.id === selectedId ? null : s.id)}
>
<div class="session-card-row">
<div class={`state-dot ${stateClass(s.state)}`} />
{editingId === s.id ? (
<input
class="session-rename-input"
value={editValue}
onInput={e => setEditValue((e.target as HTMLInputElement).value)}
onBlur={() => commitRename(s.id)}
onKeyDown={e => { if (e.key === 'Enter') commitRename(s.id); if (e.key === 'Escape') setEditingId(null) }}
onClick={e => e.stopPropagation()}
autoFocus
/>
) : (
<div class="session-id-label" onDblClick={(e: MouseEvent) => startRename(e, s.id)}>{s.id}</div>
)}
<div class="session-state-label">{s.state}</div>
<button
class="btn-kill"
title="Kill session"
onClick={(e: MouseEvent) => { e.stopPropagation(); onKill(s.id) }}
>✕</button>
</div>
<div class="session-cwd" title={s.cwd}>{s.cwd}</div>
<div class="session-badges">
<span class="badge accent">{s.backend}</span>
<span class="badge">{truncate(s.model, 22)}</span>
</div>
</div>
))}
{renderNode(tree, 0)}
</div>
{themes.length > 1 && (

View File

@ -156,32 +156,54 @@ input, select, textarea { font-family: inherit; }
}
.theme-select:focus { border-color: var(--accent-focus); }
/* ---- Session Card ---- */
.session-card {
padding: 10px 10px 10px 12px;
border-radius: var(--r-lg);
cursor: pointer;
margin-bottom: 2px;
border: 1px solid transparent;
transition: background 0.12s, border-color 0.12s;
position: relative;
}
.session-card:hover {
background: var(--hover);
border-color: var(--border);
}
.session-card.selected {
background: var(--accent-soft);
border-color: var(--accent-border);
}
.session-card-row {
/* ---- Session Tree ---- */
.stree-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
gap: 6px;
padding: 4px 8px 4px 0;
border-radius: var(--r-sm);
cursor: pointer;
font-family: var(--mono);
font-size: 12px;
white-space: nowrap;
position: relative;
}
.stree-row:hover {
background: var(--hover);
}
.stree-row.selected {
background: var(--accent-soft);
}
.stree-chevron {
font-size: 18px;
color: var(--text-faint);
cursor: pointer;
transition: transform 0.12s;
user-select: none;
width: 10px;
text-align: center;
line-height: 7px;
margin-top: -3px;
display: inline-flex;
align-items: center;
justify-content: center;
height: 7px;
margin-top: -3px;
}
.stree-chevron.collapsed {
transform: rotate(-90deg);
}
.stree-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
}
.stree-state {
font-size: 10px;
color: var(--text-faint);
flex-shrink: 0;
}
.state-dot {
@ -203,43 +225,6 @@ input, select, textarea { font-family: inherit; }
50% { opacity: 0.6; box-shadow: 0 0 3px var(--running-glow); }
}
.session-id-label {
font-family: var(--mono);
font-size: 12px;
font-weight: 500;
color: var(--text);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-state-label {
font-family: var(--mono);
font-size: 10px;
color: var(--text-faint);
flex-shrink: 0;
}
.session-cwd {
font-family: var(--mono);
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-left: 15px;
margin-bottom: 6px;
}
.session-badges {
display: flex;
align-items: center;
gap: 5px;
padding-left: 15px;
flex-wrap: wrap;
}
.btn-kill {
display: none;
background: none;
@ -252,7 +237,7 @@ input, select, textarea { font-family: inherit; }
border-radius: var(--r-sm);
}
.btn-kill:hover { color: var(--danger); }
.session-card:hover .btn-kill { display: block; }
.stree-row:hover .btn-kill { display: block; }
/* ---- Badges ---- */
.badge {
@ -276,6 +261,11 @@ input, select, textarea { font-family: inherit; }
border-color: rgba(251, 191, 36, 0.22);
background: rgba(251, 191, 36, 0.05);
}
.badge.dim {
color: var(--text-faint);
border-color: transparent;
background: none;
}
/* ---- Buttons ---- */
.btn {