/** * Reading the client's own session state: the pure derivations the observer * and the delivery path both ask questions of. * * @module @dsh-plugin/session-notify/client/core/session */ import { PENDING_KINDS } from '../constants.js' /** * Discriminate a pending interaction the client published. * * The domains that can ask the user something are `@deepseek-ai/dsh-client-ui-approval` * (a tool authorisation) and `@deepseek-ai/dsh-client-ui-user-questions` (a * question batch, or the plan review its `planReviewOf` marks); each publishes a * literal `kind` on its pending value. Anything else is ignored rather than * guessed at. * @param value - `status.pendingInteraction`. * @returns a served kind, or undefined. */ export function servedPendingKind(value) { if (value === undefined || value === null || typeof value !== 'object') return undefined const kind = value.kind return typeof kind === 'string' && PENDING_KINDS.includes(kind) ? kind : undefined } /** Trim free text down to one popup line. */ export function preview(value) { if (typeof value !== 'string') return '' const flat = value.replace(/\s+/g, ' ').trim() return flat.length > 80 ? `${flat.slice(0, 79)}…` : flat } /** The first question text a pending interaction exposes, across its shapes. */ export function questionText(interaction) { if (interaction === null || typeof interaction !== 'object') return '' const first = Array.isArray(interaction.questions) ? interaction.questions[0] : undefined if (typeof first?.question === 'string') return preview(first.question) if (typeof first?.text === 'string') return preview(first.text) if (typeof interaction.question === 'string') return preview(interaction.question) if (typeof interaction.prompt === 'string') return preview(interaction.prompt) return preview(interaction.displayReason?.text ?? interaction.reason?.text ?? '') } /** Copy one session title, falling back to its identity. */ export function titleOf(summary, sessionId) { const title = typeof summary?.title === 'string' ? summary.title.trim() : '' return title === '' ? sessionId.slice(0, 8) : title } /** Whether the user is looking at this exact conversation right now. */ export function isOnScreen(sessionId, list) { if (list === undefined) return false for (const id of Object.keys(list.byId ?? {})) { const summary = list.byId[id] if (summary !== undefined && (summary.retainedBy?.mainView ?? 0) > 0) return id === sessionId } return false } /** * Whether a queued candidate is still owed, read from the LIVE snapshot rather * than the one captured when the transition was seen. * * The two triggers are owed for different reasons, so they are checked for * different things: * * - A **pending interaction** (approval, question, plan review) is owed until * the user answers it. It is deliberately NOT gated on the run state: the * session is `running` while it waits — the client's own `observeRunning` * only mirrors `api-session/status` and says nothing about a pending request — * so a running gate would silently drop every approval and question alert. * - A **completion** is owed only while the conversation stayed idle. A * conversation that resumed inside the settle window owes no alert, and the * snapshot the transition was derived from still carries the pre-transition * `running` flag of the session record. * @param candidate - the alert waiting for its settle tick. * @param live - the freshest `{ list, status }` the observer has seen. * @returns whether the alert is still owed. */ export function stillOwed(candidate, live) { if (candidate.test === true) return true if (candidate.pendingKind !== '') { const sessionStatus = live.status?.get?.(candidate.sessionId) // No status entry contradicts the request, so it is still the user's move. if (sessionStatus === undefined) return true return servedPendingKind(sessionStatus.pendingInteraction) === candidate.pendingKind } const list = live.list if (list === undefined) return true const summary = list.byId?.[candidate.sessionId] if (summary === undefined) return true const running = live.status?.get?.(candidate.sessionId)?.running ?? summary.running return running !== true }