diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index ca16163903..f0145cc7c0 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -24,7 +24,7 @@ import { $reactionsEnabled, setReactionsEnabled } from '@/store/reactions-enable import { $reasoningCollapsedByDefault, setReasoningCollapsedByDefault } from '@/store/reasoning-disclosure' import { $sessionListDensity, type SessionListDensity, setSessionListDensity } from '@/store/session-list-density' import { $tabStripDefault, setTabStripDefault, type TabStripDefault } from '@/store/tabstrip-prefs' -import { $retiredTips, $tipsEnabled, resetTips, setTipsEnabled } from '@/store/tips' +import { $spentTipCount, $tipsEnabled, resetTips, setTipsEnabled } from '@/store/tips' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' import { $toursEnabled, setToursEnabled } from '@/store/tours' import { @@ -407,7 +407,7 @@ export function AppearanceSettings() { const reactionsEnabled = useStore($reactionsEnabled) const tipsEnabled = useStore($tipsEnabled) const toursEnabled = useStore($toursEnabled) - const retiredTips = useStore($retiredTips) + const spentTips = useStore($spentTipCount) const vibeHeartsEnabled = useStore($vibeHeartsEnabled) const backdrop = useStore($backdrop) const introSplash = useStore($introSplash) @@ -842,9 +842,9 @@ export function AppearanceSettings() { ]} value={tipsEnabled ? 'on' : 'off'} /> - {/* The ✕ on a tip is permanent, so this is the only way back. - It appears once there is something to bring back. */} - {retiredTips.length > 0 && ( + {/* A tip shows once (✕ or timer), so this is the only way to a + second lap. It appears once there is something to bring back. */} + {spentTips > 0 && ( )} diff --git a/apps/desktop/src/components/tips/use-tip-rotation.ts b/apps/desktop/src/components/tips/use-tip-rotation.ts index 53a7310ab7..cb4791181d 100644 --- a/apps/desktop/src/components/tips/use-tip-rotation.ts +++ b/apps/desktop/src/components/tips/use-tip-rotation.ts @@ -10,7 +10,8 @@ * bubble, and a six-hour cooldown persisted across launches, so quitting and * reopening isn't a way to farm them. In practice that lands around one tip per * day of use and takes weeks to walk the catalog, which is the point — ten tips - * in an afternoon is how a nicety turns into a thing people switch off. + * in an afternoon is how a nicety turns into a thing people switch off. And the + * walk is one lap: a tip shown once is never offered again (`nextTip`). * * Then "quiet" does the rest. A tip is still the app interrupting, so it waits * for a moment that is genuinely idle — nothing streaming, no dialog, menu or @@ -28,7 +29,7 @@ import { resolveTipAnchor } from '@/lib/tips/anchor' import { TIP_CATALOG } from '@/lib/tips/catalog' import { nextTip } from '@/lib/tips/rotation' import { $awaitingResponse, $busy } from '@/store/session' -import { $activeTip, $lastTipId, $nextTipAt, $retiredTips, $tipsEnabled, showTip } from '@/store/tips' +import { $activeTip, $lastTipId, $nextTipAt, $retiredTips, $tipsEnabled, $tipShownAt, showTip } from '@/store/tips' import { offerLocalSetupTip } from './local-setup-offer' @@ -108,7 +109,9 @@ export function useTipRotation(copy: Translations['tips']) { const chosen = nextTip( TIP_CATALOG.map(tip => tip.id), onScreen.map(tip => tip.id), - { lastShownId: $lastTipId.get(), retired: $retiredTips.get() } + // `$tipShownAt` is the seen ledger: every tip that reached the screen + // is in it, so a tip the timer closed is as done as one the ✕ closed. + { lastShownId: $lastTipId.get(), retired: $retiredTips.get(), seen: Object.keys($tipShownAt.get()) } ) const tip = onScreen.find(candidate => candidate.id === chosen) diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 0806c453a0..0cc63cc7fd 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -577,8 +577,8 @@ export const ar = defineLocale({ reactionsDesc: 'تفاعلات إيموجي بأسلوب iMessage — تفاعل مع الرسائل، ويمكن لـ Hermes التفاعل مع رسائلك.', tipsTitle: 'نصائح داخل التطبيق', tipsDesc: - 'فقاعة صغيرة تشير إلى جزء من التطبيق، تظهر أحيانًا أثناء الخمول ومن Hermes عند الحاجة. إغلاق نصيحة يزيلها نهائيًا.', - tipsReset: count => `استعادة ${count} نصيحة مغلقة`, + 'فقاعة صغيرة تشير إلى جزء من التطبيق، تظهر أحيانًا أثناء الخمول ومن Hermes عند الحاجة. تظهر كل نصيحة مرة واحدة.', + tipsReset: count => `إظهار ${count} نصيحة مرة أخرى`, toursTitle: 'جولات إرشادية', toursDesc: 'دع Hermes يرشدك في التطبيق، مع تعتيم الشاشة وإبراز كل خطوة.', composerPopoutTitle: 'محرر عائم', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index fb432bf97e..bad63979ad 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -707,8 +707,8 @@ export const en: Translations = { reactionsDesc: 'iMessage-style emoji tapbacks — react to messages, and Hermes can react to yours.', tipsTitle: 'In-App Tips', tipsDesc: - 'A small bubble pointing at one part of the app, shown occasionally while idle and by Hermes when it helps. Closing one retires it for good.', - tipsReset: (count: number) => `Bring back ${count} closed ${count === 1 ? 'tip' : 'tips'}`, + 'A small bubble pointing at one part of the app, shown occasionally while idle and by Hermes when it helps. Each tip appears once.', + tipsReset: (count: number) => `Show ${count} ${count === 1 ? 'tip' : 'tips'} again`, toursTitle: 'Guided Tours', toursDesc: 'Let Hermes walk you through the app, dimming the screen and spotlighting each step.', composerPopoutTitle: 'Floating Composer', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 53a4234d4d..a1dd9889b1 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -531,8 +531,8 @@ export const ja = defineLocale({ 'iMessage風の絵文字タップバック — メッセージにリアクションでき、Hermesもあなたのメッセージにリアクションします。', tipsTitle: 'アプリ内ヒント', tipsDesc: - 'アプリの一部を指す小さな吹き出し。待機中にときどき、また役に立つときは Hermes からも表示します。閉じたヒントは二度と表示されません。', - tipsReset: (count: number) => `閉じた${count}件のヒントを元に戻す`, + 'アプリの一部を指す小さな吹き出し。待機中にときどき、また役に立つときは Hermes からも表示します。各ヒントは一度だけ表示されます。', + tipsReset: (count: number) => `${count}件のヒントをもう一度表示`, toursTitle: 'ガイドツアー', toursDesc: '画面を暗くして各ステップを強調しながら、Hermes がアプリを案内します。', composerPopoutTitle: 'フローティング入力欄', diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 9fe1bbf548..ada23ff9d0 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -514,8 +514,8 @@ export const zhHant = defineLocale({ reactionsTitle: '訊息回應', reactionsDesc: 'iMessage 風格的表情回應 — 你可以對訊息做出回應,Hermes 也能回應你的訊息。', tipsTitle: '應用程式內提示', - tipsDesc: '指向應用程式某處的小氣泡:閒置時偶爾出現,需要時 Hermes 也會給你一則。關掉一則就不再出現。', - tipsReset: (count: number) => `復原 ${count} 則已關閉的提示`, + tipsDesc: '指向應用程式某處的小氣泡:閒置時偶爾出現,需要時 Hermes 也會給你一則。每則提示只出現一次。', + tipsReset: (count: number) => `再次顯示 ${count} 則提示`, toursTitle: '導覽', toursDesc: '讓 Hermes 帶你認識應用程式:調暗畫面並逐步標示每個位置。', composerPopoutTitle: '懸浮輸入框', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 171c0e1131..78ed7051f8 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -686,8 +686,8 @@ export const zh: Translations = { reactionsTitle: '消息回应', reactionsDesc: 'iMessage 风格的表情回应 — 你可以给消息添加回应,Hermes 也能回应你的消息。', tipsTitle: '应用内提示', - tipsDesc: '指向应用某处的小气泡:空闲时偶尔出现,需要时 Hermes 也会给你一条。关掉一条就不再出现。', - tipsReset: (count: number) => `恢复 ${count} 条已关闭的提示`, + tipsDesc: '指向应用某处的小气泡:空闲时偶尔出现,需要时 Hermes 也会给你一条。每条提示只出现一次。', + tipsReset: (count: number) => `再次显示 ${count} 条提示`, toursTitle: '引导导览', toursDesc: '让 Hermes 带你熟悉应用:调暗界面并逐步高亮每个位置。', composerPopoutTitle: '悬浮输入框', diff --git a/apps/desktop/src/lib/tips/rotation.test.ts b/apps/desktop/src/lib/tips/rotation.test.ts new file mode 100644 index 0000000000..8e5e369cb3 --- /dev/null +++ b/apps/desktop/src/lib/tips/rotation.test.ts @@ -0,0 +1,23 @@ +/** + * The walk is one lap: a tip that has already been on screen — closed by the + * timer, not only by the ✕ — is never offered again, and once every tip has + * had its moment the rotation is spent rather than starting the tour over. + */ + +import { describe, expect, it } from 'vitest' + +import { nextTip } from './rotation' + +const ORDER = ['a', 'b', 'c'] as const + +describe('nextTip', () => { + it('steps over tips that have already been shown, retired or not', () => { + expect(nextTip(ORDER, ORDER, { lastShownId: 'a', retired: [], seen: ['a', 'b'] })).toBe('c') + // Wrapping past the end lands on an unseen tip, never on one already shown. + expect(nextTip(ORDER, ORDER, { lastShownId: 'c', retired: [], seen: ['b', 'c'] })).toBe('a') + }) + + it('runs dry once every tip has been shown once', () => { + expect(nextTip(ORDER, ORDER, { lastShownId: 'c', retired: [], seen: [...ORDER] })).toBeNull() + }) +}) diff --git a/apps/desktop/src/lib/tips/rotation.ts b/apps/desktop/src/lib/tips/rotation.ts index 5c8c17cc62..91a8b6a849 100644 --- a/apps/desktop/src/lib/tips/rotation.ts +++ b/apps/desktop/src/lib/tips/rotation.ts @@ -7,13 +7,18 @@ * the right — and walking it that way means a user who sees three tips over a * week sees three neighbouring parts of the app, not three unrelated ones. * - * Two rules on top of the walk: + * Three rules on top of the walk: * - * 1. A hard close RETIRES a tip. It is gone for good — the only way back is - * Settings → Reset. Retiring every tip is a legitimate end state: the - * rotation runs dry and the app stops talking, which is what a user who - * closed all of them was asking for. - * 2. A tip is skipped, not waited for, when it has nothing on screen to point + * 1. The walk is ONE lap. A tip that has been on screen — whether the timer + * took it down or the user did — is not offered again; the catalog is an + * introduction, and an introduction repeated is a nag. Once every tip has + * had its moment the rotation runs dry and the app stops talking. Settings + * → Reset is the only way to a second lap. (Agent tips carry no catalog id + * and never enter this ledger.) + * 2. A hard close RETIRES a tip, which is the same silence made explicit — + * it also survives a Reset of nothing else, so the ✕ stays the heavier + * gesture. + * 3. A tip is skipped, not waited for, when it has nothing on screen to point * at. `available` is the subset that resolves right now, and the walk steps * over the rest — but it counts position against the FULL catalog, so which * panes happen to be open changes what you see and never the order you see @@ -25,11 +30,13 @@ export interface TipRotationState { lastShownId: null | string /** Hard-closed tip ids. */ retired: readonly string[] + /** Every tip id that has been on screen, however it left. */ + seen: readonly string[] } /** - * The first tip after `lastShownId` that is live and on screen, wrapping at the - * end of the catalog. Null when the rotation is spent. + * The first tip after `lastShownId` that is unseen, live and on screen, wrapping + * at the end of the catalog. Null when the rotation is spent. * * @param order Every tip id, in catalog order — the ring being walked. * @param available The subset with something on screen to point at. @@ -45,7 +52,7 @@ export function nextTip( for (let step = 1; step <= order.length; step += 1) { const id = order[(start + step + order.length) % order.length] - if (available.includes(id) && !state.retired.includes(id)) { + if (available.includes(id) && !state.retired.includes(id) && !state.seen.includes(id)) { return id } } diff --git a/apps/desktop/src/store/tips.ts b/apps/desktop/src/store/tips.ts index 2dd7bcdb4e..072ad2e910 100644 --- a/apps/desktop/src/store/tips.ts +++ b/apps/desktop/src/store/tips.ts @@ -11,10 +11,13 @@ * no bubbles, not "no bubbles unless the agent sends one" — so the switch is * mirrored to the gateway, where it takes the `tip` tool out of the model's * schema, and the bridge drops a stray tip on top of that. - * - `$retiredTips` is the hard-close ledger for the rotation. A tip the user ✕'d - * never comes back on its own; Settings → Reset is the only way, and that is - * the whole contract behind the ✕ being a heavier gesture than letting the - * bubble time out. + * - `$tipShownAt` is the seen ledger: every tip that reached the screen, with + * when. The rotation walks the catalog ONCE against it, so a tip that timed + * out is as finished as one the user closed — a second sighting of "type @ + * to attach a file" is the app forgetting it already said that. + * - `$retiredTips` is the hard-close ledger. A ✕ says the same thing louder and + * is the one record a Reset does not need to respect on its own; Settings → + * Reset clears both and starts the lap over. * - `$activeTip` is what is on screen. Ephemeral by design: a tip is a nicety, * and one that survives a reload has overstayed. * @@ -23,7 +26,7 @@ * tip one and re-arms a schedule measured in hours. */ -import { atom } from 'nanostores' +import { atom, computed } from 'nanostores' import { Codecs, persistentAtom } from '@/lib/persisted' import { TIP_CATALOG, type TipSide } from '@/lib/tips/catalog' @@ -70,9 +73,10 @@ export const $activeTip = atom(null) // model's schema entirely rather than staying on offer and being dropped. mirrorDisplayToggle('display.in_app_tips', ENABLED_KEY, $tipsEnabled) -/** When each campaign tip (id outside the rotation catalog) last showed. - * Campaign tips re-offer on their own long clock instead of walking on; - * `$retiredTips` still owns the hard ✕. */ +/** When each tip last showed, by id. The rotation reads it as the seen set + * (a catalog tip shows once); campaign tips (ids outside the catalog) read + * it as a clock and re-offer on their own long schedule. `$retiredTips` + * still owns the hard ✕. */ export const $tipShownAt = persistentAtom>( 'hermes.desktop.tips.shownAt.v1', {}, @@ -97,13 +101,23 @@ export function setTipsEnabled(enabled: boolean): void { $tipsEnabled.set(enabled) } -/** Un-retire everything, and let the rotation start over from a full deck - * rather than from wherever a six-hour cooldown had left it. */ +/** Forget every sighting and un-retire everything, and let the rotation start + * a fresh lap from a full deck rather than from wherever a six-hour cooldown + * had left it. */ export function resetTips(): void { $retiredTips.set([]) + $tipShownAt.set({}) + $lastTipId.set(null) $nextTipAt.set(null) } +/** Catalog tips a Reset would bring back: shown once or ✕'d, counted once. */ +export const $spentTipCount = computed([$retiredTips, $tipShownAt], (retired, shownAt) => { + const ids = new Set([...retired, ...Object.keys(shownAt)]) + + return TIP_CATALOG.filter(def => ids.has(def.id)).length +}) + /** Put a tip on screen, replacing whatever was there. */ export function showTip(tip: ActiveTip): void { if (tip.tipId) { diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index 44db9c8993..94f8518c5c 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -268,8 +268,10 @@ The app can also show its own, walking a built-in catalog of app features in order, paced like a game's loading-screen tips rather than a notification: a few minutes into a launch at the earliest, then at most one every six hours, and only at a genuinely idle moment. A tip from Hermes shares that cooldown, so it -also buys the user six hours of quiet from the rotation. Closing a rotation tip -with the ✕ retires that tip for good, and the settings row brings them back. +also buys the user six hours of quiet from the rotation. The rotation is a single +lap: each catalog tip shows once, whether it timed out or was closed with the ✕, +and once every tip has had its turn the app goes quiet. The settings row starts +the lap over. Both tips and tours are on by default and switched off in Settings → Appearance (`display.in_app_tips`, `display.in_app_tours`). Off covers Hermes as well as