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:
parent
854390b826
commit
906d86686e
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)}>▾</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) }}
|
||||
>✕</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 && (
|
||||
|
|
|
|||
114
src/style.css
114
src/style.css
|
|
@ -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 {
|
||||
|
|
|
|||
Reference in New Issue