Session list
ProThreads, 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.
Preview
Install
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
"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>
| Prop | Type | Default | Description |
|---|---|---|---|
| sessionsrequired | Session[] | — | The threads; pinned ones first, the rest grouped by day, newest first. |
| activeId | string | 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. |
| groupOrder | string[] | undefined | — | The order of `groupBy`'s headings; headings not listed follow, in the order first seen. |
| showStatus | boolean | undefined | false | Prints each session's status (or its `statusLabel`) in words on a second line, with the dot. Defaults to false. |
| staleAfter | number | undefined | STALE_AFTER | How long a running session may be silent before it's no longer shown as running, in milliseconds. Defaults to two minutes. |
| virtualize | boolean | undefined | false | Render 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. |
| className | string | 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.
| Key | English |
|---|---|
| threads | Threads |
| searchThreads | Search threads |
| noMatch | No threads match “{query}”. |
| noThreads | No threads yet. |
| pinned | Pinned |
| today | Today |
| yesterday | Yesterday |
| previous7Days | Previous 7 days |
| older | Older |
| threadName | Thread name |
| unread | Unread |
| actionsFor | Actions for {title} |
| rename | Rename |
| pin | Pin |
| unpin | Unpin |
| delete | Delete |