Skip to content

time: stamps cross the bridge as UTC; local time is a display concern - #1

Closed
zachwilke wants to merge 125 commits into
mainfrom
cursor/utc-timestamps-fe38
Closed

zachwilke wants to merge 125 commits into
mainfrom
cursor/utc-timestamps-fe38

Conversation

@zachwilke

Copy link
Copy Markdown
Owner

Split out of nixfred#47 (first of five, as requested). Rebased onto current main (2.5.0).

What

Stamps cross the bridge as ISO-8601 UTC (2026-09-07T18:33:12Z). Local time is a display concern: bubbles, day dividers, "Today"/"Yesterday" and read receipts convert to the reader's zone at render, so a day still breaks at your midnight.

Why

Stamps arrived as the Mac's naive wall clock and were compared against the Linux clock — the same string only while both machines sat in one timezone. A Mac an hour ahead put every read mark ahead of every message (nothing unread); an hour behind and the backlog re-toasted. In the DST fall-back hour the Mac's own clock repeats, so two messages an hour apart carried the same stamp and lexical order stopped being chronological order. Every a.ts < b.ts in the collector already assumed that property; only UTC actually provides it.

imsg's JSON emits fmt_ts (UTC). Plain-text CLI renders keep fmt_ts_local (the time a person reading imsg recent remembers).

