fix(ui): restore model and session authority

Move model search into the picker popover and keep the favorites filter beside it. Add an Advanced preference, translated across every locale, that hides only supplemental provider credit balances by default.

Reconcile terminal native events against session.active with generation guards so stale completions cannot stop continuing work. Keep inbox admission from prematurely confirming optimistic sends and follow the V2 previous/next message cursor contract in both directions.

Cover provider balance visibility, terminal reconciliation races, optimistic send settlement, and message pagination. Validation: 52 focused tests, full UI and Electron typecheck, 287 runnable UI tests, 126 browser integration tests, and release Tauri build.
This commit is contained in:
Pascal André 2026-08-25 10:54:14 +02:00
parent 3c35688a12
commit 430ed2a3a3
No known key found for this signature in database
25 changed files with 280 additions and 96 deletions

View file

@ -156,6 +156,7 @@ jobs:
node --conditions=browser --import tsx --test --test-force-exit
packages/ui/src/components/form-request-tool-target.test.ts
packages/ui/src/components/form-request.test.ts
packages/ui/src/components/session/provider-usage-panel.test.ts
packages/ui/src/lib/hooks/use-active-session-message-load.test.ts
packages/ui/src/stores/app-tabs.test.ts
packages/ui/src/stores/forms.test.ts

View file

@ -399,33 +399,20 @@ export default function ModelSelector(props: ModelSelectorProps) {
}}
>
<ComboboxInputValue value={inputValue()} />
<Combobox.Control class="selector-trigger relative w-full" data-model-selector-control>
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
<Combobox.Input
ref={searchInputRef}
class="selector-trigger-primary selector-trigger-primary--align-left bg-transparent flex-1 min-w-0"
data-model-selector
value={inputValue()}
placeholder={currentModelLabel()}
aria-label={currentModelAccessibleLabel()}
onFocus={() => {
if (!isOpen()) searchInputRef.select()
}}
onKeyDown={(event) => {
if (event.key === "Escape") queueMicrotask(restoreSelectedInput)
}}
/>
{currentModelValue() && (
<span class="selector-trigger-secondary" dir="ltr">
{currentModelValue()!.providerId}/{currentModelValue()!.id}
<Combobox.Control class="relative w-full" data-model-selector-control>
<Combobox.Input class="sr-only" data-model-selector aria-label={currentModelAccessibleLabel()} />
<Combobox.Trigger class="selector-trigger" aria-label={currentModelAccessibleLabel()}>
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{currentModelLabel()}
</span>
)}
</div>
<Combobox.Trigger
class="selector-trigger-icon"
aria-label={currentModelAccessibleLabel()}
>
<Combobox.Icon>
{currentModelValue() && (
<span class="selector-trigger-secondary" dir="ltr">
{currentModelValue()!.providerId}/{currentModelValue()!.id}
</span>
)}
</div>
<Combobox.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Combobox.Icon>
</Combobox.Trigger>
@ -433,23 +420,37 @@ export default function ModelSelector(props: ModelSelectorProps) {
<Combobox.Portal>
<Combobox.Content class="selector-popover">
<div class="selector-search-container">
<div class="selector-input-group">
<Combobox.Input
ref={searchInputRef}
class="selector-search-input flex-1 min-w-0"
value={inputValue()}
placeholder={t("modelSelector.placeholder.search")}
aria-label={t("modelSelector.placeholder.search")}
onKeyDown={(event) => {
if (event.key === "Escape") queueMicrotask(restoreSelectedInput)
}}
/>
<button
type="button"
class="selector-favorites-toggle"
aria-label={t("modelSelector.favoritesOnly.toggle.ariaLabel")}
aria-pressed={favoritesOnlyEnabled()}
disabled={!hasFavorites() || searchActive()}
data-active={favoritesOnlyEnabled()}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleFavoritesOnly()
}}
>
<Star class="w-4 h-4" fill={favoritesOnlyEnabled() ? "currentColor" : "none"} />
</button>
</div>
</div>
<Combobox.Listbox ref={listboxRef} class="selector-listbox" />
<div class="selector-footer">
<button
type="button"
class="selector-option selector-option-action w-full"
aria-label={t("modelSelector.favoritesOnly.toggle.ariaLabel")}
aria-pressed={favoritesOnlyEnabled()}
disabled={!hasFavorites() || searchActive()}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleFavoritesOnly()
}}
>
<Star class="w-4 h-4" fill={favoritesOnlyEnabled() ? "currentColor" : "none"} />
<span class="selector-option-label">{t("modelSelector.favoritesOnly.toggle.ariaLabel")}</span>
</button>
<button
type="button"
class="selector-option selector-option-action w-full"

View file

@ -0,0 +1,10 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { shouldShowProviderUsageWindow } from "./provider-usage-panel"
test("supplemental provider credit balances are opt-in", () => {
assert.equal(shouldShowProviderUsageWindow("credits_balance", false), false)
assert.equal(shouldShowProviderUsageWindow("credits_balance", true), true)
assert.equal(shouldShowProviderUsageWindow("credits", false), true)
assert.equal(shouldShowProviderUsageWindow("weekly", false), true)
})

View file

@ -3,6 +3,7 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Comp
import type { ProviderUsageResponse, ProviderUsageWindow } from "../../../../server/src/api-types"
import { serverApi } from "../../lib/api-client"
import { useI18n } from "../../lib/i18n"
import { useConfig } from "../../stores/preferences"
interface ProviderUsagePanelProps {
providerId: string
@ -12,8 +13,12 @@ interface ProviderUsagePanelProps {
const REFRESH_INTERVAL_MS = 60_000
const usageCache = new Map<string, { value: ProviderUsageResponse | null; updatedAt: number }>()
export const shouldShowProviderUsageWindow = (label: string, showCreditBalance: boolean) =>
label !== "credits_balance" || showCreditBalance
const ProviderUsagePanel: Component<ProviderUsagePanelProps> = (props) => {
const { t } = useI18n()
const { preferences } = useConfig()
const source = createMemo(() => {
const providerId = props.providerId.trim()
if (!providerId) return null
@ -65,6 +70,9 @@ const ProviderUsagePanel: Component<ProviderUsagePanelProps> = (props) => {
})
const entries = createMemo(() => Object.entries(usage()?.windows ?? {}))
const displayedEntries = createMemo(() =>
entries().filter(([label]) => shouldShowProviderUsageWindow(label, preferences().showProviderUsageCreditBalance)),
)
const windowLabel = (label: string) => {
const key = ({
@ -126,7 +134,7 @@ const ProviderUsagePanel: Component<ProviderUsagePanelProps> = (props) => {
}
>
<div class="space-y-2">
<For each={entries()}>
<For each={displayedEntries()}>
{([label, window]) => (
<div>
<div class="mb-1 flex items-baseline justify-between gap-2 text-[11px] text-primary">

View file

@ -12,7 +12,8 @@ export const AdvancedSettingsSection: Component = () => {
getBehaviorSettings(config).filter(
(setting) =>
setting.id === "behavior.autoCleanupBlankSessions" ||
setting.id === "behavior.keepUnseenSubagentIdleStatus",
setting.id === "behavior.keepUnseenSubagentIdleStatus" ||
setting.id === "behavior.providerUsageCreditBalance",
),
)

View file

@ -331,6 +331,8 @@ export const settingsMessages = {
"settings.behavior.toolInputsVisibility.subtitle": "Standardsichtbarkeit für Argumente von Tool-Aufrufen festlegen.",
"settings.behavior.usageMetrics.title": "Token-Verbrauchsmetriken",
"settings.behavior.usageMetrics.subtitle": "Token- und Kostenstatistiken für Assistenten-Nachrichten anzeigen oder ausblenden.",
"settings.behavior.providerUsageCreditBalance.title": "Anbieterguthaben",
"settings.behavior.providerUsageCreditBalance.subtitle": "Zusätzliche Guthabenstände von Anbietern im Nutzungsbereich anzeigen.",
"settings.behavior.autoCleanup.title": "Automatische Bereinigung leerer Sitzungen",
"settings.behavior.autoCleanup.subtitle": "Leere Sitzungen beim Erstellen neuer Sitzungen automatisch bereinigen.",
"settings.behavior.keepUnseenSubagentIdle.title": "Statusanzeigen von Unteragenten beibehalten",

View file

@ -331,6 +331,8 @@ export const settingsMessages = {
"settings.behavior.toolInputsVisibility.subtitle": "Set default visibility for tool call input arguments.",
"settings.behavior.usageMetrics.title": "Token usage metrics",
"settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.",
"settings.behavior.providerUsageCreditBalance.title": "Provider credit balance",
"settings.behavior.providerUsageCreditBalance.subtitle": "Show supplemental provider credit balances in the Usage panel.",
"settings.behavior.autoCleanup.title": "Auto-cleanup blank sessions",
"settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.",
"settings.behavior.keepUnseenSubagentIdle.title": "Keep subagent idle markers",

View file

@ -330,6 +330,8 @@ export const settingsMessages = {
"settings.behavior.toolInputsVisibility.subtitle": "Establece la visibilidad por defecto de los argumentos de entrada de las llamadas de herramientas.",
"settings.behavior.usageMetrics.title": "Métricas de uso de tokens",
"settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadísticas de tokens y costo en mensajes del asistente.",
"settings.behavior.providerUsageCreditBalance.title": "Saldo de créditos del proveedor",
"settings.behavior.providerUsageCreditBalance.subtitle": "Muestra saldos de créditos adicionales del proveedor en el panel Uso.",
"settings.behavior.autoCleanup.title": "Limpieza automática de sesiones en blanco",
"settings.behavior.autoCleanup.subtitle": "Limpia automáticamente las sesiones en blanco al crear nuevas.",
"settings.behavior.keepUnseenSubagentIdle.title": "Mantener marcadores idle de subagentes",

View file

@ -330,6 +330,8 @@ export const settingsMessages = {
"settings.behavior.toolInputsVisibility.subtitle": "Definir la visibilite par defaut des arguments d'entree des appels d'outils.",
"settings.behavior.usageMetrics.title": "Metriques d'utilisation des tokens",
"settings.behavior.usageMetrics.subtitle": "Afficher ou masquer les stats de tokens et de cout pour les messages de l'assistant.",
"settings.behavior.providerUsageCreditBalance.title": "Solde de crédits du fournisseur",
"settings.behavior.providerUsageCreditBalance.subtitle": "Afficher les soldes de crédits supplémentaires du fournisseur dans le panneau Utilisation.",
"settings.behavior.autoCleanup.title": "Nettoyage auto des sessions vides",
"settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.",
"settings.behavior.keepUnseenSubagentIdle.title": "Garder les marqueurs inactifs des sous-agents",

View file

@ -330,6 +330,8 @@ export const settingsMessages = {
"settings.behavior.toolInputsVisibility.subtitle": "הגדר נראות ברירת מחדל לארגומנטים של קריאות כלי.",
"settings.behavior.usageMetrics.title": "מדדי שימוש בטוקנים",
"settings.behavior.usageMetrics.subtitle": "הצג או הסתר נתוני טוקנים ועלות להודעות הסוכן.",
"settings.behavior.providerUsageCreditBalance.title": "יתרת קרדיטים של הספק",
"settings.behavior.providerUsageCreditBalance.subtitle": "הצגת יתרות קרדיטים נוספות של הספק בחלונית השימוש.",
"settings.behavior.autoCleanup.title": "ניקוי אוטומטי של סשנים ריקים",
"settings.behavior.autoCleanup.subtitle": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים.",
"settings.behavior.keepUnseenSubagentIdle.title": "השאר סמני idle של תתי-סוכנים",

View file

@ -330,6 +330,8 @@ export const settingsMessages = {
"settings.behavior.toolInputsVisibility.subtitle": "ツール呼び出しの入力引数の既定の表示状態を設定します。",
"settings.behavior.usageMetrics.title": "トークン使用量メトリクス",
"settings.behavior.usageMetrics.subtitle": "アシスタントのメッセージにトークン数とコストの統計を表示/非表示にします。",
"settings.behavior.providerUsageCreditBalance.title": "プロバイダーのクレジット残高",
"settings.behavior.providerUsageCreditBalance.subtitle": "使用状況パネルにプロバイダーの追加クレジット残高を表示します。",
"settings.behavior.autoCleanup.title": "空のセッションを自動クリーンアップ",
"settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。",
"settings.behavior.keepUnseenSubagentIdle.title": "サブエージェントの idle マーカーを保持",

View file

@ -331,6 +331,8 @@ export const settingsMessages = {
"settings.behavior.toolInputsVisibility.subtitle": "उपकरण कल इनपुट तर्कहरूको लागि पूर्वनिर्धारित दृश्यता सेट गर्नुहोस्।",
"settings.behavior.usageMetrics.title": "टोकन प्रयोग मेट्रिक्स",
"settings.behavior.usageMetrics.subtitle": "सन्देशहरूको लागि टोकन र लागत तथ्याङ्क देखाउनुहोस् वा लुकाउनुहोस्।",
"settings.behavior.providerUsageCreditBalance.title": "प्रदायक क्रेडिट मौज्दात",
"settings.behavior.providerUsageCreditBalance.subtitle": "प्रयोग प्यानलमा प्रदायकका अतिरिक्त क्रेडिट मौज्दात देखाउनुहोस्।",
"settings.behavior.autoCleanup.title": "खाली सत्रहरू स्वतः सफा गर्नुहोस्",
"settings.behavior.autoCleanup.subtitle": "नयाँ सत्रहरू सिर्जना गर्दा खाली सत्रहरू स्वतः सफा गर्नुहोस्।",
"settings.behavior.keepUnseenSubagentIdle.title": "उप-एजेन्ट निष्क्रिय (idle) मार्करहरू राख्नुहोस्",

View file

@ -330,6 +330,8 @@ export const settingsMessages = {
"settings.behavior.toolInputsVisibility.subtitle": "Задайте видимость по умолчанию для входных аргументов вызовов инструментов.",
"settings.behavior.usageMetrics.title": "Метрики использования токенов",
"settings.behavior.usageMetrics.subtitle": "Показывать или скрывать статистику токенов и стоимости в сообщениях ассистента.",
"settings.behavior.providerUsageCreditBalance.title": "Баланс кредитов провайдера",
"settings.behavior.providerUsageCreditBalance.subtitle": "Показывать дополнительные балансы кредитов провайдера на панели использования.",
"settings.behavior.autoCleanup.title": "Автоочистка пустых сессий",
"settings.behavior.autoCleanup.subtitle": "Автоматически очищать пустые сессии при создании новых.",
"settings.behavior.keepUnseenSubagentIdle.title": "Оставлять idle-маркеры субагентов",

View file

@ -330,6 +330,8 @@ export const settingsMessages = {
"settings.behavior.toolInputsVisibility.subtitle": "设置工具调用输入参数的默认可见性。",
"settings.behavior.usageMetrics.title": "令牌用量指标",
"settings.behavior.usageMetrics.subtitle": "显示或隐藏助手消息的令牌与成本统计。",
"settings.behavior.providerUsageCreditBalance.title": "提供商额度余额",
"settings.behavior.providerUsageCreditBalance.subtitle": "在用量面板中显示提供商的额外额度余额。",
"settings.behavior.autoCleanup.title": "自动清理空会话",
"settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。",
"settings.behavior.keepUnseenSubagentIdle.title": "保留子智能体 idle 标记",

View file

@ -78,11 +78,14 @@ test("model picker delegates keyboard selection to its accessible Kobalte input"
assert.match(source, /onInputChange=\{setInputValue\}/)
assert.match(source, /const context = useComboboxContext\(\)[\s\S]{0,100}context\.setInputValue\(props\.value\)/)
assert.match(source, /else if \(triggerMode !== "input"\) setInputValue\(""\)/)
assert.equal(source.match(/<Combobox\.Input/g)?.length, 1)
assert.match(source, /<Combobox\.Input[\s\S]{0,300}data-model-selector/)
assert.match(source, /<Combobox\.Input[\s\S]{0,300}value=\{inputValue\(\)\}[\s\S]{0,200}if \(!isOpen\(\)\) searchInputRef\.select\(\)/)
assert.equal(source.match(/<Combobox\.Input/g)?.length, 2)
assert.match(source, /<Combobox\.Input class="sr-only" data-model-selector/)
const search = source.slice(source.indexOf('<div class="selector-search-container">'), source.indexOf("<Combobox.Listbox"))
assert.match(search, /<Combobox\.Input[\s\S]{0,300}value=\{inputValue\(\)\}/)
assert.match(search, /class="selector-input-group"[\s\S]*class="selector-favorites-toggle"/)
assert.match(search, /aria-pressed=\{favoritesOnlyEnabled\(\)\}[\s\S]*disabled=\{!hasFavorites\(\) \|\| searchActive\(\)\}[\s\S]*data-active=\{favoritesOnlyEnabled\(\)\}/)
assert.doesNotMatch(search, /<input\b/)
assert.match(source, /onKeyDown=\{\(event\) => \{\s*if \(event\.key === "Escape"\) queueMicrotask\(restoreSelectedInput\)/)
assert.doesNotMatch(source, /class="sr-only"/)
assert.doesNotMatch(source, /dispatchEvent\(new KeyboardEvent/)
assert.doesNotMatch(source, /const first = pickerOptions\(\)\.find/)
assert.equal(source.match(/aria-label=\{currentModelAccessibleLabel\(\)\}/g)?.length, 2)
@ -100,6 +103,8 @@ test("model picker delegates keyboard selection to its accessible Kobalte input"
assert.match(source, /const closePicker = \(\) => \{\s*setIsOpen\(false\)\s*restoreSelectedInput\(\)/)
assert.match(source, /if \(!next\) restoreSelectedInput\(\)/)
assert.match(source, /closePicker\(\)\s*setProvidersModalOpen\(true\)/)
const footer = source.slice(source.indexOf('<div class="selector-footer">'), source.indexOf("</Combobox.Content>"))
assert.doesNotMatch(footer, /toggleFavoritesOnly/)
})
test("provider auth keeps its catalog location across deferred operation steps", () => {

View file

@ -302,6 +302,16 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS
}
},
},
{
kind: "toggle",
id: "behavior.providerUsageCreditBalance",
titleKey: "settings.behavior.providerUsageCreditBalance.title",
subtitleKey: "settings.behavior.providerUsageCreditBalance.subtitle",
get: (p) => Boolean(p.showProviderUsageCreditBalance),
set: (next) => {
updatePreferences?.({ showProviderUsageCreditBalance: next })
},
},
...(isLocalTauriHost() && actions.useTauriNativeEventTransport && actions.setUseTauriNativeEventTransport
? [
{

View file

@ -1925,7 +1925,13 @@ function handleInstanceInvalidation(instanceId: string, event: Parameters<NonNul
if (sessionId && (force || event.type.startsWith("session.")) && (
activeSessionId().get(instanceId) === sessionId
&& isLatestWindow(messageStoreBus.getOrCreate(instanceId).getMessageWindow(sessionId))
)) projectOpenCodeMessages(instanceId, sessionId, data, preserveOmitted)
)) projectOpenCodeMessages(
instanceId,
sessionId,
data,
preserveOmitted,
force || event.type !== "session.inbox.enqueued",
)
}
const project = (data: ReturnType<typeof applyOpenCodeDataEvent>, preserveOmitted = true) => {
projectMessages(data, preserveOmitted)

View file

@ -41,6 +41,7 @@ export function seedSessionMessagesV2(
messageInfos?: Map<string, MessageInfo>,
expectedRevision?: number,
preserveOmitted = false,
confirmPending = true,
): boolean {
if (!session || !Array.isArray(messages)) return false
const store = messageStoreBus.getOrCreate(instanceId)
@ -72,7 +73,7 @@ export function seedSessionMessagesV2(
bumpRevision: false,
}))
store.hydrateMessages(metadata.id, normalizedMessages, messageInfos?.values(), { preserveOmitted })
store.hydrateMessages(metadata.id, normalizedMessages, messageInfos?.values(), { preserveOmitted, confirmPending })
return true
}

View file

@ -210,7 +210,7 @@ export interface InstanceMessageStore {
state: InstanceMessageState
setState: SetStoreFunction<InstanceMessageState>
addOrUpdateSession: (input: SessionUpsertInput) => void
hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>, options?: { preserveOmitted?: boolean }) => void
hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>, options?: { preserveOmitted?: boolean; confirmPending?: boolean }) => void
reconcileEmptyAuthoritativeSnapshot: (sessionId: string) => void
reconcileAuthoritativeMessageIds: (sessionId: string, authoritativeIds: ReadonlySet<string>, baselineRevisions: ReadonlyMap<string, number>) => void
markSendPending: (messageId: string) => void
@ -493,7 +493,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
sessionId: string,
inputs: MessageUpsertInput[],
infos?: Iterable<MessageInfo>,
options?: { preserveOmitted?: boolean },
options?: { preserveOmitted?: boolean; confirmPending?: boolean },
) {
if (!Array.isArray(inputs) || inputs.length === 0) return
@ -607,12 +607,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
clearRecordDisplayCacheForMessages(instanceId, omittedIds)
}
// A send that reappears under its own id is confirmed — it is no longer in
// flight, so retire its pending marker.
serverIds.forEach((id) => {
pendingSendIds.delete(id)
if (serverIdsWithParts.has(id)) optimisticPartIdsByMessage.delete(id)
})
// Inbox admission can render before the message endpoint projects the
// accepted prompt. Only authoritative projection confirms the send.
if (options?.confirmPending !== false) {
serverIds.forEach((id) => {
pendingSendIds.delete(id)
if (serverIdsWithParts.has(id)) optimisticPartIdsByMessage.delete(id)
})
}
if (infoList) {
for (const info of infoList) {

View file

@ -568,7 +568,13 @@ export function getOpenCodeMutationRevision(instanceId: string, sessionId: strin
return mutationRevision(messageRevisionKey(instanceId, sessionId))[0]()
}
export function projectOpenCodeMessages(instanceId: string, sessionId: string, data: Data, preserveOmitted = true): void {
export function projectOpenCodeMessages(
instanceId: string,
sessionId: string,
data: Data,
preserveOmitted = true,
confirmPending = true,
): void {
const source = data.session.message.list(sessionId).slice(-MESSAGE_WINDOW_PAGE_SIZE)
const store = messageStoreBus.getOrCreate(instanceId)
if (source.length) {
@ -580,6 +586,7 @@ export function projectOpenCodeMessages(instanceId: string, sessionId: string, d
new Map(normalized.map((item) => [item.info.id, item.info])),
undefined,
preserveOmitted,
confirmPending,
)
} else if (!preserveOmitted) {
store.reconcileEmptyAuthoritativeSnapshot(sessionId)

View file

@ -103,6 +103,7 @@ export interface UiSettings {
toolInputsVisibility: ToolInputsVisibilityPreference
showUsageMetrics: boolean
usageMetricsExpansion: ExpansionPreference
showProviderUsageCreditBalance: boolean
autoCleanupBlankSessions: boolean
keepUnseenSubagentIdleStatus: boolean
modelVisibility: ModelVisibilityPreferences
@ -194,6 +195,7 @@ const defaultUiSettings: UiSettings = {
toolInputsVisibility: "collapsed",
showUsageMetrics: true,
usageMetricsExpansion: "collapsed",
showProviderUsageCreditBalance: false,
autoCleanupBlankSessions: true,
keepUnseenSubagentIdleStatus: false,
modelVisibility: {},
@ -300,6 +302,8 @@ function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
sanitized.usageMetricsExpansion,
usageMetricsFallback,
),
showProviderUsageCreditBalance:
sanitized.showProviderUsageCreditBalance ?? defaultUiSettings.showProviderUsageCreditBalance,
autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultUiSettings.autoCleanupBlankSessions,
keepUnseenSubagentIdleStatus:
sanitized.keepUnseenSubagentIdleStatus ?? defaultUiSettings.keepUnseenSubagentIdleStatus,

View file

@ -1336,16 +1336,16 @@ async function loadMessages(
...(cursor ? { cursor } : { order: "desc" }),
}, options?.signal ? { signal: options.signal } : undefined)
if (!isCurrent()) return
if (response.cursor?.next === planned.seekNewer) {
if (response.cursor?.previous === planned.seekNewer) {
resolvedNext = cursor
? { kind: "history", resumeCursor: cursor, newerCursors: [null, null] }
: emptyLatestWindow()
break
}
const next = response.cursor?.next
if (!next || seen.has(next)) throw new Error(tGlobal("messageSection.loadError.detail"))
seen.add(next)
cursor = next
const previous = response.cursor?.previous
if (!previous || seen.has(previous)) throw new Error(tGlobal("messageSection.loadError.detail"))
seen.add(previous)
cursor = previous
}
responseAscending = false
} else {
@ -1355,23 +1355,20 @@ async function loadMessages(
...(planned.cursor ? { cursor: planned.cursor } : { order: planned.order ?? "desc" }),
}, options?.signal ? { signal: options.signal } : undefined)
}
const nextCursor = response.cursor?.next ?? undefined
if (planned.cursor && nextCursor === planned.cursor) {
const olderCursor = response.cursor?.previous ?? undefined
const newerCursor = response.cursor?.next ?? undefined
const responseCursor = intent === "oldest" || planned.forward ? newerCursor : olderCursor
if (planned.cursor && responseCursor === planned.cursor) {
throw new Error("Repeated message cursor")
}
if (!isCurrent()) return
const latestSession = sessions().get(instanceId)?.get(sessionId)
if (latestSession?.runtimeStatusKnown && latestSession.status === "idle") {
store.retirePendingSends(sessionId)
}
const forwardPage = intent === "oldest" || planned.forward
const nextWindow = forwardPage
? nextCursor
? { ...resolvedNext, olderCursor: undefined, newerCursors: [nextCursor] }
? newerCursor
? { ...resolvedNext, olderCursor: undefined, newerCursors: [newerCursor] }
: emptyLatestWindow()
: withOlderCursor(resolvedNext, nextCursor)
: withOlderCursor(resolvedNext, olderCursor)
const hasLatestRevisionConflict = () => nextWindow.kind === "latest"
&& getOpenCodeMessageRevision(instanceId, sessionId) !== liveMessageRevision
const apiMessages = responseAscending ? [...response.data] : [...response.data].reverse()

View file

@ -58,8 +58,20 @@ const pendingSessionFetches = new Map<string, {
status: SessionStatus
retry?: SessionRetryState | null
}>()
const nativeLifecycleGenerations = new Map<string, number>()
let activeRetryToast: ToastHandle | null = null
function advanceNativeLifecycle(instanceId: string, sessionId: string): number {
const key = `${instanceId}\0${sessionId}`
const generation = (nativeLifecycleGenerations.get(key) ?? 0) + 1
nativeLifecycleGenerations.set(key, generation)
return generation
}
function isCurrentNativeLifecycle(instanceId: string, sessionId: string, generation: number): boolean {
return nativeLifecycleGenerations.get(`${instanceId}\0${sessionId}`) === generation
}
function speakCompletedAssistantText(instanceId: string, sessionId: string): void {
const store = messageStoreBus.getOrCreate(instanceId)
const messageId = store.getLastAssistantMessageId(sessionId)
@ -104,20 +116,28 @@ function handleNativeSessionEvent(instanceId: string, event: NativeSessionEvent)
void fetchSessionInfo(instanceId, event.data.sessionID, event.location?.directory)
return
case "session.compaction.started":
advanceNativeLifecycle(instanceId, event.data.sessionID)
ensureSessionStatus(instanceId, event.data.sessionID, "compacting", event.location?.directory)
return
case "session.compaction.failed":
setTerminalNativeSessionStatus(instanceId, event.data.sessionID, true, event.location?.directory)
void reconcileTerminalNativeSessionStatus(instanceId, event.data.sessionID, {
failed: true,
directory: event.location?.directory,
})
return
case "session.execution.interrupted":
withSession(instanceId, event.data.sessionID, (session) => { session.generationRecovery = "interrupted" })
setTerminalNativeSessionStatus(instanceId, event.data.sessionID, true, event.location?.directory)
void loadMessages(instanceId, event.data.sessionID, { force: true }).catch((error) => {
log.warn("Failed to refresh interrupted session messages", { instanceId, sessionId: event.data.sessionID, error })
void reconcileTerminalNativeSessionStatus(instanceId, event.data.sessionID, {
failed: true,
interrupted: true,
refreshMessages: true,
directory: event.location?.directory,
})
return
case "session.execution.succeeded":
setTerminalNativeSessionStatus(instanceId, event.data.sessionID, false, event.location?.directory)
void reconcileTerminalNativeSessionStatus(instanceId, event.data.sessionID, {
failed: false,
directory: event.location?.directory,
})
return
}
@ -131,10 +151,44 @@ function handleNativeSessionEvent(instanceId: string, event: NativeSessionEvent)
event.type.startsWith("session.reasoning.") ||
event.type.startsWith("session.tool.")
) {
advanceNativeLifecycle(instanceId, sessionId)
ensureSessionStatus(instanceId, sessionId, "working", event.location?.directory)
}
}
async function reconcileTerminalNativeSessionStatus(
instanceId: string,
sessionId: string,
options: { failed: boolean; interrupted?: boolean; refreshMessages?: boolean; directory?: string },
): Promise<void> {
const generation = advanceNativeLifecycle(instanceId, sessionId)
let activeSessions: Record<string, unknown> | null = null
try {
activeSessions = await getRootClient(instanceId).session.active()
} catch (error) {
log.warn("Failed to reconcile terminal session state", { instanceId, sessionId, error })
}
if (!isCurrentNativeLifecycle(instanceId, sessionId, generation)) return
if (activeSessions && Object.prototype.hasOwnProperty.call(activeSessions, sessionId)) {
withSession(instanceId, sessionId, (session) => { session.generationRecovery = null })
const existing = sessions().get(instanceId)?.get(sessionId)
if (existing) setSessionStatus(instanceId, sessionId, "working", { force: true })
else ensureSessionStatus(instanceId, sessionId, "working", options.directory)
return
}
if (options.interrupted) {
withSession(instanceId, sessionId, (session) => { session.generationRecovery = "interrupted" })
}
setTerminalNativeSessionStatus(instanceId, sessionId, options.failed, options.directory)
if (options.refreshMessages) {
void loadMessages(instanceId, sessionId, { force: true }).catch((error) => {
log.warn("Failed to refresh interrupted session messages", { instanceId, sessionId, error })
})
}
}
function setTerminalNativeSessionStatus(instanceId: string, sessionId: string, failed: boolean, directory?: string): void {
const existing = sessions().get(instanceId)?.get(sessionId)
if (existing) setSessionStatus(instanceId, sessionId, "idle", { force: true })
@ -448,6 +502,7 @@ function handleSessionDeleted(instanceId: string, event: EventSessionDeleted): v
if (!sessionId) return
log.info(`[SSE] Session deleted: ${sessionId}`)
nativeLifecycleGenerations.delete(`${instanceId}\0${sessionId}`)
removeSessionRuntimeState(instanceId, sessionId)
}
@ -455,6 +510,7 @@ function handleSessionIdle(instanceId: string, event: SessionIdle): void {
const sessionId = event.data.sessionID
if (!sessionId) return
advanceNativeLifecycle(instanceId, sessionId)
if (shouldSendOsNotificationForSession("idle", instanceId, sessionId)) {
const title = getInstanceDisplayName(instanceId)
const label = getSessionTitle(instanceId, sessionId)
@ -471,6 +527,7 @@ function handleSessionStatus(instanceId: string, event: SessionStatusUpdated): v
const sessionId = event.data.sessionID
if (!sessionId) return
advanceNativeLifecycle(instanceId, sessionId)
const rawStatus = event.data.status
const status = mapSdkSessionStatus(rawStatus)
const retry = mapSdkSessionRetry(rawStatus)
@ -502,6 +559,7 @@ function handleSessionCompacted(instanceId: string, event: SessionCompactionEnde
if (!sessionID) return
log.info(`[SSE] Session compacted: ${sessionID}`)
advanceNativeLifecycle(instanceId, sessionID)
const existing = sessions().get(instanceId)?.get(sessionID)
if (existing) setSessionStatus(instanceId, sessionID, "working", { force: true })
@ -525,7 +583,12 @@ function handleSessionCompacted(instanceId: string, event: SessionCompactionEnde
function handleSessionError(instanceId: string, event: SessionExecutionFailed): void {
const error = event.data.error
const sessionId = event.data.sessionID
if (sessionId) setTerminalNativeSessionStatus(instanceId, sessionId, true, event.location?.directory)
if (sessionId) {
void reconcileTerminalNativeSessionStatus(instanceId, sessionId, {
failed: true,
directory: event.location?.directory,
})
}
log.error(`[SSE] Session error:`, error)
let message = tGlobal("sessionEvents.sessionError.unknown")

View file

@ -148,4 +148,54 @@ describe("native session event reducer", () => {
sdkManager.destroyClientsForInstance(instanceId)
}
})
it("keeps active work when a terminal event is stale or the session remains active", async () => {
const instanceId = "native-terminal-reconciliation"
const sessionId = "session"
let resolveActive!: (value: Record<string, unknown>) => void
const client = {
session: {
active: () => new Promise<Record<string, unknown>>((resolve) => { resolveActive = resolve }),
},
} as any
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)
addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client })
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, {
id: sessionId, instanceId, parentId: null, title: sessionId, agent: "build",
model: { providerId: "provider", modelId: "model" }, status: "working", retry: null,
idleSince: null, generationRecovery: null, runtimeStatusKnown: true,
projectID: "project", location: { directory: "/work" }, time: { created: 1, updated: 1 },
} as any]])))
try {
handleNativeSessionEvent(instanceId, {
id: "succeeded", created: 2, type: "session.execution.succeeded",
durable: { aggregateID: sessionId, seq: 1, version: 1 },
data: { sessionID: sessionId }, location: { directory: "/work" },
})
handleNativeSessionEvent(instanceId, {
id: "restarted", created: 3, type: "session.execution.started",
durable: { aggregateID: sessionId, seq: 2, version: 1 },
data: { sessionID: sessionId }, location: { directory: "/work" },
})
resolveActive({})
await delay(10)
assert.equal(sessions().get(instanceId)?.get(sessionId)?.status, "working")
client.session.active = async () => ({ [sessionId]: {} })
handleNativeSessionEvent(instanceId, {
id: "interrupted", created: 4, type: "session.execution.interrupted",
durable: { aggregateID: sessionId, seq: 3, version: 1 },
data: { sessionID: sessionId, reason: "user" }, location: { directory: "/work" },
})
await delay(10)
assert.equal(sessions().get(instanceId)?.get(sessionId)?.status, "working")
assert.equal(sessions().get(instanceId)?.get(sessionId)?.generationRecovery, null)
} finally {
setSessions((previous) => { const next = new Map(previous); next.delete(instanceId); return next })
clearInstanceDeletedSessionAuthority(instanceId)
removeInstance(instanceId, { authoritative: false })
sdkManager.destroyClientsForInstance(instanceId)
}
})
})

View file

@ -237,7 +237,7 @@ describe("session request authority", () => {
if (input.cursor && pendingSecondPage) return pendingSecondPage.promise
return input.cursor
? { data: [apiMessage("old-2"), apiMessage("old-1")], cursor: {} }
: { data: [apiMessage("new-2"), apiMessage("new-1")], cursor: { next: "page-2" } }
: { data: [apiMessage("new-2"), apiMessage("new-1")], cursor: { previous: "page-2" } }
} }
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
@ -287,7 +287,7 @@ describe("session request authority", () => {
? { data: history.slice(200, 250), cursor: {} }
: failRefresh
? Promise.reject(new Error("replacement refresh failed"))
: { data: history.slice(0, 200), cursor: { next: "older-page" } } }
: { data: history.slice(0, 200), cursor: { previous: "older-page" } } }
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
try {
@ -346,7 +346,7 @@ describe("session request authority", () => {
const oldPage = deferred<any>()
;(old.client as any).message = { list: (input: any) => input.cursor
? oldPage.promise
: Promise.resolve({ data: [apiMessage("old-new")], cursor: { next: "old-page" } }) }
: Promise.resolve({ data: [apiMessage("old-new")], cursor: { previous: "old-page" } }) }
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
await loadMessages(instanceId, sessionId)
const oldRequest = loadMoreMessages(instanceId, sessionId)
@ -356,7 +356,7 @@ describe("session request authority", () => {
const currentPage = deferred<any>()
let currentPageCalls = 0
;(current.client as any).message = { list: (input: any) => {
if (!input.cursor) return Promise.resolve({ data: [apiMessage("current-new")], cursor: { next: "current-page" } })
if (!input.cursor) return Promise.resolve({ data: [apiMessage("current-new")], cursor: { previous: "current-page" } })
currentPageCalls += 1
return currentPage.promise
} }
@ -381,7 +381,7 @@ describe("session request authority", () => {
const { client, cleanup } = setup(instanceId)
let failOlder = false
;(client as any).message = { list: async (input: any) => {
if (!input.cursor) return { data: [apiMessage("new-2"), apiMessage("new-1")], cursor: { next: "page-2" } }
if (!input.cursor) return { data: [apiMessage("new-2"), apiMessage("new-1")], cursor: { previous: "page-2" } }
if (failOlder) throw new Error("older failed")
return { data: [apiMessage("old-2"), apiMessage("old-1")], cursor: {} }
} }
@ -410,7 +410,7 @@ describe("session request authority", () => {
let calls = 0
;(client as any).message = { list: (input: any) => {
calls += 1
if (calls === 1) return Promise.resolve({ data: [apiMessage("latest")], cursor: { next: "older" } })
if (calls === 1) return Promise.resolve({ data: [apiMessage("latest")], cursor: { previous: "older" } })
if (input.cursor === "older") return Promise.resolve({ data: [apiMessage("old")], cursor: {} })
if (calls === 3) return staleLatest.promise
return Promise.resolve({ data: [apiMessage("fresh")], cursor: {} })
@ -543,7 +543,7 @@ describe("session request authority", () => {
const oldest = deferred<any>()
;(client as any).message = { list: (input: any) => input.order === "asc"
? oldest.promise
: Promise.resolve({ data: [apiMessage("latest")], cursor: { next: "older" } }) }
: Promise.resolve({ data: [apiMessage("latest")], cursor: { previous: "older" } }) }
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
try {
await loadMessages(instanceId, sessionId)
@ -566,7 +566,7 @@ describe("session request authority", () => {
const oldest = deferred<any>()
;(client as any).message = { list: (input: any) => input.order === "asc"
? oldest.promise
: Promise.resolve({ data: [apiMessage("latest")], cursor: { next: "older" } }) }
: Promise.resolve({ data: [apiMessage("latest")], cursor: { previous: "older" } }) }
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
try {
await loadMessages(instanceId, sessionId)
@ -592,7 +592,7 @@ describe("session request authority", () => {
const oldest = deferred<any>()
;(client as any).message = { list: (input: any) => input.order === "asc"
? oldest.promise
: Promise.resolve({ data: [apiMessage("latest")], cursor: { next: "older" } }) }
: Promise.resolve({ data: [apiMessage("latest")], cursor: { previous: "older" } }) }
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
try {
await loadMessages(instanceId, sessionId)
@ -618,7 +618,7 @@ describe("session request authority", () => {
const oldest = deferred<any>()
;(client as any).message = { list: (input: any) => input.order === "asc"
? oldest.promise
: Promise.resolve({ data: [apiMessage("latest")], cursor: { next: "older" } }) }
: Promise.resolve({ data: [apiMessage("latest")], cursor: { previous: "older" } }) }
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
try {
await loadMessages(instanceId, sessionId)
@ -682,7 +682,7 @@ describe("session request authority", () => {
? stale.promise
: Promise.resolve({ data: [apiMessage(`native-${mutation}`)], cursor: { next: "newer" } })
}
return Promise.resolve({ data: [apiMessage("latest")], cursor: { next: "older" } })
return Promise.resolve({ data: [apiMessage("latest")], cursor: { previous: "older" } })
} }
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
try {
@ -710,7 +710,7 @@ describe("session request authority", () => {
const stale = deferred<any>()
let oldestCalls = 0
;(client as any).message = { list: (input: any) => {
if (input.order !== "asc") return Promise.resolve({ data: [apiMessage("latest")], cursor: { next: "older" } })
if (input.order !== "asc") return Promise.resolve({ data: [apiMessage("latest")], cursor: { previous: "older" } })
oldestCalls += 1
return oldestCalls === 1 ? stale.promise : Promise.resolve({ data: [apiMessage("authoritative")], cursor: {} })
} }
@ -749,7 +749,7 @@ describe("session request authority", () => {
? staleNewer.promise
: Promise.resolve({ data: [apiMessage("survivor")], cursor: {} })
}
return Promise.resolve({ data: [apiMessage("removed")], cursor: { next: "older" } })
return Promise.resolve({ data: [apiMessage("removed")], cursor: { previous: "older" } })
} }
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
try {
@ -786,7 +786,7 @@ describe("session request authority", () => {
}
if (input.cursor === "newer-from-start") return { data: [apiMessage("middle-1"), apiMessage("middle-2")], cursor: { next: "newer-middle" } }
if (input.cursor === "newer-middle") return { data: [apiMessage("recent")], cursor: {} }
return { data: [apiMessage("new-2"), apiMessage("new-1")], cursor: { next: "page-2" } }
return { data: [apiMessage("new-2"), apiMessage("new-1")], cursor: { previous: "page-2" } }
} }
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
try {
@ -817,9 +817,9 @@ describe("session request authority", () => {
const { client, cleanup } = setup(instanceId)
const latestPage = 40
;(client as any).message = { list: async (input: any) => {
if (!input.cursor) return { data: [apiMessage(`page-${latestPage}`)], cursor: { next: `c${latestPage - 1}` } }
if (!input.cursor) return { data: [apiMessage(`page-${latestPage}`)], cursor: { previous: `c${latestPage - 1}` } }
const page = Number(input.cursor.slice(1))
return { data: [apiMessage(`page-${page}`)], cursor: page > 0 ? { next: `c${page - 1}` } : {} }
return { data: [apiMessage(`page-${page}`)], cursor: page > 0 ? { previous: `c${page - 1}` } : {} }
} }
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
try {