sessions list: readable tabs, a real separator, headings that stay put

Four things, three you asked for and one the screenshot gave away.

The separator under the tabs now borrows the panel header's own tokens — --panel-header-bg and
--panel-header-border — rather than bg-muted/30 + border-border. Two rules a few pixels apart that do not
match read as a mistake, and border-border was faint enough here to look like no separator at all.

The active tab was bg-background sitting on a bg-background/60 track: a few percent of opacity was the only
thing distinguishing it. The track is gone, each tab is its own control with a gap between them, and the
active one carries a border, a solid fill and a shadow so it reads as raised.

Directory headings are sticky and heavier. An expanded directory can be taller than the panel, so the
heading scrolled away with its contents and halfway down a long list nothing on screen said which directory
you were reading. Opaque bg-muted, not a tint — a translucent sticky header shows the rows sliding under it.

And the paths were rendering backwards: dir="rtl" keeps a long path's tail visible but reorders the whole
string, so ~/officer-dev/platform displayed as officer-dev/platform/~ with the home marker at the end. Plain
truncate now, full path on the title.
This commit is contained in:
2026-08-17 17:58:41 +00:00
parent d59bc0d152
commit 640a74bcd5
2 changed files with 69 additions and 22 deletions
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { MessageSquare, Plus, RefreshCw } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -56,7 +56,7 @@ export const SessionList = () => {
// link and on back/forward, before any panel has published.
const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>();
const groupCwd = cwdFromSplat(splat);
const [, setSelected] = useSelectedChatSession();
const [selected, setSelected] = useSelectedChatSession();
const [view, setView] = useLocalStorageState<View>(VIEW_KEY, 'tree');
const [expanded, setExpanded] = useLocalStorageState<string[]>(EXPANDED_KEY, []);
@@ -85,17 +85,32 @@ export const SessionList = () => {
const activeRef = useScrollActiveIntoView();
/*
A group in the URL opens that directory in the Tree.
The directory the open conversation lives in — from the URL group when there is one, otherwise from the
session itself once its detail has resolved.
`/chat/g/<path>` is how the old dropdown addressed a directory and it is still a live URL shape, so it has
to mean something here. Expanding rather than filtering keeps the rest of the tree visible, which is the
whole reason the dropdown was replaced. Derived rather than written into storage, so a link never
permanently rearranges someone's tree.
A deep link `/chat/<id>` carries no directory at all: the id alone determines it, and the cwd only
arrives when the detail fetch publishes the selection. So this is null for the first moment of a deep
link and then becomes real, which is exactly what the effect below waits for.
*/
const treeExpanded = useMemo(
() => (groupCwd && !expanded.includes(groupCwd) ? [...expanded, groupCwd] : expanded),
[expanded, groupCwd],
);
const activeCwd = groupCwd ?? selected?.cwd ?? null;
/*
Open the folder the current conversation is in.
A tree that shows the open chat's directory collapsed is asking you to find something it already knows.
`/chat/g/<path>` needs the same treatment, and both are the same question, so one effect covers them.
An EFFECT, not a derived value, and the distinction is the whole behaviour: deriving it would force the
node open on every render, so collapsing the folder you are working in would be impossible — it would
spring back. This nudges it open once per conversation and then leaves you alone, which is what a file
tree does. The ref is what makes "once" true; without it, collapsing would re-fire on the next render.
*/
const autoExpanded = useRef<string | null>(null);
useEffect(() => {
if (!activeCwd || autoExpanded.current === activeCwd) return;
autoExpanded.current = activeCwd;
setExpanded((prev) => (prev.includes(activeCwd) ? prev : [...prev, activeCwd]));
}, [activeCwd, setExpanded]);
/** Live rows joined to a transcript id, so any view can mark a row as live. */
const liveById = useMemo(
@@ -118,14 +133,27 @@ export const SessionList = () => {
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center gap-2 border-b border-border px-2 py-2">
<Tabs value={view} onValueChange={(v) => setView(v as View)}>
<TabsList className="inline-flex h-auto items-center rounded-md bg-muted p-0.5 text-muted-foreground">
{/* The header is its own bar, not a row that happens to sit above a list. A single hairline was not
enough separation once the body could be an accordion — the tree's own directory borders are the
same weight, so the panel read as one continuous stack with a stray row on top. A tinted background
plus the rule makes the boundary unambiguous at a glance. */}
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--panel-header-border)] bg-[var(--panel-header-bg)] px-2 py-2">
{/* `flex-1` so the tabs spread across whatever the header has left after the two buttons. They were
sized to their labels, which left most of the bar empty and made the three targets needlessly
small in a panel that is already narrow. `min-w-0` so long labels shrink rather than pushing the
buttons off the edge.
Each tab is its own control — a gap between them and no track behind them — because the shared
track was the same colour as the active tab sitting on it, which made "which tab am I on" a
question about a few percent of opacity. The active one now carries a border, a solid background
and a shadow, so it reads as raised rather than as slightly lighter. */}
<Tabs value={view} onValueChange={(v) => setView(v as View)} className="min-w-0 flex-1">
<TabsList className="flex h-auto w-full items-center gap-1 bg-transparent p-0 text-muted-foreground">
{TABS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="cursor-pointer rounded px-2.5 py-1 text-xs font-medium leading-normal ring-0 transition-all hover:text-foreground focus-visible:ring-0 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
className="flex-1 cursor-pointer rounded-md border border-transparent px-2 py-1 text-xs font-medium leading-normal ring-0 transition-all hover:bg-foreground/5 hover:text-foreground focus-visible:ring-0 data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
>
{tab.label}
{/* The count that decides whether Live is worth a click. Only when non-zero — a permanent
@@ -168,7 +196,7 @@ export const SessionList = () => {
pwds={pwds}
home={defaultCwd}
liveSessions={liveSessions}
expanded={treeExpanded}
expanded={expanded}
onExpandedChange={setExpanded}
activeId={sessionId}
actions={actions}
@@ -56,20 +56,39 @@ const DirectoryNode = ({ pwd, home, expanded, onToggle, live, activeId, actions
return (
<div className="border-b border-border/60">
<div className="group/dir flex items-center">
{/*
Sticky, and visibly heavier than the rows it contains.
An expanded directory can be taller than the panel, and a plain header scrolled away with its
contents — so halfway down a long list there was nothing on screen saying which directory you were
reading. Sticking it to the top of the scroll area keeps that answer permanently visible, which is
the entire job of a heading in a tree.
The weight matters as much as the position: at the same visual weight as a session row, the headings
disappeared into the list the moment a node opened. `bg-muted` (opaque, not tinted — a translucent
header shows the rows sliding underneath it) plus a heavier border when open.
*/}
<div
className={`group/dir sticky top-0 z-10 flex items-center bg-muted ${
expanded ? 'border-b border-border shadow-sm' : ''
}`}
>
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 px-3 py-2.5 text-left transition-colors hover:bg-muted/50"
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 px-3 py-2.5 text-left transition-colors hover:bg-foreground/5"
>
<ChevronRight
className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${expanded ? 'rotate-90' : ''}`}
/>
{/* The full path from `~`, not the basename. Several projects share a leaf name — `platform`,
`src`, `web` — and the basename alone made two different directories indistinguishable. `dir`
keeps the tail visible when the path is too long for the panel, which is the useful half. */}
<span dir="rtl" className="min-w-0 flex-1 truncate text-left text-xs font-medium">
{/* The full path from `~`, not the basename: several projects share a leaf name — `platform`,
`src`, `web` — and the basename alone made two directories indistinguishable.
Plain `truncate`, NOT `dir="rtl"`. The RTL trick keeps a long path's tail visible, but it
reorders the whole string as well: `~/officer-dev/platform` rendered as `officer-dev/platform/~`,
with the home marker moved to the end. The full path is on the `title` instead. */}
<span title={shortenPath(pwd.cwd, home)} className="min-w-0 flex-1 truncate text-left text-xs font-semibold">
{shortenPath(pwd.cwd, home)}
</span>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">{pwd.sessionCount}</span>