Upgrading is covered from both ends, and both halves are load-bearing together: loadState() migrates marks a pre-UTC release wrote, and stamps are normalised at the two fetch doors (fetchMessages, loadThread, plus fetchChats' last) so a Mac still on the old bridge keeps working. Migrating the marks alone would sort every message below every mark (" " < "T") and silently empty the badge and the toasts.

This closes the open ROADMAP item ("Timestamps are Mac-local wall-clock strings").

How it was verified

  • bun test is green (497 tests; CI-equivalent Mac-tool tests including new test_wire_time.py)
  • Any send/receive behavior was exercised against my own number only — never a contact or a group
  • UI change → screenshot attached
  • Touches an invariant in CLAUDE.md → named below, and the doc updated if it changed

invariants touched: Every stamp inside Blip is UTC; local time is a DISPLAY concern. Marks migrate on load; stamps normalise at the fetch doors. The suite is pinned to TZ=UTC (test-setup.ts); timezone.test.ts and bridge/mac/test_wire_time.py set their own zones and are what actually cover the conversion.

Rebase notes

main moved a long way since nixfred#47. Conflict resolutions kept current-main behaviour (chat-id sanitising, pin_name, failed-send PendingSend fields, XDG_CONFIG_HOME test isolation) and applied toUtcStamp on fetchChats().last. A selectIncomingLinks fixture added on main after nixfred#47 still used a naive stamp; it is now UTC so it does not sort below a UTC watermark (" " < "T").

Supersedes the UTC slice of nixfred#47 (688f2cb).

Open in Web Open in Cursor 

Fileri and others added 30 commits September 5, 2026 08:55
j/k and the arrow keys moved the cursor index but never touched
threadFlick.contentY, so past the visible rows the selection walked
off-screen in the thread list, the search hits and the new-message picker.

The list is a multi-section Column (pinned grid, headers, three
Repeaters), so ListView.positionViewAtIndex is not available. This is the
shape Omarchy's audio and Tailscale panels have, and the fix is their
helper: each row registers itself as root.cursorRow when its hasCursor
turns true, and the three move functions call scrollCursorIntoView(),
which clamps contentY so the row sits inside the viewport with a
Style.space(6) margin. Synchronous on purpose — a cursor move does not
change the model, so the row is already laid out.

Rows now decide "am I the cursor?" by comparing modelData.chat against a
root.cursorChat string instead of calling threadIndex(), which scanned
every thread per row per keypress; threadIndex() had no other callers and
is gone.

The app window had no list navigation at all: only the bar panel's
PanelKeyCatcher drove moveCursor. BlipWindow's navCatcher now forwards
Up/Down/Enter through a new view.catchNavKey(), which declines while any
editor has focus, so the compose, search and new-message fields keep
their own arrow handling (the window's "normal editor behavior" rule).
moveCursor, moveSearchCursor and moveNewCursor wrapped with a modulo, so
Down on the last of 300 threads landed on the first. Now that the list
scrolls to follow the cursor, that press reads as a jump to the top, not
a loop. Clamp to [0, length-1] instead, as Omarchy's Dropdown does. The
wrap arrived with the a98f59e refactor and was never called out as a
decision.
With the cursor stopping at the ends, Up on the first thread did nothing.
It now calls startSearch(), which scrolls the list to the top (the field
sits above the rows — pressed deep in the list, `/` and `n` used to focus
a field that was out of view) and focuses it. Down in an empty search or
new-message field goes back to the list with the cursor on the first row:
the list is showing its top, so that is where the eye is. Esc keeps the
old cursor and brings its row back into view — out of a field and out of
a conversation alike; back() still parks a mouse user at the top. Same
round trip as Omarchy's SearchableDropdown.

The rows are rebuilt when the mode flips, so the scroll back is deferred
a turn — the same reason Omarchy's Bluetooth panel defers
positionViewAtIndex.

The search hits and the new-message picker get the matching half: their
field already holds focus while the arrows walk the hits, so Up from the
first hit just scrolls the field back into view.
Four source-level checks in the existing "QML safety invariants" style:
every move function clamps and ends in scrollCursorIntoView() (a modulo
would bring the wrap back); rows register as cursorRow and threadIndex()
stays gone; startSearch/startNew scroll to the top while exitSearch,
exitNew and back() restore the cursor row; the window's catchNavKey yields
to a focused editor before touching the arrows. A shared qmlFunction()
helper slices a root-level function body up to its own closing brace, so
the checks do not depend on the order functions appear in the file.
`omarchy-shell shell toggle nixfred.blip` and the stock SUPER+CTRL+<n>
panel binds did nothing; the shell logged "summon: no live bar widget for:
nixfred.blip". Bar.findPanelWidget accepts a bar widget as a panel only
when its item has open(), close() and an `opened` property, and BarWidget
had the two functions but not the property.

One readonly property — the same line Omarchy's clock and weather widgets
carry. README gets the stock o.bind for it next to the SUPER+M snippet
and a line in the IPC list; ui.test.ts pins the property.

On a multi-monitor bar the panel lives on the first screen's copy
(`leader`); a follower's open() is a no-op, as before.
The ⇱ button said "Open the app window (SUPER+M)" and the security-code
toast said "Super+Shift+V types it". Neither key exists unless the user
has added the optional o.bind from the README, so on a stock install both
promise a chord that does nothing. Omarchy's own widgets (53 tooltips)
name no keys anywhere; the README keeps the bindings.
Double-click and the ⇱ button each closed the popout before calling
showApp(); IPC `app` — what the README's SUPER+M bind calls — did not, so
SUPER+CTRL+M then SUPER+M left the panel and the window up together. The
window is the same BlipView, larger, and both mark read while focused, so
two copies side by side is never the intent.

The close moves into showApp() itself and the two callers drop theirs.
ui.test.ts pins the order inside showApp() instead of in BlipView.
Inside a conversation the compose field holds focus, and the arrows only
moved its caret; reading older messages meant the mouse wheel.

Up/Down in an EMPTY compose field now select a bubble — Up from nothing
takes the newest, then older; Down past the newest drops the selection
and re-sticks the view to the bottom so new messages follow again. The
selected row is kept in view and drawn with the theme's hover-cursor fill
(Style.hoverFillFor, what Omarchy's own rows use): one Rectangle beside
`content` in the Flickable, positioned from the row's y/height, so no
delegate carries a background. Esc drops the selection first (back to
the bottom) and leaves the thread second, the two-step Esc a text
selection in a bubble already gets. A reload renumbers the rows, so
onBubblesChanged clears it. With text typed the arrows keep moving the
caret.

PgUp/PgDn page the conversation; Home/End (from an empty field) take the
oldest and newest bubble. The wheel handler and every key path share
scrollConversation(), the one place that clamps contentY and maintains
the bottom-stick — the flag that gates the deferred push reload.

The selection is an index into `bubbles` and the target for keyboard
actions (copy, open attachment, quote-reply); it adds no state to the
model. README rows, CHANGELOG, two invariant tests.
With a bubble selected and the compose field empty (and no file queued —
that Enter is a send and stays one): Enter opens the bubble's first
attachment, else its link card, else the first URL in its text, through
the same openAttachment()/openLink() a click reaches; Ctrl+C copies its
text through copyText(); Ctrl+R quotes it into the compose field as
"> …" and leaves the selection.

The quote is plain text on purpose: iMessage's inline reply is not
reachable through the bridge — no message GUID leaves the Mac and
Messages' AppleScript dictionary has no reply-to. README row, CHANGELOG,
one invariant test.
copyBubble(): the bubble's text wins; a bubble that is only a picture
copies its first image attachment as an image. That rides the existing
fetch queue — the same Mac round trip a click makes — whose boolean
"open when done" becomes an action string ("", "open", "copy"), and a
"copy" job ends in `wl-copy --type <mime> < <file>` with the path and type
passed as arguments, never interpolated into the shell script.
The note under the compose box was visible only while it had text, so
"copied" (and "sending…", and the attach confirmation) pushed the compose
box and the bubbles down for a second and a half and let them back up.
It now renders a single space when empty and keeps one caption line of
height. Colour follows meaning: progress and confirmations dim, failures
red — before, everything but "sending…" was red, "copied" included.
The arrows' empty-field rule left no way to walk the bubbles once a reply
was half-typed. Three answers, all keeping the caret's arrows intact:

Edge rule — Up leaves the draft for the bubbles from its first line, Down
from its last line while a bubble is selected. Omarchy's own lists (menu,
clipboard, emojis) get this for free from single-line fields; the compose
box is multi-line, so the caret rectangle decides. With a one-line reply,
the usual case, the first Up goes straight to the bubbles.

PgUp/PgDn — they move no caret, so they always work: PgUp selects the
topmost wholly visible bubble and, when the selection is already there,
pages so that row lands at the bottom edge (a screen with one row of
overlap); PgDn mirrors it, and past the newest leaves like Down. Only a
WHOLLY visible row counts as the edge — a sliver of the neighbour above
otherwise turned the next PgUp into a single step; a partly visible row is
the fallback for a picture taller than the view. On a Mac keyboard this is
Fn+Up/Down.

Shift+PgUp/PgDn — one bubble at a time from anywhere in a draft, for the
reader who does not want to move the caret first.

edgeVisibleBubble() scans the Repeater's items (at most 80); the reveal
that keeps the selected row in view is shared as revealBubbleCursor().
Home/End keep their empty-field rule. The actions still require an empty
field.
Up from the first row hands focus to the search field and keeps the cursor
on row 0 so Esc and Down return there — but the row went on drawing the
cursor fill under a focused text field, and a click in the field showed
the same. The two thread-row colour bindings now also require
cursorShown (!searchField.activeFocus); the cursor value is untouched.
A pin can live on a retired group row, and Messages merges a phone SMS
thread with an email iMessage thread under one conversation. Matching
only the live row dropped the pin and split the 1:1. Cluster those rows,
match pins against every id, and keep alias messages in the thread view.

Co-authored-by: Cursor <cursoragent@cursor.com>
Typing in a thread then switching to another lost whatever you had
written: openThread() hard-cleared the compose field. Now the text is
kept per chat id and restored when you return, and the panel and the
app window share the same drafts.

It stays in memory only — never written to disk — so it honours the
standing "message text never lands on disk" invariant; a draft survives
a thread or surface switch within the session but not a shell restart.

- BarWidget.draftCache: chat id -> unsent text, one map for both
  BlipViews under the host (the demo host mirrors it).
- composeField.onTextChanged writes every edit under the open chat.
  A send clears the field and with it the draft; leaving a thread nulls
  active before clearing, so the draft stays. Loading a draft fires the
  same handler and writes the same text back, which is harmless.
- openThread() loads the chat's draft instead of blanking, caret at end.

Three ui.test.ts invariants, one asserting drafts never touch disk; the
CLAUDE.md "no message content on disk" invariant names drafts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…irst cursor move

The pinned tile's avatar is a direct child of a ColumnLayout but sized
itself with a width: binding. A layout sizes its children from their
Layout hints, and with none set it took the item's width at first
measurement: 56, the clamp floor, because the tiles are built before
the grid has its width. Every relayout then forced 56 back; the
binding's real value, about 63 at the panel's width, only stuck when a
relayout followed a re-evaluation, which in practice was the first
arrow press on the list. Measured on the running panel: grid width
19 -> 320, avatar 56 -> 62.8 -> 56, grid height 166 until the first
cursor move, then 180.

Bind Layout.preferredWidth/Height instead. Same formula, same clamp;
layout and binding agree from the first frame (62.8 and grid height
180 before any key is pressed, measured the same way).

One ui.test.ts invariant; CHANGELOG under Unreleased.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eyboard and every link

Enter on a bubble whose text is a link sent it straight to the browser.
A keypress that opens an arbitrary URL with no chance to see where it
goes is the wrong default for a messaging client; the share sheet
already exists for exactly this — host, full URL, then Open / Copy /
Send as a deliberate choice — so Enter goes there instead. A picture
still opens directly (it is local).

The sheet was mouse-only, so it learns the keyboard: 1/2/3 pick, Up/Down
move the highlight, Enter takes it, Esc closes; hovering moves the same
highlight, so mouse and keys agree. Other keys fall through to the
compose field, so a sheet over a draft never blocks typing. A sheet that
opened by itself — a link you sent, a link that arrived while Blip was
open, IPC — behaves the same, the sheet being the warning, except for a
700 ms grace after it appears, when Enter and digits still belong to the
draft: a link landing in the same instant as the Enter meant to send
must not be opened by it.

A message with several links offers them all. openBubble() hands the
sheet every link of the message, in order, found with the same pattern
and trailing-punctuation rule linkify() uses for the anchors the mouse
clicks (www. gets https://), plus Apple's card URL when the text lacks
it. The same list arrives after a send (allUrls) and from the collector
(IncomingLink.urls beside url; readers of url are untouched), through
BarWidget and shareLink(), which now takes one URL or a list. ←/→ and
‹ › step through them, the header counts "1 of 2", host / URL / QR
follow, and the QR box keeps its place while the next code is made.

Found on the way: the sheet's TapHandlers took only a passive grab, so
a click on the scrim, the card or a button also reached whatever lay
under it — a thread row, a text selection, a link in a bubble.
gesturePolicy ReleaseWithinBounds takes an exclusive grab on press; the
tap now ends in the sheet. The compose field's Esc closes the sheet
before it drops a bubble selection or leaves the thread (the audit's
shareUrl check, on this branch too, so the rebase merges cleanly).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…number

The bridge treated two Contacts cards in ONE source sharing a number as
ambiguity and named nobody. That is right when the names differ (a married
name kept beside the old one), but "Mom ❤️" and "Mom❤️" are a duplicate of
the same card, and a heavily used conversation in the list was a bare
number with no photo. name_for now compares names within a source through
_name_key (NFKC, casefold, whitespace stripped) before calling a collision
ambiguous; the first spelling wins. _avatar_candidates follows the same rule
(CLAUDE.md: names and photos follow the one rule): every duplicate is a
photo candidate, oldest first, so the first with a picture wins.

Messages' "Filter Unknown Senders" files a stranger's SMS under a chat id
with "(filtered)" appended (`any;-;+1818…(filtered)`). isGroupChat() keeps
treating that shape as not-a-DM — nothing sends to it — but the list label
is now the number (prettyHandle), not the raw id.

`imsg attachment` on a row Messages in iCloud has not downloaded to the Mac
(filename NULL, transfer_state 0) said "no such visible attachment", which
sent people hunting for a Blip bug; it now says the attachment is not on the
Mac yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A send was ssh + osascript on the Mac, a fixed 1.5 s wait for Messages to
write the row, then a thread reload — "sending…" under an unchanged compose
field for about three seconds. Now send() draws the bubble at once
(pending: true, captioned "Sending…"), clears the field, and queues text
sends so a second message need not wait.

The in-flight sends live in pendingSends (BarWidget memory only) and ride
every thread reload on stdin (--pending-stdin; message text never on argv).
withPendingSends() in thread.ts keeps each bubble until a real from-me row
with the same text lands — one row resolves one send, a row older than the
send (minus clock skew) does not — so an early reload cannot make it blink.
Post-send reloads fire after 600 ms, retry quietly a few times, and no
longer flash "loading…". A failed send drops its bubble, restores the text
to the field unless something newer was typed, and says why. The read
watermark (--seen) skips pending bubbles: their ts is this machine's clock.

New invariant in CLAUDE.md ("Sends are optimistic"); ui.test.ts contracts
updated to the new send shape; twelve tests for the reconciliation rules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The tiles sit in a ColumnLayout, which sizes children from Layout hints; a
width: binding lost to the layout's first measurement, so they rendered small
until the next relayout (the first cursor move). preferredWidth/Height fixes
it at the first frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
…luster

A re-keyed group keeps its pin on a retired chat row's group_id, and Messages
folds a phone SMS row with an email iMessage row under one group_id while
keeping two chat_identifiers. Matching only the live row dropped the pin from
Favorites and split the 1:1 in two. pin_order_for now sees every id in the
cluster, _group_cluster_map folds merged DMs, and selectThread keeps alias DM
rows the way it already keeps group alias rows.

Mac side changed: imsg must be redeployed to ~/.blip/bin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
Text typed but not sent is kept per chat in BarWidget.draftCache and restored
on open, so switching threads no longer loses it; the panel and the app window
share one map. Memory only — back() and resetToList() null `active` before
clearing the field, so leaving a thread keeps the draft while a send, which
clears with `active` set, drops it.

Conflicts: CHANGELOG.md and ui.test.ts, both append-at-the-same-spot with nixfred#42.
Kept both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
…ches the window

j/k and the arrows walked the selection out of the viewport in the thread list,
the search hits and the new-message picker. Rows now register themselves as the
cursor row (one string compare per row instead of an O(n) threadIndex scan) and
every move scrolls it back into view, with the helper Omarchy's own multi-section
panels use. The cursor clamps at the ends instead of wrapping, Up from the top
hands focus to the search field, Down in an empty field hands it back, and the
app window gets Up/Down/Enter while no editor has focus.

Conflict: CHANGELOG.md, append-at-the-same-spot. Kept both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
Two fixes found against a real Mac.

Contacts: two cards in ONE source sharing a number were treated as ambiguity,
so "Mom ❤️" and "Mom❤️" — one card saved twice — cost a conversation its name
and photo. _name_key (NFKC, casefold, whitespace-stripped) decides sameness
within a source before a collision counts as ambiguous, and _avatar_candidates
follows the same rule. Two genuinely different names still resolve to nobody.
A "(filtered)" stranger's chat now shows its number, and an attachment Messages
in iCloud has not pulled to the Mac says so instead of "no such attachment".

Send: the bubble is drawn as Enter is pressed and the Mac round trip happens
behind it. In-flight sends live in memory and ride every thread reload on
stdin (--pending-stdin, never argv); withPendingSends() keeps each bubble
until a real from-me row with the same text lands, one row per send, and
never resolves against a row older than the send minus clock skew. A failure
drops the bubble and puts the text back. New invariant in CLAUDE.md.

Merges cleanly with nixfred#41's drafts: send clears the field with `active` set, so
the draft drops with the send.

Note: CI never ran on this branch (no checks reported). Verified locally —
380 bun tests, every Mac-side python suite, py_compile on imsg.

Conflict: CHANGELOG.md, append-at-the-same-spot. Kept both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
Bar.findPanelWidget (Bar.qml:509) skips any widget whose `opened` is
undefined, so `omarchy-shell shell toggle nixfred.blip` and every
SUPER+CTRL+<n> logged "summon: no live bar widget" and did nothing. One
readonly property, the line Omarchy's own clock and weather widgets carry.
Verified against the installed shell source, not the PR description.

showApp() now closes the popout itself, so double-click, the ⇱ button and IPC
`app` agree. The ⇱ tooltip and the security-code toast no longer name SUPER+M
or Super+Shift+V: both are bindings the README asks you to add yourself.

Conflict: main added `at: Date.now()` to the code toast while this shortened
its body — kept both. CHANGELOG.md and ui.test.ts were append-at-the-same-spot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
A bubble cursor inside a thread: Up/Down select a bubble (from an empty
compose field, or from the first/last line of a draft), Enter opens its
attachment, link card or first URL, Ctrl+C copies its text — or the picture
itself for a picture-only bubble — and Ctrl+R quotes it. PgUp/PgDn select the
topmost/bottommost visible bubble and page from there, Shift+PgUp/PgDn step
one at a time, Home/End reach the ends. Esc drops the selection before it
leaves the thread. scrollConversation() becomes the single writer of the
Flickable's contentY, so the bottom-stick that gates the deferred push reload
cannot drift. The status line keeps its height, and only failures are red.

Integration, resolved here rather than bounced back (branch base was 390df88,
six merges behind):

- showShareUrl() gets back the Astra A#8 QR-process kill the branch predates.
  It matters more with this PR than without: stepping between links re-runs
  qrencode, which is exactly the race that published one sheet's code under
  another's outFile.
- The post-send block keeps nixfred#43's optimistic-send structure and takes only
  nixfred#39's multi-URL share call; the field-clearing lines nixfred#39 still carried were
  superseded by nixfred#43, which clears at send time.
- nixfred#43's fetch-failure note read root.fetchJobOpen, which this PR replaces with
  the fetchJobAction string. Left alone it would have evaluated undefined and
  the note would have silently stopped firing; it now tests fetchJobAction !== ""
  ("" = auto-pull stays quiet, "open"/"copy" = the user asked).
- The Astra A#7 test pinned the old one-line Esc handler; updated to the
  three-way handler, share sheet still closing before back().

387 bun tests, every Mac-side python suite, qmlformat parse on all four QML
files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
A test wrote saveState() with an undefined directory, producing a literal
`undefined/` directory holding a state file, and 3c4e65d committed it. It has
shipped in the repo ever since. No current test recreates it (checked by
running the suite after deleting), so this is the artifact of a bug already
fixed, not a live one.

Deliberately NOT adding it to .gitignore: if a future test regains an
undefined path, `git status` showing the directory is the symptom that finds
it. Ignoring it would hide exactly the bug this file is evidence of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
IPC `goto ""` opened a nameless thread with no header, no name and nothing to
send to — the panel sat on a blank conversation until you pressed Esc. A
script with an unset variable is exactly how you get there, and `goto` is in
the README. show() now tests the id against the same shape guard the toast's
argv path already uses and returns false instead; the handler answers "not a
conversation id" rather than claiming "shown". An id that merely LOOKS like a
handle but is unknown still opens, which is deliberate — that is how you start
a conversation with a number you have never messaged.

The share sheet outlived the conversation it belonged to. It opens by itself
on an ARRIVING link, and neither openThread(), back() nor resetToList() closed
it, so it floated over the next conversation offering a QR for a link no
longer on screen — and since the host runs resetToList() on every open, a
sheet left up when the panel closed came back over the thread list. All three
now dismiss it. Esc still closes the sheet before it unwinds the view
(Astra A#7).

Verified live on the running shell, not just in tests: goto with empty,
blank, path-shaped, unicode and 5000-character input all answer "not a
conversation id" while a real group id and an unknown-but-plausible number
both still open; the sheet is gone after navigating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7

LR-T: ipc-goto-guard, share-sheet-lifetime, live-driving, blank-thread
LR-D: blip, qml, ipc, ui
LR-K: drive-it-dont-read-it, an-id-that-is-not-one, sheet-belongs-to-the-link
The shots in docs/img were rendered at 438f5c0, before nixfred#39 and nixfred#42 landed, so
the README showed a share sheet with only "Esc closes" (it now offers
"1-3 or ↑↓ Enter" for a message carrying several links) and pinned avatars at
a size the layout no longer produces.

Made the usual way: scripts/demo/blip-shots, shipping QML against the fake
bridge, invented people in the 555-0100 range. No real conversation is in
these.

Worth knowing, because it is visible in share-sheet.png: nixfred#42 shrank the pinned
avatars in the app window's narrow list column, from roughly the full cell
width to 56px. That is the sizing formula finally taking effect rather than a
regression — a `width:` binding inside a ColumnLayout never applied, so the
tiles had been rendering at the cell width instead of the intended 0.62 of it.
Whether 0.62 is the ratio you want at that column width is a look question,
and yours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7

LR-T: demo-harness, screenshots, pinned-avatar-size, docs-drift
LR-D: blip, docs, ui
LR-K: docs-show-a-ui-that-exists, layout-hints-beat-width-bindings
ensureWindow() defers the real `visible = true` to a Qt.callLater, because a
freshly created BlipWindow has to run its window.json restore pass first. The
handler toggled and then read root.windowVisible, which had not settled yet, so
the show path returned "window hidden" — and the hide path, being synchronous,
returned "window hidden" too. The string was therefore constant, and a script
polling it to decide whether the app was up got the wrong answer every time.

toggleWindow() now returns what it DID rather than what a property says a
moment later.

Found by driving the live IPC surface: `window` twice in a row answered
"window hidden" both times while `status` reported the window focused.
Verified after the fix across three toggles — every return value now matches
the `status` that follows it.

LR-T: ipc-window-toggle, stale-property-read, callLater, live-driving
LR-D: blip, qml, ipc
LR-K: report-what-you-did-not-what-a-property-says, quickshell-never-remaps
nixfred and others added 27 commits September 10, 2026 13:28
…ople

Fred reported a conversation titled 3734fc1a806e4557bbb8e8967... instead of the
people in it. nixfred#55 added exactly that fallback yesterday, and it was
unreachable.

`imsg chats` substitutes the chat identifier when a group has no display name,
so `name` comes back as the raw hex id rather than empty. Both naming sites in
mergeChats read `groupInfo?.name || c.name || <fallback>`, and a truthy id won
the chain every time - the participant fallback could never run for precisely
the groups it exists for. Pre-existing, not a regression: before nixfred#55 the same
chain ended in `|| c.id`, so these groups have always shown an id. nixfred#55 added
the fallback behind the value that shadows it.

namedGroup() treats a name that IS the chat id, or any of its aliases, as no
name at all. A re-keyed group's retired id is just as much not-a-name. Both
sites go through it, and the new-chat site now gets a real handle-to-name map
built from every participant the window already resolved, instead of the
`new Map()` it was passed - without that it could only ever have produced bare
phone numbers.

Verified against the live bridge, not just tests: the reported group now reads
"Craig Chapman & Keith Coughlin", and across the whole list 26 hex-id groups
now show a name with zero still showing a raw id.

The existing nixfred#55 test passed because it used name:null. The real Mac sends the
id. The added case covers that shape through both merge paths, through a
re-keyed alias, and asserts a genuine title still wins.

475 bun tests, every Mac-side suite, qmlformat and a clean tsc.

LR-T: group-names, mergeChats, namedGroup, imsg-chats, pr-55-followup
LR-D: blip, collector, groups
LR-K: a-name-that-is-the-id-is-not-a-name, a-fallback-behind-a-truthy-value-is-dead-code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
… store

Messages files stickers — Memoji, Genmoji, sticker packs — under
~/Library/Messages/StickerCache/, not Attachments/. The path guard in
`imsg attachment` knew Attachments and ~/.blip/sent only, so every
sticker anyone sent came back as "attachment path escapes the Messages
store": a true statement about the guard, a false one about the file.
On one Mac, 50 of 2413 attachment rows live there.

The guard admits StickerCache as a third root. Nothing else about it
changes: the path is resolved before the check, a sibling that shares
the name prefix does not match (commonpath), the size ceiling, the
O_NOFOLLOW open and the S_ISREG check on the descriptor all still apply.
The three-root check moves into _in_message_store() so a test can reach
it; the group-photo guard keeps its own Attachments-only check, since a
group photo never lives elsewhere.

Test: a file under each root is admitted; ~/Library/Messages/chat.db,
a StickerCache-evil sibling, a symlink planted in StickerCache that
points outside, and StickerCache/../chat.db are refused. Verified on
the Mac that had the failure: the sticker streams (26 KB JPEG), an
attachment row pointing into /var/folders is still refused.
The test preload pointed XDG_CACHE_HOME at a scratch dir but never
XDG_CONFIG_HOME, and contact-search.ts reads `country_code=` through it,
falling back to ~/.config. So the suite read whatever bridge.conf the
developer actually runs Blip with.

Proved it here rather than taking the description on trust: with
`country_code=47` in a scratch XDG_CONFIG_HOME, main fails 5 tier2 cases -
`(404) 555-0123` normalises to `+474045550123` - and with this PR merged it
fails none. Fred's own conf has no country_code, which is exactly why nobody
on this machine ever saw it.

The failure mode is the wrong way round for a test suite: CI has no
bridge.conf at all, so CI stays green while the suite breaks for any
contributor who has actually set Blip up, in a country other than this one.
Green where nobody uses it, red where they do.

476 bun tests, Mac-side suites and qmlformat green.

LR-T: pr-64, test-isolation, xdg-config-home, country-code, tier2
LR-D: blip, tests, contacts
LR-K: green-in-ci-because-ci-is-missing-the-config

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
…ers every ssh key

The README asked for Full Disk Access on /usr/libexec/sshd-keygen-wrapper
without saying what that actually grants. TCC keys the grant to the binary,
not to a key, so from that point on every ssh key that can open a shell on
that account can read chat.db - not just Blip's confined one.

That is worth stating at the moment the reader is being asked to do it,
rather than leaving them to infer it. Blip's own key still runs only the
bridge tools, and the note points at docs/SECURITY.md for pinning other keys
or closing port 22 a layer down, which is the mitigation nixfred#46 added.

Documentation only. 476 bun tests green.

LR-T: pr-65, readme, full-disk-access, tcc, security-docs
LR-D: blip, docs, security
LR-K: the-grant-is-per-binary-not-per-key

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
The checker nixfred#51 landed asked Hunspell for en_US and nothing else, so a draft
written in any other language came back underlined word by word - the feature
actively got in the way of anyone not writing English.

`spell=en_US,nb_NO` checks against several dictionaries at once and
`spell=off` turns the underlines off entirely. Names follow the dictionary
files that `hunspell -D` lists, and a name that is not installed is skipped
rather than failing the run. The default is unchanged, so nobody who was happy
notices anything.

Right shape for this project: another bridge.conf key, parsed not sourced,
rather than a settings surface. blip-setup's list of hand-set keys that must
survive a re-run gains it too.

477 bun tests, Mac-side suites and qmlformat green.

LR-T: pr-63, spellcheck, hunspell, bridge-conf, dictionaries, i18n
LR-D: blip, composer, config
LR-K: a-checker-that-only-knows-english-underlines-every-other-language

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
The thread list, pinned tiles, search hits and new-message hits each
highlighted with their own hard-coded Qt.rgba(foreground, 0.08), while the
conversation's bubble cursor already used Omarchy's hover-cursor colour and
alpha. Four copies of a constant that the theme is supposed to own.

They now all read root.hoverFill, which is Style.hoverFillFor(foreground,
accent), and the duplicate local binding on the bubble band goes away. Checked
after merging: zero hard-coded 0.08 fills remain in BlipView. Default themes
render identically; a theme that sets its own hover-cursor values now actually
gets them in the list, which was the point.

This is the follow-up nixfred#45 flagged when it landed the bubble cursor and noted
the list rows still hard-coded the value.

Conflict: CHANGELOG.md, this entry and nixfred#63's landing at the same spot. Kept
both.

476 bun tests, Mac-side suites and qmlformat green.

LR-T: pr-66, hover-fill, theme, omarchy-style, pr-45-followup
LR-D: blip, qml, theme
LR-K: four-copies-of-a-constant-the-theme-should-own

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
…light themes

Timestamps, previews and labels dimmed with Qt.darker(foreground, 1.45).
Darker is only dimmer when the foreground is light on a dark background; on a
light theme it drives the colour AWAY from the background, so secondary text
came out heavier than the messages it sits under. Measured on Catppuccin
Latte: 10:1 for the timestamps against 7:1 for the message text.

Now Qt.alpha(foreground, 0.66), which moves toward the background on any
theme, and matches what Omarchy's own placeholder text does. Dark themes
render the same; the composer placeholder stops carrying its own separate
Qt.darker constant too.

Conflict: CHANGELOG.md again, third entry landing in the same spot. Kept both.

The test pins it with a negative assertion - no Qt.darker on the foreground
anywhere - which is a strong invariant but worth knowing about if a future
change ever wants darker for something legitimately.

477 bun tests, Mac-side suites and qmlformat green.

LR-T: pr-67, theme, light-theme, contrast, secondary-text, qt-alpha
LR-D: blip, qml, theme
LR-K: darker-is-not-dimmer-on-a-light-theme

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
…Messages store

Messages files stickers - Memoji, Genmoji, sticker packs - under
~/Library/Messages/StickerCache/ rather than Attachments/, and the path guard
knew only the latter. Every sticker anyone sent came back as "attachment path
escapes the Messages store": true about the guard, false about the file, which
sent the reader hunting for a path problem that was not there. On the
reporter's Mac that is 50 of 2413 attachment rows, so roughly one picture in
fifty never showed.

STICKER_ROOT joins ATTACHMENTS_ROOT and SENT_ROOT, and the three-root test
moves into _in_message_store(path) so it can be tested directly.

I verified the guard independently rather than trusting the description,
because this widens what the bridge will read off disk. Built a fixture with
all four shapes: a file under StickerCache is admitted, while a
"StickerCache-evil" sibling that merely shares the name prefix, a symlink
inside StickerCache pointing outside, and a StickerCache/../chat.db escape are
all refused. realpath still runs before the check, the test is commonpath and
not startswith, and the size ceiling, the O_NOFOLLOW open and the S_ISREG
check on the descriptor all still run after it. The group-photo guard keeps
its own Attachments-only check, which is correct - a group photo never lives
elsewhere.

Conflict: CHANGELOG.md, fourth entry into the same spot. Kept both.

477 bun tests, all nine Mac-side suites including this PR's five new guard
cases, and qmlformat green.

LR-T: pr-68, stickers, attachment-path-guard, stickercache, path-traversal
LR-D: blip, bridge, mac, security
LR-K: true-about-the-guard-false-about-the-file

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
… built

WHAT: A 5,400-word contributor file crediting all 16 people who shaped Blip:
the 11 who merged the 51 PRs (+12,262/-1,173) and the 5 who filed issues that
changed the code. Each gets a section describing the specific technical work,
not a thank-you line.

WHY: "Thanks contributors" is a cop-out. These people found bugs a maintainer
cannot find from inside his own repo: SQLite sorting NULL last on DESC so an
empty group chat row overwrote the live one and broke 27 conversations
(@tolewis, 17 lines); a Family Sharing child's address book outvoting the
owner's own contact card two-to-one (@adamgamble); a last-ten-digit phone key
that is a whole national number in North America and nothing anywhere else,
leaving 99 of 386 Norwegian numbers nameless (@Fileri); EXIF orientation that
sips, Qt and imv each ignore differently (@adamgamble); glob()[0] picking a
dead Contacts source so setup reported a failure whose suggested fix did
nothing (@jethrojones).

OSINT: X handles matched ONLY from what each person published themselves —
GitHub's twitter_username field, or a link on their own website — then verified
as live accounts with matching real names via the `x` tool. 7 of 16 have one.
Nothing guessed; the 9 without are named as such with an invitation to send
one. Attaching a wrong handle in a public praise doc credits a stranger.

VERIFIED: PR count, author count and line totals recomputed from `gh pr list`
against the doc before commit (caught an off-by-one: 51 merged, not 50).

CONTEXT: Fred asked for a war room on this, modelled on the Infomarchy 1.4.0
release post. He is an infrastructure architect, not a coder, and wanted that
said plainly at the end along with credit to @dhh for Omarchy.

ENABLES: git log --grep='CONTRIBUTORS' finds who built what and why they are
credited; the file is the source for the X release post.
TAGS: blip, contributors, credit, osint, x-handles, community, release-post

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
WHAT: A 22,831-character plain-text cut of CONTRIBUTORS.md for posting to X.
All 16 contributors keep a section; the original 31,836-char file stays
untouched as the repo's contributor record.

WHY: The full file was 6,014 characters over Fred's paste limit. Cut 9,005
characters by dropping markdown (X renders none of it), the OSINT methodology
footer, and secondary detail — while keeping every person and every number that
carries the impact: 27 of 28 duplicate group rows resolving to an empty chat
(tolewis), 99 of 386 Norwegian numbers nameless (Fileri), 81ms to an optimistic
bubble (zachwilke), 7 of 16 Contacts sources opening while the checker claimed
failure (jethrojones), two sons outvoting a wife's contact card (adamgamble).

THE CATCH WORTH REMEMBERING: on X an @ is a live mention, so an @ in front of a
GitHub handle either tags a stranger who happens to own that name or is dead
text. Only 8 of 17 handles are verified X accounts. The other 9 are now bare
GitHub handles, with a closing note explaining that an @ means verified X and a
bare name means GitHub. The long CONTRIBUTORS.md keeps its @ prefixes because
GitHub resolves them correctly there — same text, different rendering context,
opposite correct answer.

ENABLES: git log --grep='X-POST' finds the release-post source and the
at-mention rule.
CONTEXT: Fred's war-room contributor credit for Blip, modelled on the
Infomarchy 1.4.0 post.
TAGS: blip, contributors, credit, x-post, at-mentions, osint, release

LR-T: blip, contributors, x-post, at-mention-hazard, credit
LR-D: blip, docs, community, osint
LR-K: an-at-sign-on-x-tags-whoever-owns-that-name, same-text-different-context-different-answer

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
… names

WHAT: Rebuilt CONTRIBUTORS-X-POST.md (24,800 chars, under the 25k limit) from
the commit author on every PR rather than from GitHub display names. 19 people:
11 merged contributors, 4 whose PRs have not landed, 4 issue reporters.

WHY IT WAS WRONG: Fred caught it before posting. The first version was built
from `gh pr list --state merged` and treated GitHub display names as real
names. Three concrete errors:
- "Nova" was published as a name. nova-centauri's commits are authored
  Steve Barrett <stevebarrett122@gmail.com>. A real person nearly got thanked
  by a display handle.
- jefehoser was written up as anonymous, "no name published". All five of his
  commits are authored Danny Cecil <github@dannycecil.net>.
- Four contributors were missing entirely because their PRs were CLOSED, not
  merged: Jordan Walsh (#3, jq single-quoted inside a single-quoted QML string
  killed BlipView.qml while a stale IPC handler still reported success),
  Luke Morrison (nixfred#4, new chat to an Android number fails error 22 then retries
  iMessage forever), John Washburn (nixfred#6, bar double-click fell through to
  Omarchy's gesture area), Steve Barrett (nixfred#5, notification hardening).

HANDLE VERIFICATION, two gates, both required: the person published the handle
themselves (GitHub twitter_username, GitHub social_accounts, or their own
site), AND the live X account's display name matches the commit-author name.
That surfaced two handles the first pass missed because it read only one field:
@lukejmorrison and @washburnello, both declared on GitHub. 10 handles tagged,
9 people named without a tag rather than tagged wrongly.

NEAR MISS WORTH RECORDING: searching X for his own projects surfaced
@kurtbuhler announcing FileBlade, which reads like the author. It is not Erik.
GitHub: Fileri/fileblade is fork=true, parent data-goblin/fileblade, owned by
Kurt of Belgium (X kurtbuhler). Erik forked it the day after. Tagging that
plausible-looking signal would have credited a stranger with 6,874 followers.

ENABLES: git log --grep='commit authors' finds the rule that a GitHub display
name is not a person's name and the commit object is the source of truth.
CONTEXT: Fred's contributor credit for Blip, modelled on the Infomarchy 1.4.0
post. He pushed back twice and was right both times.
TAGS: blip, contributors, credit, x-post, osint, commit-authors, verification

LR-T: blip, contributors, x-post, handle-verification, commit-authors
LR-D: blip, docs, community, osint
LR-K: a-display-name-is-not-a-persons-name, the-commit-object-is-the-source-of-truth, plausible-signal-tagged-a-stranger

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
…marchy/shell

WHAT: The recommended Hyprland SUPER+M bind launched the Blip app with
  qs -p /usr/share/omarchy/shell ipc call nixfred.blip app. It now uses
  omarchy-shell nixfred.blip app.
WHY: On a host running a dev-linked Omarchy shell (gus: quickshell -p
  ~/Projects/video.on.desktop.omarchy/omarchy/shell) the hardcoded path
  answers 'No running instances', so SUPER+M silently did nothing when no
  window existed. omarchy-shell finds the running shell whatever its path.
CONTEXT: Fred 2026-09-13 'SUPER M is not opening the app'. gus also had no
  bind at all (host config, fixed outside the repo). Avatars in the same
  report were a stale deploy + session state; deploy of main + shell restart
  brought all 9 pinned photos back (verified by window capture).

LR-T: blip, superm, keybind, hyprland, omarchy-shell, readme, dev-link
LR-D: blip, omarchy, hyprland
LR-K: superm-bind-hardcoded-shell-path, omarchy-shell-path-independent

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
Eight ssh calls in step 4 ran a remote command without -n, so each one
inherited the script's stdin and forwarded it to the Mac. With stdin from a
pipe or a redirect they drain it, and the later `read -r -p` hits EOF; under
`set -e` the script then dies at

    Press Enter to run the permission check:

with exit 1 -- AFTER the Mac install, the python3 probe and the dedicated-key
enrolment have already run. The setup is in fact complete at that point, which
is what makes the failure so confusing to read: it looks like the permission
check failed, and it never ran.

The first reachability probe on the same line already passed -n, so this is an
oversight rather than a deliberate asymmetry. None of the eight remote commands
read stdin (install.sh, blip-check and the python3 import probe were checked),
so -n costs nothing. `ssh -O exit` is left alone -- it runs no remote command.

Adds a source-level invariant test: every ssh invocation in the script that
runs a remote command carries -n, and the two prompts that would starve are
still present. With the fix reverted the test names all eight offenders.
Eleven Text elements in BlipView.qml declared no textFormat. Qt's default is
Text.AutoText, which sniffs the string and renders it as rich text when it
looks like markup -- so each of those is one upstream filter away from parsing
HTML out of a message, and a rich-text sink will fetch a remote <img> on its
own.

This is not a live vulnerability and is not claimed as one. Nothing reaches
those sinks with a "<" today: collector.firstUrl/allUrls and their QML mirrors
all exclude <>"', and the only unfiltered entry is the IPC share path, which is
gated behind automation=on and sits inside the same-user boundary
docs/SECURITY.md already accepts. The point is the sink, not the filter: 45 of
the 46 sinks that DO declare a format already say PlainText (the one RichText
is the deliberate linkified bubble), so these eleven were the unfinished edge
of a rule the file otherwise follows everywhere.

Two of them render text that arrived in a message -- root.shareUrl and
root.linkHost(root.shareUrl) in the share sheet -- which is what makes this
worth closing by construction rather than by the upstream regexes staying
correct forever.

Adds a UI invariant test that walks every .qml file, finds each Text/TextEdit
block and asserts it declares its own textFormat, so a new sink cannot pick up
AutoText silently. With the eleven reverted the test names all eleven.
The code-copy Process passed the digits to `sh -c 'printf %s "$VAR" | wl-copy'`
through Process.environment. That was already a deliberate step away from argv,
but it trades one /proc exposure for a longer-lived one: wl-copy does not exit
after a copy, it stays resident to serve the selection, so the variable remains
readable in /proc/<wl-copy>/environ for as long as the clipboard holds the code.

Measured on Omarchy with a dummy value: after the copy, wl-copy is still alive
and its environ still carries the value. That outlives both things the
surrounding code is careful about -- the toast that offered the code, and the
five-minute window copyCode() enforces before it refuses an older toast's code.
noteCode() already keeps the digits out of the notification body precisely so
they are not persisted; this is the same concern one process along.

The mode is owner-only (0400), so this is the same-user boundary
docs/SECURITY.md already accepts, not a new hole. It is the LIFETIME that is
wrong: a value meant to live five minutes in memory sat in a long-running
process's environment instead.

wl-copy is now exec'd directly and the code is written to its stdin, which is
closed immediately after -- the stdinEnabled/write/close pattern this codebase
already uses for qrProc, threadProc, previewProc and the contact-details
worker. It also drops the shell hop. Verified byte-identical on the clipboard,
with no trailing newline either way, and no wl-copy process carrying the value.

Adds a test asserting the copy Process declares no environment, runs wl-copy
directly, and closes stdin after one write.
The README covers install and update but never removal, so the only way to find
what Blip put on disk was to read blip-setup. That matters more than usual
here: the install touches ~/bin, two ssh files, three XDG directories and the
Mac's authorized_keys, and one of the caches holds real media.

Every path is taken from the installer rather than from memory -- four shims,
not three (imsg-read is easy to miss), the .pre-blip.<epoch> backups that
blip-setup leaves when it displaces an existing tool, and the ~/.cache/blip
subdirectories as they actually exist (att, avatars, linkpreview).

Says plainly what is NOT removed and why: the ControlMaster block in
~/.ssh/config is only appended when the host had none and is harmless to keep,
and the Mac's TCC grants belong to sshd-keygen-wrapper rather than to Blip, so
revoking them affects everything else reached over ssh. Those are steps a user
should take deliberately, not as a side effect of uninstalling.

Verified against this install on both machines: every documented path exists as
described, all four shims are blip-shim, and on the Mac exactly one
authorized_keys line of eight matches blip-dispatch -- so the grep removes that
line and leaves the everyday key alone.
…gh the environment

copycode handed the digits to sh -c 'printf %s "$BLIP_CODE" | wl-copy' through
Process.environment. wl-copy stays resident to serve the selection, so the code
sat readable in /proc/<wl-copy>/environ for as long as the clipboard held it,
past the five minutes copyCode() enforces and past the toast that offered it.
Owner-only (0400), so the same-user boundary docs/SECURITY.md already accepts;
the LIFETIME was the bug. wl-copy is now exec'd directly and the code is written
to stdin, closed after one write (the ContactDetails.qml onStarted pattern).

Verified independently on gus with dummy values: the env path left BLIP_CODE in
the resident wl-copy's environ; the stdin path left it in none. 478 bun tests
green on the branch. CLAUDE.md invariant updated in the PR.

LR-T: pr-73, security-code, wl-copy, proc-environ, clipboard, copycode
LR-D: blip, barwidget, security
LR-K: the-lifetime-was-the-bug

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4334ZpEBLWvazTdqvhm8J
…l back to AutoText

Eleven Text elements in BlipView.qml declared no textFormat, so Qt used
AutoText: any string that looks like markup renders as rich text, and a
rich-text sink fetches a remote <img> on its own. Not a live hole - the URL
regexes exclude <>"' and the IPC share path sits behind automation=on - but
45 of the 46 declared sinks already said PlainText. Now all do, and a UI test
walks every .qml and fails on any Text/TextEdit without its own textFormat.

Verified: main's BlipView.qml under the new test fails naming exactly the
eleven lines; the PR version passes. Merged on top of nixfred#73 cleanly. 479 bun
tests and qmlformat green. CHANGELOG entry added in the merge (the PR had none).

LR-T: pr-72, textformat, plaintext, autotext, rich-text, qml-invariant
LR-D: blip, qml, security
LR-K: safe-by-declaration-not-by-filter

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PeUtMhaSaA9aiazebBUMS
… nesting

The nixfred#72 test stripped ONE level of nested blocks (/\{[^{}]*\}/) before looking
for textFormat, so a child with its own nested block kept its properties in
the parent's text: Text { Rectangle { textFormat: ...; Item { } } } passed
while the Text itself declared nothing. Collect only characters at depth 1
during the brace walk instead, so no child at any depth can speak for it.

Verified: a fixture of exactly that shape passes the old test and fails the
new one; main's pre-nixfred#72 BlipView.qml still fails naming all eleven lines;
479 bun tests green. Test-only, no runtime change, no deploy.

LR-T: pr-72-followup, textformat, ui-test, brace-depth, false-negative
LR-D: blip, tests, qml
LR-K: a-child-must-not-speak-for-its-parent

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PeUtMhaSaA9aiazebBUMS
…behind

ianswope's new "Removing Blip" section: plugin, the four ~/bin shims and
their .pre-blip.<epoch> backups, ~/.config ~/.local/state ~/.cache blip dirs,
the dedicated ssh key, and on the Mac ~/.blip plus the confined
authorized_keys line (grep -v fails safe if it is the only line). The
~/.ssh/config Host block and the sshd-keygen-wrapper TCC grants stay optional.

Verified every path against scripts/blip-setup, bridge/mac/install.sh,
collector.ts, linkpreview.ts and a live install on gus. Added in the merge:
step 6 now names Accessibility (granted when mark-read is enabled), a
CHANGELOG entry, and CLAUDE.md's shape diagram lists imsg-read among the shims
(the contributor flagged that omission). 479 bun tests green.

LR-T: pr-74, readme, uninstall, removal, tcc, accessibility, imsg-read
LR-D: blip, docs
LR-K: document-what-uninstall-leaves-behind

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GShQoaqmuXnWgMLn1SzAFL
…ot hit EOF

Eight ssh calls in step 4 ran a remote command without -n, inherited the
wizard's stdin and forwarded it to the Mac. With stdin from a pipe or redirect
they drained it, the later read -r -p hit EOF and set -e exited 1 at "Press
Enter to run the permission check" - AFTER install.sh, the python3 probe and
the key enrolment had succeeded, so a working setup looked failed and invited
a pointless re-enrol. Same mechanism as the shim preflight invariant in
CLAUDE.md (ssh -n), reaching the last place it had not been applied.

Verified independently: a stub ssh that drains stdin unless given -n makes a
set -e script exit 1 before its prompt, and reach it with -n. install.sh and
blip-check read no stdin (grepped), so -n costs nothing; ssh -O exit runs no
remote command and is correctly exempt. The new source-level test run against
main's blip-setup names exactly the eight lines. Merged over nixfred#72/nixfred#73/nixfred#74
without conflict; 481 bun tests and bash -n green. Script-only, nothing to
deploy to the plugin dir. CHANGELOG entry added in the merge.

LR-T: pr-71, blip-setup, ssh-n, stdin, eof, set-e, read-prompt, wizard
LR-D: blip, setup, bridge
LR-K: ssh-eats-stdin-again

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PeUtMhaSaA9aiazebBUMS
… entry

Release audit before 2.5.0, same method as 2.4.0: every merge since v2.4.0
checked against Unreleased. Missing and now written: nixfred#73 (security code on
wl-copy stdin), nixfred#50 (failed send keeps its bubble), nixfred#51 (accessible composer,
spelling, arrow keys), nixfred#52 (resizable panel + compact header), nixfred#53 (Ctrl+1-9
pins, back arrow), nixfred#55 + 2197e0d (unnamed groups named after people,
composite avatar), nixfred#59/nixfred#60/nixfred#61 (contact scans, read-only card view, vCard
copy/save), nixfred#48 (window restores on its workspace), nixfred#36 (no uninvited consent
prompt from blip-check). Attributions checked against gh PR authors: nixfred#48 is
@jefehoser; nixfred#36 reported by @jacobaross. Skipped as not user-facing: nixfred#64
(test isolation), nixfred#65 (README line), CONTRIBUTORS docs.

