refactor(desktop): give the status dot one source of truth and a quieter look
Priority between the overlapping signals — a session can be working and unread and running a background job at once — was resolved at the call site from five separate membership lookups, which is how surfaces drift apart. `$sessionDotStateById` does it once and hands each surface a single answer. The dot's visual language collapses to three colors on one fill/hollow axis with nothing moving. Motion on a six-pixel circle can only say "something is happening", which the row's arc already says better, and it cost a repaint per frame on every row at once; filled now means producing and hollow means open but quiet. Working and stalled had differed by 30% opacity and were in practice the same dot. A settled session paints its project color or nothing, rather than a grey mark of the same weight as a real status next to every resting row. The switcher had grown its own dot with its own three states, so it disagreed with the sidebar on the same session. It renders the shared one now.
This commit is contained in:
parent
b70c5cadb3
commit
302ee80b6f
|
|
@ -1,88 +1,76 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { StatusPulse } from '@/components/ui/status-pulse'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
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 { $sessionDotStateById, type SessionDotState } from '@/store/session-dot-state'
|
||||
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.
|
||||
// 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
|
||||
pulse?: {
|
||||
className: string
|
||||
opacity: number
|
||||
}
|
||||
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'
|
||||
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 steady — a clarify/approval is blocking the turn. Steady (not
|
||||
// pulsing) reads as "your turn", distinct from the accent pulse of a turn.
|
||||
// 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} quest-glow bg-amber-500`,
|
||||
className: `${DOT_BASE} bg-amber-500`,
|
||||
role: 'status',
|
||||
title: r => r.waitingForAnswer
|
||||
},
|
||||
// Accent pulse — the LLM turn is actively running.
|
||||
// Accent — the turn is running. The row's arc carries the motion.
|
||||
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)]`,
|
||||
pulse: {
|
||||
className: 'absolute inset-0 rounded-full bg-(--ui-accent) opacity-0',
|
||||
opacity: 0.7
|
||||
},
|
||||
className: `${DOT_BASE} bg-(--ui-accent)`,
|
||||
role: 'status'
|
||||
},
|
||||
// Quiet accent pulse — the turn is still authoritative-running, but no
|
||||
// stream activity has arrived for the watchdog window.
|
||||
// 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} bg-(--ui-accent) opacity-70`,
|
||||
pulse: {
|
||||
className: 'absolute inset-0 rounded-full bg-(--ui-accent) opacity-0',
|
||||
opacity: 0.4
|
||||
},
|
||||
className: `${DOT_BASE} border border-(--ui-accent)`,
|
||||
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.
|
||||
// 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} bg-muted-foreground/80`,
|
||||
pulse: {
|
||||
className: 'absolute inset-0 rounded-full bg-muted-foreground/80 opacity-0',
|
||||
opacity: 0.6
|
||||
},
|
||||
className: `${DOT_BASE} border border-(--ui-text-tertiary)`,
|
||||
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."
|
||||
// Emerald — the turn finished while the user was looking elsewhere.
|
||||
unread: {
|
||||
ariaLabel: r => r.finishedUnread,
|
||||
className: `${DOT_BASE} bg-emerald-500`,
|
||||
role: 'status',
|
||||
title: r => r.finishedUnread
|
||||
},
|
||||
// Settled: the project color, or nothing at all. An uncolored session used to
|
||||
// get a grey dot, which put a mark of the same weight as a status next to
|
||||
// every resting row and made "no color" look like a state of its own.
|
||||
idle: {
|
||||
className: 'size-1 rounded-full bg-(--ui-text-quaternary) opacity-80'
|
||||
className: 'size-1 rounded-full'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -105,14 +93,13 @@ export interface SessionStatusDotProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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()
|
||||
|
|
@ -123,17 +110,9 @@ export function SessionStatusDot({ storedSessionId, session, branchStem, classNa
|
|||
useStore($sessionColorById)
|
||||
const color = sessionColorFor(session) ?? null
|
||||
|
||||
// Per-session membership as booleans via useStoreSelector: these arrays tick
|
||||
// on every stream delta (any session working/stalled/etc changes the array
|
||||
// reference), but a given dot only repaints when ITS OWN membership flips.
|
||||
// A plain useStore(array).includes(id) re-rendered every dot on every tick.
|
||||
const needsInput = useStoreSelector($attentionSessionIds, ids => ids.includes(storedSessionId))
|
||||
const isWorking = useStoreSelector($workingSessionIds, ids => ids.includes(storedSessionId))
|
||||
const isStalled = useStoreSelector($stalledSessionIds, ids => ids.includes(storedSessionId))
|
||||
const isUnread = useStoreSelector($unreadFinishedSessionIds, ids => ids.includes(storedSessionId))
|
||||
const hasBackground = useStoreSelector($backgroundRunningSessionIds, ids => ids.includes(storedSessionId))
|
||||
|
||||
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })
|
||||
// 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 => states[storedSessionId] ?? 'idle')
|
||||
const variant = DOT_VARIANTS[dotState]
|
||||
|
||||
return (
|
||||
|
|
@ -143,24 +122,18 @@ export function SessionStatusDot({ storedSessionId, session, branchStem, classNa
|
|||
{branchStem}
|
||||
</span>
|
||||
) : null}
|
||||
{dotState === 'idle' && color ? (
|
||||
<span aria-hidden="true" className="size-1 rounded-full" style={{ backgroundColor: color }} />
|
||||
{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)}
|
||||
>
|
||||
{variant.pulse ? (
|
||||
<StatusPulse
|
||||
aria-hidden="true"
|
||||
className={variant.pulse.className}
|
||||
kind="ping"
|
||||
opacity={variant.pulse.opacity}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,43 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { sessionDotState, sessionShowsRunningArc } from './session-row-state'
|
||||
import { sessionShowsRunningArc } from './session-row-state'
|
||||
|
||||
describe('session row running appearance', () => {
|
||||
it('keeps the running arc when an authoritative turn becomes quiet', () => {
|
||||
expect(sessionShowsRunningArc({ isWorking: true, needsInput: false })).toBe(true)
|
||||
expect(
|
||||
sessionDotState({
|
||||
hasBackground: false,
|
||||
isStalled: true,
|
||||
isUnread: false,
|
||||
isWorking: true,
|
||||
needsInput: false
|
||||
})
|
||||
).toBe('stalled')
|
||||
})
|
||||
|
||||
it('uses the needs-input treatment instead of the running arc', () => {
|
||||
expect(sessionShowsRunningArc({ isWorking: true, needsInput: true })).toBe(false)
|
||||
expect(
|
||||
sessionDotState({
|
||||
hasBackground: true,
|
||||
isStalled: true,
|
||||
isUnread: true,
|
||||
isWorking: true,
|
||||
needsInput: true
|
||||
})
|
||||
).toBe('needs-input')
|
||||
})
|
||||
|
||||
it('keeps background and unread states below active-turn states', () => {
|
||||
expect(
|
||||
sessionDotState({
|
||||
hasBackground: true,
|
||||
isStalled: false,
|
||||
isUnread: true,
|
||||
isWorking: false,
|
||||
needsInput: false
|
||||
})
|
||||
).toBe('background')
|
||||
it('shows no arc for a session that is not running', () => {
|
||||
expect(sessionShowsRunningArc({ isWorking: false, needsInput: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,42 +1,12 @@
|
|||
export type SessionDotState = 'background' | 'idle' | 'needs-input' | 'stalled' | 'unread' | 'working'
|
||||
|
||||
interface SessionRowState {
|
||||
hasBackground: boolean
|
||||
isStalled: boolean
|
||||
isUnread: boolean
|
||||
isWorking: boolean
|
||||
needsInput: boolean
|
||||
}
|
||||
|
||||
/** Resolve the sidebar dot's mutually-exclusive display state by priority. */
|
||||
export function sessionDotState({
|
||||
hasBackground,
|
||||
isStalled,
|
||||
isUnread,
|
||||
isWorking,
|
||||
needsInput
|
||||
}: SessionRowState): SessionDotState {
|
||||
if (needsInput) {
|
||||
return 'needs-input'
|
||||
}
|
||||
|
||||
if (isWorking) {
|
||||
return isStalled ? 'stalled' : 'working'
|
||||
}
|
||||
|
||||
if (hasBackground) {
|
||||
return 'background'
|
||||
}
|
||||
|
||||
return isUnread ? 'unread' : 'idle'
|
||||
}
|
||||
|
||||
/** A quiet turn is still authoritatively running. Keep the unmistakable row
|
||||
* arc until the gateway reports completion; only a blocking prompt suppresses
|
||||
* it in favour of the needs-input treatment. */
|
||||
export function sessionShowsRunningArc({
|
||||
isWorking,
|
||||
needsInput
|
||||
}: Pick<SessionRowState, 'isWorking' | 'needsInput'>): boolean {
|
||||
}: {
|
||||
isWorking: boolean
|
||||
needsInput: boolean
|
||||
}): boolean {
|
||||
return isWorking && !needsInput
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,14 @@ function makeSession(overrides: Partial<SessionInfo> & { title: string }): Sessi
|
|||
|
||||
const tipTrigger = (el: HTMLElement) => el.closest('[data-slot="tooltip-trigger"]')
|
||||
|
||||
// The status dot always paints an aria-hidden placeholder so every row's title
|
||||
// keeps the same left edge, so "the row's aria-hidden span" no longer names the
|
||||
// avatar on its own. `inline-grid` is PlatformAvatar's own layout class in both
|
||||
// of its branches — brand glyph and first-letter fallback — and the row passes
|
||||
// it no display class that tailwind-merge could drop it for.
|
||||
const handoffAvatar = (container: HTMLElement) =>
|
||||
container.querySelector<HTMLElement>('span[aria-hidden="true"].inline-grid')
|
||||
|
||||
const noop = vi.fn()
|
||||
|
||||
describe('SidebarSessionRow', () => {
|
||||
|
|
@ -149,11 +157,7 @@ describe('SidebarSessionRow', () => {
|
|||
/>
|
||||
)
|
||||
|
||||
// PlatformAvatar's span is the only aria-hidden SPAN this row ever
|
||||
// renders (idle dot / arc-border / branch-stem are all inactive here) —
|
||||
// Codicon icons (e.g. the kebab trigger) are also aria-hidden but render
|
||||
// as <i>, not <span>, so this selector doesn't accidentally match them.
|
||||
expect(container.querySelector('span[aria-hidden="true"]')).toBeNull()
|
||||
expect(handoffAvatar(container)).toBeNull()
|
||||
})
|
||||
|
||||
it('wraps the handoff platform avatar in a Tip for a session started on another platform', () => {
|
||||
|
|
@ -176,11 +180,11 @@ describe('SidebarSessionRow', () => {
|
|||
|
||||
// PlatformAvatar is the REAL component here (see the note above the vi.mock
|
||||
// block, #67500 third pass) — it renders the Telegram brand SVG rather
|
||||
// than the platform name as text, so query the avatar span itself (the
|
||||
// row's only aria-hidden span in this state) rather than text content,
|
||||
// and confirm its tooltip trigger actually attaches to it — proving the
|
||||
// real forwardRef/...rest path works, not a mock that fakes it.
|
||||
const avatar = container.querySelector('span[aria-hidden="true"]')
|
||||
// than the platform name as text, so query the avatar span itself rather
|
||||
// than text content, and confirm its tooltip trigger actually attaches to
|
||||
// it — proving the real forwardRef/...rest path works, not a mock that
|
||||
// fakes it.
|
||||
const avatar = handoffAvatar(container)
|
||||
expect(avatar).toBeTruthy()
|
||||
expect(tipTrigger(avatar as HTMLElement)).toBeTruthy()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@ import { useNavigate } from 'react-router'
|
|||
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $unreadFinishedSessionIds } from '@/store/session'
|
||||
import { $attentionSessionIds, $workingSessionIds } from '@/store/session-states'
|
||||
import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher'
|
||||
|
||||
import { SessionStatusDot } from './chat/session-status-dot'
|
||||
import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud'
|
||||
import { openSession } from './open-session'
|
||||
|
||||
|
|
@ -18,9 +17,6 @@ export function SessionSwitcher() {
|
|||
const open = useStore($switcherOpen)
|
||||
const sessions = useStore($switcherSessions)
|
||||
const index = useStore($switcherIndex)
|
||||
const working = useStore($workingSessionIds)
|
||||
const attention = useStore($attentionSessionIds)
|
||||
const unread = useStore($unreadFinishedSessionIds)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const activeRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -33,10 +29,6 @@ export function SessionSwitcher() {
|
|||
return null
|
||||
}
|
||||
|
||||
const workingIds = new Set(working)
|
||||
const attentionIds = new Set(attention)
|
||||
const unreadIds = new Set(unread)
|
||||
|
||||
const pick = (sessionId: string) => {
|
||||
closeSwitcher()
|
||||
openSession(sessionId, navigate)
|
||||
|
|
@ -77,11 +69,7 @@ export function SessionSwitcher() {
|
|||
}}
|
||||
ref={selected ? activeRef : undefined}
|
||||
>
|
||||
<SwitcherDot
|
||||
attention={attentionIds.has(session.id)}
|
||||
unread={unreadIds.has(session.id)}
|
||||
working={workingIds.has(session.id)}
|
||||
/>
|
||||
<SessionStatusDot className="shrink-0" session={session} storedSessionId={session.id} />
|
||||
<span className="min-w-0 flex-1 truncate">{sessionTitle(session)}</span>
|
||||
{i < 9 && (
|
||||
<span
|
||||
|
|
@ -101,20 +89,3 @@ export function SessionSwitcher() {
|
|||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
function SwitcherDot({ attention, working, unread }: { attention: boolean; working: boolean; unread: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'size-1 shrink-0 rounded-full',
|
||||
attention
|
||||
? 'bg-amber-400'
|
||||
: working
|
||||
? 'animate-pulse bg-(--ui-accent)'
|
||||
: unread
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-(--ui-text-quaternary)/50'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,3 +5,13 @@
|
|||
* would corrupt the cache — fail loud instead. */
|
||||
export const stableArray = <T>(prev: readonly T[], next: T[]): readonly T[] =>
|
||||
prev.length === next.length && prev.every((v, i) => v === next[i]) ? prev : Object.freeze(next)
|
||||
|
||||
/** {@link stableArray} for a keyed projection. */
|
||||
export const stableRecord = <T>(
|
||||
prev: Readonly<Record<string, T>>,
|
||||
next: Record<string, T>
|
||||
): Readonly<Record<string, T>> => {
|
||||
const keys = Object.keys(next)
|
||||
|
||||
return keys.length === Object.keys(prev).length && keys.every(k => prev[k] === next[k]) ? prev : Object.freeze(next)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* SESSION DOT STATE — one map from session id to the single status a surface
|
||||
* should paint, so the sidebar, the pane tabs, and the switcher can never
|
||||
* disagree about what a session is doing.
|
||||
*
|
||||
* It exists for two reasons the individual membership atoms cannot cover:
|
||||
*
|
||||
* 1. PRIORITY IN ONE PLACE. The signals overlap — a session can be working AND
|
||||
* unread AND running a background job — and resolving that per call site is
|
||||
* how surfaces drift apart.
|
||||
* 2. LINEAGE. Compression rotates a conversation's stored id, so a sidebar row,
|
||||
* a persisted tile, and the route can each hold a different tip of one
|
||||
* lineage. Every state is claimed under every alias (see `lineageAliases`),
|
||||
* so a surface gets the right answer whichever tip it happens to hold.
|
||||
*
|
||||
* The inputs are all reference-stable across stream deltas, so this recomputes
|
||||
* on status edges rather than per token.
|
||||
*/
|
||||
|
||||
import { computed } from 'nanostores'
|
||||
|
||||
import { stableRecord } from '@/lib/stable-array'
|
||||
|
||||
import { $backgroundRunningSessionIds } from './composer-status'
|
||||
import { $sessions, $unreadFinishedSessionIds, lineageAliases } from './session'
|
||||
import { $attentionSessionIds, $stalledSessionIds, $workingSessionIds } from './session-states'
|
||||
|
||||
export type SessionDotState = 'background' | 'idle' | 'needs-input' | 'stalled' | 'unread' | 'working'
|
||||
|
||||
let dotStates: Readonly<Record<string, SessionDotState>> = {}
|
||||
|
||||
export const $sessionDotStateById = computed(
|
||||
[
|
||||
$attentionSessionIds,
|
||||
$workingSessionIds,
|
||||
$stalledSessionIds,
|
||||
$backgroundRunningSessionIds,
|
||||
$unreadFinishedSessionIds,
|
||||
$sessions
|
||||
],
|
||||
(attention, working, stalled, background, unread, sessions) => {
|
||||
const next: Record<string, SessionDotState> = {}
|
||||
|
||||
const claim = (ids: readonly string[], state: SessionDotState) => {
|
||||
for (const id of ids) {
|
||||
for (const alias of lineageAliases(id, sessions)) {
|
||||
next[alias] = state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Weakest claim first — each pass overwrites the one above it, so the order
|
||||
// below IS the priority order. A blocking prompt outranks everything: it is
|
||||
// the only state that needs the user.
|
||||
claim(unread, 'unread')
|
||||
claim(background, 'background')
|
||||
claim(working, 'working')
|
||||
|
||||
// Stalled REFINES working rather than rivalling it — the turn is still
|
||||
// authoritatively running, it has just gone quiet — so it only downgrades a
|
||||
// session already claimed as working. The hint outlives its turn by a tick
|
||||
// on some paths; without this it could invent a running session.
|
||||
for (const id of stalled) {
|
||||
for (const alias of lineageAliases(id, sessions)) {
|
||||
if (next[alias] === 'working') {
|
||||
next[alias] = 'stalled'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
claim(attention, 'needs-input')
|
||||
|
||||
return (dotStates = stableRecord(dotStates, next))
|
||||
}
|
||||
)
|
||||
Loading…
Reference in New Issue