From 9f469bb04ea478628794258b1fbf642fbaf43526 Mon Sep 17 00:00:00 2001 From: "pouriade.com" Date: Fri, 23 Oct 2026 10:50:27 -0700 Subject: [PATCH] Fold the sidebar's chats under a Chats heading The chats now sit in a section that folds away under its Chats heading, as the tags do. FoldingSection in chat-list holds the look both share, with the heading's end button, such as the tags' clear button, placed by the section rather than its caller. sidebar.ts splits the old sectionHeading in two: sectionHeading, bold and in the text's ink, for Tags and Chats, and groupHeading, grey and regular weight with less room above, for the chats' dates, so the dates read as a step below. sidebarSection holds the room above each section. The loading skeleton draws the Chats heading and a date in the place the chats will take, so nothing moves as they arrive. --- CHANGELOG.md | 1 + .../chat-list/components/ChatList.tsx | 44 +++++++++++-------- .../chat-list/components/ChatListSkeleton.tsx | 27 +++++++++--- .../chat-list/components/FoldingSection.tsx | 29 ++++++++++++ .../chat-list/components/TagFilter.tsx | 43 +++++++++--------- apps/web/src/shared/ui/Disclosure.tsx | 4 +- apps/web/src/shared/ui/sidebar.ts | 22 ++++++++-- specs/styling.md | 2 +- specs/web-ui.md | 2 +- 9 files changed, 119 insertions(+), 55 deletions(-) create mode 100644 apps/web/src/features/chat-list/components/FoldingSection.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 164dbe3..548bba8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ What changed in each release of SCN Chat, for the people who use it, run it, or - Screen readers read a setting's description and any problem found with it along with its field, and a field the server found a problem with rings red. - When a plugin's settings can't be read, the message beside Reset is in red, as the other errors in a card are. - The Tags section in the sidebar unfolds and folds as a reply's sources and steps do, rather than jumping open and shut. +- The sidebar's chats sit under a Chats heading that folds them away, as the Tags heading does. Both headings are bolder and darker, and the dates under Chats lighter and closer together, so the dates read as a step below them. - The composer's model and thinking effort choices moved out of the card into a quiet line under it, leaving only Attach and Send beside the box. A long model name is cut short there rather than pushing the effort onto a second line, and hovering it shows the whole name. ## [0.5.1] - 2026-10-22 diff --git a/apps/web/src/features/chat-list/components/ChatList.tsx b/apps/web/src/features/chat-list/components/ChatList.tsx index e34368a..09e99e0 100644 --- a/apps/web/src/features/chat-list/components/ChatList.tsx +++ b/apps/web/src/features/chat-list/components/ChatList.tsx @@ -14,7 +14,7 @@ import { cn } from '../../../shared/cn.ts' import { lastError } from '../../../shared/errors.ts' import { NewChatIcon, SearchIcon } from '../../../shared/icons.tsx' import { ErrorAlert } from '../../../shared/ui/Alert.tsx' -import { sectionHeading, sidebarScroll } from '../../../shared/ui/sidebar.ts' +import { groupHeading, sidebarScroll } from '../../../shared/ui/sidebar.ts' import { useDebounced } from '../../../shared/useDebounced.ts' import type { ChatListItem } from '../../../store/core.ts' import { @@ -32,6 +32,7 @@ import { groupByDate } from '../lib/groups.ts' import { markSeen, type Seen, useSeen } from '../lib/seen.ts' import { ChatItem } from './ChatItem.tsx' import { ChatRowsSkeleton, SearchSkeleton } from './ChatListSkeleton.tsx' +import { FoldingSection } from './FoldingSection.tsx' import { SyncDot, SyncPanel, useSyncNow } from './SyncStatus.tsx' import { TagFilter } from './TagFilter.tsx' @@ -50,8 +51,9 @@ const isUnseen = (chat: ChatListItem, seen: Seen | null, current: string | undef seen[chat.skey] !== chat.latestReply /** - * The chat sidebar's contents: New chat and the search, the chats under their tags and dates or - * what the search found, and the account at the foot. Only the chats scroll. + * The chat sidebar's contents: New chat and the search, then the tags and the chats, each folding + * away under its heading, with the chats under their dates, or what the search found, and the + * account at the foot. Only the chats scroll. */ export function ChatList() { const [tag, setTag] = useState(null) @@ -182,19 +184,24 @@ export function ChatList() { they were, rather than mounting every row again. */}
- {/* At once, as it takes over from the frame's. */} - {loading && } {tags.length > 0 && } - - {/* Where the loaded chats end, which loads the next page as it nears view. */} - {list.hasNextPage &&
} - {list.isFetchingNextPage && } + {/* At once, as it takes over from the frame's, in the place the chats will take. */} + {loading && } + {conversations.length > 0 && ( + + + {/* Where the loaded chats end, which loads the next page as it nears view, so no + more pages load while the chats are folded away. */} + {list.hasNextPage &&
} + {list.isFetchingNextPage && } + + )}
{/* Who is signed in, at the foot, with a dot on the avatar for how current the chats are. */} @@ -238,11 +245,12 @@ interface ChatRowsProps { * page leaves them alone. */ function ChatRows(props: ChatRowsProps) { + // In a box of their own, so the first date is the first of them, after the section's heading. return ( - <> +
{groupByDate(props.conversations).map((group) => (
-

{group.label}

+

{group.label}

    {group.items.map((chat) => (
))} - +
) } diff --git a/apps/web/src/features/chat-list/components/ChatListSkeleton.tsx b/apps/web/src/features/chat-list/components/ChatListSkeleton.tsx index 09d3a34..1626ae6 100644 --- a/apps/web/src/features/chat-list/components/ChatListSkeleton.tsx +++ b/apps/web/src/features/chat-list/components/ChatListSkeleton.tsx @@ -1,12 +1,18 @@ import { cn } from '../../../shared/cn.ts' import { Loading, Skeleton, SkeletonText } from '../../../shared/ui/Skeleton.tsx' -import { sectionHeading, sidebarRow, sidebarScroll } from '../../../shared/ui/sidebar.ts' +import { + groupHeading, + sectionHeading, + sidebarRow, + sidebarScroll, + sidebarSection, +} from '../../../shared/ui/sidebar.ts' /** The widths of the skeleton's chat titles, so the rows read as a list of different chats. */ const titles = ['w-4/5', 'w-3/5', 'w-2/3', 'w-1/2', 'w-3/4', 'w-2/5', 'w-3/5', 'w-1/2'] interface RowsProps { - /** Whether it heads the rows with a date's heading, as the chat list does. */ + /** Whether it heads the rows with the Chats heading and a date's, as the chat list does. */ heading?: boolean label: string /** Whether they show at once, as in the page's frame. */ @@ -18,11 +24,20 @@ interface RowsProps { /** Chats on their way, in the shape of the sidebar's rows: one-line titles of differing lengths. */ export function ChatRowsSkeleton(props: RowsProps) { return ( - + {props.heading && ( -
- -
+ <> +
+ +
+
+ +
+ )}
{titles.slice(0, props.rows).map((width, index) => ( diff --git a/apps/web/src/features/chat-list/components/FoldingSection.tsx b/apps/web/src/features/chat-list/components/FoldingSection.tsx new file mode 100644 index 0000000..ec09214 --- /dev/null +++ b/apps/web/src/features/chat-list/components/FoldingSection.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react' +import { cn } from '../../../shared/cn.ts' +import * as Disclosure from '../../../shared/ui/Disclosure.tsx' +import { sectionHeading, sidebarSection } from '../../../shared/ui/sidebar.ts' + +interface Props { + /** The heading's text. */ + label: string + /** A button at the heading's end, such as the tags' clear button. */ + action?: ReactNode + children: ReactNode +} + +/** + * A section of the sidebar, such as the tags or the chats, which folds away under its heading, + * whose small chevron follows the label, as in Linear. It starts open. + */ +export function FoldingSection(props: Props) { + return ( + + + {props.label} + + + {props.action &&
{props.action}
} + {props.children} +
+ ) +} diff --git a/apps/web/src/features/chat-list/components/TagFilter.tsx b/apps/web/src/features/chat-list/components/TagFilter.tsx index 29fb8b0..1ed554d 100644 --- a/apps/web/src/features/chat-list/components/TagFilter.tsx +++ b/apps/web/src/features/chat-list/components/TagFilter.tsx @@ -2,8 +2,8 @@ import { cn } from '../../../shared/cn.ts' import { CloseIcon, HashIcon } from '../../../shared/icons.tsx' import { plural } from '../../../shared/plural.ts' import { IconButton } from '../../../shared/ui/Button.tsx' -import * as Disclosure from '../../../shared/ui/Disclosure.tsx' -import { sectionHeading, sidebarRow } from '../../../shared/ui/sidebar.ts' +import { sidebarRow } from '../../../shared/ui/sidebar.ts' +import { FoldingSection } from './FoldingSection.tsx' interface Props { /** Every tag on the user's chats, with how many chats carry it, in order of first use. */ @@ -15,29 +15,26 @@ interface Props { /** * The user's tags as a section of rows that filter the list to one tag, which folds away under its - * heading, whose small chevron follows the label, as in Linear. Picking the active tag again, or - * the clear button at the heading's end, shows every chat. + * heading. Picking the active tag again, or the clear button at the heading's end, shows every + * chat. */ export function TagFilter(props: Props) { return ( - - - Tags - - - {props.active && ( - props.onChange(null)} - className="absolute end-0 top-0" - > - - - )} + props.onChange(null)} + > + + + ) + } + >
    {props.tags.map((t) => (
  • @@ -58,6 +55,6 @@ export function TagFilter(props: Props) {
  • ))}
-
+ ) } diff --git a/apps/web/src/shared/ui/Disclosure.tsx b/apps/web/src/shared/ui/Disclosure.tsx index 6619c20..1ed9c22 100644 --- a/apps/web/src/shared/ui/Disclosure.tsx +++ b/apps/web/src/shared/ui/Disclosure.tsx @@ -3,8 +3,8 @@ import { cn } from '../cn.ts' import { ChevronDownIcon, ChevronRightIcon } from '../icons.tsx' /* - * A section that opens under its summary, such as the sidebar's tags, a reply's sources, or a step - * of its work, imported as `import * as Disclosure` and composed: + * A section that opens under its summary, such as the sidebar's tags and chats, a reply's sources, + * or a step of its work, imported as `import * as Disclosure` and composed: * * * diff --git a/apps/web/src/shared/ui/sidebar.ts b/apps/web/src/shared/ui/sidebar.ts index 72e3850..def2a17 100644 --- a/apps/web/src/shared/ui/sidebar.ts +++ b/apps/web/src/shared/ui/sidebar.ts @@ -6,11 +6,25 @@ export const sidebarRow = 'flex min-h-row w-full items-center gap-2.25 overflow-hidden whitespace-nowrap rounded-row px-row-inset py-1.25 text-start font-medium text-ink no-underline leading-[1.45] transition-colors duration-(--dur-feedback) hover:bg-row-hover aria-[current=page]:bg-row-current aria-[current=page]:text-ink-strong [&_svg]:text-muted aria-[current=page]:[&_svg]:text-current' /** - * A section's heading in the sidebar, small and grey, with room above to close off the section - * before. It scrolls with its rows, since one held at the top would sit in the list's fade. + * A section of the sidebar, such as the tags or the chats, with room above to close off the + * section before, unless it comes first. */ -export const sectionHeading = - 'mt-4.5 mb-1 flex items-center gap-1.5 px-row-inset py-0.75 font-medium text-caption text-section-ink leading-[1.4]' +export const sidebarSection = 'mt-4.5 first:mt-0' + +/** + * What the sidebar's headings share: small text in line with the rows' text. They scroll with + * their rows, since one held at the top would sit in the list's fade. + */ +const heading = 'mb-1 flex items-center gap-1.5 px-row-inset py-0.75 text-caption leading-[1.4]' + +/** A section's heading, such as Tags or Chats, a step bolder and darker than those inside it. */ +export const sectionHeading = `${heading} font-semibold text-ink` + +/** + * A group's heading inside a section, such as a date over the chats, small and grey, with a little + * room above to close off the group before. + */ +export const groupHeading = `${heading} mt-3 font-normal text-section-ink` /** * The part of the sidebar that scrolls, between what stays put above and below it. It reaches the diff --git a/specs/styling.md b/specs/styling.md index 6d2efd5..2da0e25 100644 --- a/specs/styling.md +++ b/specs/styling.md @@ -98,7 +98,7 @@ Spacing literals from `design-reference` snap to the nearest quarter step of the - `Menu.tsx`: a menu of actions behind an icon button, imported as `import * as Menu` and composed from `Root`, which holds whether it is open, as `open` and `onOpenChange`, its `Trigger`, an `IconButton`, and a `Popup` of `Item`s, which a `Separator` can group, after the WAI-ARIA menu button pattern. The arrow keys, Home, and End move between the items, past disabled ones, and the arrows on the button open it on its first item or its last. Tab closes it and moves on from the button, Escape closes it back on the button, and a press outside closes it. Choosing an item closes it, back on the button unless the item put the focus somewhere, as a form it opens does, and even when the focus was on the page, as in Safari, which doesn't focus a pressed button, and an item whose `onClick` prevents the default keeps it open, as one showing another view does. The popup is placed by the button and rendered into the body, unless `anchored` or `portal` say otherwise, the same `Placement` that `usePopover` takes. A panel of links and other controls, such as the account menu, is a `Popover` from `usePopover` instead, which Tab moves through, and with `arrows` the arrow keys, Home, and End too, as the WAI-ARIA disclosure pattern allows. It starts the focus on its control marked `data-autofocus`, else its first, so the account menu opens on Settings, past the status's Sync. - `Alert.tsx`: `Alert`, something that went wrong in red, read out as it appears, and `ErrorAlert`, an error's message as one, or nothing while there is no error, as the settings kit's save bar shows. Its `variant` says where it sits: `plain` red words in the flow, as large as the text around them, `field` under a setting or field after its description, `card` across a card's foot on a soft red ground, or `form`, a soft red box of its own under a form's lone field, as when signing in. A plain alert takes `size="small"` under a list or beside a form's buttons, and the others are always small. An `action`, such as a button that resets what failed, sits beside it without being read out with it. It is spans drawn as a block, so it can sit where only phrasing may, such as an item's names. - `Separator.tsx`: `Separator`, a hairline in `separator` between groups, such as the account menu's theme row and the rows around it, with room above and below that a `className` can change. -- `Disclosure.tsx`: a section that opens under its summary, such as the sidebar's tags, a reply's sources, or a step of its work, imported as `import * as Disclosure` and composed from `Root`, a native `
` that unfolds as it opens, its `Summary`, without the browser's marker, and a `Chevron` in the summary that turns as its own disclosure opens, not one around it. The chevron points toward the end of the line and turns down, or with `points="down"` points down and turns up, as a chip that opens like a dropdown. A summary styles what else changes while open through a named group on its root, since disclosures nest. +- `Disclosure.tsx`: a section that opens under its summary, such as the sidebar's tags and chats, a reply's sources, or a step of its work, imported as `import * as Disclosure` and composed from `Root`, a native `
` that unfolds as it opens, its `Summary`, without the browser's marker, and a `Chevron` in the summary that turns as its own disclosure opens, not one around it. The chevron points toward the end of the line and turns down, or with `points="down"` points down and turns up, as a chip that opens like a dropdown. A summary styles what else changes while open through a named group on its root, since disclosures nest. - `PanelCard.tsx`: `PanelCard`, a floating panel of controls under its title, such as a chat's sharing or instructions, and `panelForm`, the class for a form around one, which lays out no box of its own so the card takes no room in the row it opens from. - `FileChip.tsx`: `FileChip`, a file as a well with a quote's corner, its preview or a file icon, its name over a detail line from `detailOf`, such as "PDF · 1.2 MB", both at medium weight as a chip's label is, the detail set apart only by its grey, and whatever ends it, shared by the composer's attachments and a sent message's files. - `CopyButton.tsx`: Copy, as words or as an icon that cross-fades to a check. diff --git a/specs/web-ui.md b/specs/web-ui.md index b89e55d..7908ef3 100644 --- a/specs/web-ui.md +++ b/specs/web-ui.md @@ -69,7 +69,7 @@ The root route's `beforeLoad` asks `/api/me` who is signed in, through the query - **ChatList.** The chat sidebar's contents, under the sidebar's head row, from top to bottom: - New chat, which goes to `/`, so a chat is only created once its first message is sent, with a search button beside it that shows the search box below, focused, and hides it again. The box shows from the start when the URL holds a search. Hiding it, by the button or by Escape in the box, clears the search, and Escape returns focus to the button. - Tags, when any chat has some: a section that folds away under its heading, with a row for every tag across the chats and how many chats carry it, which filters the list to that tag, or shows every chat again when picked while active. A button at the heading's end clears the filter. Tags come from each chat's index entry. - - The chats, newest first, under date headings by `updatedAt` in local time: Today, Yesterday, Previous 7 days, Previous 30 days, then by month, with the year for other years. Each chat is one line. Untitled chats show "New chat". A chat with a pending reply shows a breathing green dot labeled "Replying", and one whose newest complete reply this device hasn't seen shows a still dot labeled "New reply" and a bolder title. A menu on each chat, shown on hover or focus and always on a touch screen, renames it, edits its tags as a comma-separated list cut to the lexicon's 32 tags of 64 characters, or deletes it after asking, through the chat routes, then syncs the index. Renaming, tagging, or confirming a delete takes the chat's place in the list as a card. Deleting the open chat goes to `/`. The chats load 50 at a time, the next page as the end of the list nears, with a few skeleton rows while it loads. A tag's count and the filter cover every chat, and the filter pages through only its chats. Only the chats, or the search's results, scroll, fading out at both ends. + - The chats, when there are any: a section that folds away under its Chats heading, as the tags do, with the chats newest first under date headings by `updatedAt` in local time: Today, Yesterday, Previous 7 days, Previous 30 days, then by month, with the year for other years. Each chat is one line. Untitled chats show "New chat". A chat with a pending reply shows a breathing green dot labeled "Replying", and one whose newest complete reply this device hasn't seen shows a still dot labeled "New reply" and a bolder title. A menu on each chat, shown on hover or focus and always on a touch screen, renames it, edits its tags as a comma-separated list cut to the lexicon's 32 tags of 64 characters, or deletes it after asking, through the chat routes, then syncs the index. Renaming, tagging, or confirming a delete takes the chat's place in the list as a card. Deleting the open chat goes to `/`. The chats load 50 at a time, the next page as the end of the list nears, with a few skeleton rows while it loads. A tag's count and the filter cover every chat, and the filter pages through only its chats. Only the chats, or the search's results, scroll, fading out at both ends. - An account card shows who is signed in: their avatar, display name, and `@handle` from `/api/me/profile` and `/api/me`. Without a display name it shows the handle alone, and without an avatar, or when it fails to load, the name's first letter in a rounded square. A dot on the avatar's corner shows how current this device's copy is: green when synced, breathing while syncing or downloading, grey when open in another tab, and red when it failed, with the status in words on hover: "Synced 2 min ago", "Downloading 3 chats…", "Syncing…", or that the copy is open in another tab or failed to open. The card is a button that opens a menu above it. The menu starts with where the chats live, "Stored on your PDS", or "Stored on this server" with "Your PDS doesn't support spaces yet" beneath, then the same dot, the status in words, and a Sync button that syncs the index now, then Settings, Admin for admins, a Theme row with System, Light, and Dark buttons and a Report an issue link to the project's issues on Tangled, which opens in a new tab, set apart by a line above and below, and Sign out. It opens with the focus on Settings, Tab and the arrow keys, Home, and End move between its controls, Escape closes it back on the card, a click outside closes it, and choosing a theme leaves it open. What this device has seen is kept in `localStorage` per account, as the newest seen reply of each chat. The first list on a device seeds it, so older replies don't all show as new, and the open chat's newest reply counts as seen. Icons come from `src/shared/icons.tsx`: Lucide's, and the app's own puffier ones drawn to sit with the logo's bubble letters, in the text color and hidden from assistive technology, which a fork can swap for another set. -- knotmirror 0.0.1