Session list

Pro

Threads, newest first, grouped by day, with pinned threads on top. A thread agents are working in shows a live dot; one that needs you pulses amber. Search filters by title. Double-click or the menu renames in place.

groupBy replaces the day groups with your own ("Needs you", "Projects", "Finished"), in groupOrder. A session's detail adds a second line, and showStatus prints each status in words beside it, so state is never colour alone. virtualize renders only the rows in or near view, for long lists. A session with lastHeardAt whose run has been silent past staleAfter, or whose host is offline, stops showing as running and says so instead. A session with an href is a link, so it opens in a new tab and its address can be copied; the open one is marked as the current page.

Markdown

Preview

Install

With the Candor CLI

First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/session-list

Brings in: dropdown-menu, locale, relative-time, run-heartbeat, status, status-dot, team, use-virtual-list, utils · npm: lucide-react@^1.67.0

Usage

TSX · The example above
"use client"

import * as React from "react"
import { SessionList } from "@/components/session-list"
import type { Session } from "@/lib/team"

const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString()

const initial: Session[] = [
  { id: "s1", title: "Add a region column to orders", updatedAt: minutesAgo(2), status: "running", detail: "Writing the migration" },
  { id: "s2", title: "Why is checkout slow on Staging?", updatedAt: minutesAgo(30), status: "waiting", detail: "Asked you about the cache" },
  { id: "s3", title: "Clean up stale feature flags", updatedAt: minutesAgo(60 * 5), unread: true },
  { id: "s4", title: "Release checklist", updatedAt: minutesAgo(60 * 24 * 3), pinned: true },
  { id: "s5", title: "Index orders.customer_id", updatedAt: minutesAgo(60 * 26) },
  { id: "s6", title: "Rename the billing repository", updatedAt: minutesAgo(60 * 24 * 4) },
]

// With an href each row is a real link: it opens in a new tab and its address can be copied. A plain click still goes
// through onOpen, for your router.
const linked = initial.map((s) => ({ ...s, href: `#thread-${s.id}` }))

export default function Example() {
  const [sessions, setSessions] = React.useState(linked)
  const [active, setActive] = React.useState("s1")
  const [byState, setByState] = React.useState(false)
  return (
    <div className="flex h-[26rem] w-72 flex-col overflow-hidden rounded-lg border bg-sidebar py-2">
      <label className="mx-3 mb-2 flex items-center gap-1.5 text-xs text-muted-foreground">
        <input type="checkbox" checked={byState} onChange={(e) => setByState(e.target.checked)} />
        Group by state
      </label>
      <SessionList
        className="min-h-0 flex-1"
        sessions={sessions}
        // Your own groups, in your order, with each status printed in words.
        groupBy={byState ? (s) => (s.status === "waiting" ? "Needs you" : s.status === "running" ? "Working" : "Everything else") : undefined}
        groupOrder={["Needs you", "Working"]}
        showStatus={byState}
        activeId={active}
        onOpen={setActive}
        onRename={(id, title) => setSessions((l) => l.map((s) => (s.id === id ? { ...s, title } : s)))}
        onPin={(id, pinned) => setSessions((l) => l.map((s) => (s.id === id ? { ...s, pinned } : s)))}
        onDelete={(id) => setSessions((l) => l.filter((s) => s.id !== id))}
      />
    </div>
  )
}

<SessionList>

PropTypeDefaultDescription
sessionsrequiredSession[]—The threads; pinned ones first, the rest grouped by day, newest first.
activeIdstring | undefined—The id of the open thread, marked as the current page.
groupBy((session: Session) => string) | undefined—Your own groups instead of pinned and days: returns a session's group heading. Pinned sessions aren't lifted out; return a "Pinned" group yourself if you want one. Newest first within each group.
groupOrderstring[] | undefined—The order of `groupBy`'s headings; headings not listed follow, in the order first seen.
showStatusboolean | undefinedfalsePrints each session's status (or its `statusLabel`) in words on a second line, with the dot. Defaults to false.
staleAfternumber | undefinedSTALE_AFTERHow long a running session may be silent before it's no longer shown as running, in milliseconds. Defaults to two minutes.
virtualizeboolean | undefinedfalseRender only the rows in or near view, for lists of hundreds of sessions. Defaults to false.
onOpen((id: string) => void) | undefined—Called with a thread's id when it's chosen. For a session with an `href`, a plain click calls this instead of following the link (for an app's own router); other clicks (new tab, new window) open the link as usual.
onRename((id: string, title: string) => void) | undefined—Allows renaming in place; called with the id and new title when the edit is committed.
onPin((id: string, pinned: boolean) => void) | undefined—Adds Pin or Unpin to a thread's menu; called with the id and whether it should now be pinned.
onDelete((id: string) => void) | undefined—Adds Delete to a thread's menu; called with the id when it's chosen, without a confirmation.
classNamestring | undefined—Extra classes for the outer element.

Strings

The fixed words, in English by default. Change any of them under sessionList in <LocaleProvider strings={{ … }}>; the table is exported as sessionListStrings.

KeyEnglish
threadsThreads
searchThreadsSearch threads
noMatchNo threads match “{query}”.
noThreadsNo threads yet.
pinnedPinned
todayToday
yesterdayYesterday
previous7DaysPrevious 7 days
olderOlder
threadNameThread name
unreadUnread
actionsForActions for {title}
renameRename
pinPin
unpinUnpin
deleteDelete