No version bump yet: blip-b2 is landing the avatar-cache fix and nixfred#62 for
Fred, and 2.5.0 waits for those.

LR-T: changelog, release-audit, 2.5.0, attribution, backfill
LR-D: blip, docs, release
LR-K: audit-the-release-not-assume-it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PeUtMhaSaA9aiazebBUMS
…atch

WHAT: The photo map moves from BlipView to BarWidget.avatarCache (file://
paths only, never bytes), the same way draftCache already lives on the host.
avatar.ts gains cachedAvatar() (the disk's answer or null) and --batch:
handles one per line on stdin, one JSON line out per handle, disk hits first
and the Mac only for misses, and once the Mac proves unreachable the rest are
answered unreachable instead of each sitting out a 20 s timeout. --retry now
trusts a .none marker for AVATAR_RETRY_MS (15 min) instead of skipping it.
BlipView streams the batch through a SplitParser, gathers one frame of row
requests per batch with Qt.callLater, and turns anything a crashed run never
answered into letters that the next open retries.

WHY: Fred, 2026-09-13: "contacts photos ... seem to load each time on the
desktop app SUPER-M". The window is rebuilt on every show (a Quickshell
FloatingWindow never re-maps), so its map started empty and every row spawned
its own bun avatar.ts, serially, ~14 ms each. Worse, --retry ignored the
negative marker outright, so all 251 photoless contacts of 318 cached on gus
went back to the Mac, ~171 ms of ssh each, on every open: about 45 s of
pictures trickling in. Measured: all 251 markers rewritten within the hour.

