@starwish-ai/xingyuan-dsh 0.5.1 → 0.5.2

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,34 @@ 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.2] - 2026-08-27
9
+
10
+ ### Added
11
+ - **Controllable conversation-view tabs**: the six tabs (Today / Wishes / Tasks /
12
+ Calendar / Growth / Memory) are no longer always present. Default mode **follows the
13
+ session preset** — tabs appear only in sessions composed from the `xingyuan` preset and
14
+ stay out of every other conversation. Settings → XingYuan gains a **Tab visibility**
15
+ card with three modes (`Follow session` / `Always show` / `Always hide`) plus
16
+ per-tab toggles (`hiddenTabs`, all shown by default). The preference namespace
17
+ (`xingyuan-ui`) is installed by the bundle layer, so it works before any XingYuan
18
+ session exists and stays reachable even when tabs are fully hidden (a preset-layer
19
+ namespace would be unavailable exactly when needed). Today's page shows a one-line
20
+ notice when "Always show" puts the tabs into a non-XingYuan session (pages stay fully
21
+ browsable and operable; only the chat-side capabilities are absent). Read-only URL
22
+ pages (`/xingyuan/*`) and chat cards are unaffected.
23
+ - New tab-visibility policy (`visibleTabIds`) with dirty-value tolerance (unknown/duplicate
24
+ hidden ids are ignored); covered by `test/tab-policy.test.ts`.
25
+
26
+ ### Fixed
27
+ - Client boot crash: the tab-visibility controller subscribed to the injected `sessions`
28
+ service itself, which has no `subscribe` — the session-list snapshot lives on
29
+ `sessions.list`. The client half failed to load entirely ("Failed to load plugins"),
30
+ which also hid the Settings → XingYuan card. Subscriptions now target
31
+ `sessions.list` (`getSnapshot`/`subscribe`).
32
+ - Tab-visibility toggles had inverted semantics: the check chips rendered "hidden" as
33
+ "checked", so ticking a tab produced no change. `shown` now reads
34
+ `!hiddenTabs.includes(id)` (checked = shown).
35
+
8
36
  ## [0.5.1] - 2026-08-27
9
37
 
10
38
  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
 
@@ -235,7 +235,16 @@ const ZH_DICT = {
235
235
  'settings.pref.memoryLimit': '记忆注入上限',
236
236
  'settings.pref.memoryLimitHint': '每次对话自动注入上下文的记忆条数上限(5-200,默认 40);失焦后保存。',
237
237
  'settings.pref.limitInvalid': '请输入 5-200 的整数',
238
+ 'settings.tabs.title': '标签页显示',
239
+ 'settings.tabs.mode.follow': '跟随会话',
240
+ 'settings.tabs.mode.show': '始终显示',
241
+ 'settings.tabs.mode.hide': '始终隐藏',
242
+ 'settings.tabs.chooseTab': '显示的标签页',
243
+ 'settings.tabs.loading': '标签页选项加载中…',
244
+ 'settings.tabs.unavailable': '当前连接不支持持久化设置,标签页显示选项不可用。',
245
+ 'settings.tabs.hint': '跟随会话:仅星愿预设的会话显示标签页;其他会话自动隐藏。可单独勾选/取消各标签。',
238
246
  'settings.dataHint': '业务数据存于本机 ~/.dsh/xingyuan/,备份即拷贝该目录。',
247
+ 'today.noPresetHint': '当前会话未启用星愿预设:页面数据可浏览、操作可用,但对话中的星愿能力不可用。',
239
248
  // --- 微行动卡 ---
240
249
  'micro.cleared': '已清除「{task}」的拆解',
241
250
  'micro.finished': '「{task}」微行动完成(可打卡了)',
@@ -532,7 +541,16 @@ const EN_DICT = {
532
541
  'settings.pref.memoryLimit': 'Memory injection limit',
533
542
  'settings.pref.memoryLimitHint': 'Max memories auto-injected per chat (5-200, default 40); saved on blur.',
534
543
  'settings.pref.limitInvalid': 'Enter an integer between 5 and 200',
544
+ 'settings.tabs.title': 'Tab visibility',
545
+ 'settings.tabs.mode.follow': 'Follow session',
546
+ 'settings.tabs.mode.show': 'Always show',
547
+ 'settings.tabs.mode.hide': 'Always hide',
548
+ 'settings.tabs.chooseTab': 'Visible tabs',
549
+ 'settings.tabs.loading': 'Loading tab options…',
550
+ 'settings.tabs.unavailable': 'This connection cannot persist settings; tab visibility options are unavailable.',
551
+ 'settings.tabs.hint': 'Follow session: tabs appear only in sessions using the XingYuan preset; other sessions hide them automatically. Toggle individual tabs below.',
535
552
  'settings.dataHint': 'Data lives locally at ~/.dsh/xingyuan/ — back it up by copying the folder.',
553
+ 'today.noPresetHint': 'This session is not using the XingYuan preset: pages stay browsable and usable, but XingYuan chat capabilities are unavailable.',
536
554
  'micro.cleared': 'Cleared the micro-step plan of “{task}”',
537
555
  'micro.finished': 'Micro-steps of “{task}” finished (ready to check in)',
538
556
  '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,16 @@ 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
+ // (preset 命名空间)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui,未选星愿也可调)
87
83
  ctx.slots.inject('settings.section', () => {
88
84
  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 }));
85
+ const uiscope = ctx.settingsScope.bind({ namespace: 'xingyuan-ui' });
86
+ return ctx.slots.register({ name: 'settings.section', id: 'xingyuan', order: 60, label: () => t('settings.tabLabel'), locale: XY_NS }, () => SettingsSection({ scope, uiscope }));
90
87
  });
91
88
  }
@@ -1,10 +1,27 @@
1
- /** 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限(设置命名空间)。 */
1
+ /** 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限
2
+ * (preset 命名空间)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui,未选星愿也可调)。 */
2
3
  import { createElement, useEffect, useRef, useState, useSyncExternalStore } from 'react';
3
4
  import { getJson, postJson } from '../api.js';
4
5
  import { toastError } from '../ui.js';
5
6
  import { useXyT, activeLocale } from '../i18n.js';
7
+ import { TAB_IDS } from '../../tab-policy.js';
6
8
  const COACH_IDS = ['gentle', 'strict', 'humorous'];
7
9
  const COACH_KEYS = ['settings.coach.gentle', 'settings.coach.strict', 'settings.coach.humorous'];
