From 640a74bcd5ef1a77f6eabf4cb6aea45582e3058b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 17 Aug 2026 17:58:41 +0000 Subject: [PATCH] sessions list: readable tabs, a real separator, headings that stay put MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/apps/ChatHistory/SessionList.tsx | 60 ++++++++++++++----- .../src/apps/ChatHistory/SessionTree.tsx | 31 ++++++++-- 2 files changed, 69 insertions(+), 22 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 3e4dea1e..d71b0f50 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -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_KEY, 'tree'); const [expanded, setExpanded] = useLocalStorageState(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/` 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/` 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/` 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(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 (
-
- setView(v as View)}> - + {/* 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. */} +
+ {/* `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. */} + setView(v as View)} className="min-w-0 flex-1"> + {TABS.map((tab) => ( {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} diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionTree.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionTree.tsx index 886ddfc7..22f9c3e7 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionTree.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionTree.tsx @@ -56,20 +56,39 @@ const DirectoryNode = ({ pwd, home, expanded, onToggle, live, activeId, actions return (
-
+ {/* + 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. + */} +