Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions apps/desktop/src/app/chat/sidebar/session-row-details.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'

import type { SessionInfo } from '@/types/hermes'

import { sessionRowDetails, sessionRowEstimate, type SessionRowFormatters } from './session-row-details'

const en: SessionRowFormatters = {
messageCount: count => `${count} ${count === 1 ? 'message' : 'messages'}`,
toolCallCount: count => `${count} ${count === 1 ? 'tool call' : 'tool calls'}`
}

const session = (overrides: Partial<SessionInfo> = {}): SessionInfo => ({
ended_at: null,
id: 's1',
input_tokens: 0,
is_active: false,
last_active: 1,
message_count: 26,
model: 'google/gemini-3.1-pro',
output_tokens: 0,
preview: ' Explore\nGmail-like density tiers for session rows. ',
source: 'desktop',
started_at: 1,
title: 'Session density exploration',
tool_call_count: 8,
...overrides
})

describe('session row details', () => {
it('provides density-aware virtual row estimates', () => {
expect(sessionRowEstimate('compact')).toBe(28)
expect(sessionRowEstimate('comfortable')).toBe(45)
expect(sessionRowEstimate('detailed')).toBe(63)
})

it('keeps the detailed estimate even when preview is omitted as a title duplicate', () => {
const details = sessionRowDetails(session({ title: null }), en)

expect(details.preview).toBeNull()
expect(sessionRowEstimate('detailed')).toBe(63)
})

it('formats deterministic metadata without ambiguous call wording', () => {
expect(sessionRowDetails(session({ git_branch: 'feature/menu' }), en)).toEqual({
metadata: 'feature/menu · gemini-3.1-pro · 26 messages · 8 tool calls',
preview: 'Explore Gmail-like density tiers for session rows.'
})
})

it('uses singular labels and omits unavailable fields', () => {
expect(
sessionRowDetails(
session({
git_branch: null,
message_count: 1,
model: null,
preview: null,
title: 'Manual title',
tool_call_count: 1
}),
en
)
).toEqual({ metadata: '1 message · 1 tool call', preview: null })
})

it('omits zero counts from metadata so the sidebar stays clean', () => {
expect(
sessionRowDetails(
session({ git_branch: null, message_count: 0, model: null, tool_call_count: 0 }),
en
)
).toEqual({ metadata: '', preview: 'Explore Gmail-like density tiers for session rows.' })
})

it('normalizes whitespace-only title, branch, and preview values', () => {
expect(
sessionRowDetails(
session({
git_branch: ' ',
preview: ' ',
title: ' '
}),
en
)
).toEqual({ metadata: 'gemini-3.1-pro · 26 messages · 8 tool calls', preview: null })
})

it('omits the preview when it already supplies the displayed title', () => {
expect(sessionRowDetails(session({ title: null }), en)).toEqual({
metadata: 'gemini-3.1-pro · 26 messages · 8 tool calls',
preview: null
})
})

it('localizes count labels via the formatter interface', () => {
const ja: SessionRowFormatters = {
messageCount: count => `${count} 件のメッセージ`,
toolCallCount: count => `${count} 件のツール呼び出し`
}

expect(
sessionRowDetails(
session({ git_branch: null, message_count: 3, model: null, tool_call_count: 5 }),
ja
)
).toEqual({
metadata: '3 件のメッセージ · 5 件のツール呼び出し',
preview: 'Explore Gmail-like density tiers for session rows.'
})
})
})
37 changes: 37 additions & 0 deletions apps/desktop/src/app/chat/sidebar/session-row-details.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { SessionListDensity } from '@/store/session-list-density'
import type { SessionInfo } from '@/types/hermes'

export interface SessionRowDetails {
metadata: string
preview: null | string
}

export interface SessionRowFormatters {
messageCount: (count: number) => string
toolCallCount: (count: number) => string
}

const modelLabel = (model: null | string) => model?.split('/').pop()?.trim() || null
const oneLine = (value: null | string) => value?.replace(/\s+/g, ' ').trim() || null

export const sessionRowEstimate = (density: SessionListDensity) =>
({ compact: 28, comfortable: 45, detailed: 63 })[density]