10
+ /** 显隐三态(顺序即分段按钮顺序;与 tab-policy 字面量同源)。 */
11
+ const MODE_OPTIONS = [
12
+ { mode: 'follow', key: 'settings.tabs.mode.follow' },
13
+ { mode: 'show', key: 'settings.tabs.mode.show' },
14
+ { mode: 'hide', key: 'settings.tabs.mode.hide' },
15
+ ];
16
+ /** 六个标签的显示名键(与 tab-visibility 的 entry labelKey 同源)。 */
17
+ const TAB_LABEL_KEYS = {
18
+ today: 'tab.today',
19
+ wishes: 'tab.wishes',
20
+ tasks: 'tab.tasks',
21
+ calendar: 'tab.calendar',
22
+ growth: 'tab.growth',
23
+ memory: 'tab.memory',
24
+ };
8
25
  export function SettingsSection(props) {
9
26
  const t = useXyT();
10
27
  const scope = props.scope;
@@ -12,6 +29,9 @@ export function SettingsSection(props) {
12
29
  const snap = useSyncExternalStore((listener) => scope.subscribe(listener), () => scope.getSnapshot());
13
30
  const writable = snap.status === 'ready' && snap.writable;
14
31
  const limit = String(snap.value?.memoryInjectLimit ?? 40);
32
+ // 界面偏好命名空间(标签页显隐)独立订阅:两个命名空间各自走自己的快照
33
+ const uisnap = useSyncExternalStore((listener) => props.uiscope.subscribe(listener), () => props.uiscope.getSnapshot());
34
+ const uiWritable = uisnap.status === 'ready' && uisnap.writable;
15
35
  // 教练风格与画像存于星愿数据库 global 单例(与对话侧工具同一数据源),经 /xingyuan/api/profile 读写
16
36
  const [profile, setProfile] = useState(undefined);
17
37
  const [profileError, setProfileError] = useState(undefined);
@@ -25,11 +45,44 @@ export function SettingsSection(props) {
25
45
  const [savedMsg, setSavedMsg] = useState('');
26
46
  // 二次确认开关:本地乐观值(undefined=跟随远端快照)——写入在途时 UI 立即响应,失败回滚
27
47
  const [pendingToggle, setPendingToggle] = useState(undefined);
48
+ // 标签页显隐:模式与勾选各自乐观(写入在途时禁用对应控件,失败回滚 + toast)
49
+ const [pendingMode, setPendingMode] = useState(undefined);
50
+ const [pendingHidden, setPendingHidden] = useState(undefined);
28
51
  const savedTimer = useRef(undefined);
29
52
  // 档案加载代数守卫:慢响应不得覆盖用户已开始的草稿
30
53
  const profileSeqRef = useRef(0);
31
54
  // 开关显示值:乐观本地值优先(写入在途),否则跟随远端快照
32
55
  const confirmWrites = pendingToggle !== undefined ? pendingToggle : snap.value?.confirmWrites !== false;
56
+ // 标签页显隐显示值:乐观优先,否则远端快照,再兜底 schema 默认
57
+ const tabMode = pendingMode ?? uisnap.value?.tabVisibilityMode ?? 'follow';
58
+ const hiddenTabs = pendingHidden ?? uisnap.value?.hiddenTabs ?? [];
59
+ /** 切换显隐模式:乐观写,失效即回滚(分段按钮视觉与持久化口径一致)。 */
60
+ const switchTabMode = (next) => {
61
+ if (!uiWritable || pendingMode !== undefined || next === tabMode)
62
+ return;
63
+ setPendingMode(next);
64
+ void props.uiscope.set('tabVisibilityMode', next)
65
+ .then(() => setPendingMode(undefined))
66
+ .catch((err) => {
67
+ setPendingMode(undefined);
68
+ toastError(err);
69
+ });
70
+ };
71
+ /** 勾选单个标签(勾选 = 显示):成员关系按 TAB_IDS 稳定序重算,写入整体数组。 */
72
+ const toggleTab = (id, willShow) => {
73
+ if (!uiWritable || pendingHidden !== undefined || tabMode === 'hide')
74
+ return;
75
+ const next = willShow
76
+ ? TAB_IDS.filter((tid) => tid !== id && hiddenTabs.includes(tid))
77
+ : TAB_IDS.filter((tid) => tid === id || hiddenTabs.includes(tid));
78
+ setPendingHidden(next);
79
+ void props.uiscope.set('hiddenTabs', next)
80
+ .then(() => setPendingHidden(undefined))
81
+ .catch((err) => {
82
+ setPendingHidden(undefined);
83
+ toastError(err);
84
+ });
85
+ };
33
86
  const loadProfile = () => {
34
87
  const seq = ++profileSeqRef.current;
35
88
  setProfileError(undefined);
@@ -182,5 +235,32 @@ export function SettingsSection(props) {
182
235
  },
183
236
  }), createElement('span', { className: 'xy-hint' }, t('settings.pref.memoryLimitHint')), limitError
184
237
  ? 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')));
238
+ : null)),
239
+ // 标签页显示:模式三态(跟随会话/始终显示/始终隐藏)+ 六个标签勾选 chips。
240
+ // 与教练风格卡同一 xy-seg 视觉语法;「始终隐藏」时勾选区整组禁用置灰。
241
+ // 命名空间常驻于 bundle 层(未选星愿预设也可调),见 src/ui-settings.ts 头注。
242
+ createElement('section', { className: 'xy-panel' }, createElement('h3', { className: 'xy-panel-head' }, t('settings.tabs.title')), uisnap.status === 'unavailable'
243
+ ? createElement('p', { className: 'xy-hint' }, t('settings.tabs.unavailable'))
244
+ : uisnap.status === 'loading'
245
+ ? createElement('p', { className: 'xy-hint' }, t('settings.tabs.loading'))
246
+ : null, createElement('div', { className: 'xy-seg', role: 'group', 'aria-label': t('settings.tabs.title') }, ...MODE_OPTIONS.map((opt) => createElement('button', {
247
+ key: opt.mode,
248
+ className: `xy-seg-btn${tabMode === opt.mode ? ' xy-on' : ''}`,
249
+ 'aria-pressed': tabMode === opt.mode,
250
+ disabled: !uiWritable || pendingMode !== undefined,
251
+ onClick: () => switchTabMode(opt.mode),
252
+ }, t(opt.key)))), createElement('div', {
253
+ className: 'xy-seg',
254
+ role: 'group',
255
+ 'aria-label': t('settings.tabs.chooseTab'),
256
+ }, ...TAB_IDS.map((id) => {
257
+ const shown = !hiddenTabs.includes(id);
258
+ return createElement('button', {
259
+ key: id,
260
+ className: `xy-seg-btn${shown ? ' xy-on' : ''}`,
261
+ 'aria-pressed': shown,
262
+ disabled: !uiWritable || pendingHidden !== undefined || tabMode === 'hide',
263
+ onClick: () => toggleTab(id, !shown),
264
+ }, t(TAB_LABEL_KEYS[id]));
265
+ })), createElement('p', { className: 'xy-hint' }, t('settings.tabs.hint'))), createElement('p', { className: 'xy-hint' }, t('settings.dataHint')));
186
266
  }
