🐛 fix(client): 授权与提问提醒不再被运行状态丢掉
This commit is contained in:
+21
-18
@@ -8,7 +8,8 @@
|
|||||||
/**
|
/**
|
||||||
* Build the copy table for one translate function.
|
* Build the copy table for one translate function.
|
||||||
* @param t - the translate function every string here goes through.
|
* @param t - the translate function every string here goes through.
|
||||||
* @returns `copyFor` (candidate → notice) and the two diagnosis renderers.
|
* @returns `copyFor` (candidate → notice), `deliveryFacts` and `replayValue`
|
||||||
|
* (the two status values the settings page shows).
|
||||||
*/
|
*/
|
||||||
export function createCopy(t) {
|
export function createCopy(t) {
|
||||||
/** Build the delivered copy for one candidate. */
|
/** Build the delivered copy for one candidate. */
|
||||||
@@ -17,6 +18,12 @@ export function createCopy(t) {
|
|||||||
if (candidate.kind === 'completion') {
|
if (candidate.kind === 'completion') {
|
||||||
return { kind: 'completion', title: t('notification.completion'), body: t('body.completion', { title }) }
|
return { kind: 'completion', title: t('notification.completion'), body: t('body.completion', { title }) }
|
||||||
}
|
}
|
||||||
|
// A plan review arrives as a question with its own discriminator, so it is
|
||||||
|
// answered before the question branch below — a plan review says what it is,
|
||||||
|
// rather than repeating the plan text the user has not read yet.
|
||||||
|
if (candidate.pendingKind === 'plan-review') {
|
||||||
|
return { kind: 'question', title: t('notification.question'), body: t('body.planReview', { title }) }
|
||||||
|
}
|
||||||
if (candidate.kind === 'question') {
|
if (candidate.kind === 'question') {
|
||||||
const question = candidate.detail
|
const question = candidate.detail
|
||||||
return {
|
return {
|
||||||
@@ -25,15 +32,6 @@ export function createCopy(t) {
|
|||||||
body: question === '' ? t('body.question', { title }) : `${title} · ${question}`,
|
body: question === '' ? t('body.question', { title }) : `${title} · ${question}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Kept in v1.0.6's order, which makes this branch unreachable today: the
|
|
||||||
// observer queues a plan review as `kind: 'question'`, so the branch above
|
|
||||||
// already answered with the plan's own text. Restoring the documented copy
|
|
||||||
// (`body.planReview`, "计划正在等待你确认") means testing `pendingKind`
|
|
||||||
// before `kind` — a user-visible behavior change, so it is deliberately NOT
|
|
||||||
// part of this refactor. `tests/unit/copy.test.js` pins today's output.
|
|
||||||
if (candidate.pendingKind === 'plan-review') {
|
|
||||||
return { kind: 'question', title: t('notification.question'), body: t('body.planReview', { title }) }
|
|
||||||
}
|
|
||||||
const tool = candidate.detail
|
const tool = candidate.detail
|
||||||
return {
|
return {
|
||||||
kind: 'approval',
|
kind: 'approval',
|
||||||
@@ -42,9 +40,14 @@ export function createCopy(t) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Describe the newest alert's fate in one line. */
|
/**
|
||||||
const describeDelivery = (last, tr) => {
|
* Split the newest delivery into the three facts the status list shows.
|
||||||
if (last === null || last === undefined) return tr('config.diag.none')
|
* @param last - `store.lastDelivery`.
|
||||||
|
* @param tr - the translate function the outcome label is rendered with.
|
||||||
|
* @returns `{ at, outcome, title }`, or null when nothing was ever delivered.
|
||||||
|
*/
|
||||||
|
const deliveryFacts = (last, tr) => {
|
||||||
|
if (last === null || last === undefined) return null
|
||||||
const outcome = last.outcome.startsWith('system-refused')
|
const outcome = last.outcome.startsWith('system-refused')
|
||||||
? tr('config.diag.refused')
|
? tr('config.diag.refused')
|
||||||
: last.outcome === 'system'
|
: last.outcome === 'system'
|
||||||
@@ -56,13 +59,13 @@ export function createCopy(t) {
|
|||||||
: last.outcome === 'replay'
|
: last.outcome === 'replay'
|
||||||
? tr('config.diag.replayDelivery')
|
? tr('config.diag.replayDelivery')
|
||||||
: tr('config.diag.popup')
|
: tr('config.diag.popup')
|
||||||
return `${tr('config.diag.last')}: ${last.at} · ${outcome} · ${last.title}`
|
return { at: last.at, outcome, title: last.title ?? '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Say how many away-channel alerts are still waiting to be replayed in-app. */
|
/** How many away-channel alerts are still waiting for the user to come back. */
|
||||||
const describeReplay = (count, tr) => (
|
const replayValue = (count, tr) => (
|
||||||
count === 0 ? tr('config.diag.replayNone') : tr('config.diag.replay', { count })
|
count === 0 ? tr('config.diag.replayNone') : tr('config.diag.replayValue', { count })
|
||||||
)
|
)
|
||||||
|
|
||||||
return { copyFor, describeDelivery, describeReplay }
|
return { copyFor, deliveryFacts, replayValue }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { SETTLE_MS, THROTTLE_MS } from '../constants.js'
|
import { SETTLE_MS, THROTTLE_MS } from '../constants.js'
|
||||||
import { windowIsAway } from '../platform.js'
|
import { windowIsAway } from '../platform.js'
|
||||||
import { isOnScreen, stillWorth } from './session.js'
|
import { isOnScreen, stillOwed } from './session.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the delivery path.
|
* Build the delivery path.
|
||||||
@@ -67,29 +67,33 @@ export function createDelivery({ store, copy, live, toasts, system, replay }) {
|
|||||||
* never actually receives.
|
* never actually receives.
|
||||||
* @param candidate - the alert to deliver.
|
* @param candidate - the alert to deliver.
|
||||||
* @param ignoreThrottle - deliver immediately, bypassing the flood guard.
|
* @param ignoreThrottle - deliver immediately, bypassing the flood guard.
|
||||||
|
* @returns `false` when the flood guard suppressed this alert, `true` when the
|
||||||
|
* delivery path itself ran (including a deliberate skip for the conversation
|
||||||
|
* on screen).
|
||||||
*/
|
*/
|
||||||
const deliver = (candidate, ignoreThrottle) => {
|
const deliver = (candidate, ignoreThrottle) => {
|
||||||
if (ignoreThrottle !== true) {
|
if (ignoreThrottle !== true) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now - lastDelivery < THROTTLE_MS) return
|
if (now - lastDelivery < THROTTLE_MS) return false
|
||||||
lastDelivery = now
|
lastDelivery = now
|
||||||
}
|
}
|
||||||
if (!windowIsAway()) {
|
if (!windowIsAway()) {
|
||||||
if (isOnScreen(candidate.sessionId, live.list)) return
|
if (isOnScreen(candidate.sessionId, live.list)) return true
|
||||||
toasts.showToast(candidate)
|
toasts.showToast(candidate)
|
||||||
recordDelivery('popup', candidate)
|
recordDelivery('popup', candidate)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
const result = system.notifySystem(candidate, candidate.test === true)
|
const result = system.notifySystem(candidate, candidate.test === true)
|
||||||
if (result.outcome === 'raised') {
|
if (result.outcome === 'raised') {
|
||||||
recordDelivery('system', candidate, result)
|
recordDelivery('system', candidate, result)
|
||||||
if (candidate.test !== true) replay.holdForReplay(candidate, result)
|
if (candidate.test !== true) replay.holdForReplay(candidate, result)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
// A test alert still has to reach the user, and so does a real one when
|
// A test alert still has to reach the user, and so does a real one when
|
||||||
// the system channel refuses: the popup is the channel that remains.
|
// the system channel refuses: the popup is the channel that remains.
|
||||||
toasts.showToast(candidate)
|
toasts.showToast(candidate)
|
||||||
recordDelivery(`system-refused-${result.outcome}`, candidate)
|
recordDelivery(`system-refused-${result.outcome}`, candidate)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,11 +102,19 @@ export function createDelivery({ store, copy, live, toasts, system, replay }) {
|
|||||||
* One candidate per tick keeps the throttle meaningful, and anything still
|
* One candidate per tick keeps the throttle meaningful, and anything still
|
||||||
* queued arms its own follow-up tick — a burst used to leave every candidate
|
* queued arms its own follow-up tick — a burst used to leave every candidate
|
||||||
* after the first stranded in the queue with no timer to flush it.
|
* after the first stranded in the queue with no timer to flush it.
|
||||||
|
*
|
||||||
|
* A pending interaction the flood guard holds back is put back in the queue
|
||||||
|
* instead of being dropped: only the user can clear it, so it stays owed, and
|
||||||
|
* the next tick retries it. Flood control still drops the completions it was
|
||||||
|
* written for.
|
||||||
*/
|
*/
|
||||||
const flushSettle = () => {
|
const flushSettle = () => {
|
||||||
settleTimer = 0
|
settleTimer = 0
|
||||||
const candidate = pending.shift()
|
const candidate = pending.shift()
|
||||||
if (candidate !== undefined && stillWorth(candidate, live)) deliver(candidate, false)
|
if (candidate !== undefined && stillOwed(candidate, live)) {
|
||||||
|
const delivered = deliver(candidate, false)
|
||||||
|
if (delivered === false && candidate.pendingKind !== '') pending.push(candidate)
|
||||||
|
}
|
||||||
if (pending.length > 0) settleTimer = setTimeout(() => flushSettle(), SETTLE_MS)
|
if (pending.length > 0) settleTimer = setTimeout(() => flushSettle(), SETTLE_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-12
@@ -8,9 +8,13 @@
|
|||||||
import { PENDING_KINDS } from '../constants.js'
|
import { PENDING_KINDS } from '../constants.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discriminate a pending interaction the client published. Only the three
|
* Discriminate a pending interaction the client published.
|
||||||
* domains the Harness itself renders are served; anything else is ignored
|
*
|
||||||
* rather than guessed at.
|
* 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`.
|
* @param value - `status.pendingInteraction`.
|
||||||
* @returns a served kind, or undefined.
|
* @returns a served kind, or undefined.
|
||||||
*/
|
*/
|
||||||
@@ -55,21 +59,39 @@ export function isOnScreen(sessionId, list) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a queued candidate is still worth delivering. Read from the LIVE
|
* Whether a queued candidate is still owed, read from the LIVE snapshot rather
|
||||||
* snapshot rather than the one captured when the transition was seen: a
|
* than the one captured when the transition was seen.
|
||||||
* conversation that resumed in the settle window owes no alert, and the
|
*
|
||||||
* snapshot the transition was derived from still carries the pre-transition
|
* The two triggers are owed for different reasons, so they are checked for
|
||||||
* `running` flag of the session record.
|
* 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 candidate - the alert waiting for its settle tick.
|
||||||
* @param live - the freshest `{ list, status }` the observer has seen.
|
* @param live - the freshest `{ list, status }` the observer has seen.
|
||||||
* @returns whether the alert is still owed.
|
* @returns whether the alert is still owed.
|
||||||
*/
|
*/
|
||||||
export function stillWorth(candidate, live) {
|
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
|
const list = live.list
|
||||||
if (candidate.test === true || list === undefined) return true
|
if (list === undefined) return true
|
||||||
const summary = list.byId?.[candidate.sessionId]
|
const summary = list.byId?.[candidate.sessionId]
|
||||||
if (summary === undefined) return true
|
if (summary === undefined) return true
|
||||||
const status = live.status
|
const running = live.status?.get?.(candidate.sessionId)?.running ?? summary.running
|
||||||
const running = status?.get?.(candidate.sessionId)?.running ?? summary.running
|
|
||||||
return running !== true
|
return running !== true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user