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