Each widget in the Widgets panel (and the open widget's header) gets an
"Open in a new window" button, so a pad or board can sit beside the chat.
It opens the same filled-in URL the frame uses (widgetCompleteUrl, now
shared) with noopener,noreferrer, standalone as in Element. Only
cross-origin https URLs get the button: room state can carry anything,
e.g. a script URL from another client.
Refs #205
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Long-press opens the message action sheet (#166), but on iOS the system
takes a long-press for itself: the link/image callout or the text-selection
loupe races the 450 ms press. Message rows on touch screens now carry
data-touch-actions (no -webkit-touch-callout), and the pressed row carries
data-pressing from touch-down until the finger lifts, which turns text
selection off for that moment only. Copy Text stays in the sheet.
Refs #199
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
#279: Room Settings → Soundboard said clips are "inherited by every room
under this space" for plain rooms too; the wording now follows
room.isSpaceRoom(). The Push to Deafen description only mentions Escape
while rebinding. "can pin a messages" → "can pin a message".
#280: the tracking stripper removed Reddit's context=, which is what
makes a comment permalink show its parent comments. Only share_id and
rdt are stripped now.
Fixes#279Fixes#280
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Production Synapse caps delayed events at 24h, but the dialog let any
later time be picked and then showed the raw server error. The date
input now has min/max (today .. now+24h), a too-late time shows "You can
schedule up to 24 hours ahead." as soon as it is picked (and blocks
Schedule), and ORG.MATRIX.MSC4140_DELAY_TOO_LARGE maps to the same text.
Fixes#275
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
#269 read-receipt pill sits at the message row's right edge, not where
the text ends (outside the shrink-to-fit content column; grows in
Compact). Bubble layout unchanged.
#270 Most Active Members: the bar's % is of its own track, so every row
shows its count.
#271 Noise Suppression description at T200 like the rest.
#272 decoration preview shows your own avatar under the decoration; the
empty state is a visible dashed circle.
#273 grouped settings get a divider between tiles (SettingTileGroup, via
globalStyle; SettingTile roots carry data-setting-tile), and on phones
the Sync tile's buttons sit under its description.
Fixes#269Fixes#270Fixes#271Fixes#272Fixes#273
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
#266: remembered widget approvals (lotus.widgetConsent.*) survived logout,
so they applied silently to the next account on the device and listed the
previous account's rooms; clearPlaintextCaches now sweeps them. Space
structure, call membership and Lotus room-policy state join
PROTECTED_STATE_TYPES, so no widget can be granted them.
#267: any website could auto-submit a form to /share-target and land the
user in the share picker with attacker-chosen text and files. The worker
now refuses a share whose Origin is another site. (Sec-Fetch-Site, as the
issue suggested, never reaches a service worker: checked in Chromium.)
Origin null/absent, as the OS share sheet sends, stays accepted, and
/share now says the content came from another app or website. Both logout
paths await clearSharedPayload(); the server-forced one also clears the
soundboard clip cache, as #57 intended.
Fixes#266Fixes#267
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Members and bookmarks were 266px, the gallery 320, the threads list 340,
a thread and widgets 360. Swapping panels resized the room view, so the
header icons slid sideways (74px for members -> threads) and a second
click on the same spot opened Widgets instead of closing the panel. All
six now use SIDE_PANEL_WIDTH (340px, styles/sidePanel.ts).
Fixes#263
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Forwarding from an encrypted room into an unencrypted one posts the text
(and a re-uploaded plaintext attachment) where the server can read it,
with the original author attached, and the dialog said nothing. Show a
note above Send naming the unencrypted destination(s).
Refs #330
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
The tray only knew what this browser had scheduled (localStorage), but
Synapse lists every pending one via MSC4140 GET /delayed_events. Load it
when the tray mounts and on window focus (localStorage stays as a first-
paint cache), keeping only non-state m.room.message events. Logout now
says "N scheduled messages still send after you log out" and offers to
cancel them.
Fixes#322
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Two bugs in the one-row "+N" collapse:
- measure() used the border box, which grows by 4 px while collapsed
(margin -2 / padding 2), so a row within 4 px of fitting flipped
collapsed↔expanded every frame (flicker, layout shifts, ~11 % of a core
per looping message). It now measures the content box.
- The collapsed style used the `margin` shorthand, which overrode the
timeline's marginTop and, when removed, cleared it; the row then stuck
to the message text. Longhand margins keep marginTop (minus 2 px).
Verified headless (12 reactions, viewport sweep 1300–1400 px): before,
90 style flips in 1.5 s at 1344 px and margin-top 0px after a cycle;
now 0 flips at every width and margin-top stays 8px.
Fixes#305
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
handleReject related the MSC4310 decline to the caller's call.member event
(the notification's reference), but MSC4310 and Element Call match a
decline against the rtc.notification event itself, so the caller kept
"Calling…" for the full 90 s after being told "Declined", and declines
from other clients (correctly referencing the notification) didn't stop
Lotus ringing on other devices.
- The incoming call keeps the notification's own event id; Reject relates
the decline to it.
- The "declined on another device" dismissal and declinedRing accept
either id (older Lotus builds referenced the membership).
- The missed-call line is skipped for a call we declined.
Verified headless (DM, Bob rings, Reject): the decline used to reference
the membership; it now references the notification. Unit test added.
Fixes#313
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
DeliveryStatus showed whatever mEvent.status was at the last timeline
re-render, and a local echo's status changes (SENDING → SENT → confirmed,
NOT_SENT → retried) only emit event-level events, so the last message in
a quiet room kept "Sending..." (or a stale "Failed to send. Click to
retry" after an outbox retry) indefinitely. It now re-renders on
MatrixEventEvent.Status / LocalEventIdReplaced.
Not browser-verified in this change (checks: tsc, unit tests, eslint).
Fixes#306
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
- #319: logout wiped the drafts, then reloaded, and the reload's pagehide
made RoomInput flush the open composer's (decrypted) draft straight back
to localStorage. clearPlaintextCaches now sets a module flag and
persistDraft stops writing once it's set (resets with the new page).
- #296: the device-wide encrypted-search opt-in (searchCacheEnabled)
survived logout, so the next account started indexing decrypted text
without opting in. It's now cleared, along with the SDK's per-user
filter key.
Verified headless with a throwaway account (search opt-in on, "SECRET
half-typed reply" in the composer, Settings → Log out): before, the draft
and searchCacheEnabled=true remained; now neither does. Unit test added.
Fixes#319Fixes#296
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Several dialogs are <Box as="form"> with the header ✕ (folds IconButton,
no type attribute, so a submit button) as the form's first button. On
implicit submission, Enter in a text field "clicked" ✕, closing the dialog
and throwing away what was typed (a poll's question and answers, a
schedule, a report reason).
The header close/cancel IconButtons now have type="button": Create Poll,
Schedule Message, Report User, Report Room, Account Data editor, State
Event editor, device verification setup, and the message delete/report
dialogs.
Verified headless: in Create Poll, typing a question and pressing Enter
used to close the dialog; now it stays open with the question kept.
Fixes#315
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
The media gallery and activity log register filtered timeline sets on the
room (getOrCreateFilteredTimelineSet); the room re-emits their events as
live RoomEvent.Timeline, so every matching event reached "new event in
the room" listeners twice for the rest of the session. The timeline
window shifted by an extra message per event (loaded messages vanished),
and notifications, unread counts, widget feeds and ring handling saw
duplicates.
isFromFilteredTimelineSet() (unit-tested) identifies emits from a filtered
set; the room timeline, roomToUnread, message notifications, clock-skew
sampling, call ring/decline handling and widget event feeds ignore them.
Verified headless (40 messages, every 5th a file; gallery opened; Bob
sends 3 files + 1 text): before, the first rendered message drifted from
the room start to "file15"; now it stays put.
Fixes#259
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
m.room.retention can be set to anything by anyone with state power. A
fat-fingered `max_lifetime: 1000` blanked the timeline for every Lotus
user and, for members with "Enforce Message Retention" on, the sweeper
redacted all their messages within a minute. The RETENTION_MIN_MS floor
existed but was never applied.
- effectiveRetentionMs(): anything under 10 min counts as off; used by
getRoomRetentionMs (sweeper) and the timeline's hiding.
- The sweeper never deletes under a policy less than 24 h old, so a
mistake or a malicious change can be reverted before anything is lost.
(Local hiding of expired messages still applies at once.)
Verified headless (the issue's repro, enforcement on): after
max_lifetime 1000, all 4 messages stay visible and none are redacted
after 45 s (before: timeline blank, all 4 redacted). Unit tests for the
floor and the grace window.
Fixes#298
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Follow-up to #359: the personal room name now also shows in message-search
result groups and the search room filter (whose room search matches it
too), Inbox → Notifications, the threads list and the thread panel header.
Verified headless with general renamed to "Zebra Lounge": a global message
search and Inbox → Notifications both show "Zebra Lounge".
Fixes#336
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Joining a voice channel alone sends a call notification (m.rtc.notification)
to the room, and the timeline rendered every unanswered one as "Missed call
from X", so everyone in the channel saw a missed call from someone who had
just joined it. Ringing already excluded these rooms; the missed-call line
didn't.
utils/callRing.ts (unit-tested) holds the one rule for both: only DMs and
invite-only groups outside any space ring or show "Missed call"; voice
rooms, space channels, restricted and public rooms don't.
Verified headless with the same two notifications from Bob: the Voice
Lounge chat showed 2 "Missed call" lines before, 0 after; a small
invite-only group still shows its missed call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
The personal room name (io.lotus.room_names) was only used by the
sidebar, header, intro and call bar; the quick switcher couldn't find a
room by it ("No match found") and showed the server name, as did the
forward/share pickers, toasts, OS notifications and reminders.
useRoomMeta gains useLocalRoomNames() / getLocalRoomName(); the quick
switcher matches both names and shows the personal one, the forward and
share pickers show and filter by it, and message/reminder notifications
use it for the room title.
Verified headless with general renamed to "Zebra Lounge": Ctrl+K "Zebra"
and "general" both list "Zebra Lounge"; the forward picker filtered by
"zebra" lists "Zebra Lounge".
Not covered here (follow-up): message-search result headers, the inbox
notifications page and the threads list still use the server name.
Refs #336
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
The composer's location button sent the exact position (6 decimals) from a
single click once the browser permission had been granted, discarding the
fix's accuracy, so a ±3.5 km Wi-Fi/IP fix was shown as exact.
- ShareLocationDialog: "Share your location?" with the coordinates and
±accuracy, a "Check on OpenStreetMap" link, and Cancel / Send
approximate (~1 km) / Send. Focus starts on Cancel; Esc cancels.
- utils/geoShare.ts (unit-tested): RFC 5870 geo URI with `;u=` accuracy;
approximate shares round to 2 decimals and never claim better than 1 km.
- Location cards show the ± when the event carries `u=`.
- The toolbar icon is a pin instead of the globe.
Verified headless (fix 40.748817,-73.985428 ±3500 m): the click opens the
dialog and sends nothing; Cancel/Esc send nothing; Send approximate →
geo:40.75,-73.99;u=3500; Send → geo:40.748817,-73.985428;u=3500; the card
reads "40.74882, -73.98543 (±3.5 km)".
Fixes#340
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Until now a send that failed (offline, homeserver down, a blip) went
straight to "Failed to send": nothing retried it, and a reload dropped it
without trace (chronological pending ordering keeps local echoes in memory
only).
- Outbox (utils/outbox.ts + features/outbox/OutboxFeature): own message
sends (text, stickers, reactions, polls; not call signalling or
redactions) are mirrored to localStorage from their first local echo until
the server confirms them or the user cancels.
- After a reload they come back as local echoes via room.addPendingEvent,
same shape as the SDK's own. Recent ones (< 1 h) are sent again with the
same txnId; older ones come back as "Failed to send" for the user to
retry or cancel. Ones the server already has (transaction id seen in
/sync) are dropped, so no duplicates.
- Retries: network failures (ConnectionError, 408/429/5xx) are re-sent when
the connection returns (sync recovers or the browser goes back online),
and after a blip while online (5 s, backing off, max 10 per message).
Oldest first, in order per room. 4xx / consent / encryption failures are
left to the user.
- UI: a network failure while offline shows a clock, "Queued. Will send
when you're back online" (thread view too), not the red ✕. The ✕ is now
a button: click to retry.
- Logout wipes the outbox with the other plaintext caches (the content is
decrypted, like drafts).
Tested end to end against a local Synapse (Chromium): offline → queued →
sent once on reconnect; homeserver unreachable → queued → sent once; failed
send → reload → sent once and shown once; server accepted but response lost
→ reload → no duplicate; 2 h old entry → failed, not sent, click ✕ → sent;
cancel → gone after reload; encrypted room → restored message goes out as
m.room.encrypted with no plaintext and decrypts; one-off failure retried by
itself in ~5 s; no page errors. Unit tests for the pure parts; Playwright
20 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Lotus permalinks (#130) were built from window.location.origin. In the
desktop app that's the local tauri-plugin-localhost server (hash-routed), so
"Copy Lotus Link" copied e.g. http://localhost:…/#/home/!room…, which works
for nobody else. And the link recogniser only knew that local base, so a real
https://chat.lotusguild.org/home/… link in a message opened the browser
instead of the room.
- useLotusShareBase: in the desktop app, links for other people use config
`webAppUrl` (https only, set by cinny-desktop #23) in web path-routing
form; otherwise the origin, as before. Used by "Copy Lotus Link" on
messages, the space menu and space tabs.
- The recogniser accepts several bases: the origin, plus `webAppUrl` in the
desktop app.
- The web app is unchanged.
Verified with a simulated desktop (Tauri bridge + webAppUrl) against a local
Synapse. Copied links are https://chat.lotusguild.org/home/<room>/<event> and
https://chat.lotusguild.org/<space>. A public link in a message renders as
the room pill and clicking it opens the room in-app, with nothing sent to the
system browser. The web app still copies origin links. Unit tests for the
base selection; Playwright 20 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The Linux desktop app runs on WebKitGTK, which ships without WebRTC (2.52
has no RTCPeerConnection; 2.54 disables it outright pending a libwebrtc
backend around 2.56), so calls can't work there. Until now the call button
just disappeared, the call room said "Your browser does not support WebRTC"
with Join disabled, and an incoming call couldn't be answered.
In the desktop app (isTauri) without WebRTC:
- call rooms: "Calls aren't available in the desktop app on Linux yet: its
web engine has no WebRTC" + an "Open in browser" button;
- incoming-call overlay: the same, with "Answer in browser";
- room header: the call button stays, and opens the room in the browser.
The link is the room in the web app (config.json `webAppUrl`, https only,
new key); the user presses Join there. Deliberately not an auto-join link:
a crafted URL must not be able to join a call and open someone's mic. It
opens through the desktop's new-window handler (web/mail schemes only → the
system browser). Without `webAppUrl` the explanation shows with no button;
browsers without WebRTC keep the old message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
matrix-widget-api's host transport handled a message from ANY window on the
page as long as it carried the widget's id; its strictOriginCheck only
compares with the host's own origin and is off by default. The call's id is
the fixed 'call-embed', so any other frame (a room widget, a URL-preview
embed) could post fromWidget actions as the call. Reproduced locally: an
opaque-origin frame posting one io.lotus.hotkey keydown for the PTT key
turned a push-to-talk user's mic on ("● Live").
restrictWidgetMessages() swaps each ClientWidgetApi transport's listener
for one that requires ev.source === the widget iframe's window and
ev.origin === the widget's origin. Applied to the call and to room widgets
(so one widget can't impersonate another). Verified: the spoof no longer
opens the mic; PTT/deafen from inside the call, screenshare, speaking
indicator and room widgets (capability prompt, send, live events) unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
markAsRead ran on every room visit (timeline at the bottom and focused) and
sent a threaded receipt for every unread thread, so a reply in a thread you
started or replied in lost its unread badge the moment you glanced at the
room, without opening the thread.
Reads from just viewing the timeline are now "passive":
- threads you follow (started, replied in, or were mentioned in) stay unread
until their panel is opened; other threads are still cleared so they don't
keep the room dot lit forever;
- while a followed thread has an unread reply, the main receipt is scoped to
the main timeline instead of unthreaded, because an unthreaded receipt
also reads every older thread reply (the next main message would clear the
thread anyway). The check also asks whether the latest reply is read, since
the thread's count lags when the reply and a main message share a sync;
- the thread open in the panel is skipped, as the panel sends its own
receipt (was two identical receipts per reply).
Explicit "mark as read" (room menu, Escape, bulk actions) still clears
everything. Unit tests for each rule plus a local-homeserver e2e that checks
the server's per-thread count survives a reply + newer main message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Thread images opened the room media lightbox, which looks the event up in
the room's detached media timeline. Thread replies never reach that
timeline, so a thread image always showed "1 / 1" with no prev/next, after
paging the room's media up to six times. The thread panel now builds the
viewer's items from its own root + loaded replies, and "Go to message"
scrolls the thread panel instead of the room.
The viewer gains a "Copy image" button: fetches the displayed media (blob
URL for E2EE, authenticated URL otherwise), re-encodes to PNG when needed,
and writes it via ClipboardItem with a promise so Safari keeps the click's
user activation. Hidden where ClipboardItem is missing. No "open in new
tab": an E2EE blob URL is revoked when the viewer closes and authenticated
media 401s in a bare tab.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The reaction button's aria-label used getShortcodeFor(), which returns
undefined until the lazily loaded emoji data arrives. The same button read
"🎉 reaction, 1 person" on first render and "tada reaction, 2 people" after
any later re-render. It now always uses the emoji itself (screen readers
speak it by its proper name, e.g. "party popper").
Custom (mxc) emoji were labelled just "custom emoji"; they now use the
shortcode carried on the reaction event (":lotus_blob: reaction").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
With the mention list open, pressing Enter sent the half-typed text
("hey @bo") instead of inserting the highlighted person — found while
investigating the "composer hit a snag" report; only Tab picked. Enter now
picks the top suggestion for people, rooms and commands, as in Discord,
Slack and Element.
useAutocompleteEnter listens in the window capture phase (so it runs before
the composer's submit handler) and only while the list has suggestions —
with no command match Enter still sends. Emoji suggestions are deliberately
left on Tab only, so "lol :p" + Enter still sends rather than inserting an
emoji. IME composition and Shift/Ctrl+Enter are untouched. Tab now inserts
the member's display name, same as a click.
Verified: "@bo" + Enter inserts the mention and sends nothing; the next
Enter sends "hey @bob hi" with m.mentions; ":smi" + Enter still sends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Reported on chat.lotusguild.org: picking a name from the @-mention list
showed "The message composer hit a snag." — clicking OK continued with the
draft intact. The composer's known failure mode is a transient render error
right after an autocomplete insert (slate-react's DOM selection sync racing
the model; 477df4ae fixed one such path). It did not reproduce locally in 60
attempts (click / Enter / Tab, fast and slow, display names with spaces and
emoji, Chromium and WebKit), so this makes it harmless and diagnosable:
- ComposerErrorBoundary: the first failure clears the selection and remounts
the composer immediately, with no notice (the draft is intact). A second
failure within 5 s shows the notice, now saying the draft is safe, with
"Reload composer" and "Copy details" (time, error, stack, component stack,
browser). Every failure is logged to the console as "[composer] render
error".
- Used for the room composer and, newly, the thread panel composer (which had
no boundary, so the same error took the whole panel down).
Verified with a temporary injected crash (not committed): one crash recovers
with the draft kept and no notice; two within 5 s show the notice, Copy
details copies the report, Reload composer restores the full draft.
Chromium e2e 19 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
When a multi-line paste looks like code, a chip above the composer asks
"That looks like code. Format it as a code block (js)?" with Format as code /
No thanks. It never converts on its own; typing, No, or 8 s dismiss it, and
Settings → Editor → "Offer to Format Pasted Code" turns it off.
- utils/looksLikeCode.ts (pure, tested): ≥ 3 lines and ≥ 2 of indentation
with depth changes, statement terminators, operators + brackets, keywords,
monospace/<pre> clipboard HTML, or SQL clause lines. Prose guards: quoted
replies, URL lists, long sentence-punctuated lines, plain-word lines,
markdown lists. Language guess only when fairly sure (js/ts/python/sql/
rust/c/php).
- Accept rebuilds the pasted paragraphs as one code block from their plain
text (code lines hold text only) and leaves the caret after it.
- Code blocks carry an optional lang → <code class="language-js"> (whitelisted
identifier only).
Verified in Chromium: pasting a JS function shows the chip; Format as code →
sent formatted_body is <pre><code class="language-js"> with indentation intact;
a three-line prose paste shows no chip; typing after a paste dismisses it.
Unit tests: 14 detector fixtures + 2 output tests; chromium e2e 19 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The SDK gives a thread root its own id as threadRootId, so the
`!isThreadedMessage` guard hid the thread action on exactly the message that
has a thread; the "N replies" chip was the only way in. Roots now get the
action in both the hover bar and the ⋮ menu, labelled "View thread" /
"View Thread (14 replies)" with the thread icon; other messages keep
"Reply in Thread". Same handler, which opens the thread panel.
Verified in Chromium: on a 14-reply root both entries show and open the panel;
an ordinary message still shows "Reply in thread".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A row in the Threads list only opened the thread panel; there was no way to
get to the root message in the room's timeline. Each row now has a small
"Go to message" button (a sibling of the row button, laid over its corner —
no nested buttons) that navigates the room to the root event. On a phone,
where the list covers the timeline, it also closes the list, and the button
is the bigger touch size.
Verified in Chromium: desktop jumps to the root with the list still open;
Pixel 7 jumps and closes the list (34 px target).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The subtitle was only the room name. It now reads e.g. "Thread Lab · started
by alice, Fri 09:18 PM" (the user's clock/date preferences via the #139
formatter), which is what you want to know when arriving from the Threads
list. Verified in the 360 px desktop panel: fits without clipping.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA