feat(desktop): opt-in inbox-style session cards in the sidebar

A new "Inbox style" toggle in the sidebar filter menu renders the flat
recents list as cards: a workspace header line (project when it resolves,
else the cwd leaf, else Home) with the age at its right edge, the title
grouped with a one-line last-message preview, and a model + size footer.
The preview line ships on by default and has its own Show-menu toggle,
offered only while Inbox style is active — the one-line row has nowhere
to put it.

A render variant, deliberately not a grouping — it composes with whichever
grouping is active and only the flat recents list opts in; pinned, project,
and messaging surfaces keep the one-line row. Spacing hangs off a single
--card-gap variable; the title/preview pair is one grouped cell with its own
tighter internal gap. The age and kebab sit in flow inside the header line
rather than a full-height side column, so title, preview, and footer span
the card's entire width.

The card's project label reads through a selector that resolves the label
string, so tree polls with fresh atom identity repaint only rows whose label
actually changed.
This commit is contained in:
Brooklyn Nicholson 2026-08-12 21:55:54 -05:00 committed by brooklyn!
parent b6ed6542a1
commit 45f663b746
12 changed files with 207 additions and 57 deletions

View File

@ -31,6 +31,9 @@ const rowLead = 'grid size-3.5 shrink-0 place-items-center'
const rowInset = cn(rowPadX, rowGap, 'flex h-full min-w-0 items-center self-stretch py-0.5')
const rowLabel = 'min-w-0 truncate text-[0.8125rem] leading-none text-(--ui-text-secondary)'
/** Inbox-style card (workspace + age, title + preview, model + size). */
export const SIDEBAR_ROW_CARD_MIN_H = 'min-h-[3.375rem]' as const
/** Codicon size in sidebar row leads — matches the file tree (`tree.tsx`). */
export const SIDEBAR_LEAD_ICON_SIZE = '0.875rem' as const
@ -73,15 +76,16 @@ export function SidebarDateDivider({
* one selector: it holds real controls, never grab surface. */
export function SidebarRowShell({
actions,
actionsClassName,
children,
className,
...props
}: React.ComponentProps<'div'> & { actions?: React.ReactNode }) {
}: React.ComponentProps<'div'> & { actions?: React.ReactNode; actionsClassName?: string }) {
return (
<div className={cn(rowMinH, 'grid grid-cols-[minmax(0,1fr)_auto] items-stretch rounded-md', className)} {...props}>
{children}
{actions ? (
<div className="flex shrink-0 items-center self-center" data-row-actions>
<div className={cn('flex shrink-0 items-center self-center', actionsClassName)} data-row-actions>
{actions}
</div>
) : null}

View File

@ -22,6 +22,7 @@ import { useI18n } from '@/i18n'
import { desktopGit } from '@/lib/desktop-git'
import { cn } from '@/lib/utils'
import {
$sidebarCardRows,
$sidebarFiltersActive,
$sidebarGrouping,
$sidebarOrdering,
@ -34,6 +35,7 @@ import {
$sidebarViewCustomized,
$sidebarWorkspaceNodeOpen,
resetSidebarView,
setSidebarCardRows,
setSidebarGrouping,
setSidebarOrdering,
setSidebarShowArchived,
@ -87,6 +89,7 @@ const ORDERINGS: Option<SidebarOrdering>[] = [
const ROW_META: Option<SidebarRowMeta>[] = [
{ icon: 'clock', id: 'updated', label: 'Updated' },
{ icon: 'comment', id: 'preview', label: 'Preview' },
{ icon: 'symbol-numeric', id: 'tokens', label: 'Tokens' },
{ icon: 'credit-card', id: 'cost', label: 'Cost' },
{ icon: 'git-pull-request', id: 'pr', label: 'PR' },
@ -150,6 +153,7 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
const grouping = useStore($sidebarGrouping)
const ordering = useStore($sidebarOrdering)
const rowMeta = useStore($sidebarRowMeta)
const cardRows = useStore($sidebarCardRows)
const statusFilter = useStore($sidebarStatusFilter)
const projectFilter = useStore($sidebarProjectFilter)
const profileFilter = useStore($sidebarProfileFilter)
@ -190,6 +194,11 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
return hasCost || rowMeta.includes('cost')
}
// Preview is a card line; the one-line row has nowhere to put it.
if (option.id === 'preview') {
return cardRows
}
return option.id !== 'pr' || prAvailable
})
@ -263,6 +272,14 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* A render variant, not a grouping: three-line cards (project · age /
title / model · size) compose with whichever grouping is active. */}
<OptionCheckbox
checked={cardRows}
onCheck={() => setSidebarCardRows(!cardRows)}
option={{ icon: 'inbox', id: 'card-rows', label: 'Inbox style' }}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />

View File

@ -36,6 +36,7 @@ import {
$dismissedAutoProjectIds,
$panesFlipped,
$pinnedSessionIds,
$sidebarCardRows,
$sidebarCronOpen,
$sidebarFiltersActive,
$sidebarGrouping,
@ -334,6 +335,7 @@ export function ChatSidebar({
const pullRequests = useStore($pullRequestsByBranch)
const filtersActive = useStore($sidebarFiltersActive)
const showArchived = useStore($sidebarShowArchived)
const cardRows = useStore($sidebarCardRows)
const archivedSessions = useStore($archivedSessions)
const dotStates = useStore($sessionDotStateById)
// The active sort key as an id order. The flat list applies it within its
@ -1598,6 +1600,10 @@ export function ChatSidebar({
// list does — only the flat list can swap its dividers for
// WORKING / DONE.
grouping={showArchived || rankedGlobally ? 'none' : grouping === 'status' ? 'status' : 'date'}
// Inbox style is a render variant, not a grouping: only the
// flat recents list opts in, and only outside the project tree
// (whose rows already carry workspace context).
card={cardRows && !agentsGrouped}
groups={displayAgentGroups}
headerAction={
inProject && enteredProject ? (

View File

@ -26,6 +26,7 @@ vi.mock('@/i18n', () => ({
backgroundRunning: 'Running in background',
finishedUnread: 'Finished',
handoffOrigin: (platform: string) => `Started on ${platform}`,
messageCount: (count: number) => `${count} messages`,
needsInput: 'Needs input',
sessionActions: 'Session actions',
sessionRunning: 'Running',

View File

@ -13,15 +13,19 @@ import { Tip } from '@/components/ui/tooltip'
import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { pathLeaf } from '@/lib/display-path'
import { compactNumber } from '@/lib/format'
import { triggerHaptic } from '@/lib/haptics'
import { middleClickHandlers } from '@/lib/middle-click'
import { displayModelName } from '@/lib/model-status-label'
import { sessionProjectLabel } from '@/lib/session-project-label'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { coarseElapsed } from '@/lib/time'
import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $sidebarRowMeta } from '@/store/layout'
import { normalizeProfileKey } from '@/store/profile'
import { $projects } from '@/store/projects'
import { $pullRequestsByBranch, sessionPrKey } from '@/store/pull-requests'
import { $sessionDotStateById, hasLiveTurn, showsRunningArc } from '@/store/session-dot-state'
import { sessionCostUsd } from '@/store/sidebar-archive'
@ -29,6 +33,7 @@ import { sessionCostUsd } from '@/store/sidebar-archive'
import { SessionStatusDot } from '../session-status-dot'
import {
SIDEBAR_ROW_CARD_MIN_H,
SidebarRowBody,
SidebarRowGrab,
SidebarRowLabel,
@ -57,6 +62,10 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
* flat cross-profile lists Pinned and search results in the All-profiles
* view where no group header communicates ownership (#66003). */
showProfile?: boolean
/** Inbox-style card: workspace header, title + last-message preview, and a
* model · size footer. The flat recents list opts in via the filter menu;
* dense tree surfaces (projects, messaging, pins) keep the one-line row. */
card?: boolean
}
const AGE_KEY = { day: 'ageDay', hour: 'ageHour', minute: 'ageMin' } as const
@ -89,6 +98,7 @@ function SidebarSessionRowImpl({
dragging = false,
dragHandleProps,
showProfile = false,
card = false,
className,
style,
ref,
@ -126,7 +136,10 @@ function SidebarSessionRowImpl({
// Sub-cent spend rounds to "$0.00", which reads as a bug rather than as a
// cheap session — below a cent the row says nothing at all.
rowMeta.includes('cost') && cost >= 0.01 ? `$${cost.toFixed(2)}` : null,
pinnedAge ? age : null
// The card always shows its age — it IS the header line's right edge — and
// it rides the same trailing slot as everything else, so the kebab swaps
// over it on hover exactly like the one-line row.
pinnedAge || card ? age : null
].filter(Boolean) as string[]
// Everything the Show menu puts after the title shares ONE right-aligned
@ -147,7 +160,11 @@ function SidebarSessionRowImpl({
}
if (figures.length) {
const head = figures.slice(0, -1).join(' · ')
// The card's meta lines separate by spacing alone, so its header figures
// match (non-breaking pair — plain spaces collapse to one); the one-line
// row keeps the interpunct between joined figures.
const sep = card ? '\u00A0\u00A0' : ' · '
const head = figures.slice(0, -1).join(sep)
trailing.push({
key: 'figures',
@ -156,7 +173,7 @@ function SidebarSessionRowImpl({
{head}
{/* The figures own their tail: the separator goes with it. */}
<span className={cn('inline-block text-right', TAIL_HIDES)}>
{head && ' · '}
{head && sep}
{figures.at(-1)}
</span>
</span>
@ -177,6 +194,25 @@ function SidebarSessionRowImpl({
const dotState = useStoreSelector($sessionDotStateById, states => states[session.id] ?? 'idle')
const liveTurn = hasLiveTurn(dotState)
// Card header line: the workspace this belongs to — the project when it
// resolves (same function the session color reads, so name and tint agree;
// a worktree reports its repo, not the scratch dir it sits in), else the
// bare cwd leaf, else the same synthetic "Home" the project views use for
// workspace-less chats. Always text: an empty header line reads as a hole.
// A SELECTOR, not useStore($projects): the projects atom refreshes on the
// tree poll with fresh identity, and a plain subscription would re-render
// every row (card or not) on every poll. Selecting the resolved label means
// a row only repaints when its own label actually changes — and one-line
// rows always select null.
const context = useStoreSelector($projects, projects =>
card ? (sessionProjectLabel(session, projects) ?? (pathLeaf(session.cwd) || t.sidebar.projects.home)) : null
)
// Card footer line: which model worked on it and how big it got. Rendered
// as separate spans with a flex gap — a joined string can't put real space
// between them (HTML collapses runs of whitespace to one).
const model = card && session.model ? displayModelName(session.model) : ''
const size = card && session.message_count > 0 ? r.messageCount(session.message_count) : ''
// An archived session has no live status to paint, so the archive glyph takes
// the lead slot the dot would occupy instead of adding a column of its own.
const lead = session.archived ? (
@ -185,6 +221,50 @@ function SidebarSessionRowImpl({
</SidebarRowLeadGlyph>
) : null
// The trailing metadata sits in normal flow and the kebab lifts out of it,
// so this cluster's intrinsic width IS the metadata's. In the one-line row
// it rides the shell's `auto` actions column and the title truncates
// against it. In the card it renders INSIDE the header row instead — the
// shell column would span the card's full height and shave every line,
// when only the header shares its line with the age and kebab.
const actionsNode = (
<div className="relative z-2 flex shrink-0 items-center justify-end gap-1" data-row-actions>
{trailing.map(({ key, node }, index) => (
<span
className={
chipEndsSlot && index === trailing.length - 1 ? cn('inline-flex justify-end', TAIL_HIDES) : undefined
}
key={key}
>
{node}
</span>
))}
<SessionActionsMenu
onArchive={onArchive}
onBranch={onBranch}
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
profile={session.profile}
sessionId={session.id}
title={title}
>
<Button
aria-label={r.sessionActions}
className={cn(
'size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!',
trailing.length > 0 && 'absolute right-0',
pr && KEBAB_YIELDS
)}
size="icon"
variant="ghost"
>
<Codicon name="kebab-vertical" size="0.875rem" />
</Button>
</SessionActionsMenu>
</div>
)
return (
<SessionContextMenu
onArchive={onArchive}
@ -197,51 +277,10 @@ function SidebarSessionRowImpl({
title={title}
>
<SidebarRowShell
actions={
// The trailing metadata sits in normal flow and the kebab lifts out
// of it, so this slot's intrinsic width IS the metadata's — the row's
// `auto` actions column measures it and the title truncates against
// whatever is switched on, with no width to hand-maintain. Nothing
// switched on leaves the slot to the kebab alone; hover changes what
// you can see in it, never how wide it is.
<div className="relative z-2 flex items-center justify-end gap-1" data-row-actions>
{trailing.map(({ key, node }, index) => (
<span
className={
chipEndsSlot && index === trailing.length - 1 ? cn('inline-flex justify-end', TAIL_HIDES) : undefined
}
key={key}
>
{node}
</span>
))}
<SessionActionsMenu
onArchive={onArchive}
onBranch={onBranch}
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
profile={session.profile}
sessionId={session.id}
title={title}
>
<Button
aria-label={r.sessionActions}
className={cn(
'size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!',
trailing.length > 0 && 'absolute right-0',
pr && KEBAB_YIELDS
)}
size="icon"
variant="ghost"
>
<Codicon name="kebab-vertical" size="0.875rem" />
</Button>
</SessionActionsMenu>
</div>
}
actions={card ? undefined : actionsNode}
className={cn(
'group row-hover relative',
card && SIDEBAR_ROW_CARD_MIN_H,
isSelected && 'bg-(--ui-row-active-background)',
liveTurn && 'text-foreground',
// Opaque surface while lifted so the dragged row erases what's under
@ -286,7 +325,14 @@ function SidebarSessionRowImpl({
// Every trailing figure lives in the actions slot, which the row
// measures — so the title needs a gap from it and nothing else. Hover
// changes what you can see in that slot, never how wide it is.
className={cn('z-0 pr-2', branchStem && 'pl-3.5')}
className={cn(
'z-0 pr-2',
branchStem && 'pl-3.5',
// The card is a grid with ONE spacing knob: --card-gap. Every row
// gap is gap-y-(--card-gap); the title/preview group opts out
// with its own tighter internal flex gap.
card && 'flex-col items-stretch justify-center py-1.5 [--card-gap:0.6rem] gap-(--card-gap)'
)}
// Middle-click = open in a new tab (browser muscle memory).
{...middleClickHandlers(() => {
triggerHaptic('selection')
@ -383,6 +429,7 @@ function rowPropsEqual(a: SidebarSessionRowProps, b: SidebarSessionRowProps): bo
a.reorderable === b.reorderable &&
a.dragging === b.dragging &&
a.showProfile === b.showProfile &&
a.card === b.card &&
a.dragHandleProps === b.dragHandleProps &&
a.className === b.className &&
a.style === b.style

View File

@ -163,6 +163,11 @@ interface SidebarSessionsSectionProps {
// pinned, messaging groups, and the project overview, where the order isn't
// strictly by recency so a bucket would be misleading.
grouping?: 'date' | 'none' | 'status'
// Inbox style: render every flat session row as a three-line card (project ·
// age / title / model · size). A render variant that composes with whichever
// grouping is active — the flat recents list opts in; dense tree surfaces
// (pinned, projects, messaging) keep the one-line row.
card?: boolean
}
export function SidebarSessionsSection({
@ -204,7 +209,8 @@ export function SidebarSessionsSection({
projectBackRow,
dndSensors,
showProfileTags = false,
grouping = 'none'
grouping = 'none',
card = false
}: SidebarSessionsSectionProps) {
const { t } = useI18n()
const dividerLabels = t.sidebar.dateDivider
@ -243,6 +249,7 @@ export function SidebarSessionsSection({
(session: SessionInfo, draggable: boolean, branchStem?: string) => {
const rowProps = {
branchStem,
card,
isPinned: pinned,
isSelected: session.id === activeSessionId,
onArchive: () => onArchiveSession(session.id),
@ -263,6 +270,7 @@ export function SidebarSessionsSection({
},
[
activeSessionId,
card,
onArchiveSession,
onBranchSession,
onDeleteSession,
@ -444,6 +452,7 @@ export function SidebarSessionsSection({
const virtual = (
<VirtualSessionList
activeSessionId={activeSessionId}
card={card}
className={contentClassName}
dividerAction={dividerAction}
onArchiveSession={onArchiveSession}

View File

@ -16,6 +16,7 @@ import { SidebarSessionRow } from './session-row'
interface SessionRowCommonProps {
branchStem?: string
card?: boolean
isPinned: boolean
isSelected: boolean
onArchive: () => void
@ -29,6 +30,8 @@ interface SessionRowCommonProps {
export interface VirtualSessionListProps {
activeSessionId: null | string
/** Render every session row as the three-line inbox card. */
card?: boolean
className?: string
/** Hover-revealed control for date dividers (the group-level "+"). */
dividerAction?: React.ReactNode
@ -44,10 +47,15 @@ export interface VirtualSessionListProps {
}
const ROW_ESTIMATE_PX = 28
// Matches the card's typical rendered height (four lines when a preview
// exists) so long card lists don't jump under the scroll thumb before
// self-measurement catches up.
const CARD_ROW_ESTIMATE_PX = 66
const OVERSCAN_ROWS = 12
export const VirtualSessionList: FC<VirtualSessionListProps> = ({
activeSessionId,
card = false,
className,
dividerAction,
rows: listRows,
@ -66,7 +74,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
const virtualizer = useVirtualizer({
count: listRows.length,
estimateSize: () => ROW_ESTIMATE_PX,
estimateSize: () => (card ? CARD_ROW_ESTIMATE_PX : ROW_ESTIMATE_PX),
getItemKey: index => {
const row = listRows[index]
@ -108,6 +116,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
const commonProps: SessionRowCommonProps = {
branchStem,
card,
isPinned: pinned,
isSelected: session.id === activeSessionId,
onArchive: () => onArchiveSession(session.id),

View File

@ -1985,6 +1985,7 @@ export const en: Translations = {
renameDesc: 'Leave empty to clear.',
untitledPlaceholder: 'Untitled session',
untitledChat: id => `Chat ${id}`,
messageCount: count => `${count} ${count === 1 ? 'message' : 'messages'}`,
ageNow: 'now',
ageDay: 'd',
ageHour: 'h',

View File

@ -1674,6 +1674,7 @@ export interface Translations {
renameDesc: string
untitledPlaceholder: string
untitledChat: (id: string) => string
messageCount: (count: number) => string
ageNow: string
ageDay: string
ageHour: string

View File

@ -2176,6 +2176,7 @@ export const zh: Translations = {
renameDesc: '留空则清除。',
untitledPlaceholder: '无标题会话',
untitledChat: id => `会话 ${id}`,
messageCount: count => `${count} 条消息`,
ageNow: '刚刚',
ageDay: '天',
ageHour: '时',

View File

@ -0,0 +1,40 @@
import { liveSessionProjectId } from '@/app/chat/sidebar/projects/workspace-groups'
import { pathLeaf } from '@/lib/display-path'
import type { ProjectInfo, SessionInfo } from '@/types/hermes'
/**
* The PROJECT a session belongs to, as a label for the sidebar card.
*
* Resolution order:
* 1. `liveSessionProjectId` the same resolver the session COLOR reads, so
* an explicitly-projected row's name and its inherited tint always agree.
* Returns an explicit project id, or a repo root for an auto-project.
* 2. the recorded `git_repo_root` leaf.
* 3. null (a chat with no workspace at all).
*
* Why step 2 exists: `liveSessionProjectId` deliberately refuses to place a
* worktree that lives OUTSIDE its repo root (`bb/<slug>` beside the checkout
* rather than in-tree `.worktrees/`), because for LANE MEMBERSHIP a sibling
* directory genuinely can't be assigned from the row alone. A label has no
* such ambiguity: the backend already recorded which repo the session belongs
* to, so we can name it. Without this, most of a sibling-worktree workflow's
* rows would paint a blank line and naming the repo is exactly the point of
* showing the project instead of the cwd (`hermes-agent`, not
* `hermes-agent-cwd-copy`).
*
* This never widens membership it only names a row that placement leaves
* unplaced.
*/
export function sessionProjectLabel(session: SessionInfo, projects: ProjectInfo[]): null | string {
const projectId = liveSessionProjectId(session, projects)
if (projectId) {
const explicit = projects.find(project => project.id === projectId)
// An explicit project shows its user-given name; an auto-project's id IS
// its repo root path, so it shows that folder's leaf.
return (explicit ? explicit.name.trim() : pathLeaf(projectId)) || null
}
return pathLeaf(session.git_repo_root) || null
}

View File

@ -37,6 +37,7 @@ const SIDEBAR_GROUPING_STORAGE_KEY = 'hermes.desktop.sidebarGrouping'
const SIDEBAR_ALL_PROFILES_GROUPING_STORAGE_KEY = 'hermes.desktop.sidebarGrouping.allProfiles'
const SIDEBAR_SORT_KEY_STORAGE_KEY = 'hermes.desktop.sidebarSortKey'
const SIDEBAR_ROW_META_STORAGE_KEY = 'hermes.desktop.sidebarRowMeta'
const SIDEBAR_CARD_ROWS_STORAGE_KEY = 'hermes.desktop.sidebarCardRows'
const SIDEBAR_STATUS_FILTER_STORAGE_KEY = 'hermes.desktop.sidebarStatusFilter'
const SIDEBAR_SHOW_ARCHIVED_STORAGE_KEY = 'hermes.desktop.sidebarShowArchived'
const SIDEBAR_PROJECT_FILTER_STORAGE_KEY = 'hermes.desktop.sidebarProjectFilter'
@ -211,8 +212,9 @@ export type SidebarGrouping = 'date' | 'profile' | 'project' | 'status'
export type SidebarOrdering = 'cost' | 'created' | 'manual' | 'status' | 'tokens' | 'updated'
/** The sort keys the menu offers; `manual` is entered by dragging, not picked. */
export type SidebarSortKey = Exclude<SidebarOrdering, 'manual'>
/** Optional per-row metadata the user can switch on. */
export type SidebarRowMeta = 'cost' | 'pr' | 'profile' | 'tokens' | 'updated'
/** Optional per-row metadata the user can switch on. `preview` is card-only:
* the one-line row has nowhere to put a second line. */
export type SidebarRowMeta = 'cost' | 'pr' | 'preview' | 'profile' | 'tokens' | 'updated'
function oneOf<T extends string>(values: readonly T[], fallback: T): Codec<T> {
return {
@ -228,7 +230,7 @@ function listOf<T extends string>(values: readonly T[]): Codec<T[]> {
}
}
const ROW_META: readonly SidebarRowMeta[] = ['cost', 'pr', 'profile', 'tokens', 'updated']
const ROW_META: readonly SidebarRowMeta[] = ['cost', 'pr', 'preview', 'profile', 'tokens', 'updated']
const STATUS_FILTERS: readonly SessionStatusBucket[] = ['needs-input', 'working', 'unread', 'draft', 'idle']
const PR_FILTERS: readonly PullRequestBucket[] = ['open', 'draft', 'merged', 'closed', 'none']
export const SIDEBAR_SORT_KEYS: readonly SidebarSortKey[] = ['updated', 'created', 'status', 'tokens', 'cost']
@ -258,7 +260,7 @@ const $sidebarAllProfilesGrouping = persistentAtom<SidebarGrouping>(
// they used to inline the same literals in three places.
const SIDEBAR_DEFAULT_GROUPING: SidebarGrouping = 'date'
const SIDEBAR_DEFAULT_ORDERING: SidebarOrdering = 'updated'
const SIDEBAR_DEFAULT_ROW_META: SidebarRowMeta[] = ['updated']
const SIDEBAR_DEFAULT_ROW_META: SidebarRowMeta[] = ['preview', 'updated']
const $sidebarSortKey = persistentAtom<SidebarSortKey>(
SIDEBAR_SORT_KEY_STORAGE_KEY,
@ -272,6 +274,16 @@ export const $sidebarRowMeta = persistentAtom<SidebarRowMeta[]>(
listOf(ROW_META)
)
/** Inbox style: render the flat list's session rows as three-line cards
* (project · age / title / model · size) instead of the one-line row. A
* RENDER variant, deliberately not a grouping it composes with whichever
* grouping is active. Off by default; dense tree surfaces never use it. */
export const $sidebarCardRows = persistentAtom(SIDEBAR_CARD_ROWS_STORAGE_KEY, false, Codecs.bool)
export function setSidebarCardRows(on: boolean) {
$sidebarCardRows.set(on)
}
/** Order-insensitive: the menu appends in click order, so ['tokens','updated']
* and ['updated','tokens'] are the same view. */
function sameRowMeta(a: SidebarRowMeta[], b: SidebarRowMeta[]): boolean {
@ -335,11 +347,12 @@ export const $sidebarFiltersActive: ReadableAtom<boolean> = computed(
* offering. Broader than `$sidebarFiltersActive`, which only knows about what
* hides rows, not about how they're grouped, sorted or labelled. */
export const $sidebarViewCustomized: ReadableAtom<boolean> = computed(
[$sidebarGrouping, $sidebarOrdering, $sidebarRowMeta, $sidebarFiltersActive],
(grouping, ordering, rowMeta, filtersActive) =>
[$sidebarGrouping, $sidebarOrdering, $sidebarRowMeta, $sidebarCardRows, $sidebarFiltersActive],
(grouping, ordering, rowMeta, cardRows, filtersActive) =>
grouping !== SIDEBAR_DEFAULT_GROUPING ||
ordering !== SIDEBAR_DEFAULT_ORDERING ||
!sameRowMeta(rowMeta, SIDEBAR_DEFAULT_ROW_META) ||
cardRows ||
filtersActive
)
@ -622,6 +635,7 @@ export function resetSidebarView() {
$sidebarAllProfilesGrouping.set(SIDEBAR_DEFAULT_GROUPING)
setSidebarOrdering(SIDEBAR_DEFAULT_ORDERING)
$sidebarRowMeta.set(SIDEBAR_DEFAULT_ROW_META)
$sidebarCardRows.set(false)
clearSidebarFilters()
}