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. + */} +