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:
parent
b6ed6542a1
commit
45f663b746
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2176,6 +2176,7 @@ export const zh: Translations = {
|
|||
renameDesc: '留空则清除。',
|
||||
untitledPlaceholder: '无标题会话',
|
||||
untitledChat: id => `会话 ${id}`,
|
||||
messageCount: count => `${count} 条消息`,
|
||||
ageNow: '刚刚',
|
||||
ageDay: '天',
|
||||
ageHour: '时',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue