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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user