@@ -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
+ }
@@ -0,0 +1,58 @@
1
+ import { XY_NS, t } from './i18n.js';
2
+ import { TAB_IDS, TAB_VISIBILITY_DEFAULTS, visibleTabIds } from '../tab-policy.js';
3
+ import { CalendarPage, GrowthPage, MemoryPage, TasksPage, TodayPage, WishesPage } from './pages/index.js';
4
+ import { clearTodayHint, setTodayHint } from './tab-hint.js';
5
+ const noopUnsubscribe = () => { };
6
+ const TAB_ENTRIES = [
7
+ { tabId: 'today', id: 'xy-today', order: 21, labelKey: 'tab.today', component: TodayPage },
8
+ { tabId: 'wishes', id: 'xy-wishes', order: 22, labelKey: 'tab.wishes', component: WishesPage },
9
+ { tabId: 'tasks', id: 'xy-tasks', order: 23, labelKey: 'tab.tasks', component: TasksPage },
10
+ { tabId: 'calendar', id: 'xy-calendar', order: 24, labelKey: 'tab.calendar', component: CalendarPage },
11
+ { tabId: 'growth', id: 'xy-growth', order: 25, labelKey: 'tab.growth', component: GrowthPage },
12
+ { tabId: 'memory', id: 'xy-memory', order: 26, labelKey: 'tab.memory', component: MemoryPage },
13
+ ];
14
+ /**
15
+ * 安装标签页显隐控制器,返回随 conversation.view 声明生命周期卸载的 disposer。
16
+ * 必须在 slots.inject('conversation.view') 回调内调用:保证首次 sync() 时
17
+ * 槽已声明(未声明时 register 不落账),注册/注销全走壳的 effect 语义。
18
+ */
19
+ export function installTabVisibility(ctx) {
20
+ const sessions = ctx.get('sessions');
21
+ const scope = ctx.settingsScope.bind({ namespace: 'xingyuan-ui' });
22
+ let disposers = [];
23
+ let stopped = false;
24
+ const sync = () => {
25
+ if (stopped)
26
+ return;
27
+ for (const dispose of disposers)
28
+ dispose();
29
+ disposers = [];
30
+ const snap = scope.getSnapshot();
31
+ const value = snap.value;
32
+ const mode = value?.tabVisibilityMode ?? TAB_VISIBILITY_DEFAULTS.tabVisibilityMode;
33
+ const hidden = value?.hiddenTabs ?? TAB_VISIBILITY_DEFAULTS.hiddenTabs;
34
+ const list = sessions?.list?.getSnapshot();
35
+ const row = list?.current !== undefined ? list.byId[list.current] : undefined;
36
+ const isXingyuan = row?.agentPreset === 'xingyuan';
37
+ // 「始终显示 × 非星愿会话」的今日页轻提示(Q4=b 结论),其余模式不提示
38
+ setTodayHint(mode === 'show' && !isXingyuan);
39
+ const visible = new Set(visibleTabIds(mode, hidden, isXingyuan));
40
+ for (const entry of TAB_ENTRIES) {
41
+ if (!visible.has(entry.tabId))
42
+ continue;
43
+ disposers.push(ctx.slots.register({ name: 'conversation.view', id: entry.id, order: entry.order, label: () => t(entry.labelKey), locale: XY_NS }, entry.component));
44
+ }
45
+ };
46
+ const unsubscribeScope = scope.subscribe(sync);
47
+ const unsubscribeSessions = sessions?.list?.subscribe(sync) ?? noopUnsubscribe;
48
+ sync();
49
+ return () => {
50
+ stopped = true;
51
+ clearTodayHint();
52
+ unsubscribeSessions();
53
+ unsubscribeScope();
54
+ for (const dispose of disposers)
55
+ dispose();
56
+ disposers = [];
57
+ };
58
+ }
package/lib/client.js CHANGED
@@ -231,7 +231,16 @@ const ZH_DICT = {
231
231
  "settings.pref.memoryLimit": "记忆注入上限",
232
232
  "settings.pref.memoryLimitHint": "每次对话自动注入上下文的记忆条数上限(5-200,默认 40);失焦后保存。",
233
233
  "settings.pref.limitInvalid": "请输入 5-200 的整数",
234
+ "settings.tabs.title": "标签页显示",
235
+ "settings.tabs.mode.follow": "跟随会话",
236
+ "settings.tabs.mode.show": "始终显示",
237
+ "settings.tabs.mode.hide": "始终隐藏",
238
+ "settings.tabs.chooseTab": "显示的标签页",
239
+ "settings.tabs.loading": "标签页选项加载中…",
240
+ "settings.tabs.unavailable": "当前连接不支持持久化设置,标签页显示选项不可用。",
241
+ "settings.tabs.hint": "跟随会话:仅星愿预设的会话显示标签页;其他会话自动隐藏。可单独勾选/取消各标签。",
234
242
  "settings.dataHint": "业务数据存于本机 ~/.dsh/xingyuan/,备份即拷贝该目录。",
243
+ "today.noPresetHint": "当前会话未启用星愿预设:页面数据可浏览、操作可用,但对话中的星愿能力不可用。",
235
244
  "micro.cleared": "已清除「{task}」的拆解",
236
245
  "micro.finished": "「{task}」微行动完成(可打卡了)",
237
246
  "micro.step": "「{task}」拆解执行 · 第 {current}/{total} 步",
@@ -518,7 +527,16 @@ const EN_DICT = {
518
527
  "settings.pref.memoryLimit": "Memory injection limit",
519
528
  "settings.pref.memoryLimitHint": "Max memories auto-injected per chat (5-200, default 40); saved on blur.",
520
529
  "settings.pref.limitInvalid": "Enter an integer between 5 and 200",
530
+ "settings.tabs.title": "Tab visibility",
531
+ "settings.tabs.mode.follow": "Follow session",
532
+ "settings.tabs.mode.show": "Always show",
533
+ "settings.tabs.mode.hide": "Always hide",
534
+ "settings.tabs.chooseTab": "Visible tabs",
535
+ "settings.tabs.loading": "Loading tab options…",
536
+ "settings.tabs.unavailable": "This connection cannot persist settings; tab visibility options are unavailable.",
537
+ "settings.tabs.hint": "Follow session: tabs appear only in sessions using the XingYuan preset; other sessions hide them automatically. Toggle individual tabs below.",
521
538
  "settings.dataHint": "Data lives locally at ~/.dsh/xingyuan/ — back it up by copying the folder.",
539
+ "today.noPresetHint": "This session is not using the XingYuan preset: pages stay browsable and usable, but XingYuan chat capabilities are unavailable.",
522
540
  "micro.cleared": "Cleared the micro-step plan of “{task}”",
523
541
  "micro.finished": "Micro-steps of “{task}” finished (ready to check in)",
524
542
  "micro.step": "“{task}” micro-steps · step {current}/{total}",
@@ -1973,12 +1991,88 @@ const CARD_VIEWS = {
1973
1991
  MicroView
1974
1992
  };
1975
1993
 
1994
+ //#endregion
1995
+ //#region src/tab-policy.ts
1996
+ /**
1997
+ * 会话视图标签页显隐策略(host/client 两半侧共用 + 测试对拍)的纯函数与常量。
1998
+ *
1999
+ * 决策口径(设计评审结论):
2000
+ * - mode=follow(默认):仅当前会话 agentPreset === 'xingyuan' 时显示标签页;
2001
+ * - mode=show:所有会话都显示;mode=hide:任何会话都不显示;
2002
+ * - hiddenTabs:在「显示」前提下被勾掉的单个标签(默认空数组 = 六个全显示)。
2003
+ * 未知/重复的 hiddenTabs 值一律容错忽略(settings.yaml 手改脏值不炸)。
2004
+ */
2005
+ /** 六个会话视图标签的稳定业务名(与 slot entry id 的 xy- 前缀解耦,settings.yaml 可读)。 */
2006
+ const TAB_IDS = [
2007
+ "today",
2008
+ "wishes",
2009
+ "tasks",
2010
+ "calendar",
2011
+ "growth",
2012
+ "memory"
2013
+ ];
2014
+ /** 命名空间缺省(与 xingyuan-ui schema 默认值同源,见 src/ui-settings.ts)。 */
2015
+ const TAB_VISIBILITY_DEFAULTS = {
2016
+ tabVisibilityMode: "follow",
2017
+ hiddenTabs: []
2018
+ };
2019
+ /**
2020
+ * 计算当前应注册的标签集合。唯一事实口径:client 注册控制器、设置页回显、
2021
+ * 单元测试三方共用,任何一侧不得另写一份判定。
2022
+ */
2023
+ function visibleTabIds(mode, hiddenTabs, isXingyuanSession) {
2024
+ if (mode === "hide") return [];
2025
+ if (mode === "follow" && !isXingyuanSession) return [];
2026
+ const hidden = new Set(hiddenTabs);
2027
+ return TAB_IDS.filter((id) => !hidden.has(id));
2028
+ }
2029
+
2030
+ //#endregion
2031
+ //#region src/client/tab-hint.ts
2032
+ /**
2033
+ * 「今日」页非星愿会话提示行的快照 store。
2034
+ *
2035
+ * 语义:仅「始终显示」模式 × 当前会话非星愿预设时为 true(该模式下标签页
2036
+ * 出现在每个会话里,页面数据与操作可用,但对话中的星愿能力不可用——一行
2037
+ * 轻提示点明落差)。跟随会话模式下非星愿会话标签页整体隐藏,无提示可言。
2038
+ *
2039
+ * 独立成模块:tab-visibility 控制器写入、今日页订阅,避免控制器 ↔ 页面
2040
+ * 双向依赖环;写侧只在值变化时通知,订阅侧 useSyncExternalStore 直接消费。
2041
+ */
2042
+ let hintVisible = false;
2043
+ const listeners = /* @__PURE__ */ new Set();
2044
+ function publish(value) {
2045
+ if (value === hintVisible) return;
2046
+ hintVisible = value;
2047
+ for (const fn of [...listeners]) fn();
2048
+ }
2049
+ /** 今日页只读入口(getSnapshot/subscribe 满足 useSyncExternalStore 契约)。 */
2050
+ const todayHintStore = {
2051
+ getSnapshot: () => hintVisible,
2052
+ subscribe(fn) {
2053
+ listeners.add(fn);
2054
+ return () => {
2055
+ listeners.delete(fn);
2056
+ };
2057
+ }
2058
+ };
2059
+ /** 控制器在每次显隐重算后写入;值未变化时零通知。 */
2060
+ function setTodayHint(value) {
2061
+ publish(value);
2062
+ }
2063
+ /** 控制器卸载时清场(值复位 + 断订;今日页随模块重载自动取新模块实例)。 */
2064
+ function clearTodayHint() {
2065
+ listeners.clear();
2066
+ hintVisible = false;
2067
+ }
2068
+
1976
2069
  //#endregion
1977
2070
  //#region src/client/pages/today.ts
1978
2071
  /** 今日页:今日打卡总览 + 一键打卡/领取 + 已完成撤销。 */
1979
2072
  function TodayPage() {
1980
2073
  const t = useXyT();
1981
2074
  const stabilize = useStableScrollbar();
2075
+ const showNoPresetHint = (0, react.useSyncExternalStore)(todayHintStore.subscribe, todayHintStore.getSnapshot);
1982
2076
  const overview = usePageData("/xingyuan/api/overview");
1983
2077
  const day = usePageData(() => `/xingyuan/api/day?date=${localYmd(/* @__PURE__ */ new Date())}`);
1984
2078
  const { busy, guard } = useActionGuard();
@@ -2052,7 +2146,7 @@ function TodayPage() {
2052
2146
  }, (0, react.createElement)("div", {
2053
2147
  className: "xy-bar-fill",
2054
2148
  style: { transform: `scaleX(${ratio / 100})` }
2055
- })) : null, allDone ? (0, react.createElement)("div", { className: "xy-banner-ok" }, t("today.allDone")) : null), rows.length > 0 ? (0, react.createElement)("section", { className: "xy-group" }, (0, react.createElement)("h3", { className: "xy-group-head" }, (0, react.createElement)("span", {
2149
+ })) : null, allDone ? (0, react.createElement)("div", { className: "xy-banner-ok" }, t("today.allDone")) : null), showNoPresetHint ? (0, react.createElement)("p", { className: "xy-hint" }, t("today.noPresetHint")) : null, rows.length > 0 ? (0, react.createElement)("section", { className: "xy-group" }, (0, react.createElement)("h3", { className: "xy-group-head" }, (0, react.createElement)("span", {
2056
2150
  className: "xy-group-dot xy-group-dot-warn",
2057
2151
  "aria-hidden": "true"
2058
2152
  }), (0, react.createElement)("span", null, t("today.sectionOpen")), (0, react.createElement)("span", { className: "xy-group-count" }, String(rows.length))), (0, react.createElement)("ul", { className: "xy-grouplist" }, rows)) : null, doneRows.length > 0 ? (0, react.createElement)("section", { className: "xy-group" }, (0, react.createElement)("h3", { className: "xy-group-head" }, (0, react.createElement)("span", {
@@ -3580,9 +3674,102 @@ function MemoryPage() {
3580
3674
  }, t("memory.clearAll")), (0, react.createElement)("span", { className: "xy-meta" }, t("memory.footNote"))) : null);
3581
3675
  }
3582
3676
 
3677
+ //#endregion
3678
+ //#region src/client/tab-visibility.ts
3679
+ const noopUnsubscribe = () => {};
3680
+ const TAB_ENTRIES = [
3681
+ {
3682
+ tabId: "today",
3683
+ id: "xy-today",
3684
+ order: 21,
3685
+ labelKey: "tab.today",
3686
+ component: TodayPage
3687
+ },
3688
+ {
3689
+ tabId: "wishes",
3690
+ id: "xy-wishes",
3691
+ order: 22,
3692
+ labelKey: "tab.wishes",
3693
+ component: WishesPage
3694
+ },
3695
+ {
3696
+ tabId: "tasks",
3697
+ id: "xy-tasks",
3698
+ order: 23,
3699
+ labelKey: "tab.tasks",
3700
+ component: TasksPage
3701
+ },
3702
+ {
3703
+ tabId: "calendar",
3704
+ id: "xy-calendar",
3705
+ order: 24,
3706
+ labelKey: "tab.calendar",
3707
+ component: CalendarPage
3708
+ },
3709
+ {
3710
+ tabId: "growth",
3711
+ id: "xy-growth",
3712
+ order: 25,
3713
+ labelKey: "tab.growth",
3714
+ component: GrowthPage
3715
+ },
3716
+ {
3717
+ tabId: "memory",
3718
+ id: "xy-memory",
3719
+ order: 26,
3720
+ labelKey: "tab.memory",
3721
+ component: MemoryPage
3722
+ }
3723
+ ];
3724
+ /**
3725
+ * 安装标签页显隐控制器,返回随 conversation.view 声明生命周期卸载的 disposer。
3726
+ * 必须在 slots.inject('conversation.view') 回调内调用:保证首次 sync() 时
3727
+ * 槽已声明(未声明时 register 不落账),注册/注销全走壳的 effect 语义。
3728
+ */
3729
+ function installTabVisibility(ctx) {
3730
+ const sessions = ctx.get("sessions");
3731
+ const scope = ctx.settingsScope.bind({ namespace: "xingyuan-ui" });
3732
+ let disposers = [];
3733
+ let stopped = false;
3734
+ const sync = () => {
3735
+ if (stopped) return;
3736
+ for (const dispose of disposers) dispose();
3737
+ disposers = [];
3738
+ const value = scope.getSnapshot().value;
3739
+ const mode = value?.tabVisibilityMode ?? TAB_VISIBILITY_DEFAULTS.tabVisibilityMode;
3740
+ const hidden = value?.hiddenTabs ?? TAB_VISIBILITY_DEFAULTS.hiddenTabs;
3741
+ const list = sessions?.list?.getSnapshot();
3742
+ const isXingyuan = (list?.current !== void 0 ? list.byId[list.current] : void 0)?.agentPreset === "xingyuan";
3743
+ setTodayHint(mode === "show" && !isXingyuan);
3744
+ const visible = new Set(visibleTabIds(mode, hidden, isXingyuan));
3745
+ for (const entry of TAB_ENTRIES) {
3746
+ if (!visible.has(entry.tabId)) continue;
3747
+ disposers.push(ctx.slots.register({
3748
+ name: "conversation.view",
3749
+ id: entry.id,
3750
+ order: entry.order,
3751
+ label: () => t(entry.labelKey),
3752
+ locale: XY_NS
3753
+ }, entry.component));
3754
+ }
3755
+ };
3756
+ const unsubscribeScope = scope.subscribe(sync);
3757
+ const unsubscribeSessions = sessions?.list?.subscribe(sync) ?? noopUnsubscribe;
3758
+ sync();
3759
+ return () => {
3760
+ stopped = true;
3761
+ clearTodayHint();
3762
+ unsubscribeSessions();
3763
+ unsubscribeScope();
3764
+ for (const dispose of disposers) dispose();
3765
+ disposers = [];
3766
+ };
3767
+ }
3768
+
3583
3769
  //#endregion
3584
3770
  //#region src/client/pages/settings.ts
3585
- /** 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限(设置命名空间)。 */
3771
+ /** 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限
3772
+ * (preset 命名空间)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui,未选星愿也可调)。 */
3586
3773
  const COACH_IDS = [
3587
3774
  "gentle",
3588
3775
  "strict",
@@ -3593,12 +3780,38 @@ const COACH_KEYS = [
3593
3780
  "settings.coach.strict",
3594
3781
  "settings.coach.humorous"
3595
3782
  ];
3783
+ /** 显隐三态(顺序即分段按钮顺序;与 tab-policy 字面量同源)。 */
3784
+ const MODE_OPTIONS = [
3785
+ {
3786
+ mode: "follow",
3787
+ key: "settings.tabs.mode.follow"
3788
+ },
3789
+ {
3790
+ mode: "show",
3791
+ key: "settings.tabs.mode.show"
3792
+ },
3793
+ {
3794
+ mode: "hide",
3795
+ key: "settings.tabs.mode.hide"
3796
+ }
3797
+ ];
3798
+ /** 六个标签的显示名键(与 tab-visibility 的 entry labelKey 同源)。 */
3799
+ const TAB_LABEL_KEYS = {
3800
+ today: "tab.today",
3801
+ wishes: "tab.wishes",
3802
+ tasks: "tab.tasks",
3803
+ calendar: "tab.calendar",
3804
+ growth: "tab.growth",
3805
+ memory: "tab.memory"
3806
+ };
3596
3807
  function SettingsSection(props) {
3597
3808
  const t = useXyT();
3598
3809
  const scope = props.scope;
3599
3810
  const snap = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot());
3600
3811
  const writable = snap.status === "ready" && snap.writable;
3601
3812
  const limit = String(snap.value?.memoryInjectLimit ?? 40);
3813
+ const uisnap = (0, react.useSyncExternalStore)((listener) => props.uiscope.subscribe(listener), () => props.uiscope.getSnapshot());
3814
+ const uiWritable = uisnap.status === "ready" && uisnap.writable;
3602
3815
  const [profile, setProfile] = (0, react.useState)(void 0);
3603
3816
  const [profileError, setProfileError] = (0, react.useState)(void 0);
3604
3817
  const [nicknameDraft, setNicknameDraft] = (0, react.useState)("");
@@ -3609,9 +3822,32 @@ function SettingsSection(props) {
3609
3822
  const [saving, setSaving] = (0, react.useState)(false);
3610
3823
  const [savedMsg, setSavedMsg] = (0, react.useState)("");
3611
3824
  const [pendingToggle, setPendingToggle] = (0, react.useState)(void 0);
3825
+ const [pendingMode, setPendingMode] = (0, react.useState)(void 0);
3826
+ const [pendingHidden, setPendingHidden] = (0, react.useState)(void 0);
3612
3827
  const savedTimer = (0, react.useRef)(void 0);
3613
3828
  const profileSeqRef = (0, react.useRef)(0);
3614
3829
  const confirmWrites = pendingToggle !== void 0 ? pendingToggle : snap.value?.confirmWrites !== false;
3830
+ const tabMode = pendingMode ?? uisnap.value?.tabVisibilityMode ?? "follow";
3831
+ const hiddenTabs = pendingHidden ?? uisnap.value?.hiddenTabs ?? [];
3832
+ /** 切换显隐模式:乐观写,失效即回滚(分段按钮视觉与持久化口径一致)。 */
3833
+ const switchTabMode = (next) => {
3834
+ if (!uiWritable || pendingMode !== void 0 || next === tabMode) return;
3835
+ setPendingMode(next);
3836
+ props.uiscope.set("tabVisibilityMode", next).then(() => setPendingMode(void 0)).catch((err) => {
3837
+ setPendingMode(void 0);
3838
+ toastError(err);
3839
+ });
3840
+ };
3841
+ /** 勾选单个标签(勾选 = 显示):成员关系按 TAB_IDS 稳定序重算,写入整体数组。 */
3842
+ const toggleTab = (id, willShow) => {
3843
+ if (!uiWritable || pendingHidden !== void 0 || tabMode === "hide") return;
3844
+ const next = willShow ? TAB_IDS.filter((tid) => tid !== id && hiddenTabs.includes(tid)) : TAB_IDS.filter((tid) => tid === id || hiddenTabs.includes(tid));
3845
+ setPendingHidden(next);
3846
+ props.uiscope.set("hiddenTabs", next).then(() => setPendingHidden(void 0)).catch((err) => {
3847
+ setPendingHidden(void 0);
3848
+ toastError(err);
3849
+ });
3850
+ };
3615
3851
  const loadProfile = () => {
3616
3852
  const seq = ++profileSeqRef.current;
3617
3853
  setProfileError(void 0);
@@ -3769,7 +4005,30 @@ function SettingsSection(props) {
3769
4005
  id: "xy-limit-error",
3770
4006
  className: "xy-field-err",
3771
4007
  role: "alert"
3772
- }, t("settings.pref.limitInvalid")) : null)), (0, react.createElement)("p", { className: "xy-hint" }, t("settings.dataHint")));
4008
+ }, t("settings.pref.limitInvalid")) : null)), (0, react.createElement)("section", { className: "xy-panel" }, (0, react.createElement)("h3", { className: "xy-panel-head" }, t("settings.tabs.title")), uisnap.status === "unavailable" ? (0, react.createElement)("p", { className: "xy-hint" }, t("settings.tabs.unavailable")) : uisnap.status === "loading" ? (0, react.createElement)("p", { className: "xy-hint" }, t("settings.tabs.loading")) : null, (0, react.createElement)("div", {
4009
+ className: "xy-seg",
4010
+ role: "group",
4011
+ "aria-label": t("settings.tabs.title")
4012
+ }, ...MODE_OPTIONS.map((opt) => (0, react.createElement)("button", {
4013
+ key: opt.mode,
4014
+ className: `xy-seg-btn${tabMode === opt.mode ? " xy-on" : ""}`,
4015
+ "aria-pressed": tabMode === opt.mode,
4016
+ disabled: !uiWritable || pendingMode !== void 0,
4017
+ onClick: () => switchTabMode(opt.mode)
4018
+ }, t(opt.key)))), (0, react.createElement)("div", {
4019
+ className: "xy-seg",
4020
+ role: "group",
4021
+ "aria-label": t("settings.tabs.chooseTab")
4022
+ }, ...TAB_IDS.map((id) => {
4023
+ const shown = !hiddenTabs.includes(id);
4024
+ return (0, react.createElement)("button", {
4025
+ key: id,
4026
+ className: `xy-seg-btn${shown ? " xy-on" : ""}`,
4027
+ "aria-pressed": shown,
4028
+ disabled: !uiWritable || pendingHidden !== void 0 || tabMode === "hide",
4029
+ onClick: () => toggleTab(id, !shown)
4030
+ }, t(TAB_LABEL_KEYS[id]));
4031
+ })), (0, react.createElement)("p", { className: "xy-hint" }, t("settings.tabs.hint"))), (0, react.createElement)("p", { className: "xy-hint" }, t("settings.dataHint")));
3773
4032
  }
3774
4033
 
3775
4034
  //#endregion
@@ -3858,59 +4117,20 @@ function apply(ctx) {
3858
4117
  key: "xy-micro"
3859
4118
  }, CARD_VIEWS.MicroView)
3860
4119
  ]);
3861
- ctx.slots.inject("conversation.view", () => [
3862
- ctx.slots.register({
3863
- name: "conversation.view",
3864
- id: "xy-today",
3865
- order: 21,
3866
- label: () => t("tab.today"),
3867
- locale: XY_NS
3868
- }, TodayPage),
3869
- ctx.slots.register({
3870
- name: "conversation.view",
3871
- id: "xy-wishes",
3872
- order: 22,
3873
- label: () => t("tab.wishes"),
3874
- locale: XY_NS
3875
- }, WishesPage),
3876
- ctx.slots.register({
3877
- name: "conversation.view",
3878
- id: "xy-tasks",
3879
- order: 23,
3880
- label: () => t("tab.tasks"),
3881
- locale: XY_NS
3882
- }, TasksPage),
3883
- ctx.slots.register({
3884
- name: "conversation.view",
3885
- id: "xy-calendar",
3886
- order: 24,
3887
- label: () => t("tab.calendar"),
3888
- locale: XY_NS
3889
- }, CalendarPage),
3890
- ctx.slots.register({
3891
- name: "conversation.view",
3892
- id: "xy-growth",
3893
- order: 25,
3894
- label: () => t("tab.growth"),
3895
- locale: XY_NS
3896
- }, GrowthPage),
3897
- ctx.slots.register({
3898
- name: "conversation.view",
3899
- id: "xy-memory",
3900
- order: 26,
3901
- label: () => t("tab.memory"),
3902
- locale: XY_NS
3903
- }, MemoryPage)
3904
- ]);
4120
+ ctx.slots.inject("conversation.view", () => installTabVisibility(ctx));
3905
4121
  ctx.slots.inject("settings.section", () => {
3906
4122
  const scope = ctx.settingsScope.bind({ namespace: "xingyuan" });
4123
+ const uiscope = ctx.settingsScope.bind({ namespace: "xingyuan-ui" });
3907
4124
  return ctx.slots.register({
3908
4125
  name: "settings.section",
3909
4126
  id: "xingyuan",
3910
4127
  order: 60,
3911
4128
  label: () => t("settings.tabLabel"),
3912
4129
  locale: XY_NS
3913
- }, () => SettingsSection({ scope }));
4130
+ }, () => SettingsSection({
4131
+ scope,
4132
+ uiscope
4133
+ }));
3914
4134
  });
3915
4135
  }
3916
4136
 
package/lib/index.js CHANGED
@@ -3,6 +3,7 @@ import { makeXingyuanStore, xingyuanDomainSpec } from './domain.js';
3
3
  import { registerXingyuanRoutes } from './routes/index.js';
4
4
  import { ensurePresetRoot } from './preset-root.js';
5
5
  import { repairSessionLogs } from './session-log-repair.js';
6
+ import { installUiSettings } from './ui-settings.js';
6
7
  export { xingyuanDomainSpec, DOMAIN_VERSION, COACH_STYLES } from './domain.js';
7
8
  /** Cordis 插件名。 */
8
9
  export const name = 'xingyuan';
@@ -25,6 +26,8 @@ export async function apply(ctx, config) {
25
26
  if (domain)
26
27
  await domain.close();
27
28
  });
29
+ // 界面偏好命名空间(标签页显隐)常驻注册:不依赖 settings 服务存在,服务挂载后自动生效
30
+ installUiSettings(ctx);
28
31
  // preset 发布成功后再开领域;两步就绪后才 provide,注入方(preset 子树)由
29
32
  // cordis inject 语义等待本行激活完成
30
33
  await ensurePresetRoot();
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 会话视图标签页显隐策略(host/client 两半侧共用 + 测试对拍)的纯函数与常量。
3
+ *
4
+ * 决策口径(设计评审结论):
5
+ * - mode=follow(默认):仅当前会话 agentPreset === 'xingyuan' 时显示标签页;
6
+ * - mode=show:所有会话都显示;mode=hide:任何会话都不显示;
7
+ * - hiddenTabs:在「显示」前提下被勾掉的单个标签(默认空数组 = 六个全显示)。
8
+ * 未知/重复的 hiddenTabs 值一律容错忽略(settings.yaml 手改脏值不炸)。
9
+ */
10
+ /** 六个会话视图标签的稳定业务名(与 slot entry id 的 xy- 前缀解耦,settings.yaml 可读)。 */
11
+ export const TAB_IDS = ['today', 'wishes', 'tasks', 'calendar', 'growth', 'memory'];
12
+ /** 命名空间缺省(与 xingyuan-ui schema 默认值同源,见 src/ui-settings.ts)。 */
13
+ export const TAB_VISIBILITY_DEFAULTS = {
14
+ tabVisibilityMode: 'follow',
15
+ hiddenTabs: [],
16
+ };
17
+ /**
18
+ * 计算当前应注册的标签集合。唯一事实口径:client 注册控制器、设置页回显、
19
+ * 单元测试三方共用,任何一侧不得另写一份判定。
20
+ */
21
+ export function visibleTabIds(mode, hiddenTabs, isXingyuanSession) {
22
+ if (mode === 'hide')
23
+ return [];
24
+ if (mode === 'follow' && !isXingyuanSession)
25
+ return [];
26
+ const hidden = new Set(hiddenTabs);
27
+ return TAB_IDS.filter((id) => !hidden.has(id));
28
+ }
@@ -208,7 +208,16 @@ declare const ZH_DICT: {
208
208
  readonly 'settings.pref.memoryLimit': "记忆注入上限";
209
209
  readonly 'settings.pref.memoryLimitHint': "每次对话自动注入上下文的记忆条数上限(5-200,默认 40);失焦后保存。";
210
210
  readonly 'settings.pref.limitInvalid': "请输入 5-200 的整数";
211
+ readonly 'settings.tabs.title': "标签页显示";
212
+ readonly 'settings.tabs.mode.follow': "跟随会话";
213
+ readonly 'settings.tabs.mode.show': "始终显示";
214
+ readonly 'settings.tabs.mode.hide': "始终隐藏";
215
+ readonly 'settings.tabs.chooseTab': "显示的标签页";
216
+ readonly 'settings.tabs.loading': "标签页选项加载中…";
217
+ readonly 'settings.tabs.unavailable': "当前连接不支持持久化设置,标签页显示选项不可用。";
218
+ readonly 'settings.tabs.hint': "跟随会话:仅星愿预设的会话显示标签页;其他会话自动隐藏。可单独勾选/取消各标签。";
211
219
  readonly 'settings.dataHint': "业务数据存于本机 ~/.dsh/xingyuan/,备份即拷贝该目录。";
220
+ readonly 'today.noPresetHint': "当前会话未启用星愿预设:页面数据可浏览、操作可用,但对话中的星愿能力不可用。";
212
221
  readonly 'micro.cleared': "已清除「{task}」的拆解";
213
222
  readonly 'micro.finished': "「{task}」微行动完成(可打卡了)";
214
223
  readonly 'micro.step': "「{task}」拆解执行 · 第 {current}/{total} 步";
@@ -1,5 +1,7 @@
1
- /** 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限(设置命名空间)。 */
1
+ /** 星愿设置整页(设置 → 星愿):教练风格/画像(星愿库)+ 二次确认开关与注入上限
2
+ * (preset 命名空间)+ 标签页显隐(bundle 常驻命名空间 xingyuan-ui,未选星愿也可调)。 */
2
3
  import { type ReactElement } from 'react';
4
+ import { type TabId, type TabVisibilityMode } from '../../tab-policy.js';
3
5
  /** 与 dsh-client-runtime 的 SettingsScope 快照对齐的叶子视图(只读字段)。 */
4
6
  interface ScopeSnapshotLike {
5
7
  readonly status: 'loading' | 'ready' | 'unavailable';
@@ -9,6 +11,22 @@ interface ScopeSnapshotLike {
9
11
  };
10
12
  readonly writable: boolean;
11
13
  }
14
+ /** xingyuan-ui 命名空间快照(标签页显隐字段)。 */
15
+ interface UiScopeSnapshotLike {
16
+ readonly status: 'loading' | 'ready' | 'unavailable';
17
+ readonly value?: {
18
+ readonly tabVisibilityMode?: TabVisibilityMode;
19
+ readonly hiddenTabs?: readonly TabId[];
20
+ };
21
+ readonly writable: boolean;
22
+ }
23
+ /** xingyuan-ui 命名空间的 scope 形状(bind 返回结构,见 dsh-client-runtime contract/settings-scope)。 */
24
+ export interface UiScopeLike {
25
+ getSnapshot(): UiScopeSnapshotLike;
26
+ subscribe(listener: () => void): () => void;
27
+ set(field: string, value: unknown): Promise<void>;
28
+ unset(field: string): Promise<void>;
29
+ }
12
30
  /**
13
31
  * settingsScope.bind() 返回的真实形状:getSnapshot()/subscribe()/set()/unset()。
14
32
  * 注意 scope 本身没有 .value 字段——值在快照里(此前直接读 scope.value 导致整节渲染崩溃)。
@@ -21,5 +39,6 @@ export interface SettingsScopeLike {
21
39
  }
22
40
  export declare function SettingsSection(props: {
23
41
  scope: SettingsScopeLike;
42
+ uiscope: UiScopeLike;
24
43
  }): ReactElement;
25
44
  export {};
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 「今日」页非星愿会话提示行的快照 store。
3
+ *
4
+ * 语义:仅「始终显示」模式 × 当前会话非星愿预设时为 true(该模式下标签页
5
+ * 出现在每个会话里,页面数据与操作可用,但对话中的星愿能力不可用——一行
6
+ * 轻提示点明落差)。跟随会话模式下非星愿会话标签页整体隐藏,无提示可言。
7
+ *
8
+ * 独立成模块:tab-visibility 控制器写入、今日页订阅,避免控制器 ↔ 页面
9
+ * 双向依赖环;写侧只在值变化时通知,订阅侧 useSyncExternalStore 直接消费。
10
+ */
11
+ /** 今日页只读入口(getSnapshot/subscribe 满足 useSyncExternalStore 契约)。 */
12
+ export declare const todayHintStore: {
13
+ getSnapshot: () => boolean;
14
+ subscribe(fn: () => void): () => void;
15
+ };
16
+ /** 控制器在每次显隐重算后写入;值未变化时零通知。 */
17
+ export declare function setTodayHint(value: boolean): void;
18
+ /** 控制器卸载时清场(值复位 + 断订;今日页随模块重载自动取新模块实例)。 */
19
+ export declare function clearTodayHint(): void;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 会话视图标签页显隐控制器(client 半侧)。
3
+ *
4
+ * 机制:conversation.view 标签环把「全部已注册 entries」投影为标签(无
5
+ * per-session 过滤),因此按会话预设自动显隐只能动态维护注册表——本控制器
6
+ * 订阅「界面偏好设置快照 × 会话列表」,每次任一输入变化时 dispose 旧组、
7
+ * 按 src/tab-policy.ts 纯策略 register 应显示的组(标签环对槽版本号订阅,
8
+ * 重投影即刷新)。切换瞬间至多一帧旧标签,壳的 resolveActiveView 对已注销
9
+ * 的活跃视图回落 Chat(官方契约),不会渲染空白。
10
+ *
11
+ * 判据:settingsScope('xingyuan-ui').tabVisibilityMode × hiddenTabs ×
12
+ * 当前(staged)会话 SessionSummary.agentPreset === 'xingyuan'。
13
+ */
14
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
15
+ /**
16
+ * 安装标签页显隐控制器,返回随 conversation.view 声明生命周期卸载的 disposer。
17
+ * 必须在 slots.inject('conversation.view') 回调内调用:保证首次 sync() 时
18
+ * 槽已声明(未声明时 register 不落账),注册/注销全走壳的 effect 语义。
19
+ */
20
+ export declare function installTabVisibility(ctx: ClientContext): () => void;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 会话视图标签页显隐策略(host/client 两半侧共用 + 测试对拍)的纯函数与常量。
3
+ *
4
+ * 决策口径(设计评审结论):
5
+ * - mode=follow(默认):仅当前会话 agentPreset === 'xingyuan' 时显示标签页;
6
+ * - mode=show:所有会话都显示;mode=hide:任何会话都不显示;
7
+ * - hiddenTabs:在「显示」前提下被勾掉的单个标签(默认空数组 = 六个全显示)。
8
+ * 未知/重复的 hiddenTabs 值一律容错忽略(settings.yaml 手改脏值不炸)。
9
+ */
10
+ /** 六个会话视图标签的稳定业务名(与 slot entry id 的 xy- 前缀解耦,settings.yaml 可读)。 */
11
+ export declare const TAB_IDS: readonly ["today", "wishes", "tasks", "calendar", "growth", "memory"];
12
+ export type TabId = (typeof TAB_IDS)[number];
13
+ /** 显隐模式:跟随会话 / 始终显示 / 始终隐藏。 */
14
+ export type TabVisibilityMode = 'follow' | 'show' | 'hide';
15
+ /** 命名空间缺省(与 xingyuan-ui schema 默认值同源,见 src/ui-settings.ts)。 */
16
+ export declare const TAB_VISIBILITY_DEFAULTS: {
17
+ tabVisibilityMode: TabVisibilityMode;
18
+ hiddenTabs: TabId[];
19
+ };
20
+ /**
21
+ * 计算当前应注册的标签集合。唯一事实口径:client 注册控制器、设置页回显、
22
+ * 单元测试三方共用,任何一侧不得另写一份判定。
23
+ */
24
+ export declare function visibleTabIds(mode: TabVisibilityMode, hiddenTabs: readonly TabId[], isXingyuanSession: boolean): readonly TabId[];
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 星愿 bundle 层「界面偏好」设置命名空间(xingyuan-ui):
3
+ * 会话视图标签页显隐(跟随会话/始终显示/始终隐藏 + 单标签勾选)。
4
+ *
5
+ * 挂在 bundle 常驻层而非 preset 层的理由(设计评审结论):
6
+ * preset 层命名空间(xingyuan)随星愿会话挂载/卸载而存在/消失——用户处于
7
+ * 「全部隐藏」状态想找回标签时,恰恰可能没有任何星愿会话在跑,开关不可达。
8
+ * bundle 层命名空间常驻:未选过星愿预设也能调整,且与 dsh host-backed
9
+ * settings 惯例一致(跟随 $DSH_HOME/settings.yaml 跨 Web 端口)。
10
+ *
11
+ * installSettingsSection 经 ctx.inject(['settings']) 等待 settings 服务挂载,
12
+ * 服务缺席(headless 等)时整段不运行,bundle 激活不依赖它。
13
+ */
14
+ import type { Context } from '@deepseek-ai/cordis';
15
+ import z from '@deepseek-ai/schemastery';
16
+ import { type TabId, type TabVisibilityMode } from './tab-policy.js';
17
+ /** 界面偏好命名空间(client 半侧 settingsScope 同名配对,勿改)。 */
18
+ export declare const UI_NS: import("@deepseek-ai/dsh-settings").SettingsNamespace;
19
+ /** 命名空间解析值(schema 默认 → base 层 → 用户层)。 */
20
+ export interface UiSettings {
21
+ tabVisibilityMode: TabVisibilityMode;
22
+ hiddenTabs: TabId[];
23
+ }
24
+ /** 配置 schema(默认值写进 schema;schemastery 无 z.enum,枚举用 const+union)。 */
25
+ export declare const UiSettingsSchema: z<UiSettings>;
26
+ /** bundle 层安装界面偏好命名空间(幂等:注册随插件 fiber 生命周期自动清理)。 */
27
+ export declare function installUiSettings(ctx: Context): void;
@@ -0,0 +1,22 @@
1
+ import z from '@deepseek-ai/schemastery';
2
+ import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings';
3
+ import { TAB_IDS, TAB_VISIBILITY_DEFAULTS } from './tab-policy.js';
4
+ /** 界面偏好命名空间(client 半侧 settingsScope 同名配对,勿改)。 */
5
+ export const UI_NS = settingsNamespace('xingyuan-ui');
6
+ /** 配置 schema(默认值写进 schema;schemastery 无 z.enum,枚举用 const+union)。 */
7
+ export const UiSettingsSchema = z.object({
8
+ tabVisibilityMode: z.union([
9
+ z.const('follow'),
10
+ z.const('show'),
11
+ z.const('hide'),
12
+ ]).default(TAB_VISIBILITY_DEFAULTS.tabVisibilityMode),
13
+ hiddenTabs: z.array(z.union(TAB_IDS.map((id) => z.const(id)))).default([]),
14
+ });
15
+ /** bundle 层安装界面偏好命名空间(幂等:注册随插件 fiber 生命周期自动清理)。 */
16
+ export function installUiSettings(ctx) {
17
+ installSettingsSection(ctx, UI_NS, UiSettingsSchema, TAB_VISIBILITY_DEFAULTS, {
18
+ // 本命名空间只有 client 半侧消费(经 wire 读快照),host 侧无派生事实
19
+ setSource: () => { },
20
+ onChange: () => { },
21
+ });
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starwish-ai/xingyuan-dsh",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "星愿 XingYuan for DeepSeek Harness: 对话创建愿望/任务、机会日打卡、微行动、记忆与图表的 Agent Preset 组合包",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -95,6 +95,7 @@
95
95
  "@deepseek-ai/cordis": "^4.0.1",
96
96
  "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
97
97
  "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
98
+ "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
98
99
  "@deepseek-ai/dsh-storage": "^0.1.1-rc.2",
99
100
  "@deepseek-ai/dsh-storage-domain": "^0.1.1-rc.2",
100
101
  "@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",