165 lines
7.3 KiB
TypeScript
165 lines
7.3 KiB
TypeScript
import { useStore } from '@nanostores/react'
|
|
|
|
import { type Translations, useI18n } from '@/i18n'
|
|
import { useStoreSelector } from '@/lib/use-session-slice'
|
|
import { cn } from '@/lib/utils'
|
|
import { $sessionColorById, sessionColorFor } from '@/store/session-color'
|
|
import { $sessionDotStateById, type SessionDotState } from '@/store/session-dot-state'
|
|
import type { SessionInfo } from '@/types/hermes'
|
|
|
|
// A pure lookup table: each state maps to its className, aria-label, and title.
|
|
// No priority resolution here — $sessionDotStateById already picked one.
|
|
// Label/title resolve from sidebar.row translations, keyed by name.
|
|
type DotVariant = {
|
|
ariaLabel?: (r: Translations['sidebar']['row']) => string
|
|
className: string
|
|
role?: 'status'
|
|
title?: (r: Translations['sidebar']['row']) => string
|
|
}
|
|
|
|
// Shared base for every active dot; idle is smaller and uses its own class.
|
|
const DOT_BASE = 'size-1.5 rounded-full'
|
|
|
|
// Three colors and one fill/hollow axis, none of it moving. Motion on a 6px
|
|
// circle can only say "something is happening" — which the row's arc already
|
|
// says, better — while costing a repaint per frame on every row at once. What
|
|
// the dot is for is telling states APART, and that is a job for color and fill:
|
|
// filled means producing, hollow means open but quiet. The two states this
|
|
// replaces differed by 30% opacity and were, in practice, the same dot.
|
|
const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
|
|
// Amber — a clarify/approval is blocking the turn. The one "act now" color,
|
|
// and the only state the user is required to do something about.
|
|
'needs-input': {
|
|
ariaLabel: r => r.needsInput,
|
|
className: `${DOT_BASE} bg-amber-500`,
|
|
role: 'status',
|
|
title: r => r.waitingForAnswer
|
|
},
|
|
// Accent — the turn is running. The row's arc carries the motion.
|
|
working: {
|
|
ariaLabel: r => r.sessionRunning,
|
|
className: `${DOT_BASE} bg-(--ui-accent)`,
|
|
role: 'status'
|
|
},
|
|
// Hollow accent — still authoritatively running, but nothing has arrived for
|
|
// the watchdog window. Same color as working because it IS working; hollow
|
|
// because nothing is coming out of it right now.
|
|
stalled: {
|
|
ariaLabel: r => r.sessionRunning,
|
|
className: `${DOT_BASE} border border-(--ui-accent)`,
|
|
role: 'status',
|
|
title: r => r.sessionRunning
|
|
},
|
|
// Hollow muted — a terminal(background=true) process outlived the turn. An
|
|
// outline reads as "still open" without claiming the model is working; a
|
|
// filled grey dot read as finished, the opposite of what this means.
|
|
background: {
|
|
ariaLabel: r => r.backgroundRunning,
|
|
className: `${DOT_BASE} border border-(--ui-text-tertiary)`,
|
|
role: 'status',
|
|
title: r => r.backgroundRunning
|
|
},
|
|
// Emerald — the turn finished while the user was looking elsewhere. The
|
|
// color is theme-derived (`--ui-success`, a success green rotated toward the
|
|
// accent) so eight finished dots can't sit in the sidebar fighting a palette
|
|
// they don't belong to. Under a green accent it stays emerald.
|
|
unread: {
|
|
ariaLabel: r => r.finishedUnread,
|
|
className: `${DOT_BASE} bg-(--ui-success)`,
|
|
role: 'status',
|
|
title: r => r.finishedUnread
|
|
},
|
|
// Hollow grey, the faintest ink the app has — nothing has ever run here. It
|
|
// shares the outline with `background` because both mean "open, not
|
|
// producing", and sits a shade dimmer because a draft is the one state that
|
|
// has yet to do anything at all.
|
|
draft: {
|
|
ariaLabel: r => r.draftSession,
|
|
className: `${DOT_BASE} border border-(--ui-text-quaternary)`,
|
|
title: r => r.draftSession
|
|
},
|
|
// Settled: the project color when there is one, else the faintest filled
|
|
// grey. Every session shows SOME mark — a row with nothing in the lead slot
|
|
// reads as broken next to its neighbours, so "no color" falls back to the
|
|
// quietest ink rather than to an invisible dot.
|
|
idle: {
|
|
className: 'size-1 rounded-full bg-(--ui-text-quaternary)'
|
|
}
|
|
}
|
|
|
|
/** The dot a state paints, for surfaces that describe a status rather than
|
|
* render a session — the sidebar's status filter, say. Idle carries no color
|
|
* of its own (it inherits the project's), so callers supply one. */
|
|
export const sessionDotClassName = (state: SessionDotState): string => DOT_VARIANTS[state].className
|
|
|
|
export interface SessionStatusDotProps {
|
|
/** The STORED session id — the key every live-state atom (working /
|
|
* attention / stalled / unread / background) is keyed by, on BOTH surfaces:
|
|
* the sidebar row's `session.id` and a pane tile's `storedSessionId` are the
|
|
* same stored id (`$workingSessionIds` et al. map `storedSessionId`).
|
|
*
|
|
* Null on a new chat that has yet to reach the backend — no id to key by,
|
|
* and no turn behind it, which is the draft state by definition. */
|
|
storedSessionId: null | string
|
|
/** The session row for color resolution — recents OR the project tree. Both
|
|
* call sites already hold it; passing it lets the idle dot inherit the
|
|
* project color even for a session older than the paginated recents page
|
|
* (which has no `$sessionColorById` entry). */
|
|
session?: null | SessionInfo
|
|
/** TUI-style tree stem for a branched session (`└─ ` / `├─ `). */
|
|
branchStem?: string
|
|
/** Applied to the OUTER wrapper (stem + dot) — e.g. hover-fade on the
|
|
* reorder handle. */
|
|
className?: string
|
|
}
|
|
|
|
/**
|
|
* SESSION STATUS DOT — the ONE primitive the sidebar row, the pane tabs, and
|
|
* the session switcher render, so a session's status can never disagree
|
|
* between surfaces. It resolves everything itself from the stored session id:
|
|
* the live state (via `$sessionDotStateById`, already reduced to one mutually
|
|
* exclusive answer) and the color (override → project, via `sessionColorFor`).
|
|
* An idle session shows its project color; the active states own the dot with
|
|
* their semantic color so an attention cue is never masked by the tint.
|
|
*/
|
|
export function SessionStatusDot({ storedSessionId, session, branchStem, className }: SessionStatusDotProps) {
|
|
const { t } = useI18n()
|
|
const r = t.sidebar.row
|
|
|
|
// Subscribe to the shared color map for reactivity; sessionColorFor falls
|
|
// back to the resolver for a session outside the recents page.
|
|
useStore($sessionColorById)
|
|
const color = sessionColorFor(session) ?? null
|
|
|
|
// Selector, not a plain useStore: the map is rebuilt whenever any session's
|
|
// status changes, but a given dot only repaints when ITS OWN state flips.
|
|
const dotState = useStoreSelector($sessionDotStateById, states =>
|
|
storedSessionId ? (states[storedSessionId] ?? 'idle') : 'draft'
|
|
)
|
|
|
|
const variant = DOT_VARIANTS[dotState]
|
|
|
|
return (
|
|
<span className={cn('flex items-center gap-0.5', className)}>
|
|
{branchStem ? (
|
|
<span aria-hidden className="shrink-0 font-mono text-[0.625rem] leading-none text-(--ui-text-quaternary)">
|
|
{branchStem}
|
|
</span>
|
|
) : null}
|
|
{dotState === 'idle' ? (
|
|
// Rendered even with no color to paint: an empty dot of the same size
|
|
// keeps every row's title on one left edge, so a session finishing
|
|
// can't shift the list under the pointer.
|
|
<span aria-hidden="true" className={variant.className} style={color ? { backgroundColor: color } : undefined} />
|
|
) : (
|
|
<span
|
|
aria-label={variant.ariaLabel?.(r)}
|
|
className={variant.className}
|
|
role={variant.role}
|
|
title={variant.title?.(r)}
|
|
/>
|
|
)}
|
|
</span>
|
|
)
|
|
}
|