fix(ui-chat): persist work details collapse timing

This commit is contained in:
imccyu committed 2026-10-10 14:22:02 +08:00
1 parent 7d14714601
commit 61112eab8f
16 files changed
+108 -66

No files matched your search

+24 -17
View File
@@ -943,7 +943,7 @@ describe('web e2e: settings modal and General preferences', () => {
})
})
describe('web e2e: browser-local collapse timing preference', () => {
describe('web e2e: Host-backed collapse timing preference', () => {
let scaffold: WebScaffold
let browser: Browser
const uiWait = { timeout: 10_000 }
@@ -974,11 +974,12 @@ describe('web e2e: browser-local collapse timing preference', () => {
title: 'When to Collapse Work Details', completion: 'On completion', nextInput: 'On next message',
description: 'Choose when to automatically collapse work details',
},
] as const)('keeps $locale collapse timing on $theme only for the current Client', async (copy) => {
] as const)('persists $locale collapse timing on $theme across Clients', async (copy) => {
const contextOptions = {
viewport: { width: 1680, height: 1000 }, locale: copy.browserLocale,
colorScheme: copy.theme, timezoneId: 'Asia/Shanghai',
}
await scaffold.ctx.settings.update('ui-chat', { collapseTiming: 'completion' })
const context = await browser.newContext(contextOptions)
onTestFinished(() => context.close())
const page = await context.newPage()
@@ -1076,19 +1077,23 @@ describe('web e2e: browser-local collapse timing preference', () => {
await menu.waitFor({ state: 'hidden' })
await page.setViewportSize({ width: 1680, height: 1000 })
await timing.getByRole('button', { name: copy.nextInput, exact: true }).waitFor()
expect(await readPersistence()).toEqual(before)
await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value, uiWait)
.toMatchObject({ transcriptView: 'detailed', collapseTiming: 'next-input' })
const saved = await readPersistence()
expect(saved.host).not.toBe(before.host)
expect(saved.storage).toEqual(before.storage)
await dialog.getByRole('button', { name: copy.close, exact: true }).click()
await openSettings(page, copy.locale)
await timing.getByRole('button', { name: copy.nextInput, exact: true }).waitFor()
expect(await readPersistence()).toEqual(before)
expect(await readPersistence()).toEqual(saved)
await dialog.getByRole('button', { name: copy.close, exact: true }).click()
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await openSettings(page, copy.locale)
await timing.getByRole('button', { name: copy.completion, exact: true }).waitFor()
await timing.getByRole('button', { name: copy.nextInput, exact: true }).waitFor()
acknowledgeReloadConnectionLoss(tripwire, warningStart)
expect(await readPersistence()).toEqual(before)
expect(await readPersistence()).toEqual(saved)
if (screenshotDir !== undefined) {
await page.screenshot({ path: join(screenshotDir, 'reloaded.png'), fullPage: true })
}
@@ -1100,10 +1105,10 @@ describe('web e2e: browser-local collapse timing preference', () => {
await openSettings(sharedPage, copy.locale)
const sharedTiming = sharedPage.getByRole('dialog', { name: copy.settings, exact: true })
.getByText(copy.title, { exact: true }).locator('../..')
await sharedTiming.getByRole('button', { name: copy.completion, exact: true }).waitFor()
await sharedTiming.getByRole('button', { name: copy.nextInput, exact: true }).waitFor()
const sharedPersistence = await readPersistence(sharedPage)
expect(sharedPersistence.host).toBe(before.host)
expect(sharedPersistence.settings).toEqual(before.settings)
expect(sharedPersistence.host).toBe(saved.host)
expect(sharedPersistence.settings).toEqual(saved.settings)
expect(sharedPersistence.storage.local).toEqual(before.storage.local)
if (screenshotDir !== undefined) {
await sharedPage.screenshot({ path: join(screenshotDir, 'shared-tab.png'), fullPage: true })
@@ -1119,22 +1124,24 @@ describe('web e2e: browser-local collapse timing preference', () => {
await openSettings(isolatedPage, copy.locale)
const isolatedTiming = isolatedPage.getByRole('dialog', { name: copy.settings, exact: true })
.getByText(copy.title, { exact: true }).locator('../..')
await isolatedTiming.getByRole('button', { name: copy.completion, exact: true }).waitFor()
await isolatedTiming.getByRole('button', { name: copy.nextInput, exact: true }).waitFor()
const isolatedPersistence = await readPersistence(isolatedPage)
expect(isolatedPersistence.host).toBe(before.host)
expect(isolatedPersistence.settings).toEqual(before.settings)
expect(isolatedPersistence.host).toBe(saved.host)
expect(isolatedPersistence.settings).toEqual(saved.settings)
if (screenshotDir !== undefined) {
await isolatedPage.screenshot({ path: join(screenshotDir, 'isolated-context.png'), fullPage: true })
}
expect(await readPersistence()).toEqual(before)
await timing.getByRole('button', { name: copy.completion, exact: true }).click()
await page.getByRole('menuitem', { name: copy.nextInput, exact: true }).click()
expect(await readPersistence()).toEqual(saved)
await timing.getByRole('button', { name: copy.nextInput, exact: true }).click()
await page.getByRole('menuitem', { name: copy.completion, exact: true }).click()
await timing.getByRole('button', { name: copy.completion, exact: true }).waitFor()
expect(await readPersistence()).toEqual(before)
expect(settingsWrites).toEqual([])
await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value, uiWait)
.toMatchObject({ transcriptView: 'detailed', collapseTiming: 'completion' })
expect((await readPersistence()).storage).toEqual(before.storage)
await sharedTiming.getByRole('button', { name: copy.completion, exact: true }).waitFor()
await isolatedTiming.getByRole('button', { name: copy.completion, exact: true }).waitFor()
expect(settingsWrites.length).toBeGreaterThan(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
expect(sharedTripwire.pageErrors).toEqual([])
+2 -2
View File
@@ -30,8 +30,8 @@
en: b4cd36e7ab91281e
zh: 8ccf412b9dde61d5
/deepseek-ai-dsh-client-ui-chat/turn-process-folding:
en: 0c63ffa8f245050e
zh: f421d1a569b16c3d
en: 4cd7c81d70d2f365
zh: 18eca6d16e44b6c2
/deepseek-ai-dsh-client-ui-chat/grouped-rendering:
en: 8b2586e6b9f188a7
zh: 6687f9e67432d04f
+1 -1
View File
@@ -84,7 +84,7 @@ When Chat ends with an open Turn control and that Turn has no visible input, the
Work-details modes control process-group display and reasoning previews. Compact, Standard, and Detailed fold eligible completed Turns without hiding the final answer; Verbose retains the duration/status header without a collapse action and shows historical process rows directly. The [business-rule reference](src/client/conversation-nodes/README.md#display-modes) contains the mode table, title behavior, whole-Turn eligibility, clocks, and disclosure resets.
The Settings row below Work details offers an [independent collapse-timing preference](src/client/conversation-nodes/README.md#collapse-timing).
The Settings row below Work details offers an [independent collapse-timing preference](src/client/conversation-nodes/README.md#collapse-timing), saved through the same Host profile configuration.
-----
+1 -1
View File
@@ -84,7 +84,7 @@ Chat 末尾为进行中的 Turn 控制行,且该轮尚无可见输入时,第
工作步骤展示模式控制过程组显示与推理预览。简洁、标准、详细模式收起符合条件的已完成轮次,不隐藏最终答案;完全展开模式保留时长或状态抬头,但不支持收起,历史过程行直接显示。[业务规则明细](src/client/conversation-nodes/README.zh.md#display-modes) 统一说明模式表、组头行为、整轮折叠资格、时钟与开合重置。
工作步骤展示下方的设置行提供[独立的收起时机设置](src/client/conversation-nodes/README.zh.md#collapse-timing)。
工作步骤展示下方的设置行提供[独立的收起时机设置](src/client/conversation-nodes/README.zh.md#collapse-timing),通过相同的 Host profile 配置保存。
-----
@@ -8,6 +8,18 @@ export const CHAT_SETTINGS_NAMESPACE = 'ui-chat'
/** Field carrying the work-details presentation mode. */
export const TRANSCRIPT_VIEW_FIELD = 'transcriptView'
/** Field carrying the independent work-details collapse timing. */
export const COLLAPSE_TIMING_FIELD = 'collapseTiming'
/** Available points when a completed Turn returns to its historical presentation. */
export const COLLAPSE_TIMINGS = ['completion', 'next-input'] as const
/** When a completed Turn returns to its historical presentation. */
export type CollapseTiming = typeof COLLAPSE_TIMINGS[number]
/** Preserve completion-time folding when no timing preference is saved. */
export const DEFAULT_COLLAPSE_TIMING: CollapseTiming = 'completion'
/** Work-details presentation modes a user can choose. */
export const TRANSCRIPT_VIEW_MODES = ['compact', 'standard', 'detailed', 'verbose'] as const
@@ -50,6 +62,8 @@ export const DEFAULT_LINK_OPENING: LinkOpening = 'sidebar'
export interface ChatSettings {
/** Work-details preference; absence uses the client default, and legacy saved values remain accepted. */
transcriptView?: TranscriptViewMode | typeof LEGACY_TRANSCRIPT_VIEW_MODE | typeof LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE | null
/** Independent collapse timing; absence preserves completion-time folding. */
collapseTiming?: CollapseTiming
/** Detail level for composer statistics and completed-Turn usage. */
performanceUsage: PerformanceUsageMode
/** Default destination for Chat HTTP(S) links. */
@@ -58,6 +72,7 @@ export interface ChatSettings {
/** Durable Chat schema; also the wire envelope the browser scope validates against. */
export const ChatSettingsFields = {
[COLLAPSE_TIMING_FIELD]: z.union([...COLLAPSE_TIMINGS]).default(DEFAULT_COLLAPSE_TIMING),
linkOpening: z.union(['sidebar', 'new-tab']).default(DEFAULT_LINK_OPENING),
performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE),
// Missing and unrecognized modes defer to the client's default.
@@ -33,8 +33,8 @@
en: b25bf00ce3a6a119
zh: 0ab47a4014ea5e1e
/chat-conversation-node-rules/display-modes/collapse-timing:
en: c31be5bb8cb77dc7
zh: 9550ab598119076f
en: a7cd26255c76f714
zh: c8dc2475c3ce3739
/chat-conversation-node-rules/display-modes/group-title-rules:
en: 5b40d65e660549b7
zh: 09b6894aa00154e7
@@ -136,7 +136,7 @@ Reading a default does not automatically save configuration. When the user chang
<a id="collapse-timing"></a>
### Collapse timing
Settings → General → Collapse work details appears directly below Work details and is independent of the display mode. On completion is the default and retains immediate folding and tail positioning. On next message enables delayed folding and its coordinated transitions. The choice lasts for the current Client instance: closing Settings preserves it; reloading or reopening the app resets it to On completion. It never writes Host settings, browser storage, or Session data.
Settings → General → Collapse work details appears directly below Work details and is independent of the display mode. On completion is the default and retains immediate folding and tail positioning. On next message enables delayed folding and its coordinated transitions. The choice is saved as `ui-chat.collapseTiming` through the same Host profile settings as Work details and is restored after reload or reopening the app. Profiles without a saved value use On completion without writing that default. It does not write browser storage or Session data.
With On next message, the latest Turn keeps its running presentation after completion: Compact and Standard retain their process headers, and Detailed retains its flat process rows. A pending ordinary transcript submission or a newer Turn ends this exemption without clearing the reader's manual whole-Turn choice. Inner disclosures reset only when their outer process actually hides. Queued input and steering alone do not end it. The latest Turn of a reopened Session also receives the exemption; manual whole-Turn choices still take precedence. Switching back to On completion ends the exemption immediately.
@@ -136,7 +136,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
<a id="collapse-timing"></a>
### 收起时机
设置 → 通用设置 → 过程收起时机位于工作步骤展示正下方,与展示模式独立。「完成时收起」是默认值,保留立即折叠和即时跟尾定位;「下次发送时收起」启用延后折叠及其协调过渡。选择仅在当前 Client 实例内有效:关闭设置面板后保留,刷新或重新打开应用时恢复为「完成时收起」。此偏好不写入 Host 设置、浏览器存储或 Session 数据。
设置 → 通用设置 → 过程收起时机位于工作步骤展示正下方,与展示模式独立。「完成时收起」是默认值,保留立即折叠和即时跟尾定位;「下次发送时收起」启用延后折叠及其协调过渡。选择以 `ui-chat.collapseTiming` 保存到与工作步骤展示相同的 Host profile 设置,刷新或重新打开应用后会恢复。未保存此值的 profile 使用「完成时收起」,不会自动写入默认值。此偏好不写入浏览器存储或 Session 数据。
选择「下次发送时收起」后,最新轮次完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。普通 transcript 输入进入待发送状态,或出现更新轮次时,取消这项例外,但保留用户对整轮的手动开合选择。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新轮次也享有例外;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。
@@ -1,19 +1,14 @@
/** Chat presentation derived from Host-backed detail and in-memory collapse timing. */
/** Chat presentation derived from Host-backed detail and collapse timing. */
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { TranscriptViewMode } from '../chat-settings.ts'
/** Available points when a completed Turn returns to its historical presentation. */
export const COLLAPSE_TIMINGS = ['completion', 'next-input'] as const
/** When a completed Turn returns to its historical presentation. */
export type CollapseTiming = typeof COLLAPSE_TIMINGS[number]
import type { CollapseTiming, TranscriptViewMode } from '../chat-settings.ts'
export { COLLAPSE_TIMINGS, type CollapseTiming } from '../chat-settings.ts'
/** Presentation capabilities that one work-details mode enables. */
export interface ChatPresentationPolicy {
/** Preference identity for layout resets and diagnostics; renderers select the capabilities below. */
readonly mode: TranscriptViewMode
/** Browser-local experiment; completion retains the default immediate folding and scrolling. */
/** Saved collapse timing; completion retains the default immediate folding and scrolling. */
readonly collapseTiming: CollapseTiming
/** Whether a normally completed Turn folds its process rows behind the whole-Turn control. */
readonly foldCompletedTurns: boolean
@@ -73,7 +68,7 @@ const TIMING_POLICIES = {
/**
* Resolve a stable policy for one detail mode and collapse timing.
* @param mode - persisted work-details mode.
* @param collapseTiming - in-memory timing; defaults to immediate completion folding.
* @param collapseTiming - persisted timing; defaults to immediate completion folding.
* @returns the same policy object for the same pair of choices.
*/
export function presentationPolicyFor(
@@ -84,9 +79,9 @@ export function presentationPolicyFor(
}
/**
* Combine Host-backed detail and in-memory timing without owning subscriptions.
* Combine Host-backed detail and persisted timing without owning subscriptions.
* @param mode - live work-details mode.
* @param collapseTiming - live, in-memory collapse timing.
* @param collapseTiming - live Host-backed collapse timing.
* @returns observable policy whose subscriptions follow both preferences.
*/
export function derivePresentationPolicy(
@@ -12,12 +12,12 @@ export interface TranscriptViewRowInjected {
hooks: {
/** Persisted work-details preference bound as useTranscriptView. */
transcriptView: SnapshotStore<TranscriptViewMode>
/** Browser-local timing bound as useCollapseTiming. */
/** Persisted collapse timing bound as useCollapseTiming. */
collapseTiming: SnapshotStore<CollapseTiming>
}
/** Change the work-details presentation. */
setTranscriptView: (mode: TranscriptViewMode) => void
/** Retain collapse timing for this Client lifetime without changing Host settings. */
/** Persist collapse timing independently of the work-details mode. */
setCollapseTiming: (timing: CollapseTiming) => void
}
@@ -40,7 +40,7 @@ const COLLAPSE_LABELS = {
} as const satisfies Record<CollapseTiming, ChatKey>
/**
* Render work-details and browser-local collapse-timing selectors.
* Render the independent, Host-backed work-details and collapse-timing selectors.
* @param props - composed Settings slot props.
* @returns the preference row.
*/
@@ -1,9 +1,10 @@
/** Host-backed work details and independent, in-memory collapse timing. */
/** Independent work-details preferences backed by the same Host configuration form. */
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { CollapseTiming } from './presentation-policy.ts'
import {
COLLAPSE_TIMING_FIELD, DEFAULT_COLLAPSE_TIMING,
DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD,
type ChatSettings, type TranscriptViewMode,
} from '../chat-settings.ts'
@@ -13,8 +14,8 @@ export class TranscriptViewPolicy {
private readonly unsubscribe: () => void
/** Reactive current mode, including the client default before Host settings arrive. */
readonly mode: SnapshotStore<TranscriptViewMode>
/** Independent Client-lifetime choice; each new instance starts with completion-time folding. */
readonly collapseTiming: SnapshotStore<CollapseTiming> = createSnapshotStore<CollapseTiming>('completion')
/** Reactive collapse timing, defaulting to completion until Host settings arrive. */
readonly collapseTiming: SnapshotStore<CollapseTiming> = createSnapshotStore<CollapseTiming>(DEFAULT_COLLAPSE_TIMING)
/**
* @param host - durable Chat settings scope.
@@ -43,11 +44,13 @@ export class TranscriptViewPolicy {
}
/**
* Keep collapse timing in memory without writing Host or browser storage.
* Publish and persist collapse timing without changing the work-details mode.
* @param timing - immediate completion folding or deferred folding at the next input.
*/
setCollapseTiming(timing: CollapseTiming): void {
if (this.collapseTiming.getSnapshot() === timing) return
this.collapseTiming.set(timing)
void this.host.set(COLLAPSE_TIMING_FIELD, timing)
}
/** Adopt the latest accepted Host section without writing it back. */
@@ -58,5 +61,7 @@ export class TranscriptViewPolicy {
const mode = saved === LEGACY_TRANSCRIPT_VIEW_MODE || saved === LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE
? 'detailed' : saved ?? this.defaultMode
if (this.mode.getSnapshot() !== mode) this.mode.set(mode)
const timing = section.collapseTiming ?? DEFAULT_COLLAPSE_TIMING
if (this.collapseTiming.getSnapshot() !== timing) this.collapseTiming.set(timing)
}
}
+4 -1
View File
@@ -2,7 +2,7 @@
import type {} from '@deepseek-ai/dsh-settings'
import type { Volatile, Context } from '@deepseek-ai/cordis'
import type { ChatSettings, LinkOpening, PerformanceUsageMode } from './chat-settings.ts'
import type { ChatSettings, CollapseTiming, LinkOpening, PerformanceUsageMode } from './chat-settings.ts'
import z from '@deepseek-ai/schemastery'
import { TRANSCRIPT_VIEW_FIELD } from './chat-settings.ts'
@@ -18,6 +18,8 @@ export {
export interface Config {
/** Completed turn transcript presentation. */
transcriptView: Volatile<ChatSettings['transcriptView']>
/** When completed work details return to their historical presentation. */
collapseTiming: Volatile<CollapseTiming>
/** Performance and usage detail level. */
performanceUsage: Volatile<PerformanceUsageMode>
/** Default destination for Chat HTTP(S) links. */
@@ -27,6 +29,7 @@ export interface Config {
/** Live preferences projected to the browser. */
export const Config = z.object({
[TRANSCRIPT_VIEW_FIELD]: ChatSettingsFields[TRANSCRIPT_VIEW_FIELD].volatile(),
collapseTiming: ChatSettingsFields.collapseTiming.volatile(),
performanceUsage: ChatSettingsFields['performanceUsage'].volatile(),
linkOpening: ChatSettingsFields.linkOpening.volatile(),
})
@@ -137,7 +137,7 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true,
}
describe('Chat inject API', () => {
it('projects independent Settings choices without saving timing to Host settings', async () => {
it('projects independent Settings choices and saves timing to Host settings', async () => {
const b = await bench()
try {
const row = b.runtime.slots.entries('settings.general.item')
@@ -149,12 +149,12 @@ describe('Chat inject API', () => {
expect(presentation.getSnapshot()).toMatchObject({ mode: 'detailed', collapseTiming: 'completion' })
setCollapseTiming('next-input')
expect(presentation.getSnapshot()).toMatchObject({ mode: 'detailed', collapseTiming: 'next-input' })
expect(b.chatSettings.set).not.toHaveBeenCalled()
b.chatSettings.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'standard', performanceUsage: 'detailed' }, revision: 1, writable: true })
expect(b.chatSettings.set).toHaveBeenCalledWith('collapseTiming', 'next-input')
b.chatSettings.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'standard', performanceUsage: 'detailed', collapseTiming: 'next-input' }, revision: 1, writable: true })
expect(presentation.getSnapshot()).toMatchObject({ mode: 'standard', collapseTiming: 'next-input' })
setCollapseTiming('completion')
expect(presentation.getSnapshot()).toMatchObject({ mode: 'standard', collapseTiming: 'completion' })
expect(b.chatSettings.set).not.toHaveBeenCalled()
expect(b.chatSettings.set).toHaveBeenLastCalledWith('collapseTiming', 'completion')
expect(b.session.prompt).not.toHaveBeenCalled()
expect(b.session.cancel).not.toHaveBeenCalled()
} finally {
@@ -200,7 +200,7 @@ describe('Chat apply wiring', () => {
expect(face.hooks.transcriptView.getSnapshot()).toBe('compact')
})
it('resets in-memory collapse timing when the Chat plugin remounts', async () => {
it('restores saved collapse timing when the Chat plugin remounts', async () => {
const b = await bench()
try {
const row = b.runtime.slots.entries('settings.general.item')
@@ -211,8 +211,8 @@ describe('Chat apply wiring', () => {
expect(hooks.collapseTiming.getSnapshot()).toBe('completion')
setCollapseTiming('next-input')
expect(hooks.collapseTiming.getSnapshot()).toBe('next-input')
expect(b.chatSettings.set).not.toHaveBeenCalled()
b.chatSettings.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'standard', performanceUsage: 'detailed' }, revision: 1, writable: true })
expect(b.chatSettings.set).toHaveBeenCalledExactlyOnceWith('collapseTiming', 'next-input')
b.chatSettings.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'standard', performanceUsage: 'detailed', collapseTiming: 'next-input' }, revision: 1, writable: true })
expect(hooks.transcriptView.getSnapshot()).toBe('standard')
expect(hooks.collapseTiming.getSnapshot()).toBe('next-input')
expect(b.runtime.slots.entries('settings.general.item').map(entry => entry.options.id))
@@ -226,9 +226,9 @@ describe('Chat apply wiring', () => {
const remounted = remountedRow.inject!()
const remountedHooks = remounted.hooks as TranscriptViewRowInjected['hooks']
expect(remountedHooks.collapseTiming).not.toBe(hooks.collapseTiming)
expect(remountedHooks.collapseTiming.getSnapshot()).toBe('completion')
expect(remountedHooks.collapseTiming.getSnapshot()).toBe('next-input')
expect(remountedHooks.transcriptView.getSnapshot()).toBe('standard')
expect(b.chatSettings.set).not.toHaveBeenCalled()
expect(b.chatSettings.set).toHaveBeenCalledExactlyOnceWith('collapseTiming', 'next-input')
expect(b.runtime.slots.entries('settings.general.item').map(entry => entry.options.id))
.toEqual(['transcript-view', 'link-opening', 'composer-enter', 'performance-usage'])
} finally {
@@ -14,9 +14,9 @@ describe('ui-chat Host settings', () => {
const configuration = await liveConfig(ctx, { Config, apply })
const { fiber } = configuration
expect(plainConfig(configuration.fiber.config)).toEqual({ performanceUsage: 'detailed', linkOpening: 'sidebar' })
expect(plainConfig(configuration.fiber.config)).toEqual({ performanceUsage: 'detailed', linkOpening: 'sidebar', collapseTiming: 'completion' })
await configuration.update({ transcriptView: 'normal' })
expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar' })
expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar', collapseTiming: 'completion' })
for (const mode of ['expanded', 'compact', 'standard', 'detailed', 'verbose']) {
await configuration.update({ transcriptView: mode })
expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: mode })
@@ -35,6 +35,20 @@ describe('ui-chat Host settings', () => {
await fiber.dispose()
})
it('validates collapse timing and restores its saved config independently of work details', async () => {
const ctx = new Context()
const configuration = await liveConfig(ctx, { Config, apply })
expect(plainConfig(configuration.fiber.config)).toMatchObject({ collapseTiming: 'completion' })
await configuration.update({ transcriptView: 'compact', collapseTiming: 'next-input' })
const saved = plainConfig(configuration.fiber.config)
expect(saved).toMatchObject({ transcriptView: 'compact', collapseTiming: 'next-input' })
await expect(configuration.update({ collapseTiming: 'never' })).rejects.toThrow()
await configuration.fiber.dispose()
const restored = await liveConfig(ctx, { Config, apply }, saved)
expect(plainConfig(restored.fiber.config)).toMatchObject({ transcriptView: 'compact', collapseTiming: 'next-input' })
await restored.fiber.dispose()
})
it('loads without a settings provider', async () => {
const ctx = new Context()
await expect(ctx.plugin({ Config, apply }).await()).resolves.toBeDefined()
@@ -7,7 +7,7 @@ import { TranscriptViewPolicy } from '../src/client/transcript-view.ts'
afterEach(() => { vi.restoreAllMocks() })
describe('TranscriptViewPolicy', () => {
it('defaults to completion and changes timing without reading or writing browser storage', () => {
it('defaults to completion and persists explicit timing without reading or writing browser storage', () => {
const storageReads = vi.spyOn(Storage.prototype, 'getItem').mockReturnValue('"next-input"')
const storageWrites = vi.spyOn(Storage.prototype, 'setItem')
const storageRemovals = vi.spyOn(Storage.prototype, 'removeItem')
@@ -32,7 +32,9 @@ describe('TranscriptViewPolicy', () => {
expect(timingChanged).toHaveBeenCalledTimes(2)
expect(policy.mode.getSnapshot()).toBe('detailed')
expect(modeChanged).not.toHaveBeenCalled()
expect(host.set).not.toHaveBeenCalled()
expect(host.set).toHaveBeenCalledTimes(2)
expect(host.set).toHaveBeenNthCalledWith(1, 'collapseTiming', 'next-input')
expect(host.set).toHaveBeenNthCalledWith(2, 'collapseTiming', 'completion')
expect(storageReads).not.toHaveBeenCalled()
expect(storageWrites).not.toHaveBeenCalled()
expect(storageRemovals).not.toHaveBeenCalled()
@@ -44,18 +46,19 @@ describe('TranscriptViewPolicy', () => {
}
})
it('starts each instance with completion timing while adopting the accepted Host mode', () => {
it('restores both saved preferences in each new instance', () => {
const host = stubConfigForm<ChatSettings>()
host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'standard', performanceUsage: 'detailed' }, revision: 1, writable: true })
const first = new TranscriptViewPolicy(host.scope)
try {
first.setCollapseTiming('next-input')
host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'standard', performanceUsage: 'detailed', collapseTiming: 'next-input' }, revision: 2 })
const second = new TranscriptViewPolicy(host.scope)
try {
expect(first.collapseTiming.getSnapshot()).toBe('next-input')
expect(second.collapseTiming.getSnapshot()).toBe('completion')
expect(second.collapseTiming.getSnapshot()).toBe('next-input')
expect(second.mode.getSnapshot()).toBe('standard')
expect(host.set).not.toHaveBeenCalled()
expect(host.set).toHaveBeenCalledExactlyOnceWith('collapseTiming', 'next-input')
} finally {
second.dispose()
}
@@ -65,21 +68,21 @@ describe('TranscriptViewPolicy', () => {
expect(host.listenerCount()).toBe(0)
})
it('adopts Host mode updates without overriding the local timing choice', () => {
it('adopts independent Host mode and timing updates without writing them back', () => {
const host = stubConfigForm<ChatSettings>()
const policy = new TranscriptViewPolicy(host.scope)
try {
policy.setCollapseTiming('next-input')
host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 1, writable: true })
host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed', collapseTiming: 'next-input' }, revision: 1, writable: true })
expect(policy.mode.getSnapshot()).toBe('compact')
expect(policy.collapseTiming.getSnapshot()).toBe('next-input')
policy.setMode('verbose')
expect(policy.collapseTiming.getSnapshot()).toBe('next-input')
expect(host.set).toHaveBeenCalledExactlyOnceWith('transcriptView', 'verbose')
expect(host.set).toHaveBeenNthCalledWith(2, 'transcriptView', 'verbose')
host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'standard', performanceUsage: 'detailed' }, revision: 2 })
expect(policy.mode.getSnapshot()).toBe('standard')
expect(policy.collapseTiming.getSnapshot()).toBe('next-input')
expect(host.set).toHaveBeenCalledTimes(1)
expect(policy.collapseTiming.getSnapshot()).toBe('completion')
expect(host.set).toHaveBeenCalledTimes(2)
} finally {
policy.dispose()
}