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.
This commit is contained in:
Teknium
2026-09-10 11:04:59 -07:00
parent 1e7d29a081
commit 77e55b4d1f
11 changed files with 89 additions and 40 deletions
@@ -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 && (
<Button
onClick={() => {
triggerHaptic('selection')
@@ -853,7 +853,7 @@ export function AppearanceSettings() {
size="inline"
variant="text"
>
{a.tipsReset(retiredTips.length)}
{a.tipsReset(spentTips)}
</Button>
)}
</div>
@@ -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)
+2 -2
View File
@@ -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: 'محرر عائم',
+2 -2
View File
@@ -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',
+2 -2
View File
@@ -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: 'フローティング入力欄',
+2 -2
View File
@@ -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: '懸浮輸入框',
+2 -2
View File
@@ -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: '悬浮输入框',
@@ -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()
})
})
+16 -9
View File
@@ -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
}
}
+24 -10
View File
@@ -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<ActiveTip | null>(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<Record<string, number>>(
'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) {
+4 -2
View File
@@ -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