mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
fix(desktop): render session status dot from one primitive (sidebar, tiles, main tab)
The sidebar row and the pane tabs each painted their own dot from different data: the sidebar read color from $sessionColorById[id] (map-only, no resolver fallback) layered with live state, while a tab painted a flat 'accent' color via sessionColorFor (map + fallback). A session older than the recents page missed the map, so the same session showed grey in the sidebar and its project color in the tab. Add a single SessionStatusDot primitive keyed by the stored session id (the key every live-state atom already uses) that resolves color (override -> project, with fallback) and live state (working/needs-input/stalled/unread/background) itself. The sidebar row, session tiles, and the main workspace tab all render it, so a session's status/color can never disagree across surfaces. Tabs gain the full live status (pulse etc.), not just a static color. Collapses the now-orphaned generic 'accent' tab-dot path into the one primitive.
This commit is contained in:
parent
067cb9e033
commit
87aaf87748
7 changed files with 181 additions and 185 deletions
|
|
@ -31,9 +31,10 @@ export interface PaneMirror<T> {
|
|||
before?: (tile: T) => null | string | undefined
|
||||
minWidth: string
|
||||
title: (key: string) => string
|
||||
/** Lead-dot color for the tile's tab (e.g. a session's project color). Re-read
|
||||
* on every `also` change, so pass the color source in `also` to keep it live. */
|
||||
accent?: (key: string) => string | undefined
|
||||
/** Custom lead NODE for the tile's tab (rendered before the label). A live,
|
||||
* self-subscribing component (e.g. a session's status dot) so the strip needn't
|
||||
* re-sync on status/color change — only `title` drives re-registration. */
|
||||
tabLead?: (key: string) => ReactNode
|
||||
render: (key: string) => ReactNode
|
||||
/** Wrap the tile's TAB (domain context menu — session verbs). */
|
||||
tabWrap?: (key: string, tab: ReactElement) => ReactNode
|
||||
|
|
@ -52,7 +53,7 @@ export interface PaneMirror<T> {
|
|||
/** Build a `watch*` fn: syncs once, then re-syncs on every source/also change.
|
||||
* Module-level state lives in the returned closure, so call it once per app. */
|
||||
export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
|
||||
const registered = new Map<string, { dispose: () => void; title: string; accent?: string }>()
|
||||
const registered = new Map<string, { dispose: () => void; title: string }>()
|
||||
const paneId = (key: string) => `${cfg.prefix}:${key}`
|
||||
|
||||
const sync = () => {
|
||||
|
|
@ -62,11 +63,10 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
|
|||
for (const tile of tiles) {
|
||||
const key = cfg.key(tile)
|
||||
const title = cfg.title(key)
|
||||
const accent = cfg.accent?.(key)
|
||||
const current = registered.get(key)
|
||||
|
||||
// register() replaces same-id in place — safe for live title/accent refreshes.
|
||||
if (current && current.title === title && current.accent === accent) {
|
||||
// register() replaces same-id in place — safe for live title refreshes.
|
||||
if (current && current.title === title) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
|
|||
area: 'panes',
|
||||
title,
|
||||
data: {
|
||||
accent,
|
||||
tabLead: cfg.tabLead ? () => cfg.tabLead!(key) : undefined,
|
||||
dock: {
|
||||
before: cfg.before?.(tile),
|
||||
pane: cfg.anchor?.(tile) ?? 'workspace',
|
||||
|
|
@ -92,7 +92,7 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
|
|||
render: () => cfg.render(key)
|
||||
})
|
||||
|
||||
registered.set(key, { dispose, title, accent })
|
||||
registered.set(key, { dispose, title })
|
||||
|
||||
if (!current) {
|
||||
registerPaneCloser(paneId(key), () => cfg.close(key))
|
||||
|
|
|
|||
141
apps/desktop/src/app/chat/session-status-dot.tsx
Normal file
141
apps/desktop/src/app/chat/session-status-dot.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $backgroundRunningSessionIds } from '@/store/composer-status'
|
||||
import { $unreadFinishedSessionIds } from '@/store/session'
|
||||
import { $sessionColorById, sessionColorFor } from '@/store/session-color'
|
||||
import { $attentionSessionIds, $stalledSessionIds, $workingSessionIds } from '@/store/session-states'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { type SessionDotState, sessionDotState } from './sidebar/session-row-state'
|
||||
|
||||
// A pure lookup table: each state maps to its className, aria-label, and title.
|
||||
// No priority resolution here — sessionDotState 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 = 'relative size-1.5 rounded-full'
|
||||
|
||||
// Pseudo-element ping ring that scales outward and fades — shared scaffold for
|
||||
// the two pulsing dots. The `before:bg-*` color is written inline per variant
|
||||
// (NOT interpolated here): Tailwind only generates utilities it can see as
|
||||
// complete static strings, so a `before:bg-${color}` template never emits.
|
||||
const PING = "before:absolute before:inset-0 before:animate-ping before:rounded-full before:content-['']"
|
||||
|
||||
const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
|
||||
// Amber steady — a clarify/approval is blocking the turn. Steady (not
|
||||
// pulsing) reads as "your turn", distinct from the accent pulse of a turn.
|
||||
'needs-input': {
|
||||
ariaLabel: r => r.needsInput,
|
||||
className: `${DOT_BASE} quest-glow bg-amber-500`,
|
||||
role: 'status',
|
||||
title: r => r.waitingForAnswer
|
||||
},
|
||||
// Accent pulse — the LLM turn is actively running.
|
||||
working: {
|
||||
ariaLabel: r => r.sessionRunning,
|
||||
className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] ${PING} before:bg-(--ui-accent) before:opacity-70`,
|
||||
role: 'status'
|
||||
},
|
||||
// Quiet accent pulse — the turn is still authoritative-running, but no
|
||||
// stream activity has arrived for the watchdog window.
|
||||
stalled: {
|
||||
ariaLabel: r => r.sessionRunning,
|
||||
className: `${DOT_BASE} bg-(--ui-accent) opacity-70 ${PING} before:bg-(--ui-accent) before:opacity-40`,
|
||||
role: 'status',
|
||||
title: r => r.sessionRunning
|
||||
},
|
||||
// Pulsing gray — a terminal(background=true) process is alive while the LLM
|
||||
// is idle. Gray (not accent) reads as "something chugging along". Brighter
|
||||
// than muted-foreground so it's visible against the surface.
|
||||
background: {
|
||||
ariaLabel: r => r.backgroundRunning,
|
||||
className: `${DOT_BASE} bg-muted-foreground/80 ${PING} before:bg-muted-foreground/80 before:opacity-60`,
|
||||
role: 'status',
|
||||
title: r => r.backgroundRunning
|
||||
},
|
||||
// Steady green — a background session's turn completed and the user hasn't
|
||||
// opened it since. "Something new here, go look."
|
||||
unread: {
|
||||
ariaLabel: r => r.finishedUnread,
|
||||
className: `${DOT_BASE} bg-emerald-500`,
|
||||
role: 'status',
|
||||
title: r => r.finishedUnread
|
||||
},
|
||||
idle: {
|
||||
className: 'size-1 rounded-full bg-(--ui-text-quaternary) opacity-80'
|
||||
}
|
||||
}
|
||||
|
||||
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`). */
|
||||
storedSessionId: 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 both the sidebar row and the pane tab
|
||||
* render, so a session's status/color can never disagree between the two
|
||||
* surfaces. It reads every signal itself from the shared stores keyed by the
|
||||
* stored session id: live state (working / needs-input / stalled / unread /
|
||||
* background, mutually exclusive via `sessionDotState`) and the resolved color
|
||||
* (override → project color, 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 inherited 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
|
||||
|
||||
const needsInput = useStore($attentionSessionIds).includes(storedSessionId)
|
||||
const isWorking = useStore($workingSessionIds).includes(storedSessionId)
|
||||
const isStalled = useStore($stalledSessionIds).includes(storedSessionId)
|
||||
const isUnread = useStore($unreadFinishedSessionIds).includes(storedSessionId)
|
||||
const hasBackground = useStore($backgroundRunningSessionIds).includes(storedSessionId)
|
||||
|
||||
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })
|
||||
|
||||
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' && color ? (
|
||||
<span aria-hidden="true" className="size-1 rounded-full" style={{ backgroundColor: color }} />
|
||||
) : (
|
||||
<span
|
||||
aria-label={DOT_VARIANTS[dotState].ariaLabel?.(r)}
|
||||
className={DOT_VARIANTS[dotState].className}
|
||||
role={DOT_VARIANTS[dotState].role}
|
||||
title={DOT_VARIANTS[dotState].title?.(r)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -46,7 +46,6 @@ import {
|
|||
sessionMatchesStoredId,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
import { $sessionColorById, sessionColorFor } from '@/store/session-color'
|
||||
import {
|
||||
$sessionStates,
|
||||
$sessionTiles,
|
||||
|
|
@ -63,6 +62,7 @@ import { type ComposerScope, ComposerScopeProvider } from './composer/scope'
|
|||
import { useComposerActions } from './hooks/use-composer-actions'
|
||||
import { paneMirror } from './pane-mirror'
|
||||
import { startSessionDrag } from './session-drag'
|
||||
import { SessionStatusDot } from './session-status-dot'
|
||||
import { useSessionTileActions } from './session-tile-actions'
|
||||
import { type SessionView, SessionViewProvider } from './session-view'
|
||||
import { SessionContextMenu } from './sidebar/session-actions-menu'
|
||||
|
|
@ -353,12 +353,6 @@ function tileTitle(storedSessionId: string): string {
|
|||
return stored ? sessionTitle(stored) : 'New session'
|
||||
}
|
||||
|
||||
/** The tab's lead-dot color — the tile's session resolved through the SAME
|
||||
* shared map the sidebar reads, so a row and its tab always agree. */
|
||||
function tileAccent(storedSessionId: string): string | undefined {
|
||||
return sessionColorFor(tileStoredRow(storedSessionId))
|
||||
}
|
||||
|
||||
/** The `@session` link payload for a tile tab drag — id + owning profile + title. */
|
||||
function tileDragPayload(storedSessionId: string): SessionDragPayload {
|
||||
const stored = tileStoredRow(storedSessionId)
|
||||
|
|
@ -513,8 +507,9 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement })
|
|||
export const watchSessionTiles = paneMirror<SessionTile>({
|
||||
source: $sessionTiles,
|
||||
// $projectTree: a tile whose session is older than the recents page resolves
|
||||
// its title/accent through the tree, which loads after the tiles register.
|
||||
also: [$sessions, $sessionColorById, $projectTree],
|
||||
// its title through the tree, which loads after the tiles register. (The tab's
|
||||
// status dot subscribes to color/state itself, so it needs no `also` entry.)
|
||||
also: [$sessions, $projectTree],
|
||||
key: t => t.storedSessionId,
|
||||
prefix: 'session-tile',
|
||||
dir: t => t.dir,
|
||||
|
|
@ -522,7 +517,13 @@ export const watchSessionTiles = paneMirror<SessionTile>({
|
|||
before: t => t.before,
|
||||
minWidth: '20rem',
|
||||
title: tileTitle,
|
||||
accent: tileAccent,
|
||||
// The tab's status dot — the SAME primitive the sidebar row renders, keyed by
|
||||
// the stored id, so a session's status/color can never disagree between the
|
||||
// two surfaces. Self-subscribing (live state + resolved color), so the strip
|
||||
// needn't re-sync when it changes.
|
||||
tabLead: storedSessionId => (
|
||||
<SessionStatusDot session={tileStoredRow(storedSessionId)} storedSessionId={storedSessionId} />
|
||||
),
|
||||
render: storedSessionId => <SessionTilePane storedSessionId={storedSessionId} />,
|
||||
tabWrap: (storedSessionId, tab) => (
|
||||
<SessionTabMenu
|
||||
|
|
|
|||
|
|
@ -14,15 +14,14 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
import { coarseElapsed } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $backgroundRunningSessionIds } from '@/store/composer-status'
|
||||
import { $unreadFinishedSessionIds } from '@/store/session'
|
||||
import { $sessionColorById } from '@/store/session-color'
|
||||
import { $attentionSessionIds, $stalledSessionIds, openSessionTile } from '@/store/session-states'
|
||||
import { $attentionSessionIds, openSessionTile } from '@/store/session-states'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { SessionStatusDot } from '../session-status-dot'
|
||||
|
||||
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
|
||||
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
|
||||
import { type SessionDotState, sessionDotState, sessionShowsRunningArc } from './session-row-state'
|
||||
import { sessionShowsRunningArc } from './session-row-state'
|
||||
import { useProfilePrewarm } from './use-profile-prewarm'
|
||||
|
||||
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
|
|
@ -88,22 +87,6 @@ export function SidebarSessionRow({
|
|||
const handoffLabel = handoffSource ? (sessionSourceLabel(handoffSource) ?? handoffSource) : null
|
||||
// True when a clarify prompt in this session is waiting on the user.
|
||||
const needsInput = useStore($attentionSessionIds).includes(session.id)
|
||||
// True when the session's most recent turn finished in the background (while
|
||||
// the user was viewing a different session) and hasn't been opened since.
|
||||
const isUnread = useStore($unreadFinishedSessionIds).includes(session.id)
|
||||
// True when the turn is still running but the stream has been quiet long
|
||||
// enough to soften the animation. This must never look like an idle row.
|
||||
const isStalled = useStore($stalledSessionIds).includes(session.id)
|
||||
// True when a terminal(background=true) process is alive in this session.
|
||||
const hasBackground = useStore($backgroundRunningSessionIds).includes(session.id)
|
||||
// The session's resolved color (idle dot tint), read from the ONE shared map
|
||||
// the pane tabs also read — an O(1) lookup, never re-derived per render.
|
||||
const projectColor = useStore($sessionColorById)[session.id] ?? null
|
||||
|
||||
// Resolve the dot's display state once — the four signals are mutually
|
||||
// exclusive by priority, so threading them as booleans through wrappers just
|
||||
// to collapse them at the leaf is backwards.
|
||||
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })
|
||||
|
||||
return (
|
||||
<SessionContextMenu
|
||||
|
|
@ -237,16 +220,16 @@ export function SidebarSessionRow({
|
|||
dragHandleProps={dragHandleProps}
|
||||
leadClassName={needsInput ? 'overflow-visible' : undefined}
|
||||
>
|
||||
<SessionRowLeadDot
|
||||
<SessionStatusDot
|
||||
branchStem={branchStem}
|
||||
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
|
||||
dotState={dotState}
|
||||
projectColor={projectColor}
|
||||
session={session}
|
||||
storedSessionId={session.id}
|
||||
/>
|
||||
</SidebarRowGrab>
|
||||
) : (
|
||||
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
|
||||
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} projectColor={projectColor} />
|
||||
<SessionStatusDot branchStem={branchStem} session={session} storedSessionId={session.id} />
|
||||
</SidebarRowLead>
|
||||
)}
|
||||
{handoffSource && handoffLabel ? (
|
||||
|
|
@ -267,128 +250,3 @@ export function SidebarSessionRow({
|
|||
</SessionContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionRowLeadDot({
|
||||
branchStem,
|
||||
dotState = 'idle',
|
||||
className,
|
||||
projectColor
|
||||
}: {
|
||||
branchStem?: string
|
||||
dotState?: SessionDotState
|
||||
className?: string
|
||||
projectColor?: null | string
|
||||
}) {
|
||||
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}
|
||||
<SidebarRowDot dotState={dotState} projectColor={projectColor} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// A pure lookup table: each state maps to its className, aria-label, and
|
||||
// title. No priority resolution here — the call site already picked one.
|
||||
// Label/title are resolved 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 = 'relative size-1.5 rounded-full'
|
||||
|
||||
// Pseudo-element ping ring that scales outward and fades — shared scaffold for
|
||||
// the two pulsing dots. The `before:bg-*` color is written inline per variant
|
||||
// (NOT interpolated here): Tailwind only generates utilities it can see as
|
||||
// complete static strings, so a `before:bg-${color}` template never emits.
|
||||
const PING = "before:absolute before:inset-0 before:animate-ping before:rounded-full before:content-['']"
|
||||
|
||||
const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
|
||||
// Amber steady — a clarify/approval is blocking the turn. Steady (not
|
||||
// pulsing) reads as "your turn", distinct from the accent pulse of a turn.
|
||||
'needs-input': {
|
||||
ariaLabel: r => r.needsInput,
|
||||
className: `${DOT_BASE} quest-glow bg-amber-500`,
|
||||
role: 'status',
|
||||
title: r => r.waitingForAnswer
|
||||
},
|
||||
// Accent pulse — the LLM turn is actively running.
|
||||
working: {
|
||||
ariaLabel: r => r.sessionRunning,
|
||||
className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] ${PING} before:bg-(--ui-accent) before:opacity-70`,
|
||||
role: 'status'
|
||||
},
|
||||
// Quiet accent pulse — the turn is still authoritative-running, but no
|
||||
// stream activity has arrived for the watchdog window.
|
||||
stalled: {
|
||||
ariaLabel: r => r.sessionRunning,
|
||||
className: `${DOT_BASE} bg-(--ui-accent) opacity-70 ${PING} before:bg-(--ui-accent) before:opacity-40`,
|
||||
role: 'status',
|
||||
title: r => r.sessionRunning
|
||||
},
|
||||
// Pulsing gray — a terminal(background=true) process is alive while the LLM
|
||||
// is idle. Gray (not accent) reads as "something chugging along". Brighter
|
||||
// than muted-foreground so it's visible against the sidebar surface.
|
||||
background: {
|
||||
ariaLabel: r => r.backgroundRunning,
|
||||
className: `${DOT_BASE} bg-muted-foreground/80 ${PING} before:bg-muted-foreground/80 before:opacity-60`,
|
||||
role: 'status',
|
||||
title: r => r.backgroundRunning
|
||||
},
|
||||
// Steady green — a background session's turn completed and the user hasn't
|
||||
// opened it since. "Something new here, go look."
|
||||
unread: {
|
||||
ariaLabel: r => r.finishedUnread,
|
||||
className: `${DOT_BASE} bg-emerald-500`,
|
||||
role: 'status',
|
||||
title: r => r.finishedUnread
|
||||
},
|
||||
idle: {
|
||||
className: 'size-1 rounded-full bg-(--ui-text-quaternary) opacity-80'
|
||||
}
|
||||
}
|
||||
|
||||
function SidebarRowDot({
|
||||
dotState,
|
||||
className,
|
||||
projectColor
|
||||
}: {
|
||||
dotState: SessionDotState
|
||||
className?: string
|
||||
projectColor?: null | string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
|
||||
// An idle session inherits its project's color (a quiet marker matching the
|
||||
// project row's own color dot). The active states (working / needs-input /
|
||||
// background / unread) own the dot and keep their semantic color, so the
|
||||
// inherited tint never competes with an attention cue.
|
||||
if (dotState === 'idle' && projectColor) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('size-1 rounded-full', className)}
|
||||
style={{ backgroundColor: projectColor }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const variant = DOT_VARIANTS[dotState]
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={variant.ariaLabel?.(r)}
|
||||
className={cn(variant.className, className)}
|
||||
role={variant.role}
|
||||
title={variant.title?.(r)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { computed } from 'nanostores'
|
|||
import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } from 'react'
|
||||
|
||||
import { PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from '@/app/chat/right-rail'
|
||||
import { SessionStatusDot } from '@/app/chat/session-status-dot'
|
||||
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
|
||||
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
|
||||
import { IdleMount } from '@/components/idle-mount'
|
||||
|
|
@ -53,7 +54,6 @@ import {
|
|||
import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
|
||||
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
|
||||
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
|
||||
import { $sessionColorById, sessionColorFor } from '@/store/session-color'
|
||||
|
||||
import type { SessionDragPayload } from '../chat/composer/inline-refs'
|
||||
import { watchRouteTiles } from '../chat/route-tile'
|
||||
|
|
@ -410,9 +410,10 @@ const syncWorkspaceTitle = () => {
|
|||
area: 'panes',
|
||||
title: stored ? storedSessionTitle(stored) : 'New session',
|
||||
data: {
|
||||
// The tab's lead dot — same shared map the sidebar row reads, so the
|
||||
// main tab and its sidebar row always show the same color.
|
||||
accent: sessionColorFor(stored),
|
||||
// The tab's status dot — the SAME primitive the sidebar row and session
|
||||
// tiles render, so the main tab never disagrees with its sidebar row. No
|
||||
// dot on a fresh draft (no session yet).
|
||||
tabLead: selected ? () => <SessionStatusDot session={stored} storedSessionId={selected} /> : undefined,
|
||||
// Pages aren't tab-able: the main zone's bar stands down while one shows.
|
||||
headerVeto: $workspaceIsPage.get(),
|
||||
placement: 'main',
|
||||
|
|
@ -427,7 +428,6 @@ const syncWorkspaceTitle = () => {
|
|||
|
||||
$selectedStoredSessionId.listen(syncWorkspaceTitle)
|
||||
$sessions.listen(syncWorkspaceTitle)
|
||||
$sessionColorById.listen(syncWorkspaceTitle)
|
||||
$workspaceIsPage.listen(syncWorkspaceTitle)
|
||||
|
||||
// Layout reset collapses every session tile into main as a tab (after the
|
||||
|
|
|
|||
|
|
@ -58,11 +58,11 @@ interface PaneChrome {
|
|||
* (artifacts/skills/plugin pages) are not tab-able surfaces. The flag is
|
||||
* live: the workspace contribution re-registers it on route changes. */
|
||||
headerVeto?: boolean
|
||||
/** A lead-dot color for this pane's TAB (a session tab inheriting its
|
||||
* project color). Generic — any pane may contribute one; the strip just
|
||||
* renders a tinted dot before the label. Live: the owning contribution
|
||||
* re-registers it when the resolved color changes. */
|
||||
accent?: string
|
||||
/** A lead NODE for this pane's TAB, rendered before the label. A session
|
||||
* pane (main workspace + tiles) passes its live `SessionStatusDot` here so
|
||||
* the tab and the sidebar row render status/color from the ONE primitive
|
||||
* (self-subscribing — it updates without the strip re-registering). */
|
||||
tabLead?: () => React.ReactNode
|
||||
}
|
||||
|
||||
export const paneChrome = (c: Contribution | undefined) => (c?.data ?? {}) as PaneChrome
|
||||
|
|
|
|||
|
|
@ -472,12 +472,8 @@ export function TreeGroup({
|
|||
role="tab"
|
||||
style={{ cursor: 'grab' }}
|
||||
>
|
||||
{chrome.accent ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-2 -mr-1 size-1 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: chrome.accent }}
|
||||
/>
|
||||
{chrome.tabLead ? (
|
||||
<span className="ml-2 -mr-1 flex shrink-0 items-center">{chrome.tabLead()}</span>
|
||||
) : null}
|
||||
<PaneTabLabel>{title}</PaneTabLabel>
|
||||
</PaneTab>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue