Sidebar Item

The session row and its list: one line or two, rename in place, pin, delete, Shift-click range select with bulk pin/delete, status dots, a context menu, drag-to-reorder, and a full render escape hatch.

Open in

Installation

npx shadcn@latest add miskibin/chat-components/sidebar-item

Also installs context-menu, sidebar-dnd.

Usage

"use client"

import {
  ChatSidebarItemList,
  type ChatSidebarItemData,
} from "@/components/ui/chat-sidebar"

export function Chats({ items }: { items: ChatSidebarItemData[] }) {
  const [activeId, setActiveId] = useState(items[0]?.id)

  return (
    <ChatSidebarItemList
      listId="recent"
      items={items}
      activeId={activeId}
      sortable
      onSelect={setActiveId}
      onRename={rename}
      onTogglePin={togglePin}
      onDelete={remove}
    />
  )
}

Examples

Shift-click to select a range

The list owns a second selection, independent of activeId: click opens a chat, Shift-click selects the range from the last click, and Ctrl/⌘-click toggles one row. Two or more selected rows raise a sticky bar (Pin, Delete, clear) and the context menu switches to bulk labels. Escape clears; Delete/Backspace deletes the set.

Shift-click a range, then Delete or Pin.

Extra context-menu entries

getMenuActions entries render above the built-in rename / pin / delete group, and separatorBefore starts a new one. Right-click any row.

Status dots and custom leading nodes

status covers the common agent states; leading replaces the dot outright when a row needs an avatar or a project glyph. SidebarItemStatusDot is exported on its own, so a legend renders from the same component the rows use.

streaming fault pending idle

Folder and branch on a row

A coding agent's sessions are usually identified by where they run, so SidebarItemBadge ships that line: folder, branch, 11px, muted, truncating. Drop it into subtitle — it holds no git logic, so it works the same against a real repository or a remembered string.

import { SidebarItemBadge } from "@/components/ui/chat-sidebar"

const items = sessions.map((session) => ({
  id: session.id,
  title: session.title,
  subtitle: (
    <SidebarItemBadge folder={session.cwd} branch={session.branch} />
  ),
  meta: relativeTime(session.updatedAt),
}))

Actions where the timestamp was

renderActions fills the slot the meta label sits in: the timestamp at rest, the controls on hover — or on focus-visible, so the keyboard reaches every one of them. They render beside the row button rather than inside it, because a button inside a button is not a button. Rows marked recede dim until you hover them: background work should not compete with the row that is waiting on you.

Hover a row, or Tab into the list

Rows that move

motion animates the list between layouts with the Web Animations API: displaced rows translate, arrivals and departures fade, and a removed row is replaced by an inert clone for the length of its fade so React keeps owning the real one. An interrupted animation resumes from its own computed progress instead of snapping.

Rich rows

subtitle adds a muted second line — a folder and branch, a model name, the last message — and the row grows to the roomier two-line padding on its own; meta pins a timestamp or a message count to the end of the title line and never shrinks, so the title truncates around it. Rows without a subtitle render exactly as before. When the shape itself has to change, renderContent replaces the whole body while the row keeps its button shell, drag listeners, context menu and rename-on-double-click; return undefined to let that row fall back to the default body.

Notes

API reference

ChatSidebarItemList

items*ChatSidebarItemData[]
Rows, in render order.
activeIdstring
Highlights one row.
listIdstring = useId()
Identifies this list in reorder events. Pass a readable one (“pinned”, “recent”) whenever you render more than one list.
draggableboolean = true
Rows can be dragged onto drop zones.
sortableboolean = false
Rows can also be dragged onto each other to reorder. Implies draggable.
groupPinnedboolean = true
Draws a separator where the pinned group ends. Assumes pinned rows come first.
showStatusDotboolean = true
Leading status dot on unpinned rows.
motionboolean = false
Animate rows between their layout positions — a chat arriving, a deletion, a filter, a drag release. Transform and opacity only, 150ms, and it stands down entirely under prefers-reduced-motion or when a single update would fade more than forty rows.
contentVisibilityboolean = true
Rows scrolled out of the sidebar skip layout and paint (content-visibility: auto), reserving their own height so the scrollbar stays honest. Turn it off for a list short enough that the reserved size costs more than the skipped work saves.
renderActions(item, ctx) => React.ReactNode
Row controls that take the meta slot on hover or keyboard focus. Identity is stabilized internally, so an inline callback still leaves rows memoized.
renameRequest{ id: string; token: number }
Opens one row's inline rename input from outside the sidebar. Bump token (0 is idle) to request it — for a command palette's “Rename current chat” or a keyboard shortcut. The row itself owns the editing state, so nothing else has to, and the input is seeded with the title the row is showing right now — a title the app regenerated in the meantime included.
emptyStateReact.ReactNode
Rendered instead of the rows when items is empty.
onSelect(id: string) => void
Plain click opens the row. Shift-click and Ctrl/Cmd-click change the selection instead.
onRename(id: string, title: string) => void
Enables double-click and context-menu renaming. Fires only on a real change. Hidden while several rows are selected.
onTogglePin(id: string, pinned: boolean) => void
Enables pin in the context menu and on the selection bar.
onTogglePinMany(ids: string[], pinned: boolean) => void
Fired instead of looping onTogglePin when two or more rows are pinned or unpinned together.
onDelete(id: string) => void
Enables delete in the context menu and on the selection bar.
onDeleteMany(ids: string[]) => void
Fired instead of looping onDelete when two or more rows are deleted at once.
onSelectedIdsChange(ids: string[]) => void
Reports the current range/toggle selection. The list owns the selection; this is only a notification.
getMenuActions(item) => SidebarItemMenuAction[]
Extra context-menu entries, prepended to the built-ins.
renderContent(item, ctx: { active, pinned, selected }) => React.ReactNode
Replaces the body of every row — the shell, drag listeners, context menu and renaming stay. Return undefined for a row to keep the default body. Its identity is stabilized internally, so an inline callback still leaves rows memoized.
className / itemClassNamestring
Merged onto the list, and onto every row.

ChatSidebarItemData

id*string
Stable key and drag id.
title*string
Falls back to "Untitled".
subtitleReact.ReactNode
Second line under the title — model name, snippet, or a SidebarItemBadge folder + branch pair. Muted, ~11px, truncating; its presence is what makes the row two lines.
metaReact.ReactNode
Trailing node on the title line — a relative time, a message count. Never shrinks; the title truncates around it.
pinnedboolean
Swaps the status dot for a pin icon.
status"idle" | "active" | "streaming" | "pending" | "fault"
Dot color. Defaults to active when the row is the active one.
leadingReact.ReactNode
Custom leading node, rendered instead of the dot.
recedeboolean
Dims the row until it is hovered, active or selected. Background work should not compete with things awaiting you: a row that is merely busy recedes, a row that needs an answer does not.

Other exports

ChatSidebarItem{ item, active?, selected?, bulk?, draggable?, sortable?, showDivider?, showStatusDot?, renameToken?, renderContent?, menuActions?, on… }
The single row, for lists you assemble yourself. The list component is the usual entry point.
ChatSidebarItemGhost{ item, active?, renderContent?, className? }
The drag preview — it renders subtitle, meta and custom content exactly like the row. Return it from renderOverlay.
SidebarItemStatusDot{ status?, className? }
The dot on its own, for legends or custom leading nodes.
SidebarItemBadge{ folder?, branch?, fullPath?, className? }
Muted 11px folder + branch line for the subtitle slot — a Folder glyph with the path's last segment (fullPath keeps all of it) and a GitBranch glyph with the branch, both truncating. Purely presentational: it reads nothing and derives nothing, so pass what your app already knows and omit the half you do not have.
isTrailingDoubleClick(detail: number) => boolean
A double-click dispatches two clicks before dblclick. The row swallows the second one so double-click-to-rename does not also select; exported for a custom row that wires both on the same element.
nextSidebarSelection(args) => { selectedIds, anchorId }
Pure helper the list uses for click / Shift / Ctrl/Cmd. Exported so a custom list can reuse the same range-select rules.
SidebarItemMenuAction{ id, label, icon?, onSelect, destructive?, separatorBefore? }
Shape of a custom context-menu entry.
SidebarItemRenderContent(item: ChatSidebarItemData, ctx: SidebarItemRenderContext) => React.ReactNode
Type of renderContent; SidebarItemRenderContext is { active: boolean; pinned: boolean; selected: boolean }.

Data slots

Every part below is addressable from a parent, so you can restyle internals without forking the component.

sidebar-itemsidebar-item-buttonsidebar-item-inputsidebar-item-subtitlesidebar-item-metasidebar-item-badgesidebar-item-statussidebar-item-actionssidebar-item-drop-verbsidebar-item-listsidebar-item-selection