@starwish-ai/xingyuan-dsh 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,127 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.3] - 2026-08-28
9
+
10
+ > **Note:** this is a patch release but it carries one breaking change — the `xingyuan`
11
+ > settings namespace is replaced by `xingyuan-pref` (see *Removed*). Consumers of the
12
+ > `./domain` subpath must also adapt to `makeXingyuanStore()` taking a second, required
13
+ > argument. Read *Upgrading from 0.5.2 or earlier* before updating.
14
+
15
+ **Chat preferences are now always editable.** Settings → XingYuan rendered the
16
+ write-confirmation toggle and the memory-injection limit as permanently disabled until a
17
+ XingYuan session had been opened once since the last dsh start; clicking them did nothing
18
+ and produced no error. Both preferences lived in the preset-layer namespace, which only
19
+ comes into existence when the preset is first mounted, so they now live in a
20
+ bundle-resident namespace registered whenever the bundle row is active.
21
+
22
+ ### Upgrading from 0.5.2 or earlier
23
+
24
+ The two chat preferences moved from the `xingyuan` block of `$DSH_HOME/settings.yaml` to a
25
+ new `xingyuan-pref` block. **A value you had customised is not carried over** and falls back
26
+ to the default: `confirmWrites` returns to `true` (the safe direction — write actions ask for
27
+ confirmation again) and `memoryInjectLimit` to `40`. Re-apply your choices under
28
+ **Settings → XingYuan → Chat preferences**. The leftover `xingyuan:` block is inert and is
29
+ safe to delete.
30
+
31
+ ### Added
32
+ - **`xingyuan-pref` settings namespace** (`src/pref-settings.ts`), installed by the bundle
33
+ layer next to the existing `xingyuan-ui` namespace, carrying `confirmWrites` (default
34
+ `true`) and `memoryInjectLimit` (default `40`, integer `5`–`200`). The schema declares
35
+ `.step(1)`, so the Host also rejects fractional values that a hand-edited settings
36
+ document or a direct RPC write could otherwise smuggle in.
37
+ - **Shared preference policy module** (`src/pref-policy.ts`) holding the bounds, defaults
38
+ and input parser for both halves — the host module depends on
39
+ `@deepseek-ai/dsh-settings` and must never reach the browser bundle. This also removes
40
+ the `5`/`200`/`40` literals that were previously repeated in the settings page. Covered
41
+ by `test/pref-policy.test.ts`.
42
+ - **Write verification**: `scope.set()` resolves rather than rejects on failure (the client
43
+ controller catches, recovers and returns silently), so `toastError` never fired. The page
44
+ now compares the snapshot after each write and raises a "did not save" toast when the
45
+ value did not change. Both controls share one scope — and therefore one write queue — so
46
+ the comparison is gated on a per-component write sequence number to stay honest under
47
+ interleaved writes (see below).
48
+ - **Regression lock** (`test/pref-settings.test.ts`): five tests pinning that the bundle
49
+ layer registers `xingyuan-pref` as soon as the settings service is available, that the
50
+ read thunk resolves fresh on every call, that the headless path falls back to the schema
51
+ defaults, and — structurally — that **no namespace bound by the settings page is ever
52
+ registered from the preset layer**. That last one is the abstract shape of this bug and is
53
+ what stops it from coming back.
54
+ - **Four-way readiness notice** for the preferences card: loading, remote/temporary
55
+ connection (`mode === 'memory'`), namespace not registered, and read-only document. The
56
+ previous code only covered the first two, leaving the rest as silently disabled controls.
57
+
58
+ ### Fixed
59
+ - The two preferences could not be changed before the first XingYuan session of the process
60
+ — the root cause described above.
61
+ - The write-confirmation toggle rendered as **on** whenever its value was unavailable
62
+ (`value?.confirmWrites !== false` reads "unknown" as "true"), which is the worst possible
63
+ default for a safety switch. It now falls back to the schema default explicitly.
64
+ - Namespace-unavailable and read-only states disabled the controls with no explanation.
65
+ - The "did not save" toast could fire on a write that **had** been saved. Both controls share
66
+ one settings scope, and the client controller fences writes with a generation counter: when
67
+ a write is superseded by a newer one it only records a pending revision and does **not**
68
+ fold the result into the snapshot, while the superseded write's `.then` still runs first and
69
+ sees the stale value. Verification is now skipped for superseded writes, and the number
70
+ input got the pending guard the toggle already had.
71
+ - Clearing the memory-injection-limit field and blurring reported "enter an integer between 5
72
+ and 200" while simultaneously refilling the field with the saved value — two contradictory
73
+ messages at once. An empty field is again treated as abandoning the edit.
74
+ - Tool descriptions for the creation / check-in / cancel-check-in tools asserted that a system
75
+ confirmation card would appear. Toggle **Confirm write actions** off and the model kept
76
+ telling users a card had been shown when none was. Tool descriptions are baked in at
77
+ registration and dsh-tools requires a plain string (no getter), so they are now worded to
78
+ hold in both states instead of asserting the card. Delete tools are unaffected — deletes
79
+ always confirm.
80
+
81
+ ### Changed
82
+ - The preset layer no longer registers a settings namespace. It reads preferences through
83
+ `ctx.xingyuan.prefs()` — a thunk that resolves the current value on every call, so edits
84
+ still take effect immediately without re-registering tools or prompts. The remaining
85
+ `Config` fields (`batchWishLimit`, `chartTrendDays`, …) have no settings UI and stay as
86
+ composition-only entry config, which matches the upstream rule that a namespace should
87
+ carry only the user-editable subset.
88
+ - **Breaking for `./domain` consumers**: `makeXingyuanStore(domain)` now takes a second,
89
+ required argument — `makeXingyuanStore(domain, readPrefs)`, where `readPrefs` is a thunk
90
+ returning the resolved chat preferences. The required-ness is deliberate: every call site
91
+ (one in production, five in tests) is surfaced by the compiler rather than silently
92
+ missing the wiring. `XingyuanStore` gained a matching `prefs()` method.
93
+
94
+ ### Removed
95
+ - The preset-layer `xingyuan` settings namespace and the `confirmWrites` /
96
+ `memoryInjectLimit` fields of the preset `Config`. **A value customised under the old
97
+ `xingyuan:` section of `$DSH_HOME/settings.yaml` is no longer read and falls back to the
98
+ default** — `confirmWrites` therefore returns to `true`, the conservative direction. The
99
+ stale section is left in the document and is harmless.
100
+
101
+ ## [0.5.2] - 2026-08-27
102
+
103
+ ### Added
104
+ - **Controllable conversation-view tabs**: the six tabs (Today / Wishes / Tasks /
105
+ Calendar / Growth / Memory) are no longer always present. Default mode **follows the
106
+ session preset** — tabs appear only in sessions composed from the `xingyuan` preset and
107
+ stay out of every other conversation. Settings → XingYuan gains a **Tab visibility**
108
+ card with three modes (`Follow session` / `Always show` / `Always hide`) plus
109
+ per-tab toggles (`hiddenTabs`, all shown by default). The preference namespace
110
+ (`xingyuan-ui`) is installed by the bundle layer, so it works before any XingYuan
111
+ session exists and stays reachable even when tabs are fully hidden (a preset-layer
112
+ namespace would be unavailable exactly when needed). Today's page shows a one-line
113
+ notice when "Always show" puts the tabs into a non-XingYuan session (pages stay fully
114
+ browsable and operable; only the chat-side capabilities are absent). Read-only URL
115
+ pages (`/xingyuan/*`) and chat cards are unaffected.
116
+ - New tab-visibility policy (`visibleTabIds`) with dirty-value tolerance (unknown/duplicate
117
+ hidden ids are ignored); covered by `test/tab-policy.test.ts`.
118
+
119
+ ### Fixed
120
+ - Client boot crash: the tab-visibility controller subscribed to the injected `sessions`
121
+ service itself, which has no `subscribe` — the session-list snapshot lives on
122
+ `sessions.list`. The client half failed to load entirely ("Failed to load plugins"),
123
+ which also hid the Settings → XingYuan card. Subscriptions now target
124
+ `sessions.list` (`getSnapshot`/`subscribe`).
125
+ - Tab-visibility toggles had inverted semantics: the check chips rendered "hidden" as
126
+ "checked", so ticking a tab produced no change. `shown` now reads
127
+ `!hiddenTabs.includes(id)` (checked = shown).
128
+
8
129
  ## [0.5.1] - 2026-08-27
9
130
 
10
131
  Patch release. Two hardening changes: sessions that contain `xingyuan/*` card events
package/README.en-US.md CHANGED
@@ -14,7 +14,7 @@ XingYuan is a wish & habit-building companion, shipped as a DeepSeek Harness plu
14
14
  - **Opportunity-day check-ins**: check-in, make-up, cancel, and future pre-checks; progress always recomputed against today
15
15
  - **Micro-actions**: break an overwhelming goal into 3–7 tiny steps and walk through them guided
16
16
  - **Growth system**: levels Lv.1–Lv.10, streak bonuses, stat cards, 30-day bar chart
17
- - **Session view tabs**: Today / Wishes / Tasks / Calendar / Growth / Memory — buttons call action endpoints directly
17
+ - **Session view tabs**: Today / Wishes / Tasks / Calendar / Growth / Memory — buttons call action endpoints directly; shown only in XingYuan-preset sessions by default, switchable to always show/hide and per-tab in Settings
18
18
  - **Memory**: important memories injected into context (limit configurable), full CRUD
19
19
  - **Charts**: 15 chart types rendered as cards
20
20
  - **Safe writes**: write operations confirmed by default (configurable); deletion always confirmed
@@ -42,6 +42,7 @@ Business data lives at `~/.dsh/xingyuan/xingyuan.sqlite` and survives uninstall
42
42
  | User profile | Nickname, occupation, interests |
43
43
  | Write confirmation | Whether create / check-in writes need confirmation (deletion always does) |
44
44
  | Memory injection limit | Max memories injected per turn |
45
+ | Tab visibility | Three modes (follow session / always show / always hide) plus per-tab toggles; defaults to follow session |
45
46
 
46
47
  ## Development
47
48
 
package/README.md CHANGED
@@ -14,7 +14,8 @@
14
14
  - **机会日打卡**:周期任务打卡、补卡、取消、未来预勾,进度跨天恒为最新
15
15
  - **微行动拆解**:把无从下手的目标拆成 3–7 个小步,引导式逐个完成或跳过
16
16
  - **成长体系**:等级 Lv.1–Lv.10、连击加成、统计卡与近 30 天柱状图
17
- - **会话视图页**:今日 / 愿望 / 任务 / 日历 / 成长 / 记忆 六个标签页,页面按钮直连动作接口
17
+ - **会话视图页**:今日 / 愿望 / 任务 / 日历 / 成长 / 记忆 六个标签页,页面按钮直连动作接口;
18
+ 默认仅星愿预设的会话显示,可在设置里切换「始终显示 / 始终隐藏」并按标签勾选
18
19
  - **记忆**:重要记忆自动注入上下文(上限可配),支持增删改查
19
20
  - **图表**:15 种统计图表自动渲染为卡片
20
21
  - **安全确认**:写操作默认二次确认(可关闭),删除始终确认
@@ -42,6 +43,7 @@ dsh plugin --profile web add @starwish-ai/xingyuan-dsh
42
43
  | 用户画像 | 昵称、职业、兴趣 |
43
44
  | 写操作二次确认 | 创建 / 打卡类写操作是否确认(删除始终确认) |
44
45
  | 记忆注入上限 | 每次注入上下文的记忆条数上限 |
46
+ | 标签页显示 | 三态模式(跟随会话 / 始终显示 / 始终隐藏)+ 六个标签逐个勾选;默认跟随会话 |
45
47
 
46
48
  ## 开发
47
49
 
@@ -230,12 +230,24 @@ const ZH_DICT = {
230
230
  'settings.pref.title': '对话偏好',
231
231
  'settings.pref.unavailable': '当前连接不支持持久化偏好(远程或临时模式),以下两项不可用。',
232
232
  'settings.pref.loading': '偏好加载中…',
233
+ 'settings.pref.notRegistered': '偏好暂不可用(组件未就绪),请稍后重试。',
234
+ 'settings.pref.readOnly': '当前设置文档为只读,无法保存修改。',
233
235
  'settings.pref.confirmWrites': '写操作二次确认',
234
236
  'settings.pref.confirmWritesHint': '创建愿望/任务、打卡、取消打卡时先弹应用内确认卡(删除始终确认);关闭后对话中的这类操作将直接执行。',
235
237
  'settings.pref.memoryLimit': '记忆注入上限',
236
238
  'settings.pref.memoryLimitHint': '每次对话自动注入上下文的记忆条数上限(5-200,默认 40);失焦后保存。',
237
239
  'settings.pref.limitInvalid': '请输入 5-200 的整数',
240
+ 'settings.pref.writeFailed': '保存未生效,请重试。',
241
+ 'settings.tabs.title': '标签页显示',
242
+ 'settings.tabs.mode.follow': '跟随会话',
243
+ 'settings.tabs.mode.show': '始终显示',
244
+ 'settings.tabs.mode.hide': '始终隐藏',
245
+ 'settings.tabs.chooseTab': '显示的标签页',
246
+ 'settings.tabs.loading': '标签页选项加载中…',
247
+ 'settings.tabs.unavailable': '当前连接不支持持久化设置,标签页显示选项不可用。',
248
+ 'settings.tabs.hint': '跟随会话:仅星愿预设的会话显示标签页;其他会话自动隐藏。可单独勾选/取消各标签。',
238
249
  'settings.dataHint': '业务数据存于本机 ~/.dsh/xingyuan/,备份即拷贝该目录。',
250
+ 'today.noPresetHint': '当前会话未启用星愿预设:页面数据可浏览、操作可用,但对话中的星愿能力不可用。',
239
251
  // --- 微行动卡 ---
240
252
  'micro.cleared': '已清除「{task}」的拆解',
241
253
  'micro.finished': '「{task}」微行动完成(可打卡了)',
@@ -527,12 +539,24 @@ const EN_DICT = {
527
539
  'settings.pref.title': 'Chat preferences',
528
540
  'settings.pref.unavailable': 'This connection cannot persist preferences (remote or temporary mode); the two options below are disabled.',
529
541
  'settings.pref.loading': 'Loading preferences…',
542
+ 'settings.pref.notRegistered': 'Preferences are unavailable right now (component not ready); please retry later.',
543
+ 'settings.pref.readOnly': 'The settings document is read-only; changes cannot be saved.',
530
544
  'settings.pref.confirmWrites': 'Confirm write actions',
531
545
  'settings.pref.confirmWritesHint': 'Show an in-app confirmation before creating wishes/tasks, checking in or canceling (deletes always confirm). Turn off to execute directly.',
532
546
  'settings.pref.memoryLimit': 'Memory injection limit',
533
547
  'settings.pref.memoryLimitHint': 'Max memories auto-injected per chat (5-200, default 40); saved on blur.',
534
548
  'settings.pref.limitInvalid': 'Enter an integer between 5 and 200',
549
+ 'settings.pref.writeFailed': 'The change did not save; please try again.',
550
+ 'settings.tabs.title': 'Tab visibility',
551
+ 'settings.tabs.mode.follow': 'Follow session',
552
+ 'settings.tabs.mode.show': 'Always show',
553
+ 'settings.tabs.mode.hide': 'Always hide',
554
+ 'settings.tabs.chooseTab': 'Visible tabs',
555
+ 'settings.tabs.loading': 'Loading tab options…',
556
+ 'settings.tabs.unavailable': 'This connection cannot persist settings; tab visibility options are unavailable.',
557
+ 'settings.tabs.hint': 'Follow session: tabs appear only in sessions using the XingYuan preset; other sessions hide them automatically. Toggle individual tabs below.',
535
558
  'settings.dataHint': 'Data lives locally at ~/.dsh/xingyuan/ — back it up by copying the folder.',
559
+ 'today.noPresetHint': 'This session is not using the XingYuan preset: pages stay browsable and usable, but XingYuan chat capabilities are unavailable.',
536
560
  'micro.cleared': 'Cleared the micro-step plan of “{task}”',
537
561
  'micro.finished': 'Micro-steps of “{task}” finished (ready to check in)',
538
562
  'micro.step': '“{task}” micro-steps · step {current}/{total}',
@@ -2,7 +2,7 @@ import { t, XY_NS, setupLocale } from './i18n.js';
2
2
  import { disposeConfirms, disposeToasts } from './ui.js';
3
3
  import { STYLE_TEXT } from './styles.js';
4
4
  import { CARD_VIEWS } from './cards.js';
5
- import { CalendarPage, GrowthPage, MemoryPage, TasksPage, TodayPage, WishesPage } from './pages/index.js';
5
+ import { installTabVisibility } from './tab-visibility.js';
6
6
  import { SettingsSection } from './pages/settings.js';
7
7
  export const inject = ['slots', 'settingsScope', 'conversationEvents'];
8
8
  /** 事件类型 → 渲染 kind。 */
@@ -73,19 +73,18 @@ export function apply(ctx) {
73
73
  ctx.slots.register({ name: 'conversation.chat.node', key: 'xy-chart' }, CARD_VIEWS.ChartView),
74
74
  ctx.slots.register({ name: 'conversation.chat.node', key: 'xy-micro' }, CARD_VIEWS.MicroView),
75
75
  ]);
76
- // 会话视图环标签页:今日 / 愿望 / 任务 / 日历 / 成长 / 记忆(仅激活时挂载,进入即取最新数据)。
76
+ // 会话视图环标签页:今日 / 愿望 / 任务 / 日历 / 成长 / 记忆。
77
+ // 显隐由 tab-visibility 控制器按「界面偏好设置 × 会话预设」动态维护:
78
+ // 默认跟随会话(星愿预设的会话才显示),设置可切始终显示/始终隐藏并按标签勾选。
77
79
  // label 用 thunk 跟随当前语言;locale 声明让壳在语言切换时刷新标签行。
78
- ctx.slots.inject('conversation.view', () => [
79
- ctx.slots.register({ name: 'conversation.view', id: 'xy-today', order: 21, label: () => t('tab.today'), locale: XY_NS }, TodayPage),
80
- ctx.slots.register({ name: 'conversation.view', id: 'xy-wishes', order: 22, label: () => t('tab.wishes'), locale: XY_NS }, WishesPage),
81
- ctx.slots.register({ name: 'conversation.view', id: 'xy-tasks', order: 23, label: () => t('tab.tasks'), locale: XY_NS }, TasksPage),
82
- ctx.slots.register({ name: 'conversation.view', id: 'xy-calendar', order: 24, label: () => t('tab.calendar'), locale: XY_NS }, CalendarPage),
83
- ctx.slots.register({ name: 'conversation.view', id: 'xy-growth', order: 25, label: () => t('tab.growth'), locale: XY_NS }, GrowthPage),
84
- ctx.slots.register({ name: 'conversation.view', id: 'xy-memory', order: 26, label: () => t('tab.memory'), locale: XY_NS }, MemoryPage),
85
- ]);
86
- // 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限(设置命名空间)
80
+ ctx.slots.inject('conversation.view', () => installTabVisibility(ctx));
81
+ // 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限
82
+ // (bundle 常驻命名空间 xingyuan-pref)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui)。
83
+ // 两个偏好命名空间都挂在常驻层:整页由本文件无条件注册,命名空间若随 preset 懒加载
84
+ // 缺席,就会出现「整页可见但两项写不进去且静默失败」。
87
85
  ctx.slots.inject('settings.section', () => {
88
- const scope = ctx.settingsScope.bind({ namespace: 'xingyuan' });
89
- return ctx.slots.register({ name: 'settings.section', id: 'xingyuan', order: 60, label: () => t('settings.tabLabel'), locale: XY_NS }, () => SettingsSection({ scope }));
86
+ const prefscope = ctx.settingsScope.bind({ namespace: 'xingyuan-pref' });
87
+ const uiscope = ctx.settingsScope.bind({ namespace: 'xingyuan-ui' });
88
+ return ctx.slots.register({ name: 'settings.section', id: 'xingyuan', order: 60, label: () => t('settings.tabLabel'), locale: XY_NS }, () => SettingsSection({ scope: prefscope, uiscope }));
90
89
  });
91
90
  }
@@ -1,17 +1,39 @@
1
- /** 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限(设置命名空间)。 */
1
+ /** 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限
2
+ * (bundle 常驻命名空间 xingyuan-pref)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui,
3
+ * 未选星愿也可调)。 */
2
4
  import { createElement, useEffect, useRef, useState, useSyncExternalStore } from 'react';
3
5
  import { getJson, postJson } from '../api.js';
4
6
  import { toastError } from '../ui.js';
5
7
  import { useXyT, activeLocale } from '../i18n.js';
8
+ import { TAB_IDS } from '../../tab-policy.js';
9
+ import { MEMORY_LIMIT_MAX, MEMORY_LIMIT_MIN, PREF_DEFAULTS, parseMemoryLimit, } from '../../pref-policy.js';
6
10
  const COACH_IDS = ['gentle', 'strict', 'humorous'];
7
11
  const COACH_KEYS = ['settings.coach.gentle', 'settings.coach.strict', 'settings.coach.humorous'];
12
+ /** 显隐三态(顺序即分段按钮顺序;与 tab-policy 字面量同源)。 */
13
+ const MODE_OPTIONS = [
14
+ { mode: 'follow', key: 'settings.tabs.mode.follow' },
15
+ { mode: 'show', key: 'settings.tabs.mode.show' },
16
+ { mode: 'hide', key: 'settings.tabs.mode.hide' },
17
+ ];
18
+ /** 六个标签的显示名键(与 tab-visibility 的 entry labelKey 同源)。 */
19
+ const TAB_LABEL_KEYS = {
20
+ today: 'tab.today',
21
+ wishes: 'tab.wishes',
22
+ tasks: 'tab.tasks',
23
+ calendar: 'tab.calendar',
24
+ growth: 'tab.growth',
25
+ memory: 'tab.memory',
26
+ };
8
27
  export function SettingsSection(props) {
9
28
  const t = useXyT();
10
29
  const scope = props.scope;
11
30
  // 正确订阅:useSyncExternalStore + getSnapshot(scope 契约见 dsh-client-runtime contract/settings-scope)
12
31
  const snap = useSyncExternalStore((listener) => scope.subscribe(listener), () => scope.getSnapshot());
13
32
  const writable = snap.status === 'ready' && snap.writable;
14
- const limit = String(snap.value?.memoryInjectLimit ?? 40);
33
+ const limit = String(snap.value?.memoryInjectLimit ?? PREF_DEFAULTS.memoryInjectLimit);
34
+ // 界面偏好命名空间(标签页显隐)独立订阅:两个命名空间各自走自己的快照
35
+ const uisnap = useSyncExternalStore((listener) => props.uiscope.subscribe(listener), () => props.uiscope.getSnapshot());
36
+ const uiWritable = uisnap.status === 'ready' && uisnap.writable;
15
37
  // 教练风格与画像存于星愿数据库 global 单例(与对话侧工具同一数据源),经 /xingyuan/api/profile 读写
16
38
  const [profile, setProfile] = useState(undefined);
17
39
  const [profileError, setProfileError] = useState(undefined);
@@ -21,15 +43,63 @@ export function SettingsSection(props) {
21
43
  // 记忆注入上限:草稿态编辑(null=显示快照值),失焦才校验提交——避免逐键持久化与非法中间值死锁
22
44
  const [limitDraft, setLimitDraft] = useState(null);
23
45
  const [limitError, setLimitError] = useState(false);
46
+ // 记忆注入上限:写入在途的乐观值(与开关的 pendingToggle 同款,用于禁用控件并顶住回显,
47
+ // 否则提交后到快照回折前会闪回旧值)
48
+ const [pendingLimit, setPendingLimit] = useState(undefined);
24
49
  const [saving, setSaving] = useState(false);
25
50
  const [savedMsg, setSavedMsg] = useState('');
26
51
  // 二次确认开关:本地乐观值(undefined=跟随远端快照)——写入在途时 UI 立即响应,失败回滚
27
52
  const [pendingToggle, setPendingToggle] = useState(undefined);
53
+ // 标签页显隐:模式与勾选各自乐观(写入在途时禁用对应控件,失败回滚 + toast)
54
+ const [pendingMode, setPendingMode] = useState(undefined);
55
+ const [pendingHidden, setPendingHidden] = useState(undefined);
28
56
  const savedTimer = useRef(undefined);
29
57
  // 档案加载代数守卫:慢响应不得覆盖用户已开始的草稿
30
58
  const profileSeqRef = useRef(0);
31
- // 开关显示值:乐观本地值优先(写入在途),否则跟随远端快照
32
- const confirmWrites = pendingToggle !== undefined ? pendingToggle : snap.value?.confirmWrites !== false;
59
+ // 偏好写序号:两个控件共用同一个 scope(即同一条写队列)。控制器以 writeGeneration
60
+ // 做栅栏——一次写被更新的写取代时只记 pendingRevision、不回折快照,而其 .then 又
61
+ // 先于后继写执行,此时比对快照必然读到旧值。故只有最后结算的那次有权判定成败。
62
+ const writeSeqRef = useRef(0);
63
+ // 开关显示值:乐观本地值优先(写入在途),否则远端快照,值缺席回落 schema 默认。
64
+ // 不可写成 `!== false`——那样「值未知」会被渲染成「已开启」,安全策略类开关尤其不能撒谎。
65
+ const confirmWrites = pendingToggle ?? snap.value?.confirmWrites ?? PREF_DEFAULTS.confirmWrites;
66
+ // 偏好区提示:判定顺序不可换——memory 模式下 status 同样是 unavailable,
67
+ // 先判 mode 才不会把「远程/临时模式」误报成「命名空间未就绪」
68
+ const prefNoticeKey = snap.status === 'loading' ? 'settings.pref.loading'
69
+ : snap.mode === 'memory' ? 'settings.pref.unavailable'
70
+ : snap.status === 'unavailable' ? 'settings.pref.notRegistered'
71
+ : !snap.writable ? 'settings.pref.readOnly'
72
+ : undefined;
73
+ // 标签页显隐显示值:乐观优先,否则远端快照,再兜底 schema 默认
74
+ const tabMode = pendingMode ?? uisnap.value?.tabVisibilityMode ?? 'follow';
75
+ const hiddenTabs = pendingHidden ?? uisnap.value?.hiddenTabs ?? [];
76
+ /** 切换显隐模式:乐观写,失效即回滚(分段按钮视觉与持久化口径一致)。 */
77
+ const switchTabMode = (next) => {
78
+ if (!uiWritable || pendingMode !== undefined || next === tabMode)
79
+ return;
80
+ setPendingMode(next);
81
+ void props.uiscope.set('tabVisibilityMode', next)
82
+ .then(() => setPendingMode(undefined))
83
+ .catch((err) => {
84
+ setPendingMode(undefined);
85
+ toastError(err);
86
+ });
87
+ };
88
+ /** 勾选单个标签(勾选 = 显示):成员关系按 TAB_IDS 稳定序重算,写入整体数组。 */
89
+ const toggleTab = (id, willShow) => {
90
+ if (!uiWritable || pendingHidden !== undefined || tabMode === 'hide')
91
+ return;
92
+ const next = willShow
93
+ ? TAB_IDS.filter((tid) => tid !== id && hiddenTabs.includes(tid))
94
+ : TAB_IDS.filter((tid) => tid === id || hiddenTabs.includes(tid));
95
+ setPendingHidden(next);
96
+ void props.uiscope.set('hiddenTabs', next)
97
+ .then(() => setPendingHidden(undefined))
98
+ .catch((err) => {
99
+ setPendingHidden(undefined);
100
+ toastError(err);
101
+ });
102
+ };
33
103
  const loadProfile = () => {
34
104
  const seq = ++profileSeqRef.current;
35
105
  setProfileError(undefined);
@@ -63,28 +133,59 @@ export function SettingsSection(props) {
63
133
  setSaving(true);
64
134
  run().catch(toastError).finally(() => setSaving(false));
65
135
  };
136
+ /**
137
+ * 校验偏好是否真的落盘:scope.set() 失败时是 resolve 而非 reject(内部 catch 后静默
138
+ * recover),故只能在写入结算后比对快照——值没变即视为未保存,须显式告知用户。
139
+ *
140
+ * seq 为发起写时取的号:只有仍是队列里最后一次写才校验,否则快照本就不会回折本次
141
+ * 的结果(被更新的写取代),比对必然误报。见 writeSeqRef。
142
+ */
143
+ const verifyWritten = (seq, field, value) => {
144
+ if (seq !== writeSeqRef.current)
145
+ return;
146
+ if (scope.getSnapshot().value?.[field] !== value)
147
+ toastError(new Error(t('settings.pref.writeFailed')));
148
+ };
66
149
  const commitLimit = () => {
67
150
  const raw = limitDraft;
68
151
  setLimitDraft(null);
69
152
  setLimitError(false);
70
- if (raw === null || !writable || raw.trim() === '')
153
+ if (raw === null || !writable)
71
154
  return;
72
- const n = Number(raw);
155
+ // 空串视为放弃编辑:静默回显保存值。若在此报错,会同时出现「已填回旧值」与
156
+ // 「请输入 5-200 的整数」两条互相矛盾的信息。
157
+ if (raw.trim() === '')
158
+ return;
159
+ const parsed = parseMemoryLimit(raw);
73
160
  // 非法输入就地报错(不再静默回弹):用户明确知道哪里错、该怎么改
74
- if (!Number.isFinite(n) || Math.round(n) !== n) {
161
+ if (parsed === undefined) {
75
162
  setLimitError(true);
76
163
  return;
77
164
  }
78
- const clamped = Math.min(200, Math.max(5, Math.round(n)));
79
- if (clamped !== Number(raw)) {
80
- // 越界但可夹取:直接按夹取值提交并回显,同时给出行内提示
81
- void scope.set('memoryInjectLimit', clamped).then(() => setLimitDraft(String(clamped))).catch(toastError);
165
+ if (parsed.clamped) {
166
+ // 越界但可夹取:按夹取值提交并回显,同时给出行内提示
167
+ const seq = ++writeSeqRef.current;
168
+ setPendingLimit(parsed.value);
169
+ void scope.set('memoryInjectLimit', parsed.value)
170
+ .then(() => {
171
+ setLimitDraft(String(parsed.value));
172
+ setPendingLimit(undefined);
173
+ verifyWritten(seq, 'memoryInjectLimit', parsed.value);
174
+ })
175
+ .catch((err) => { setPendingLimit(undefined); toastError(err); });
82
176
  setLimitError(true);
83
177
  return;
84
178
  }
85
- if (clamped === snap.value?.memoryInjectLimit)
179
+ if (parsed.value === snap.value?.memoryInjectLimit)
86
180
  return;
87
- void scope.set('memoryInjectLimit', clamped).catch(toastError);
181
+ const seq = ++writeSeqRef.current;
182
+ setPendingLimit(parsed.value);
183
+ void scope.set('memoryInjectLimit', parsed.value)
184
+ .then(() => {
185
+ setPendingLimit(undefined);
186
+ verifyWritten(seq, 'memoryInjectLimit', parsed.value);
187
+ })
188
+ .catch((err) => { setPendingLimit(undefined); toastError(err); });
88
189
  };
89
190
  const coachLabel = (id) => {
90
191
  const index = COACH_IDS.indexOf(id);
@@ -145,11 +246,11 @@ export function SettingsSection(props) {
145
246
  className: 'xy-btn xy-btn-primary', disabled: saving, onClick: saveProfile,
146
247
  }, saving ? t('settings.profile.saving') : t('settings.profile.save')), savedMsg !== '' ? createElement('span', { className: 'xy-saved', role: 'status' },
147
248
  // 勾形装饰对读屏隐藏(语义由文案承担),与卡片完成态同一语法
148
- createElement('span', { 'aria-hidden': 'true' }, '✓ '), savedMsg) : null), createElement('p', { className: 'xy-hint' }, t('settings.profile.sharedHint'))), createElement('section', { className: 'xy-panel' }, createElement('h3', { className: 'xy-panel-head' }, t('settings.pref.title')), snap.status === 'unavailable'
149
- ? createElement('p', { className: 'xy-hint' }, t('settings.pref.unavailable'))
150
- : snap.status === 'loading'
151
- ? createElement('p', { className: 'xy-hint' }, t('settings.pref.loading'))
152
- : null, createElement('label', { className: 'xy-field' }, createElement('span', { className: 'xy-field-head' }, createElement('input', {
249
+ createElement('span', { 'aria-hidden': 'true' }, '✓ '), savedMsg) : null), createElement('p', { className: 'xy-hint' }, t('settings.profile.sharedHint'))), createElement('section', { className: 'xy-panel' }, createElement('h3', { className: 'xy-panel-head' }, t('settings.pref.title')),
250
+ // 整页 section 没有官方卡片那套「按命名空间自动显隐」的保护,失败呈现归注册方
251
+ prefNoticeKey !== undefined
252
+ ? createElement('p', { className: 'xy-hint' }, t(prefNoticeKey))
253
+ : null, createElement('label', { className: 'xy-field' }, createElement('span', { className: 'xy-field-head' }, createElement('input', {
153
254
  type: 'checkbox',
154
255
  className: 'xy-toggle',
155
256
  name: 'confirmWrites',
@@ -161,19 +262,23 @@ export function SettingsSection(props) {
161
262
  // 乐观写:先落 UI 再等持久化;失败回滚到快照口径并 toast
162
263
  const next = e.target.checked;
163
264
  setPendingToggle(next);
265
+ const seq = ++writeSeqRef.current;
164
266
  void scope.set('confirmWrites', next)
165
- .then(() => setPendingToggle(undefined))
267
+ .then(() => { setPendingToggle(undefined); verifyWritten(seq, 'confirmWrites', next); })
166
268
  .catch((err) => {
167
269
  setPendingToggle(undefined);
168
270
  toastError(err);
169
271
  });
170
272
  },
171
273
  }), t('settings.pref.confirmWrites')), createElement('p', { className: 'xy-hint' }, t('settings.pref.confirmWritesHint'))), createElement('label', { className: 'xy-field' }, createElement('span', { className: 'xy-field-head' }, t('settings.pref.memoryLimit')), createElement('input', {
172
- type: 'number', min: 5, max: 200, className: 'xy-input xy-input-num', name: 'memoryInjectLimit', inputMode: 'numeric',
274
+ type: 'number', min: MEMORY_LIMIT_MIN, max: MEMORY_LIMIT_MAX,
275
+ className: 'xy-input xy-input-num', name: 'memoryInjectLimit', inputMode: 'numeric',
173
276
  'aria-label': t('settings.pref.memoryLimit'),
174
277
  'aria-invalid': limitError || undefined,
175
278
  ...(limitError ? { 'aria-describedby': 'xy-limit-error' } : {}),
176
- value: limitDraft ?? limit, disabled: !writable,
279
+ // 写入在途时顶住乐观值并禁用(与开关同款),避免闪回旧值与并发写交错
280
+ value: limitDraft ?? (pendingLimit !== undefined ? String(pendingLimit) : limit),
281
+ disabled: !writable || pendingLimit !== undefined,
177
282
  onChange: (e) => { setLimitDraft(e.target.value); setLimitError(false); },
178
283
  onBlur: commitLimit,
179
284
  onKeyDown: (e) => {
@@ -182,5 +287,32 @@ export function SettingsSection(props) {
182
287
  },
183
288
  }), createElement('span', { className: 'xy-hint' }, t('settings.pref.memoryLimitHint')), limitError
184
289
  ? createElement('span', { id: 'xy-limit-error', className: 'xy-field-err', role: 'alert' }, t('settings.pref.limitInvalid'))
185
- : null)), createElement('p', { className: 'xy-hint' }, t('settings.dataHint')));
290
+ : null)),
291
+ // 标签页显示:模式三态(跟随会话/始终显示/始终隐藏)+ 六个标签勾选 chips。
292
+ // 与教练风格卡同一 xy-seg 视觉语法;「始终隐藏」时勾选区整组禁用置灰。
293
+ // 命名空间常驻于 bundle 层(未选星愿预设也可调),见 src/ui-settings.ts 头注。
294
+ createElement('section', { className: 'xy-panel' }, createElement('h3', { className: 'xy-panel-head' }, t('settings.tabs.title')), uisnap.status === 'unavailable'
295
+ ? createElement('p', { className: 'xy-hint' }, t('settings.tabs.unavailable'))
296
+ : uisnap.status === 'loading'
297
+ ? createElement('p', { className: 'xy-hint' }, t('settings.tabs.loading'))
298
+ : null, createElement('div', { className: 'xy-seg', role: 'group', 'aria-label': t('settings.tabs.title') }, ...MODE_OPTIONS.map((opt) => createElement('button', {
299
+ key: opt.mode,
300
+ className: `xy-seg-btn${tabMode === opt.mode ? ' xy-on' : ''}`,
301
+ 'aria-pressed': tabMode === opt.mode,
302
+ disabled: !uiWritable || pendingMode !== undefined,
303
+ onClick: () => switchTabMode(opt.mode),
304
+ }, t(opt.key)))), createElement('div', {
305
+ className: 'xy-seg',
306
+ role: 'group',
307
+ 'aria-label': t('settings.tabs.chooseTab'),
308
+ }, ...TAB_IDS.map((id) => {
309
+ const shown = !hiddenTabs.includes(id);
310
+ return createElement('button', {
311
+ key: id,
312
+ className: `xy-seg-btn${shown ? ' xy-on' : ''}`,
313
+ 'aria-pressed': shown,
314
+ disabled: !uiWritable || pendingHidden !== undefined || tabMode === 'hide',
315
+ onClick: () => toggleTab(id, !shown),
316
+ }, t(TAB_LABEL_KEYS[id]));
317
+ })), createElement('p', { className: 'xy-hint' }, t('settings.tabs.hint'))), createElement('p', { className: 'xy-hint' }, t('settings.dataHint')));
186
318
  }
@@ -1,14 +1,17 @@
1
1
  /** 今日页:今日打卡总览 + 一键打卡/领取 + 已完成撤销。 */
2
- import { createElement } from 'react';
2
+ import { createElement, useSyncExternalStore } from 'react';
3
3
  import { postAction } from '../api.js';
4
4
  import { toast } from '../ui.js';
5
5
  import { useXyT } from '../i18n.js';
6
6
  import { softConfirm, useActionGuard, usePageData, useStableScrollbar, localYmd } from '../hooks.js';
7
7
  import { PageEmpty, PageError, PageSkeleton } from '../ui.js';
8
8
  import { cycleLabel, dateSuffix, formatFriendlyDate } from './format.js';
9
+ import { todayHintStore } from '../tab-hint.js';
9
10
  export function TodayPage() {
10
11
  const t = useXyT();
11
12
  const stabilize = useStableScrollbar();
13
+ // 「始终显示 × 非星愿会话」轻提示:控制器写值,本页订阅渲染(默认 false 零开销)
14
+ const showNoPresetHint = useSyncExternalStore(todayHintStore.subscribe, todayHintStore.getSnapshot);
12
15
  const overview = usePageData('/xingyuan/api/overview');
13
16
  const day = usePageData(() => `/xingyuan/api/day?date=${localYmd(new Date())}`);
14
17
  const { busy, guard } = useActionGuard();
@@ -73,7 +76,9 @@ export function TodayPage() {
73
76
  'aria-valuenow': ratio,
74
77
  'aria-label': heroTitle,
75
78
  }, createElement('div', { className: 'xy-bar-fill', style: { transform: `scaleX(${ratio / 100})` } }))
76
- : null, allDone ? createElement('div', { className: 'xy-banner-ok' }, t('today.allDone')) : null), rows.length > 0 ? createElement('section', { className: 'xy-group' }, createElement('h3', { className: 'xy-group-head' }, createElement('span', { className: 'xy-group-dot xy-group-dot-warn', 'aria-hidden': 'true' }), createElement('span', null, t('today.sectionOpen')), createElement('span', { className: 'xy-group-count' }, String(rows.length))), createElement('ul', { className: 'xy-grouplist' }, rows)) : null, doneRows.length > 0 ? createElement('section', { className: 'xy-group' }, createElement('h3', { className: 'xy-group-head' }, createElement('span', { className: 'xy-group-dot xy-group-dot-ok', 'aria-hidden': 'true' }), createElement('span', null, t('today.sectionDone')), createElement('span', { className: 'xy-group-count' }, String(doneRows.length))), createElement('ul', { className: 'xy-grouplist' }, doneRows)) : null, data.total === 0
79
+ : null, allDone ? createElement('div', { className: 'xy-banner-ok' }, t('today.allDone')) : null),
80
+ // 始终显示模式下非星愿会话:概览卡下一行轻提示(页面可浏览/操作,对话能力受限)
81
+ showNoPresetHint ? createElement('p', { className: 'xy-hint' }, t('today.noPresetHint')) : null, rows.length > 0 ? createElement('section', { className: 'xy-group' }, createElement('h3', { className: 'xy-group-head' }, createElement('span', { className: 'xy-group-dot xy-group-dot-warn', 'aria-hidden': 'true' }), createElement('span', null, t('today.sectionOpen')), createElement('span', { className: 'xy-group-count' }, String(rows.length))), createElement('ul', { className: 'xy-grouplist' }, rows)) : null, doneRows.length > 0 ? createElement('section', { className: 'xy-group' }, createElement('h3', { className: 'xy-group-head' }, createElement('span', { className: 'xy-group-dot xy-group-dot-ok', 'aria-hidden': 'true' }), createElement('span', null, t('today.sectionDone')), createElement('span', { className: 'xy-group-count' }, String(doneRows.length))), createElement('ul', { className: 'xy-grouplist' }, doneRows)) : null, data.total === 0
77
82
  ? createElement(PageEmpty, { title: t('today.empty.title'), hint: t('today.empty.hint') })
78
83
  : null);
79
84
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 「今日」页非星愿会话提示行的快照 store。
3
+ *
4
+ * 语义:仅「始终显示」模式 × 当前会话非星愿预设时为 true(该模式下标签页
5
+ * 出现在每个会话里,页面数据与操作可用,但对话中的星愿能力不可用——一行
6
+ * 轻提示点明落差)。跟随会话模式下非星愿会话标签页整体隐藏,无提示可言。
7
+ *
8
+ * 独立成模块:tab-visibility 控制器写入、今日页订阅,避免控制器 ↔ 页面
9
+ * 双向依赖环;写侧只在值变化时通知,订阅侧 useSyncExternalStore 直接消费。
10
+ */
11
+ let hintVisible = false;
12
+ const listeners = new Set();
13
+ function publish(value) {
14
+ if (value === hintVisible)
15
+ return;
16
+ hintVisible = value;
17
+ for (const fn of [...listeners])
18
+ fn();
19
+ }
20
+ /** 今日页只读入口(getSnapshot/subscribe 满足 useSyncExternalStore 契约)。 */
21
+ export const todayHintStore = {
22
+ getSnapshot: () => hintVisible,
23
+ subscribe(fn) {
24
+ listeners.add(fn);
25
+ return () => {
26
+ listeners.delete(fn);
27
+ };
28
+ },
29
+ };
30
+ /** 控制器在每次显隐重算后写入;值未变化时零通知。 */
31
+ export function setTodayHint(value) {
32
+ publish(value);
33
+ }
34
+ /** 控制器卸载时清场(值复位 + 断订;今日页随模块重载自动取新模块实例)。 */
35
+ export function clearTodayHint() {
36
+ listeners.clear();
37
+ hintVisible = false;
38
+ }