From c12be682c47df397b5dd96554884666d8b02a94e Mon Sep 17 00:00:00 2001 From: Vlad Date: Tue, 26 May 2026 13:56:56 +0200 Subject: [PATCH 01/47] feat(onboarding): discovery pills, hover-hold gestures, hover-intent auto-close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two hidden power features get a softer learning curve: 1. Long-press Send → Scheduled message / Create task 2. Right-click a chat → Share / Rename / Mark unread / Delete Three layers of help, all dismissible: - Discovery pills next to each entry point, themed in `--apple-blue` like other inline-banner CTAs: * "Did you know? Hold Send for more" — gated server-side by an "ever-created" metric (>= 2 tasks AND >= 3 scheduled msgs), read from sqlite_sequence so it survives row deletion. Browser throttles to once per day. * "Tip: right-click a chat for options" — pure client gate; first contextmenu OR hover-hold on a chat-item flips the discovered flag forever. - Hover-hold (1.5 s) opens the same menus as long-press / right-click, so users who don't think to click-and-hold still find them. Send button uses mouseenter/mouseleave; chat-items delegate via mouseover/mouseout with same-target de-dup. - Hover-intent auto-close (3.5 s) on both menus. Mouse leaves the menu → 3.5 s countdown; re-enter → cancel. Replaces the schedule menu's old 10 s blanket timeout. Plus dropped the native `title=""` tooltip on sidebar chat items — full title is visible inline, and the browser tooltip got in the way of the new hover-hold gesture. Co-Authored-By: Claude Opus 4.7 --- public/css/style.css | 66 ++++++++++ public/js/iclaw.js | 247 +++++++++++++++++++++++++++++++++++- src/routes/chats.ts | 2 + src/routes/index.ts | 2 + src/services/sendHint.ts | 38 ++++++ src/services/store.ts | 19 +++ test/unit/sendHint.test.ts | 95 ++++++++++++++ views/partials/composer.ejs | 17 ++- views/partials/sidebar.ejs | 16 ++- 9 files changed, 493 insertions(+), 9 deletions(-) create mode 100644 src/services/sendHint.ts create mode 100644 test/unit/sendHint.test.ts diff --git a/public/css/style.css b/public/css/style.css index 5ab7363..3fadc3e 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1760,6 +1760,72 @@ code { transform: none; } +/* Send-button discovery pill — floats above the Send button with a tail + pointing down. JS gates visibility (once per day until 2 tasks + 3 + scheduled msgs). Pure presentational; server-side template gates whether + it's even in the DOM. */ +.send-hint-pill { + position: absolute; + right: 0; + bottom: calc(100% + 8px); + display: inline-flex; + align-items: center; + max-width: 240px; + padding: 6px 12px; + background: var(--apple-blue); + color: var(--apple-blue-fg); + border-radius: var(--radius-md, 8px); + font-size: 0.82rem; + line-height: 1.25; + white-space: nowrap; + box-shadow: var(--shadow-md, 0 6px 18px rgba(0, 0, 0, 0.15)); + z-index: 5; + pointer-events: none; + animation: send-hint-pill-in 220ms ease-out; +} +.send-hint-pill[hidden] { display: none !important; } +.send-hint-pill::after { + content: ''; + position: absolute; + right: 12px; + top: 100%; + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid var(--apple-blue); +} +.send-hint-pill__text { + flex: 1 1 auto; + white-space: normal; +} +@keyframes send-hint-pill-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Sidebar discovery pill — teaches the right-click context menu on chat + items. Sits under the toolbar, above the chat list. Same visual + language as the send-hint pill but block-level (no tail/arrow). */ +.sidebar-hint-pill { + display: block; + margin: var(--space-2) var(--space-3) var(--space-3); + padding: 6px 12px; + background: var(--apple-blue); + color: var(--apple-blue-fg); + border-radius: var(--radius-md, 8px); + font-size: 0.82rem; + line-height: 1.25; + box-shadow: var(--shadow-sm, 0 2px 6px rgba(0, 0, 0, 0.08)); + pointer-events: none; + animation: send-hint-pill-in 220ms ease-out; +} +.sidebar-hint-pill[hidden] { display: none !important; } +.sidebar-hint-pill__text { + display: block; + white-space: normal; +} + /* Hint strip: absolute in the composer’s bottom padding — no layout shift, and no negative margin that would clip under overflow:hidden on .col-main. */ .composer-secret-ui { diff --git a/public/js/iclaw.js b/public/js/iclaw.js index e61fd31..4235db3 100644 --- a/public/js/iclaw.js +++ b/public/js/iclaw.js @@ -1870,7 +1870,6 @@ ''; } if (title != null) { - link.title = title; const titleEl = link.querySelector('.chat-item-title'); if (titleEl) titleEl.textContent = title; } @@ -2043,9 +2042,27 @@ ); } + const SIDEBAR_MENU_HOVER_INTENT_MS = 3500; + let sidebarChatMenuAutoCloseTimer = null; + + function armSidebarChatMenuAutoClose() { + if (sidebarChatMenuAutoCloseTimer != null) clearTimeout(sidebarChatMenuAutoCloseTimer); + sidebarChatMenuAutoCloseTimer = setTimeout(() => { + sidebarChatMenuAutoCloseTimer = null; + closeSidebarChatMenu(); + }, SIDEBAR_MENU_HOVER_INTENT_MS); + } + function disarmSidebarChatMenuAutoClose() { + if (sidebarChatMenuAutoCloseTimer != null) { + clearTimeout(sidebarChatMenuAutoCloseTimer); + sidebarChatMenuAutoCloseTimer = null; + } + } + function closeSidebarChatMenu() { sidebarChatMenu.hidden = true; sidebarMenuChatId = null; + disarmSidebarChatMenuAutoClose(); } function openSidebarChatMenu(clientX, clientY, chatId) { @@ -2062,8 +2079,13 @@ y = Math.max(pad, Math.min(y, window.innerHeight - mh - pad)); sidebarChatMenu.style.left = x + 'px'; sidebarChatMenu.style.top = y + 'px'; + armSidebarChatMenuAutoClose(); } + // Hover intent for the sidebar context menu — mirrors the schedule menu. + sidebarChatMenu.addEventListener('mouseenter', disarmSidebarChatMenuAutoClose); + sidebarChatMenu.addEventListener('mouseleave', armSidebarChatMenuAutoClose); + document.addEventListener('pointerdown', (e) => { if (sidebarChatMenu.hidden) return; if (sidebarChatMenu.contains(e.target)) return; @@ -2108,7 +2130,6 @@ if (link) { const te = link.querySelector('.chat-item-title'); if (te) te.textContent = nextTitle; - link.title = nextTitle; } if (activeChatId === cid && titleInput) { titleInput.value = nextTitle; @@ -2151,10 +2172,123 @@ e.preventDefault(); const id = Number(link.dataset.chatId); if (!Number.isFinite(id)) return; + markSidebarHintDiscovered(); // user discovered the gesture; never nag again openSidebarChatMenu(e.clientX, e.clientY, id); }); + + // Hover-and-hold parity: cursor parked on a chat-item for 1.5s opens + // the same context menu as right-click. Mouseover bubbles, so we use it + // for delegation; we de-dupe child movements via the "same target" check. + const HOVER_HOLD_MS = 1500; + let chatHoverTimer = null; + let chatHoverItem = null; + chatListNav.addEventListener('mouseover', (e) => { + const link = e.target.closest('a.chat-item[data-chat-id]'); + if (link === chatHoverItem) return; // still on same item (moved to child) + if (chatHoverTimer) { + clearTimeout(chatHoverTimer); + chatHoverTimer = null; + } + chatHoverItem = link; + if (!link) return; + const id = Number(link.dataset.chatId); + if (!Number.isFinite(id)) return; + chatHoverTimer = setTimeout(() => { + chatHoverTimer = null; + if (chatHoverItem !== link) return; // pointer moved before timer fired + const rect = link.getBoundingClientRect(); + markSidebarHintDiscovered(); + // Open near the item's right edge so the menu doesn't cover the title. + openSidebarChatMenu(rect.right - 12, rect.top + 8, id); + }, HOVER_HOLD_MS); + }); + chatListNav.addEventListener('mouseout', (e) => { + const link = e.target.closest('a.chat-item[data-chat-id]'); + if (!link) return; + // Cursor moved to a child of the same item — not actually leaving. + if (e.relatedTarget && link.contains(e.relatedTarget)) return; + if (link !== chatHoverItem) return; + if (chatHoverTimer) { + clearTimeout(chatHoverTimer); + chatHoverTimer = null; + } + chatHoverItem = null; + }); + } + + // ------------------------------------------------------------------------- + // sidebar right-click discovery pill — paired with the contextmenu handler + // above. Pure client gate: once the user right-clicks a chat, the flag + // is set and the pill never shows again on this device. + // ------------------------------------------------------------------------- + const SIDEBAR_HINT_DISCOVERED_KEY = 'iclaw-sidebar-hint-discovered'; + const SIDEBAR_HINT_LAST_SHOWN_KEY = 'iclaw-sidebar-hint-last-shown'; + function markSidebarHintDiscovered() { + try { + localStorage.setItem(SIDEBAR_HINT_DISCOVERED_KEY, '1'); + } catch { + // Private mode — best effort; the pill will disappear next time + // we successfully store the per-day stamp anyway. + } + const pill = document.getElementById('sidebar-hint-pill'); + if (pill && pill.parentNode) pill.parentNode.removeChild(pill); } + (function setupSidebarHintPill() { + const pill = document.getElementById('sidebar-hint-pill'); + if (!pill) return; // server skipped it (no chats yet) + + let discovered = null; + let lastShown = null; + try { + discovered = localStorage.getItem(SIDEBAR_HINT_DISCOVERED_KEY); + lastShown = localStorage.getItem(SIDEBAR_HINT_LAST_SHOWN_KEY); + } catch { + // ignore + } + if (discovered === '1') { + pill.remove(); + return; + } + + const d = new Date(); + const todayKey = + d.getFullYear() + + '-' + + String(d.getMonth() + 1).padStart(2, '0') + + '-' + + String(d.getDate()).padStart(2, '0'); + if (lastShown === todayKey) { + pill.remove(); + return; + } + + try { + localStorage.setItem(SIDEBAR_HINT_LAST_SHOWN_KEY, todayKey); + } catch { + // ignore + } + + pill.hidden = false; + const hideTimer = setTimeout(() => { + if (pill.parentNode) pill.parentNode.removeChild(pill); + }, 12_000); + + // Click on any chat (left-click) = user is moving on — dismiss the + // pill. We don't set discovered here, because they didn't actually + // use the gesture yet. + if (chatListNav) { + chatListNav.addEventListener( + 'click', + () => { + clearTimeout(hideTimer); + if (pill.parentNode) pill.parentNode.removeChild(pill); + }, + { once: true }, + ); + } + })(); + const selectionReplyFab = document.createElement('div'); selectionReplyFab.id = 'msg-selection-reply-fab'; selectionReplyFab.hidden = true; @@ -4334,7 +4468,9 @@ const scheduleDatetimeInput = document.getElementById('schedule-datetime-input'); const SCHEDULE_MIN_LEAD_MS = 3 * 60_000; const LONG_PRESS_MS = 450; + const HOVER_HOLD_MS = 1500; let schedulePressTimer = null; + let scheduleHoverTimer = null; let scheduleMenuJustOpened = false; let scheduleMenuAutoCloseTimer = null; let editingScheduledId = null; @@ -4451,6 +4587,8 @@ times.hidden = !showTimes; } + const MENU_HOVER_INTENT_MS = 3500; + function closeScheduleMenu() { if (!scheduleMenu) return; showScheduleMenuPanel('main'); @@ -4463,22 +4601,41 @@ document.removeEventListener('pointerdown', onScheduleMenuOutsidePointerDown, true); } + /** Schedule a 3.5s close. Cleared by mouseenter on the menu; restarted by + * mouseleave or any other re-entry into "user away" state. */ + function armScheduleMenuAutoClose() { + if (scheduleMenuAutoCloseTimer != null) clearTimeout(scheduleMenuAutoCloseTimer); + scheduleMenuAutoCloseTimer = setTimeout(() => { + scheduleMenuAutoCloseTimer = null; + closeScheduleMenu(); + }, MENU_HOVER_INTENT_MS); + } + function disarmScheduleMenuAutoClose() { + if (scheduleMenuAutoCloseTimer != null) { + clearTimeout(scheduleMenuAutoCloseTimer); + scheduleMenuAutoCloseTimer = null; + } + } + function openScheduleMenu() { if (!scheduleMenu || !composerHasMessageText()) return; closeComposerAttachMenus(); document.removeEventListener('pointerdown', onScheduleMenuOutsidePointerDown, true); showScheduleMenuPanel('main'); scheduleMenu.hidden = false; - if (scheduleMenuAutoCloseTimer != null) clearTimeout(scheduleMenuAutoCloseTimer); - scheduleMenuAutoCloseTimer = setTimeout(() => { - scheduleMenuAutoCloseTimer = null; - closeScheduleMenu(); - }, 10_000); + armScheduleMenuAutoClose(); setTimeout(() => { document.addEventListener('pointerdown', onScheduleMenuOutsidePointerDown, true); }, 0); } + if (scheduleMenu) { + // Hover intent — cursor on the menu pauses the auto-close; + // leaving the menu restarts the 3.5s countdown. + scheduleMenu.addEventListener('mouseenter', disarmScheduleMenuAutoClose); + scheduleMenu.addEventListener('mouseleave', armScheduleMenuAutoClose); + } + function parseScheduledStamp(stamp) { if (!stamp) return null; const s = String(stamp).trim(); @@ -4713,6 +4870,25 @@ schedulePressTimer = null; } }); + // Hover-and-hold on desktop — 1.5s of cursor parked on the button + // opens the same schedule menu as long-press. Discoverable for users + // who don't think to click-and-hold. + sendBtn.addEventListener('mouseenter', () => { + if (startedOnDraft || activeChatId == null) return; + if (!composerHasMessageText()) return; + if (scheduleHoverTimer) clearTimeout(scheduleHoverTimer); + scheduleHoverTimer = setTimeout(() => { + scheduleHoverTimer = null; + if (isScheduleMenuOpen()) return; + openScheduleMenu(); + }, HOVER_HOLD_MS); + }); + sendBtn.addEventListener('mouseleave', () => { + if (scheduleHoverTimer) { + clearTimeout(scheduleHoverTimer); + scheduleHoverTimer = null; + } + }); // Capture-phase click guard — swallows the synthetic click that follows // the pointerup at the end of a long-press, which would otherwise submit // the form. @@ -5600,6 +5776,63 @@ refreshScheduledTimes(); } + // ------------------------------------------------------------------------- + // send-button discovery pill — surfaces the long-press menu (scheduled + // message / create task) for users who haven't crossed the usage threshold + // yet. Server only renders the element when eligible; this block is + // responsible for once-per-day throttling and auto-dismiss. + // ------------------------------------------------------------------------- + (function setupSendHintPill() { + const pill = document.getElementById('send-hint-pill'); + if (!pill) return; // server decided not to surface it + + const STORAGE_KEY = 'iclaw-send-hint-last-shown'; + const AUTO_HIDE_MS = 12_000; + + function todayKey() { + const d = new Date(); + return ( + d.getFullYear() + + '-' + + String(d.getMonth() + 1).padStart(2, '0') + + '-' + + String(d.getDate()).padStart(2, '0') + ); + } + + let lastShown = null; + try { + lastShown = localStorage.getItem(STORAGE_KEY); + } catch { + // Private mode / storage disabled — treat as "never shown". + } + if (lastShown === todayKey()) return; // already shown today + + try { + localStorage.setItem(STORAGE_KEY, todayKey()); + } catch { + // ignore — we'll just nag again next page-load in that session + } + + let hideTimer = null; + function hidePill() { + if (hideTimer != null) { + clearTimeout(hideTimer); + hideTimer = null; + } + if (pill.parentNode) pill.parentNode.removeChild(pill); + } + + pill.hidden = false; + hideTimer = setTimeout(hidePill, AUTO_HIDE_MS); + + // Дрібні сигнали, що юзер зорієнтувався: почав писати або тицьнув send. + // Без цього pill «висить» поки таймер не догорить, що відволікає. + const input = document.getElementById('composer-input'); + if (input) input.addEventListener('focus', hidePill, { once: true }); + if (sendBtn) sendBtn.addEventListener('pointerdown', hidePill, { once: true }); + })(); + // ------------------------------------------------------------------------- // chat title inline rename (HTTP form fallback for now) // ------------------------------------------------------------------------- diff --git a/src/routes/chats.ts b/src/routes/chats.ts index 664de7b..07c7c39 100644 --- a/src/routes/chats.ts +++ b/src/routes/chats.ts @@ -25,6 +25,7 @@ import { openclaw, cloudShareBaseUrl } from '../services/openclaw'; import { chatStatus } from '../services/chatStatus'; import { wsHub } from '../services/wsHub'; import { sendMessage } from '../services/chatRunner'; +import { shouldShowSendHint } from '../services/sendHint'; export const chatsRouter: Router = Router(); @@ -178,6 +179,7 @@ chatsRouter.get('/:id', async (req, res, next) => { currentActivity: chatStatus.getActivity(id), scheduledList: scheduledMessages.listByChat(id), queueList: queuedMessages.listByChat(id), + sendHintShow: shouldShowSendHint(), }); } catch (err) { next(err); diff --git a/src/routes/index.ts b/src/routes/index.ts index c0d0266..dcd7c75 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -3,6 +3,7 @@ import { chats, projectSecrets, projects, tasks } from '../services/store'; import { openclaw } from '../services/openclaw'; import { openclawWs } from '../services/openclawWs'; import { chatStatus } from '../services/chatStatus'; +import { shouldShowSendHint } from '../services/sendHint'; export const indexRouter: Router = Router(); @@ -46,5 +47,6 @@ indexRouter.get('/', async (req, res) => { defaultAgent: 'openclaw/default', openclawBaseUrl: openclaw.baseUrl, workingIds: chatStatus.workingIds(), + sendHintShow: shouldShowSendHint(), }); }); diff --git a/src/services/sendHint.ts b/src/services/sendHint.ts new file mode 100644 index 0000000..c706e6a --- /dev/null +++ b/src/services/sendHint.ts @@ -0,0 +1,38 @@ +/** + * Send-button discovery hint. + * + * The long-press menu on the composer's Send button hides two power features + * — scheduled messages and "create task". New users rarely find these on + * their own, so we surface a small one-shot pill next to the button that + * teaches the gesture. + * + * Threshold: we stop nagging when the user has EVER CREATED at least + * `SEND_HINT_TASK_THRESHOLD` tasks AND `SEND_HINT_SCHEDULED_THRESHOLD` + * scheduled messages. We deliberately measure "ever created" (via + * sqlite_sequence under the hood) rather than current row counts — a + * scheduled message is deleted after it fires, and a power user who + * dispatched 10 of them shouldn't see a "did you know?" pill again just + * because the table is empty right now. + * + * Once-per-day throttling lives in the browser (localStorage) — see + * `public/js/iclaw.js`. The server only decides whether the pill is + * eligible to render at all. + */ +import { scheduledMessages, tasks } from './store'; + +/** When BOTH thresholds are met (ever-created), the user has discovered the feature. */ +export const SEND_HINT_TASK_THRESHOLD = 2; +export const SEND_HINT_SCHEDULED_THRESHOLD = 3; + +/** + * Returns `true` if the discovery hint should be eligible to render on + * this page-load. Cheap — two reads of the sqlite_sequence table. + */ +export function shouldShowSendHint(): boolean { + const tasksCount = tasks.everCreatedCount(); + const scheduledCount = scheduledMessages.everCreatedCount(); + return ( + tasksCount < SEND_HINT_TASK_THRESHOLD || + scheduledCount < SEND_HINT_SCHEDULED_THRESHOLD + ); +} diff --git a/src/services/store.ts b/src/services/store.ts index d1c6cfc..d9f9215 100644 --- a/src/services/store.ts +++ b/src/services/store.ts @@ -885,6 +885,16 @@ export const scheduledMessages = { | ScheduledMessage | undefined; }, + /** How many scheduled messages this user has EVER created (monotonic, + * survives delete-after-fire). Reads sqlite_sequence directly — the + * AUTOINCREMENT counter never goes down. Used by the discovery hint to + * decide whether the user has discovered the feature for good. */ + everCreatedCount(): number { + const row = db + .prepare("SELECT seq FROM sqlite_sequence WHERE name = 'scheduled_messages'") + .get() as { seq: number } | undefined; + return row?.seq ?? 0; + }, /** Rows whose `scheduled_at` is now or in the past — what the sweeper fires. */ listDue(limit = 50): ScheduledMessage[] { return db @@ -1012,6 +1022,15 @@ export const tasks = { const row = db.prepare('SELECT 1 AS n FROM tasks LIMIT 1').get() as { n: number } | undefined; return row != null; }, + /** How many tasks this user has EVER created (monotonic, survives row + * deletion). Reads sqlite_sequence directly — the AUTOINCREMENT counter + * never goes down. Used by the send-button discovery hint. */ + everCreatedCount(): number { + const row = db + .prepare("SELECT seq FROM sqlite_sequence WHERE name = 'tasks'") + .get() as { seq: number } | undefined; + return row?.seq ?? 0; + }, statusSignals(): { needsHuman: boolean; running: boolean; needsReview: boolean } { const row = db .prepare( diff --git a/test/unit/sendHint.test.ts b/test/unit/sendHint.test.ts new file mode 100644 index 0000000..7de662b --- /dev/null +++ b/test/unit/sendHint.test.ts @@ -0,0 +1,95 @@ +/** + * sendHint — server-side gate for the long-press discovery pill. + * + * Rule: show until the user crosses BOTH thresholds — 2 tasks AND 3 + * scheduled messages. As long as either is below, the pill is eligible. + * Throttling to once-per-day is the client's responsibility. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { db, resetTestDb } from '../helpers/db'; +import { chats, scheduledMessages, taskContextSnapshots, tasks } from '../../src/services/store'; +import { + SEND_HINT_SCHEDULED_THRESHOLD, + SEND_HINT_TASK_THRESHOLD, + shouldShowSendHint, +} from '../../src/services/sendHint'; + +afterEach(() => resetTestDb()); + +function seedTasks(n: number) { + const c = chats.create('openclaw/default'); + const snap = taskContextSnapshots.create({ + projectId: null, + sourceChatId: c.id, + payload: { agent: 'openclaw/default', chatId: c.id, messages: [] } as never, + }); + for (let i = 0; i < n; i++) { + tasks.create({ + projectId: null, + sourceChatId: c.id, + title: `T${i}`, + goal: 'g', + agent: 'openclaw/default', + contextSnapshotId: snap.id, + status: 'completed', + }); + } +} + +function seedScheduled(n: number) { + const c = chats.create('openclaw/default'); + // Stagger by minute so unique(chat_id, scheduled_at) wouldn't bite us + // (it doesn't today, but future-proof the seed). + for (let i = 0; i < n; i++) { + scheduledMessages.create({ + chatId: c.id, + content: `m${i}`, + scheduledAt: new Date(Date.now() + (i + 1) * 60_000), + }); + } +} + +describe('sendHint.shouldShowSendHint', () => { + it('shows on an empty database (clean install)', () => { + expect(shouldShowSendHint()).toBe(true); + }); + + it('keeps showing if only the task threshold is met', () => { + seedTasks(SEND_HINT_TASK_THRESHOLD); + seedScheduled(SEND_HINT_SCHEDULED_THRESHOLD - 1); + expect(shouldShowSendHint()).toBe(true); + }); + + it('keeps showing if only the scheduled threshold is met', () => { + seedTasks(SEND_HINT_TASK_THRESHOLD - 1); + seedScheduled(SEND_HINT_SCHEDULED_THRESHOLD); + expect(shouldShowSendHint()).toBe(true); + }); + + it('hides once BOTH thresholds are crossed', () => { + seedTasks(SEND_HINT_TASK_THRESHOLD); + seedScheduled(SEND_HINT_SCHEDULED_THRESHOLD); + expect(shouldShowSendHint()).toBe(false); + }); + + it('hides when far past both thresholds (idempotent)', () => { + seedTasks(SEND_HINT_TASK_THRESHOLD + 5); + seedScheduled(SEND_HINT_SCHEDULED_THRESHOLD + 5); + expect(shouldShowSendHint()).toBe(false); + }); + + // The whole point of measuring "ever created" rather than COUNT(*) — a + // power user whose scheduled messages have all fired (= rows deleted) must + // not be re-nagged about a feature they clearly mastered. + it('stays hidden after rows are deleted (ever-created semantics)', () => { + seedTasks(SEND_HINT_TASK_THRESHOLD); + seedScheduled(SEND_HINT_SCHEDULED_THRESHOLD); + expect(shouldShowSendHint()).toBe(false); + + // Wipe both tables; the sqlite_sequence counters survive. + db.prepare('DELETE FROM scheduled_messages').run(); + db.prepare('DELETE FROM tasks').run(); + + expect(shouldShowSendHint()).toBe(false); + }); +}); diff --git a/views/partials/composer.ejs b/views/partials/composer.ejs index 7da24ee..dea5d7c 100644 --- a/views/partials/composer.ejs +++ b/views/partials/composer.ejs @@ -118,13 +118,28 @@ class="composer-send" id="composer-send-btn" aria-label="Send (hold to schedule)" - title="Click to send. Hold to schedule." > + <% if (typeof sendHintShow !== 'undefined' && sendHintShow) { %> + + + <% } %> +
+ <% if (chats.length > 0) { %> + + + <% } %> +