From 45f663b7467db93f130a2ce0ae8c57da0a04d37e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 12 Aug 2026 21:55:54 -0500 Subject: [PATCH] feat(desktop): opt-in inbox-style session cards in the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/src/app/chat/sidebar/chrome.tsx | 8 +- .../src/app/chat/sidebar/filter-menu.tsx | 17 +++ apps/desktop/src/app/chat/sidebar/index.tsx | 6 + .../src/app/chat/sidebar/session-row.test.tsx | 1 + .../src/app/chat/sidebar/session-row.tsx | 141 ++++++++++++------ .../src/app/chat/sidebar/sessions-section.tsx | 11 +- .../app/chat/sidebar/virtual-session-list.tsx | 11 +- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/lib/session-project-label.ts | 40 +++++ apps/desktop/src/store/layout.ts | 26 +++- 12 files changed, 207 insertions(+), 57 deletions(-) create mode 100644 apps/desktop/src/lib/session-project-label.ts diff --git a/apps/desktop/src/app/chat/sidebar/chrome.tsx b/apps/desktop/src/app/chat/sidebar/chrome.tsx index 3d422ab3c8759..a08bfa608c0b2 100644 --- a/apps/desktop/src/app/chat/sidebar/chrome.tsx +++ b/apps/desktop/src/app/chat/sidebar/chrome.tsx @@ -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 (
{children} {actions ? ( -
+
{actions}
) : null} diff --git a/apps/desktop/src/app/chat/sidebar/filter-menu.tsx b/apps/desktop/src/app/chat/sidebar/filter-menu.tsx index f7019c3a8f707..2b205c23da47c 100644 --- a/apps/desktop/src/app/chat/sidebar/filter-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/filter-menu.tsx @@ -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[] = [ const ROW_META: Option[] = [ { 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 }) { ))} + + {/* A render variant, not a grouping: three-line cards (project · age / + title / model · size) compose with whichever grouping is active. */} + setSidebarCardRows(!cardRows)} + option={{ icon: 'inbox', id: 'card-rows', label: 'Inbox style' }} + /> diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 40adea2a544ed..10556dff1ffef 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -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 ? ( diff --git a/apps/desktop/src/app/chat/sidebar/session-row.test.tsx b/apps/desktop/src/app/chat/sidebar/session-row.test.tsx index f3c91c5411e05..90b5211852624 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.test.tsx @@ -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', diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index c4602f1ace5d6..91efb5c8ca218 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -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. */} - {head && ' · '} + {head && sep} {figures.at(-1)} @@ -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({ ) : 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 = ( +
+ {trailing.map(({ key, node }, index) => ( + + {node} + + ))} + + + +
+ ) + return ( - {trailing.map(({ key, node }, index) => ( - - {node} - - ))} - - - -
- } + 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 diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx index 5470b8ec408a1..f95d40a8df6c0 100644 --- a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx @@ -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 = ( 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 = ({ activeSessionId, + card = false, className, dividerAction, rows: listRows, @@ -66,7 +74,7 @@ export const VirtualSessionList: FC = ({ 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 = ({ const commonProps: SessionRowCommonProps = { branchStem, + card, isPinned: pinned, isSelected: session.id === activeSessionId, onArchive: () => onArchiveSession(session.id), diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 1d7c819f275ee..ae4837711515f 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 3c7499ce5bcc2..37bf953982053 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 0108834f3a74d..701d7bff689c3 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2176,6 +2176,7 @@ export const zh: Translations = { renameDesc: '留空则清除。', untitledPlaceholder: '无标题会话', untitledChat: id => `会话 ${id}`, + messageCount: count => `${count} 条消息`, ageNow: '刚刚', ageDay: '天', ageHour: '时', diff --git a/apps/desktop/src/lib/session-project-label.ts b/apps/desktop/src/lib/session-project-label.ts new file mode 100644 index 0000000000000..aa64f5236606d --- /dev/null +++ b/apps/desktop/src/lib/session-project-label.ts @@ -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/` 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 +} diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index d47ac8b4b73dd..0cd1a63adeba6 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -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 -/** 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(values: readonly T[], fallback: T): Codec { return { @@ -228,7 +230,7 @@ function listOf(values: readonly T[]): Codec { } } -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( // 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( SIDEBAR_SORT_KEY_STORAGE_KEY, @@ -272,6 +274,16 @@ export const $sidebarRowMeta = persistentAtom( 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 = 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 = 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() }