From 77e55b4d1f1d3cf2df1e8143ccacf7527b5f7cf0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:04:59 -0700 Subject: [PATCH] fix(desktop): show each in-app tip once, never lap the catalog again The idle tip rotation walked the catalog as a ring: after the last tip it wrapped to the first, so a user who had already seen every tip kept getting "Start fresh", "Teach it once", ... again every six hours for as long as they used the app. Only the X stopped a tip, and letting a bubble time out (the normal way it leaves) counted for nothing. The walk is now one lap. nextTip also steps over every tip in the seen ledger ($tipShownAt, which already recorded every catalog tip that reached the screen), so a tip shows once however it left, and the rotation runs dry once every tip has had its moment. Settings > Reset clears the seen ledger and the cursor as well as the retired set, and its button counts what a Reset would actually bring back (shown or closed, counted once). Agent tips carry no catalog id and are untouched. Live repro (Playwright against the worktree's Vite renderer, all nine tips seeded as seen, clock fast-forwarded past settle + cooldown): origin/main re-showed "Start fresh"; fixed renderer shows nothing; a fresh user (nothing seen) still gets the first tip. --- .../src/app/settings/appearance-settings.tsx | 12 +++---- .../src/components/tips/use-tip-rotation.ts | 9 +++-- apps/desktop/src/i18n/ar.ts | 4 +-- apps/desktop/src/i18n/en.ts | 4 +-- apps/desktop/src/i18n/ja.ts | 4 +-- apps/desktop/src/i18n/zh-hant.ts | 4 +-- apps/desktop/src/i18n/zh.ts | 4 +-- apps/desktop/src/lib/tips/rotation.test.ts | 23 +++++++++++++ apps/desktop/src/lib/tips/rotation.ts | 25 +++++++++----- apps/desktop/src/store/tips.ts | 34 +++++++++++++------ website/docs/reference/tools-reference.md | 6 ++-- 11 files changed, 89 insertions(+), 40 deletions(-) create mode 100644 apps/desktop/src/lib/tips/rotation.test.ts 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