VERIFIED: bun test 483 pass / 0 fail, including new cases for the retry
window, batch dedupe and disk-before-Mac order, and the stop-after-unreachable
path. A live batch over the sidebar's handles answered in 23 ms, no ssh.

ENABLES: git log --grep='avatarCache' or --grep='--batch' finds this.
TAGS: blip, avatar, contact-photos, cache, super-m, window-recreated, retry,
negative-marker, batch, splitparser, performance

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sending a URL popped the QR / copy / LocalSend sheet over the conversation
after every successful send, which interrupted the thread you were typing in.
The post-send openShare call is gone; right-click on any link, a selected
link's keys, and the auto sheet for an INCOMING link all still open it.

This reverses a 2.3.0 choice ("send it, then offer the QR / LocalSend / copy
for the same link"). Taken on Fred's 2026-09-13 go-ahead to merge it: a sheet
you did not ask for, over your own message, is the wrong default, and every
path that asks for the sheet explicitly is untouched.

Verified: trial merge onto current main (4bad82d) applied cleanly; bun test
484 pass / 0 fail with the merged tree. README caption updated in the PR.

LR-T: pr-54, share-sheet, send-link, openShare, localsend, qr
LR-D: blip, blipview, ux

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mouse wheel

WHAT: Unreleased entry for the compose-box wheel scroll that rode along in
4bad82d (the contact-photo commit swept the working tree, so its message
never mentioned it). MouseArea.onWheel on composeSlot, direct 1:1 like the
conversation, NoButton so clicks and selection reach the field, and a draft
that fits passes the wheel on.

WHY: nixfred#62 (apexbenny, on 2.3.2) saw only five lines of a long draft. The
typing-blind half shipped in 2.4.0 (ba1d8f9, composeFlick follows the caret);
the lines above the caret were still reachable only by arrow keys.

VERIFIED: bun test 484 pass / 0 fail with the new wheel test; deployed to gus,
shell log clean, status healthy.

TAGS: blip, issue-62, composer, compose-box, wheel-scroll, changelog, 2.5.0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4334ZpEBLWvazTdqvhm8J
Sixty-plus commits since 2.4.0 from six contributors. Headline changes:
contact photos from one cached batch instead of trickling in on every window
open, a failed send keeps its bubble and says why, a resizable panel with a
compact header, Ctrl+1-9 for pins, unnamed groups named after their people
with a composite avatar, contact review with vCard copy/save, and a sweep of
security fixes (security code off /proc, PlainText sinks, key pinned over
Tailscale, no uninvited consent prompt).

Release housekeeping:
- CHANGELOG audited against every merge since v2.4.0 (0549c58 backfilled
  eleven missing entries); nixfred#54 was the last one without an entry, added here.
- manifest, README badge and CHANGELOG heading move together to 2.5.0;
  ui.test.ts pins all three to each other.
- docs/img NOT regenerated: the Test Drive VM's display is smaller than the
  demo crop and shows Omarchy's first-run hint, so those shots were rejected
  on pixel check. The 2.4.0 images stay; only their header version is stale.

bun test and qmlformat green.

LR-T: release, 2.5.0, version-bump, changelog, manifest, badge
LR-D: blip, release
LR-K: photos-at-once-sends-that-stay-put

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PeUtMhaSaA9aiazebBUMS
Stamps arrived as the Mac's naive wall clock ("2026-09-07 14:33:12") and
were compared against the Linux clock — the same string only while both
machines sat in one timezone. A Mac an hour ahead put every read mark ahead
of every message, so nothing ever counted as unread; an hour behind and the
backlog re-toasted. And once a year, in the DST fall-back hour, the Mac's
own clock repeats: two messages an hour apart carried the SAME stamp, so
ordering, the watermark and "newer than the mark" stopped meaning anything
for that hour. Every `a.ts < b.ts` in the collector was already assuming
lexical order equals chronological order; only UTC actually provides it.

imsg's JSON now emits ISO-8601 UTC (fmt_ts); the plain-text renders keep the
Mac's clock (fmt_ts_local), because a person reading `imsg recent` wants the
time they remember. On the Linux side localNowTs() became nowTs() in the same
format, and every label converts to the READER's zone at render: localDay()
and formatStamp() in thread.ts, stampMs()/localDay()/fmtTime() in BlipView.
Day dividers no longer slice the date out of the string — that is UTC's day,
and the divider belongs at your midnight.

Upgrading is covered from both ends, and both halves are load-bearing
together: loadState() migrates the marks a pre-UTC release wrote, and stamps
are normalised at the two fetch doors so a Mac still on the old bridge keeps
working. Migrating the marks alone would have sorted every message below
every mark (" " < "T") and silently emptied the badge and the toasts.

The suite runs pinned to TZ=UTC, where all of this is invisible; the new
timezone.test.ts and bridge/mac/test_wire_time.py set their own zones and
are what actually cover it. Verified live against the gateway Mac on both
the old and the new bridge: 298 threads, unread unchanged, no toast storm
on migration, and 23:47:54Z rendering as 6:47 PM in CDT.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xxS9WCT7ifHhbDjYyGnHS

Co-authored-by: zachwilke <zachwilke@users.noreply.github.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-14T14:24:35.823156Z 8da602a PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@zachwilke zachwilke closed this Sep 14, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8da602a716

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread test-setup.ts
// Labels are rendered in LOCAL time from UTC wire stamps, so an unpinned TZ
// would make every clock/day assertion depend on where the test ran. Pin it;
// the tests that are ABOUT the conversion set their own zone explicitly.
process.env.TZ ||= "UTC";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Override an existing TZ when pinning the suite

When the test runner already exports a non-UTC timezone, ||= preserves it instead of performing the promised pin. I verified that TZ=America/New_York bun test produces 28 failures in timestamp formatting, decoration, and pending-send tests, so developers and CI environments with TZ set cannot satisfy the required green suite; assign UTC unconditionally and let the dedicated timezone tests temporarily override it.

AGENTS.md reference: AGENTS.md:L17-L19

Useful? React with 👍 / 👎.

Comment thread BlipView.qml
Comment on lines +1395 to +1398
function stampMs(ts) {
var t = String(ts || "")
if (t === "") return NaN
return Date.parse(t.indexOf("T") >= 0 ? t : t.replace(" ", "T"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move timestamp parsing out of QML

The optimistic-send path now implements timestamp parsing and local-day/grouping decisions in QML while the tests exercise only the separate thread.ts implementation. This creates two sources of truth for the same pending-bubble behavior, so a parser or timezone fix can make the instant bubble disagree with the bubble rendered after reload; put this logic in a tested TypeScript/module helper and leave QML to consume its result.

AGENTS.md reference: AGENTS.md:L17-L19

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants