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 = (
+