98 lines
4.2 KiB
JavaScript
98 lines
4.2 KiB
JavaScript
/**
|
|
* 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
|
|
}
|