blob: e40b593cd0a1eeb0a923f85643fbe3e77727911c [file]
/**
* Pure presentation helpers for SessionStatus + SessionBlockedReason
* used by the sidebar and chat header.
*
* Separated from the React component layer so the copy + tone mapping
* can be unit-tested without a DOM, mirroring `session-health-notice.ts`
* pattern.
*
* Two contracts enforced here:
*
* 1. **Generalized blocked-reason copy** (@kenji review): UI labels
* never expose the raw `SessionBlockedReason` enum string. The
* mapping below is the canonical translation. New blocked reasons
* must extend the core enum AND this matrix together, or the
* `unknown` fallback applies.
*
* 2. **Status tone matrix**: each SessionStatus has a single visual
* tone (`accent / warning / destructive / info / success / muted`)
* consumed by both the SessionStatusIcon and the chat-header
* status badge. Aligns with the existing session-health-notice tone
* vocabulary.
*/
import type { SessionBlockedReason, SessionStatus, SessionSummary, UiLocale } from '@maka/core';
import {
describeBlockedReason,
presentSessionStatus,
type SessionStatusPresentation,
type SessionStatusTone,
} from '@maka/ui';
import { getDesktopConversationCopy } from './locales/conversation-copy.js';
import { describeSessionErrorReason } from './session-error-presentation.js';
export { presentSessionStatus } from '@maka/ui';
export { describeBlockedReason } from '@maka/ui';
export type { SessionStatusPresentation, SessionStatusTone } from '@maka/ui';
/**
* Session-level "blocked" is only worth interrupting the user when
* they can ACT on it: configure a connection, re-login, or confirm a
* permission. `tool_failed` / `unknown` mean "the last run's bookkeeping
* didn't close cleanly" — the conversation itself is intact and
* retryable, and the failure detail already surfaces on the failed
* turn inside the chat. Runtime keeps writing the strict status (the
* #397/#410 terminal-fact invariant is untouched); this is a
* display-layer distinction only.
*/
const ACTIONABLE_BLOCKED_REASONS: ReadonlySet<SessionBlockedReason> = new Set([
'NO_REAL_CONNECTION',
'auth',
'permission_required',
]);
export function isActionableBlocked(reason: SessionBlockedReason | undefined): boolean {
return reason !== undefined && ACTIONABLE_BLOCKED_REASONS.has(reason);
}
/**
* Normalize a SessionSummary as it enters renderer state: non-actionable
* blocked sessions read as ordinary resumable sessions (`active`), so the
* sidebar grouping, row icon, and chat-header badge all agree without
* each consumer re-implementing the rule. Everything else passes through
* unchanged.
*/
export function normalizeSessionSummaryForDisplay(session: SessionSummary): SessionSummary {
if (session.status !== 'blocked' || isActionableBlocked(session.blockedReason)) return session;
const { blockedReason: _blockedReason, ...rest } = session;
void _blockedReason;
return { ...rest, status: 'active' };
}
/**
* Status tone vocabulary — extends the session-health-notice tone set
* (`info | warning | destructive`) with `accent` for active in-flight
* work, `success` for completed work, and `muted` for terminal /
* dormant buckets. Tones map to semantic color tokens in CSS
* (`[data-status-tone="..."]`).
*/
/**
* Generalized phrasing for a blocked session. Surfaces a user-readable
* cause without exposing the underlying enum identifier (per @kenji
* review: UI must not leak `NO_REAL_CONNECTION` etc. directly).
*
* Returned text is suitable for `aria-label`, `title`, and inline
* tooltip slots — short phrase, sentence-cased Chinese, no period.
*/
/**
* Compose a single-line aria-label / tooltip for a blocked session,
* combining the status label and the cause. Example:
* "需要处理 · 等待配置可用模型连接"
*
* Non-blocked sessions return just the status label.
*/
export function sessionStatusAriaLabel(status: SessionStatus, blockedReason?: SessionBlockedReason, locale: UiLocale = 'zh'): string {
const presentation = presentSessionStatus(status, locale);
if (status !== 'blocked') return presentation.label;
return `${presentation.label} · ${describeBlockedReason(blockedReason, locale)}`;
}
/**
* Generalized Chinese phrasing for a failed turn's `errorClass`
* Mirrors `describeBlockedReason()`; UI must never display the raw enum identifier.
*
* Recognized classes are written by the runtime via `classifyError()`,
* `classifyHttpStatus()`, and `event.reason` / `event.code`. The set is
* open-ended (any string the runtime emits is possible), so we map a
* known prefix-list and fall back to "未知错误" for anything else.
*
* Importantly, this helper accepts strings — not a typed enum — so
* future runtime additions (e.g. a new tool failure class) don't break
* the UI; they just fall through to the catch-all until the mapping
* is extended.
*/
export function describeTurnErrorClass(errorClass: string | undefined, locale: UiLocale = 'zh'): string {
const copy = getDesktopConversationCopy(locale).turnError;
if (!errorClass) return copy.unknown;
const reasonDescription = describeSessionErrorReason(errorClass, locale);
if (reasonDescription) return reasonDescription;
const lower = errorClass.toLowerCase();
if (lower === 'timeout' || lower.includes('timeout')) return copy.timeout;
if (lower === 'auth' || lower.includes('auth') || lower === '401' || lower === '403') return copy.auth;
if (lower === 'rate_limit' || lower.includes('rate')) return copy.rateLimit;
if (lower === 'network' || lower.includes('network') || lower.includes('fetch') || lower.includes('econn')) {
return copy.network;
}
if (lower === 'provider_unavailable' || /\b5\d\d\b/.test(lower)) return copy.provider;
if (lower === 'tool_step_cap_reached') return copy.stepCap;
if (lower === 'tool_failed' || lower.includes('tool')) return copy.tool;
if (lower === 'permission_required' || lower.includes('permission')) return copy.permission;
if (lower === 'app_restarted') return copy.restarted;
return copy.unknown;
}
export type FailedTurnRecoveryAction = 'retry' | 'continue' | 'inspect_tool' | 'check_connection';
export interface FailedTurnRecoveryPresentation {
action: FailedTurnRecoveryAction;
label: string;
}
export interface FailedTurnRecoveryInput {
errorClass?: string;
partialOutputRetained: boolean;
toolActivityCount: number;
erroredToolCount: number;
}
/**
* User-facing recovery guidance for a failed turn. This intentionally
* separates "what failed" (`describeTurnErrorClass`) from "what should I do
* next", following the same incident-summary discipline as the runtime logs:
* do not ask the user to blindly retry if a tool already ran or partial output
* was retained.
*/
export function deriveFailedTurnRecovery(input: FailedTurnRecoveryInput, locale: UiLocale = 'zh'): FailedTurnRecoveryPresentation {
const copy = getDesktopConversationCopy(locale).turnError.recovery;
const lower = input.errorClass?.toLowerCase() ?? '';
if (lower === 'app_restarted') {
return { action: 'continue', label: copy.safeResume };
}
if (lower === 'tool_step_cap_reached') {
return { action: 'continue', label: copy.stepCap };
}
if (input.erroredToolCount > 0 || lower === 'tool_failed' || lower.includes('tool')) {
return { action: 'inspect_tool', label: copy.toolError };
}
if (lower === 'provider_billing' || lower === 'auth' || lower.includes('auth') || lower === '401' || lower === '403') {
return { action: 'check_connection', label: copy.connection };
}
if (input.partialOutputRetained) {
return { action: 'continue', label: copy.partial };
}
if (input.toolActivityCount > 0) {
return { action: 'inspect_tool', label: copy.toolRecord };
}
return { action: 'retry', label: copy.retry };
}