Reworks the earlier-version OTA UI from three inline buttons into a single
'Install a previous version' button that opens a popup of the available older
betas; picking one shows an are-you-sure confirm (reusing showConfirm) before it
closes and hands off to the existing download/verify/reboot flow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a 'Reinstall an earlier version' section to the Wi-Fi OTA UI (native
dual-OTA boards only): up to three buttons for beta_(latest-1..3), each flashing
that exact published bin via the same download/verify/reboot path. The worker URL
is parameterised on a target version; the existing 'Install update' button now
routes through the same otaStartInstall(latest) helper. Buttons auto-hide when
there aren't that many earlier releases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The clock's runtime placement used the wide-bar -142 offset on every board,
overriding the V4's intended -126 build slot. On the narrow V4 portrait bar that
pulled the clock left into the width-capped node-name window — the long-standing
'clock overlaps the name' issue. Make the placement bar-width-aware: <300 px bars
(V4) use -126/-94, wide bars (T-Deck/Tanmatsu) keep -142/-110.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refines the Expansion-Kit UI (#29) so it doesn't intrude on a bare V4: the
Sensors tab and the Home environment widget now appear only when an actual
environment sensor (BME280 / GXHTV3) is detected at boot — battery and GPS no
longer count, so a V4 without the Expansion Kit board shows the normal 5-tab
layout and a clean Home (no empty "Batt ----" / "Temp ...." lines). Adds a
"Show Sensors tab" toggle in Display settings to hide it even when sensors are
present (applies on restart). Tab indices resolve at boot so Settings shifts
back to slot 4 when the Sensors tab is absent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Heltec V4 Expansion Kit local-sensor UI, board-gated behind HAS_EXPANSION_KIT
(V4 touch env only — T-Deck/Tanmatsu unaffected): a Sensors bottom tab
(atmosphere / GPS / battery + history charts), a Home env summary widget, an
Expansion Kit card in Device settings, and a Local Sensors detail page. Reads
on-board MeshCore sensors (BME280, SHT4x, battery, GPS, buzzer) via the standard
sensor/telemetry API — no new hardware drivers.
Mesh console: enable the existing Terminal on the V4 (HAS_TOUCH_UI), renamed
"Mesh console", with wifi/tcp/ble on-off + ota status quick commands. The
SD-backed file-manager pieces stay T-Deck-only (V4/Tanmatsu get a no-op path).
Tab indices are conditional so non-Expansion boards keep 5 tabs (Settings=4) and
the V4 gets 6 (Sensors=4, Settings=5). The PR's GPS-nav/waypoint and weather
pressure-alarm bundles were intentionally left out.
Ported from PR #29 by @samuelcoustet, re-applied against current main.
Co-Authored-By: samuelcoustet <257299705+samuelcoustet@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds French (AZERTY), Dutch, German (QWERTZ), Spanish and Italian layouts — both
the on-screen keyboards and the T-Deck physical-keyboard maps (KeyboardLayoutId
FR..IT). Picking a UI language now auto-enables and selects the matching keyboard
layout, so choosing French gives AZERTY without a separate trip to keyboard
settings. Accented letters still come from the long-press accent popup; no new
fonts required. Extended the settings layout-enable list to match the larger enum
(was a latent out-of-bounds once the layout count grew).
Ported from PR #29 by @samuelcoustet — the layout data applied cleanly; the
UITask wiring was re-applied by hand against current main (the PR was cut from
beta_12 and no longer merges).
Co-Authored-By: samuelcoustet <257299705+samuelcoustet@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-enables the over-the-air update that #74 stubbed out — the block then was
undersized 1.5 MB OTA slots, and both touch tables are now 3.875 MB A/B. Wi-Fi
self-update runs again on natively-flashed boards (every V4 + standalone T-Deck);
Launcher single-slot installs and the Tanmatsu (AppFS) keep the manual path.
- Gated on touchHasOtaUpdateSlot() (a spare A/B slot exists) — already drives the
update badge, so Launcher/Tanmatsu never reach the install path.
- Self-contained Arduino Update flow in the touch app (the vendored core's OTA
allow-list is meshcomod-only, so we don't route through board.startHttpOtaFromUrl).
Fetches the immutable releases/TOUCH/beta_<N>/<board>.bin over plain HTTP from
firmware.wadamesh.com on the tile worker (off the UI thread); progress polled by
a UI timer.
- Update.begin(len) does the spare-slot select AND the size check, so a legacy
too-small slot fails cleanly and falls back to the manual flasher message.
- Verify (MD5) + set-bootable via Update.end(true), then rebootDevice() (saves
chat history first). A corrupt/short download leaves the running slot intact.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The grid_items count missed the conditional "Show on map" button,
causing the card height to be calculated short when the contact has a
GPS fix. Additionally, on landscape displays (sh=240) the backdrop is
only 218px, which repeater cards with many buttons already exceeded.
Fix both: pre-check the GPS condition and include it in grid_items,
and cap the card's displayed height to the available backdrop space
with vertical scroll enabled when the content overflows. The close
badge is FLOATING so it stays anchored regardless of scroll position.
Idle power-save (light-sleep) advertised itself with a separate crescent-moon
glyph in the status bar. Replace it with an iPhone-Low-Power-Mode-style amber
tint on the battery icon (iOS systemYellow): clearer, and frees the bar slot.
The moon stays in the tree but hidden, keeping the layout and its tap handler.
A single applyBattColor() helper owns the battery colour so the map-chrome
restyle (black/white over light tiles) and the per-tick refresh never fight —
the amber overlays whatever base colour the context wants when power-save is on.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
If lv_layer_top() has a visible widget at the search coordinate (i.e. a
modal or dialog is open), stop the layer search there regardless of whether
a scrollable was found. Prevents the content behind the dialog from being
scrolled when the dialog itself has no more room to scroll.
Closing a settings item / modal / chat with keyboard-nav on snapped the
underlying list back to the top. navMaybeRebuild rebuilds the focus group when
the overlay closes, but the previously-focused element lived inside the overlay
and is gone, so focus fell to the first list item and the list scrolled up to
show it. Affected every screen because every overlay closes through this path.
Stash the page item that had focus when leaving for an overlay/chat, and restore
it on return — so focus, and the scroll position, land back where they were.
Falls back to the prior behaviour if that item is genuinely gone. Most visible on
beta_14 (keyboard-nav defaulted on); new installs now default it off.
Reported by ZorkaTheDog (#45).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a global region scope is configured, the per-channel override input
now shows it as greyed-out placeholder text instead of the generic "#region",
so users can see at a glance what scope an empty field will inherit.
Falls back to "(no default)" when no global scope is set.
A DM sent from the MeshCore companion app went out over the mesh but never
appeared on the T-Deck: the wire protocol deliberately doesn't echo sent
messages back to clients, so the on-device UI (a separate consumer) never saw
app-originated sends. Add an appSentMsgToContact() UI hook (default no-op for
non-touch UIs); the touch UI mirrors the message as a local outgoing bubble in
the recipient's thread. Called from the CMD_SEND_TXT_MSG handler on first
handling only (a client/transport retry was already mirrored).
Still open: the receive direction (T-Deck-received messages reaching the app) —
queueMessage already pushes RESP_CODE_CONTACT_MSG_RECV_V3 to all transports, so
that half needs a frame capture against the real app to pin down.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reverses the v15 force-on. New installs and existing T-Deck/V4 devices now
default to keyboard-nav OFF (a v18 one-time migration flips existing installs
to off); the user's later explicit on/off then persists. The Tanmatsu is exempt
— it has no touchscreen, so keyboard nav is its only input and stays on.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The touch-to-edit path keyed off navFocusCb (a focus CHANGE), so a field that was
already the selected one — e.g. the Profile node-name field, auto-selected on
entry — couldn't be made typable by tapping it (the tap doesn't re-focus, so
navFocusCb never fires). settingsFieldFocusCb already runs on CLICKED; now a real
tap also sets edit mode, so a selected field becomes typable on tap. Keyboard-nav
onto a field still starts in navigate mode (press Enter).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous fix returned 0 from the recursion for NAV_SKIP/HIDDEN subtrees, but
the parent loop still iterated over the added/removed child (h = h*prime + 0),
so the child COUNT still moved the signature. The accent box + @-mention picker
are created/deleted on every keystroke, so the count flipped each time → rebuild
→ edit-mode reset → typing broke mid-word (and Esc then read as navigate-back,
exiting the screen). Now skip those children in the loop entirely so they never
affect the signature.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Non-chat text fields (add-channel, Wi-Fi, room login, settings modals) live on
the top UI layer, so keyboard nav is rooted there (useTop). The accent box and
@-mention picker are ALSO on the top layer, and they were counting toward
navTreeSig() — so each keystroke that showed/hid the box flipped the signature,
triggered a navMaybeRebuild(), and that rebuild's remove-all + re-focus churn
reset edit mode, dropping you out of typing mid-word.
- navTreeSig() now skips NAV_SKIP_FLAG subtrees (accent box, mention picker), so
those passive overlays never trigger a rebuild.
- navFocusCb() ignores the transient f==null a rebuild's remove-all emits, so any
other rebuild can't reset edit mode either.
(The T-Deck never used the keyboard mirror — kbMirrorActive() is false there; the
mirror is V4-only for the on-screen keyboard.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- @-mention picker (issue #42): typing "@" in a chat composer pops a touch list
of matching contacts (prefix-filtered, up to 6); tap one to insert "@Name ".
Passive NAV_SKIP_FLAG overlay like the accent box; touch-select for now. Takes
priority over the accent box while a mention is active (composerSuggestRefresh).
- Cursor now reflects edit mode: shown only while editing, hidden while a focused
field is just the keyboard-nav target — a reliable "ready to type" cue
(navSyncCursor() each nav tick).
- Edit mode is decided only on a genuine move to a different field, so a rebuild
that re-focuses the same field (e.g. while the accent box is up) no longer
kicks you out of typing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Keyboard nav: tapping a text field by touch now enters edit mode immediately
(focus came from the pointer indev) instead of requiring Enter. Reaching a
field with the letter-nav keys still starts in navigate mode (press Enter).
- Map: yield to IDLE after each tile in renderMapTiles() so a slow multi-tile
render on loopTask can't starve the task watchdog and panic — fixes a beta_14
TWDT reboot seen during map use (CPU1=loopTask). The download worker already
does the same vTaskDelay(1) on its side.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The keyboard-nav rework gated the physical-keyboard typing target on
s_nav_ta_editing (edit mode). That flag is only ever set while keyboard
navigation is ON (navFocusCb, driven by the nav focus group). With keyboard
navigation turned OFF the nav group isn't maintained, so the flag stayed false
and `ta` was always null — keys never reached the focused field and typing was
completely dead on the T-Deck (and Tanmatsu).
Fix: when keyboard-nav is OFF there is no navigation to protect, so a focused
field is always the typing target (ta = ta_focused). Edit-mode gating still
applies only when keyboard-nav is ON. Affects handleHwKey (T-Deck) + navPump
(Tanmatsu); the V4 (no keyboard nav) was never affected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The boot crash-report prompt + the About-page export button are only useful
where the saved coredump file can actually be retrieved and sent to the devs:
the T-Deck (SD card) and Tanmatsu (FFat, via the launcher file manager). On the
Heltec V4 there's no SD and no user-reachable internal storage, so the prompt
was a dead end. Gate crashDumpCheck() to those boards only — on the V4 the dump
is never detected, so the whole crash-report UI stays off (everything downstream
gates on s_crash_dump_size). The panic coredump still sits in its flash
partition for a USB/esptool pull if ever needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Any command arriving while s_meshcomod_pending_action is non-NONE and
the timeout hasn't expired previously fell through the confirmation
block and was processed normally, letting a second 'tcp off' / 'ble off'
silently overwrite the pending action and lose the original confirmation.
Add a final else clause that blocks the new command and reminds the user
to 'ok' or 'cancel' the in-flight request.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- enableTcp() now calls startTcpServer(false) directly instead of relying
on the main loop — fixes TCP not restarting when wifi_started is false
(BLE-only mode, radio toggled off, or cold re-enable from USB).
- Add touchHasOtaUpdateSlot() guard to the OTA button creation block so
the update affordances stay hidden under Launcher (single app slot),
where ota url would fail silently.
- Fix toggleTcpCb alert race: snapshot isTcpEnabled() before the toggle
so the alert text reflects the transition, not a post-action re-read
that could mismatch during OTA suspension.
- Replace global -w with -Wno-deprecated-declarations -Wno-unused-parameter
in both touch environments to keep third-party lib noise down while
surfacing real regressions in OTA, network, and companion paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Post-beta_12 touch-UI fixes (T-Deck / Tanmatsu keyboard nav + composer):
Keyboard navigation
- Chat composer auto-edits on open (cursor shows, type immediately); every
OTHER field stays in navigate mode so the letter-nav keys keep working —
press select/Enter to edit it. Enter (or Esc/X on Tanmatsu) on an empty
composer drops back to navigate mode.
- Menu-bar tab-hotkey letters no longer fire while a text field is the focused
element (fixes a stray tab-jump while typing); directional nav still moves.
- New "Show menu-bar letters" setting (off by default) for the tab hotkeys.
Accent box
- The tap-to-pick accent box no longer steals the keyboard-nav focus group
(NAV_SKIP_FLAG) — it's a passive hint, so typing continues and a keybind no
longer navigates. Ready to wire arrow-key selection later.
Composer
- Dedicated "Special characters" picker (% $ @ # & ... currency/math/accents),
separate from the emoji picker.
- Long-press a sent message -> Resend.
Misc
- 12-hour clock honoured everywhere (top bar, chat bubbles, lock screen), not
just the chat overview.
- GPS UART RX buffer enlarged only when GPS is enabled (restores V4 RAM).
- Derive time-of-day from received mesh message timestamps when Wi-Fi/GPS off.
- Self-record the task-watchdog context on a TWDT reset for the crash report.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Room posts arrive as TXT_TYPE_SIGNED_PLAIN with the author in the 4-byte sender_prefix and a bare body. The display path passed the room contact's name, so every post showed the room name. Resolve the author from the prefix and pass it as the per-message sender via a new newRoomMsgFromPubWithMeta hook; the room stays the thread. Tested on T-Deck Plus.
Signed-off-by: Yazutsu <andrzej@gruziel.pl>
Contact list gains a "Direct (0-hop)" option in the Filter sheet (value 5)
that shows only nodes heard without any repeater hops (out_path_len == 0).
Header chip shows "Dir" when active. Selecting a filter now auto-dismisses
the sheet (matching sort behaviour).
Map options panel gets a "Direct (0-hop) only" toggle that hides both
contact markers and link lines for any multi-hop or path-unknown node.
When enabled in Settings → Keyboard, pushing the cursor past any screen
edge routes the overflow into the nearest scrollable widget rather than
absorbing it. Works on regular tab content (lv_scr_act children) and on
lv_layer_top() overlays such as the app drawer.
Implementation:
- tbFindScrollableAt() hit-tests at the vertical centre of the content
area (avoids status/tab-bar chrome and per-tab headers), searches
lv_layer_top() before lv_scr_act(), then walks ancestors to the first
scrollable with room in the overflow direction.
- scroll_by direction is negated relative to raw overflow (scroll_by(+dy)
moves children down = reveals earlier content = scroll up).
- Scroll amount is capped to lv_obj_get_scroll_top/bottom/left/right so
we never push past the widget's content bounds.
- Preference persisted as NVS key "tb_edgesc" (default off).
- Map, Snake, and emoji-picker modes are unaffected (they return before
cursor mode runs).
The GPS NMEA UART used Arduino's default 256-byte RX ring (~66 ms of slack at
38400 baud). On the busy touch UI a single long LVGL/map frame stalls the loop
past that window, dropping bytes and corrupting NMEA ephemeris subframes — each
loss costs the receiver ~30 s, turning a ~1-minute fix into several minutes or,
with frequent stalls, never acquiring. Confirmed on-device: peak UART backlog
hit 448 B at idle, well past the 256 B default. Bump the ring to 4096 B (~1 s of
slack) before the core opens Serial1 in sensors.begin(). App-side (no core
re-tag); gated to ENV_INCLUDE_GPS so both touch boards get it. A -DGPS_BUF_DEBUG
backlog probe is included (compiled out of releases).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- emoji: bake the beer mug U+1F37A and clinking beers U+1F37B as colour glyphs
into emoji_data.c (now 252) and add the beer mug to the composer picker; the
clinking pair was already listed but only rendered monochrome, now colour.
- status bar: tapping the unread badge (envelope + count) on the left now jumps
to the Chats inbox. The CLICKABLE flag is toggled per-tick so only the badge
state intercepts the tap — every other left-zone state (Back chevron, profile
name, OSM credit, blank) keeps the bar's control-center / Back tap.
- contacts: add a "Notify on new contact" toggle to the Auto-add page
(TouchCfg v8, default ON). When off it suppresses the auto-discovery toast
chip in UITask::notify(); the diag-log line and tab badge are unaffected.
- keyboard: add the Czech accents c-caron, e-caron, t-caron, z-caron, r-caron
and u-ring (plus uppercase) to the long-press / tap accent popup. Glyphs are
already baked in the extras fallback fonts, so no font regeneration needed.
Builds verified on both LilyGo_TDeck_companion_radio_touch and
heltec_v4_tft_companion_radio_usb_tcp_touch.
Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
Headline: full keyboard navigation (on by default) — WASDZ spatial focus move, S select,
Q back, per-tab hotkeys (E/R/T/U/I) shown on the icons, plus scroll up/down (F/C). Every
key is remappable in Settings -> Keyboard. Foundation for keyboard-first devices like the
Tanmatsu T-Pager (no touchscreen) and a reliable keyboard workflow on the T-Deck today.
Also: #27 receive/flood reboot fix, on-boot crash-report prompt, map zoom +/- buttons (#26),
route-replay wide overview, 'App drawer as home' option. The in-progress Tanmatsu (ESP32-P4)
port source is now public. Mesh America catalog + release.sh kept from main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- T-Deck: trackball mouse/D-pad navigation toggle (Settings -> Keyboard).
- Map (#20): microSD-tile mode now caches Wi-Fi-fetched tiles onto the SD card so
the offline library fills in + grows instead of going blank past its edge.
- Power: PR #19 idle power-save (Dan Vybiral / @Vybo) relocated into a new
'Battery' group under Settings -> System, alongside a button that opens the
battery / power-history screen.
- TouchCfg v10 (ui_scale, tb_keypad, sleep_idle). Mesh America catalog -> beta_10.
Curated touch overlay onto main (the WIP Tanmatsu ESP32-P4 build system stays on
the tanmatsu-port branch). Co-Authored-By: Dan Vybiral <dan.vybiral@greencode.cz>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opt-in Idle power-save for the T-Deck: when parked (screen off, on battery, no
companion client, Wi-Fi+BLE off, mesh idle) the main loop is throttled with
vTaskDelay so the FreeRTOS idle task can halt the CPU between ticks, on top of the
existing 80 MHz screen-off downclock — without ever stopping mesh RX. Default OFF
(Settings -> Lock, persisted TouchCfg v8 sleep_idle); status-bar moon indicator +
idle stats. Heltec V4 compiles (gate never passes there). A plain scheduler throttle
rather than esp_light_sleep_start(), which trips INT_WDT in this build.
Co-authored-by: Dan Vybiral <dan.vybiral@greencode.cz>
- App drawer: size tiles to fit so the bottom row's labels aren't clipped, and
show a persistent scrollbar when the grid overflows (discoverable scroll).
- Snake: steer via the hardware swipe detector (works on the V4 cap-touch and
the T-Deck), stop touch/swipe leaking to the screen behind the overlay, and
gate the status bar while playing.
- Snake app-drawer icon drawn from shapes — the U+1F40D emoji isn't in the baked
colour-emoji set, so it rendered as a tofu box.
- Add beta_7 release notes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- BLE: user-settable 6-digit pairing code (Settings -> Bluetooth), MyMesh::setBLEPin.
- Lock wallpaper: pick via the file manager (fast, any folder) + 'Set as wallpaper'
in the image viewer; downscaling JPEG decoder accepts images > 1024 px and keeps
the held buffer small; clear message for progressive JPEGs.
- Map tiles: validate JPEG magic before caching + re-fetch bad cached tiles
(fixes black quadrants).
- Channel @reply: quick-reply now appends at the cursor instead of wiping the
mention.
- Audio: heap pre-flight before i2s_driver_install (fixes the esp_timer NO_MEM
abort when toggling sound under BLE+Wi-Fi memory pressure).
- App drawer: squircle tiles. Log hygiene: CORE_DEBUG_LEVEL 1->0 (mute benign
vfs/Preferences spam off the USB-CDC companion stream).
- Core bump to core-v1.16.4 (drops the 4-byte chat-payload trailer, issue #13).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
loadLegacyHistoryFromStorage() tried to read MAX_UI_MESSAGES (now 500)
records from a file that only contained 96 (old ring size). It read the
thread metadata fine — populating unread counts — then hit EOF on the
messages and returned false. The reconciliation pass that resets phantom
unread counts never ran, so the first boot after the ring bump showed
high unread counters against empty channels.
Fix: derive the actual slot count from the remaining file size and only
read that many message records, zero-filling the rest. The phantom-badge
reconciliation in loadHistoryFromStorage() then clears any thread whose
messages were already evicted.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Thread metadata (~4 KB, contains unread counts) and the message ring
(now ~115 KB at 500 slots) are now persisted to separate files
(/ui_threads_v1.bin and /ui_msgs_v1.bin).
Thread metadata flushes on the original 200 ms delay so unread counts
survive a hard reset. The message ring flushes lazily every 2 s,
batching burst arrivals into a single write instead of hammering SPIFFS
on every message — reducing flash wear and WdtHeavyGuard exposure as
the ring grows.
MAX_UI_MESSAGES 96 → 500 (PSRAM cost: 26 KB → 133 KB, well within
budget). Ring overflow within a session is now extremely unlikely on
LoRa; the cross-session phantom-badge fix (post-load reconciliation)
from the previous commit handles the remaining edge case.
Migration: on first boot the loader falls back to the legacy combined
file (/ui_chat_history_v1.bin), then schedules writes of the new split
files. Old file is left in place and ignored on subsequent boots.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The display ring is a fixed-size cache independent of the per-thread
unread counter. After a reboot the ring may not contain the messages
that were counted as unread (they were evicted before the save), leaving
a non-zero badge on a thread that opens to an empty chat.
Fix: after loading history, zero the unread counter for any thread whose
messages are absent from the ring. The unread counter and the ring are
independent — the ring is a display buffer only — so opening the thread
via the first fix (markThreadRead on close) handles the mid-session case;
this one-shot check handles the cross-reboot case.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The 96-slot message ring can overflow when there is sustained traffic
across many threads. When ring wraparound silently overwrites a channel's
messages, thread.unread stays non-zero but getThreadMessageIndexes
returns 0 — producing a badge with a count but an empty chat on open.
The same phantom persists across reboots because unread is saved to
disk independently of the ring contents.
Fix has three parts:
1. counted_as_unread per UIMessage (RAM-only): set true when a message
is the reason a thread's unread counter was incremented. Cleared when
the thread is marked read, so future evictions of those slots don't
double-decrement.
2. Eviction hook in appendMessage: before overwriting a ring slot,
decrement its thread's unread if the slot was counted_as_unread.
Keeps the counter in sync as the ring wraps.
3. Post-load reconciliation in loadHistoryFromStorage: after restoring
thread+message state from disk, any thread whose persisted unread
count exceeds its actual incoming messages in the ring gets clamped
(or reset to 0 if the ring has nothing for it). Also reconstructs
the counted_as_unread flags so evictions after boot are tracked.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>