export function sessionRowDetails(session: SessionInfo, fmt: SessionRowFormatters): SessionRowDetails {
const preview = oneLine(session.preview)
const hasOwnTitle = Boolean(session.title?.trim())

const metadata = [
session.git_branch?.trim() || null,
modelLabel(session.model),
session.message_count > 0 ? fmt.messageCount(session.message_count) : null,
session.tool_call_count > 0 ? fmt.toolCallCount(session.tool_call_count) : null
]
.filter(Boolean)
.join(' · ')

return {
metadata,
preview: hasOwnTitle ? preview : null
}
}
47 changes: 38 additions & 9 deletions apps/desktop/src/app/chat/sidebar/session-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ 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 { $sessionListDensity } from '@/store/session-list-density'
import { sessionCostUsd } from '@/store/sidebar-archive'
import { $todoProgressBySession } from '@/store/todos'

Expand All @@ -43,6 +44,7 @@ import {
SidebarRowShell
} from './chrome'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
import { sessionRowDetails } from './session-row-details'
import { useProfilePrewarm } from './use-profile-prewarm'

interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
Expand Down Expand Up @@ -134,6 +136,14 @@ function SidebarSessionRowImpl({
const r = t.sidebar.row
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(session.profile)
const title = sessionTitle(session)
const density = useStore($sessionListDensity)
const fmt = t.sidebar

const details = sessionRowDetails(session, {
messageCount: fmt.messageCount,
toolCallCount: fmt.toolCallCount
})

const age = formatAge(session.last_active || session.started_at, r)
const handleLabel = `Reorder ${title}`
// Opt-in row metadata from the sidebar's filter menu. Read from the store
Expand Down Expand Up @@ -311,6 +321,10 @@ function SidebarSessionRowImpl({
className={cn(
'group row-hover relative',
card && SIDEBAR_ROW_CARD_MIN_H,
// Density-aware minimum heights for the inline (non-card) row: the
// metadata / preview lines below need the extra rows (#68119).
!card && density !== 'compact' && 'min-h-[2.75rem]',
!card && density === 'detailed' && 'min-h-[3.875rem]',
isSelected && 'bg-(--ui-row-active-background)',
liveTurn && 'text-foreground',
// Opaque surface while lifted so the dragged row erases what's under
Expand Down Expand Up @@ -438,15 +452,30 @@ function SidebarSessionRowImpl({
<>
{leadNode}
{handoffBadge}
<OverflowTip label={title}>
<SidebarRowLabel
className="hover-marquee flex-1 font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90"
onPointerEnter={armMarquee}
onPointerLeave={disarmMarquee}
>
<span className="hover-marquee-inner">{title}</span>
</SidebarRowLabel>
</OverflowTip>
<span className="min-w-0 flex-1 self-center">
<OverflowTip label={title}>
<SidebarRowLabel
className="hover-marquee block font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90"
onPointerEnter={armMarquee}
onPointerLeave={disarmMarquee}
>
<span className="hover-marquee-inner">{title}</span>
</SidebarRowLabel>
</OverflowTip>
{/* Session-list density (#68119): comfortable adds one
deterministic metadata line; detailed adds the initial
request preview. Compact keeps today's one-line row. */}
{density !== 'compact' && details.metadata && (
<span className="mt-0.5 block truncate text-[0.625rem] leading-none text-(--ui-text-tertiary)">
{details.metadata}
</span>
)}
{density === 'detailed' && details.preview && (
<span className="mt-1 block truncate text-[0.625rem] leading-none text-(--ui-text-quaternary)">
{details.preview}
</span>
)}
</span>
</>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const virtualizer = {
{ end: 26, index: 0, start: 0 },
{ end: 68, index: 1, start: 26 }
],
measure: vi.fn(),
measureElement: vi.fn()
}

Expand Down
26 changes: 22 additions & 4 deletions apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useStore } from '@nanostores/react'
import { useVirtualizer } from '@tanstack/react-virtual'
import type * as React from 'react'
import { type FC, useRef } from 'react'
import { type FC, useEffect, useRef } from 'react'

import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { type SidebarListRow } from '@/lib/session-date-groups'
import { sessionBucketLabel } from '@/lib/time'
import { cn } from '@/lib/utils'
import { sessionPinId } from '@/store/session'
import { $sessionListDensity } from '@/store/session-list-density'

import { SidebarDateDivider } from './chrome'
import { SidebarSessionRow } from './session-row'
import { sessionRowEstimate } from './session-row-details'

interface SessionRowCommonProps {
branchStem?: string
Expand Down Expand Up @@ -46,11 +49,11 @@ export interface VirtualSessionListProps {
sortable: boolean
}

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 DIVIDER_ESTIMATE_PX = 28
const OVERSCAN_ROWS = 12

export const VirtualSessionList: FC<VirtualSessionListProps> = ({
Expand All @@ -71,10 +74,19 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
const { t } = useI18n()
const dividerLabels = t.sidebar.dateDivider
const scrollerRef = useRef<HTMLDivElement | null>(null)
const density = useStore($sessionListDensity)

const virtualizer = useVirtualizer({
count: listRows.length,
estimateSize: () => (card ? CARD_ROW_ESTIMATE_PX : ROW_ESTIMATE_PX),
estimateSize: (index: number) => {
const row = listRows[index]

if (row?.kind === 'divider') {
return DIVIDER_ESTIMATE_PX
}

return card ? CARD_ROW_ESTIMATE_PX : sessionRowEstimate(density)
},
getItemKey: index => {
const row = listRows[index]

Expand All @@ -86,6 +98,10 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
overscan: OVERSCAN_ROWS
})

// Rows are measured after paint, so changing density must invalidate cached
// measurements from the previous mode before off-screen rows re-enter.
useEffect(() => virtualizer.measure(), [density, virtualizer])

const virtualItems = virtualizer.getVirtualItems()
const totalSize = virtualizer.getTotalSize()

Expand Down Expand Up @@ -161,7 +177,9 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
)}
ref={scrollerRef}
>
<div className="relative" style={{ height: `${totalSize}px` }}>{rows}</div>
<div className="relative" style={{ height: `${totalSize}px` }}>
{rows}
</div>
</div>
)
}
Expand Down
43 changes: 43 additions & 0 deletions apps/desktop/src/app/settings/appearance-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { $backdrop, setBackdrop } from '@/store/backdrop'
import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $reactionsEnabled, setReactionsEnabled } from '@/store/reactions-enabled'
import { $reasoningCollapsedByDefault, setReasoningCollapsedByDefault } from '@/store/reasoning-disclosure'
import { $sessionListDensity, type SessionListDensity, setSessionListDensity } from '@/store/session-list-density'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { $translucency, setTranslucency } from '@/store/translucency'
import { $zoomPercent, setZoomPercent } from '@/store/zoom'
Expand Down Expand Up @@ -248,6 +250,8 @@ export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const reasoningCollapsedByDefault = useStore($reasoningCollapsedByDefault)
const sessionListDensity = useStore($sessionListDensity)
const zoomPercent = useStore($zoomPercent)
const embedMode = useStore($embedMode)
const embedAllowed = useStore($embedAllowed)
Expand Down Expand Up @@ -291,6 +295,12 @@ export function AppearanceSettings() {
{ id: 'technical', label: a.technical }
] as const

const sessionDensityOptions = [
{ id: 'compact', label: a.sessionDensityCompact },
{ id: 'comfortable', label: a.sessionDensityComfortable },
{ id: 'detailed', label: a.sessionDensityDetailed }
] as const satisfies readonly { id: SessionListDensity; label: string }[]

const embedOptions = [
{ id: 'ask', label: a.embedsAsk },
{ id: 'always', label: a.embedsAlways },
Expand Down Expand Up @@ -433,6 +443,21 @@ export function AppearanceSettings() {

<TerminalFontSetting />

<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setSessionListDensity(id)
}}
options={sessionDensityOptions}
value={sessionListDensity}
/>
}
description={a.sessionDensityDesc}
title={a.sessionDensityTitle}
/>

<ListRow
action={
<div className="flex items-center gap-3">
Expand Down Expand Up @@ -510,6 +535,24 @@ export function AppearanceSettings() {
title={a.toolViewTitle}
/>

<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setReasoningCollapsedByDefault(id === 'on')
}}
options={[
{ id: 'off', label: t.common.off },
{ id: 'on', label: t.common.on }
]}
value={reasoningCollapsedByDefault ? 'on' : 'off'}
/>
}
description={a.reasoningCollapsedDesc}
title={a.reasoningCollapsedTitle}
/>

<ListRow
action={
<div className="flex flex-col items-end gap-1.5">
Expand Down
Loading
Loading