The stack's account of a failure was scattered across four writers in three processes and reachable only from a terminal, so anyone running the app from a launcher had no way to see why something did not work, and no way to attach that account to a report. The console tails the file the host assigns and filters it by domain, severity and text, with an export for the whole file or for exactly what the filters pass.
Every failure the backend reports now goes to the log as well as the strip, through one reporter, which is also what stops a reason the module could not supply from rendering as a trailing colon.
Only a bounded window of the file is held, shared out between domains rather than kept strictly newest first, and a domain gives up its ordinary lines before its severe ones. A writer producing thousands of lines a minute would otherwise push every quieter one out of reach within minutes, leaving a filter chip counting lines nothing could show and a header counting errors that were no longer there. What a line says about itself outranks what relayed it, so a severity the writer stated beats the pseudo level the host stamped, and a field naming a module beats the innermost scope beside it.
A toast covered the conversation for four seconds and then took the only copy of the message with it, so two failures in quick succession left the first unread and unrecoverable. The strip across the foot keeps the newest one until it is activated and counts the ones behind it.
The pending marker was tracked in the backend as a single set of addresses for whichever conversation was open, so switching away had to clear it or its rows would leak into the next group. Coming back re-read the roster from the module, which listed committed members only, and the invite vanished from the panel until the group committed it minutes later.
The module's roster now flags an invite it has not committed yet, so the marker is read per conversation from the module instead of being tracked here, and survives any number of switches. memberCount stays committed-only, and a pending invite no longer counts as being on the roster when a message arrives from an address the panel has not seen. The same rev answers the roster call for a direct conversation, where it used to error, so the details panel resolves the peer's address instead of leaving the row blank.
A layout nested in another layout fills by default, so the sidebar and the right column took the slack meant for the thread: at 1024px the sidebar rendered 415px against a design of 320. Both are pinned to their widths now, the thread carries a floor so a window too narrow for three columns clips the right one rather than the middle, and selecting a conversation puts the caret in its composer.
Every hex identifier the chat shows named the platform's generic monospace family itself, behind a local token standing in for one the design system did not have. It has one now, so the stand-in and its upstream TODO go.
Every component has to instantiate from its declared properties alone, with mock data and no host context, and the chat vocabulary the redesign added had no such proof: the icon button, the day chip, the facepile and its overflow, the panel heading and its count, a detail row's copy, and the thread header holding one line of description whatever it is given.
A copy button appeared on every roster row the pointer crossed, so a list whose whole job is to say who is in the group was never at rest. A right-click on a row now offers Copy address, and the row itself is nothing but the member's face, identity and standing, with an invitation that has not committed saying so in words.
A group's roster had a header and a floating button and nothing else, while the conversation's own facts (its full description, what kind it is, its id, the other participant's address) were locked in two modal dialogs that had to be dismissed before the thread could be read again. The roster is now a card with its count in the heading and Add member pinned to the foot, and one details panel above it carries what both dialogs did, opening and closing from the thread header without covering anything.
A thread of identical bubbles under a bare title gave no answer to "who is this and who else is here", and the composer carried a labelled button and a growing row of hints for a field that only ever sends text. The header now names the conversation with its avatar, one elided line of description and the faces of its members, and offers its remaining facts behind one toggle; a message carries its sender's face and name in their own colour where a run of theirs begins, dates ride on a chip over the thread rather than a rule through it, and the composer is one pill with the send action at its end.
The panes met edge to edge with only a hairline between them, so nothing on screen was grouped and the eye had to find the boundaries. The sidebar is now a conversations card over the account card on an inset background, its rows carry the avatar with the time and unread count stacked out of the name's way, and one full-width New chat button naming what each entry starts replaces the header and its menu. The status strip goes with it: the connection state belongs on the account card, and the messages it carried were either failures the toast already reports or narration of what the list itself shows.
A list of hex labels reads as one undifferentiated column, and the eye needs something faster than eight characters to tell one conversation or member from another. Every model now carries the initials and a colour ramp for its row's identity, hashed the same way in one place so an account keeps its colour across the sidebar, the roster and this account's own card, and a group is squared off and glyphed so it reads apart from a person.
The redesign leans on glyphs the design system does not carry (a group, a person, an info toggle, a send arrow, an add-member, a caret), and the hosts' Qt has no SVG image plugin, so the module can only load rasters. The sources live under tools/icons with a script that renders them, so a glyph can be edited rather than redrawn, and the existing copy icon is re-cut from the same stroke family.
Sharing an address took three actions (a button, a dialog, a copy) for a value the app already knows the moment it is online, and the footer printed an installation id nobody can act on instead. The backend now reads get_address() on the online transition and publishes it, and an account card at the foot of the sidebar carries the short identity, the connection state and the address itself with a copy beside it.
The scroll-bar, sizing, scrim and focus fixes this branch pairs with live in logos-co/logos-design-system#39; a plain build of this branch should render with them instead of the module builder's pinned design system. A top-level input routes the builder's design-system input to that branch.
An underscore prefix only labels a function internal while leaving it callable on the component's surface; holding it in a child QtObject actually scopes it away, the shape the design system's own controls use. The component tests switch from the removed helpers to the action buttons' clicked signal, which exercises the same guards through the surface a user reaches.
The transient error toast was bottom-centered in the window overlay, which laid it over the message input and send button, the exact controls a failed action points the user back to. Show it as a top-center banner instead.
The "you" tag that marks the current user in a group's member list was set in the badge text size, small enough to be awkward to read beside the member name.
Size it like the name.
The address and description areas were fixed-height boxes taller than their usual contents, so a one-word description sat in an 80px well with a scrollbar down the side of it.
The address now takes the height of its content and stops at the height it used to have, scrolling past that. The description, where a content-sized scroll view sheared the label beneath it, is instead a plain text bounded to a few lines.
The selected row's tint sat close enough to the hover tint that the open conversation was hard to pick out, and a conversation with no messages left its second line blank, which read as a rendering gap.
The selection is now distinctly tinted and an empty conversation says so where its last message would be.
Selecting message text worked, but the desktop reflex of right-clicking a message offered nothing, so copying meant dragging over the text first.
A right-click now opens a menu whose Copy takes the selection when there is one and the whole message otherwise. The rows share one menu.
A direct conversation had no details surface at all: the Details button was reserved for groups, so there was nowhere to read or copy the address of the person you were talking to.
Details now opens for any conversation, with a dialog for direct ones carrying the peer's address and the conversation id. The address comes from the conversation's roster, which the backend now fetches for direct conversations too, and its row stays hidden until the roster provides one.
Copying a member's address meant clicking the row with nothing saying so, and the dialogs put their copy action in the footer, away from the value it acts on.
Each copyable value now carries a copy button beside it, keeping the row click and the inline confirmation as they were.
The icon ships with the plugin rather than coming from the design system, so it renders on hosts whose design system predates it.
The header carried two side-by-side buttons that truncated their own labels to "New gr...", plus a connectivity dot the status bar already shows.
A single "+ New" button now opens a menu with an entry per kind of conversation, and the dot is gone. Sharing your own address stays the full-width action at the foot of the list.
A conversation arriving from an invitation appeared in the sidebar with nothing to distinguish it, so a group someone added you to slid in unnoticed among the existing rows.
An incoming conversation now carries the unread badge until it is opened, the same as an unread message does.
The details dialog rendered its plural source string literally, "1 member(s)", and counted only the committed roster, so a group with an invitation in flight looked like it had lost the invited person.
It now reads "3 members, 1 invited", with the backend reporting outstanding invitations alongside the roster size.
Inviting someone reported "peer joins when the group commits", the explainer named deMLS and its commit timer, and a pending row's tooltip read "waiting for commit". None of that means anything to the person waiting for a friend to show up in a group.
The same facts, including the up-to-a-minute wait, are now stated in the user's terms.
The status bar kept the last event it was handed, so "Address ready" or an invite confirmation sat there for the rest of the session and read as the app's current state.
A message now clears after a few seconds; the connectivity label and the identity stay, since those are state rather than announcements.
The composer cleared on submit and the failure path only raised a toast, so a rejected message was gone: the text had already been dropped from the field and from the conversation's draft.
The backend now reports the refused message and the thread offers it back in the composer, leaving a composer the user has already typed into alone.
The composer showed "Chat not connected" whenever it was disabled, so a connected app with no conversation selected reported a connection problem it did not have.
It now names the actual cause, keeping the offline wording for offline and asking for a selection otherwise.
Selecting a conversation updated the header, the message model and the roster as three independent streams, and the view renders them through separate replicas, so a switch showed the previous conversation's rows under the new title, then a false "No messages yet", then the real thread.
The backend now reports which conversation the models actually hold, and the panes render rows only while that matches the selection: a thread still loading stays blank for a moment, then shows placeholder bubbles, and only a settled model may call a thread empty.
The sidebar and the header no longer wait for that round trip at all: a clicked row carries its own name, kind and description, which the view applies straight away and drops once the backend confirms the switch.
Message bubbles formatted their own clock time in QML while the conversation list formatted its in the model, so the same kind of value showed up in two shapes at once, "16:44:34" next to "4:45 PM".
Both now render the system locale's short time with the seconds field dropped, and the bubble takes its label from a model role instead of formatting.
* feat: take chat storage from the host-assigned session directory
The platform assigns every module instance its own persistence directory, so picking one here duplicated a decision the host had already made and left the init contract claiming an owner it did not have.
The standalone runner's `--user-dir` now selects the session directory the multi-instance dev and CI flows key off, so one host-level knob keeps two instances' state apart instead of a chat-specific environment variable.
* feat: stop choosing the delivery node's port
Running several instances on one host meant inventing a distinct delivery port for each and threading it through an environment variable, the docs, and both doc-test drivers, a coordination burden that bought nothing the transport cannot decide for itself.
chat_module's init no longer takes a port and its delivery node listens on ports it picks itself, so a session directory is the only thing that has to differ per instance.
The button that starts a one-to-one conversation read as "New chat" directly beneath the "Chat" pane title, so the name of the conversation type collided with the name of the application; calling that type a DM tells the two conversation types apart at a glance.
"conversation" stays the umbrella term covering both DMs and groups, and "Chat" stays the product name, so the pane header and the backend status strings keep their wording.
Getting a message body out of the app meant retyping it: the bubble rendered its content as a plain Text, which has no selection support.
Selecting text moves focus off the composer, so Enter stops sending until the user clicks back into it. That is inherent to the selected item being the one that receives Ctrl+C, and is accepted rather than papered over with focus-restoring machinery that would fight the user mid-selection.
UX follow-ups across the chat UI that need no libchat change, as nine self-contained commits that each pass the QML checks on their own.
- Preserve New Group and New Conversation drafts across a close, and bound the group name (64) and description (280) with a live over-limit counter.
- Clearer sidebar actions ("New chat" / "New group", chat first), actionable empty states, and an unread badge recoloured to primary with dark ink and an accessible count.
- Order conversations by recency through a QSortFilterProxyModel, and show a relative activity date ("14:03", "Yesterday", or a short date) supplied by the model.
- Fix the thread header at 48px so the three pane headers align, and reveal a truncated subtitle in a hover tooltip.
- Add a group Details dialog (full description, member count, copyable conversation id) reachable from the thread header.
- Group consecutive messages and separate threads by day, from roles the message model derives from the previous message.
- Replace the single-line composer with a multi-line editor that grows then scrolls; Enter sends, Shift+Enter inserts a newline.
- Move member-add to a dialog with copy feedback, and retire the now-unused SubmitRow.
- Show a one-line preview of the latest message in each conversation row, capped at 160 characters to match the module.
Pins chat_module to the merged logos-chat-module #49 so a rehydrate reads back each conversation's preview.
Creating a group opened an unnamed conversation labelled "Group <short-id>". A new dialog now collects a required group name and an optional description: the shared name becomes every member's display name for the conversation, and the description shows under the title in the message thread header. Metadata is set once at creation, so a joiner reads the same name and description the creator set.
Display name precedence is the local nickname, then the group's shared name, then the "Group <short-id>" fallback. The chat_module pin moves to the commit that adds the name and description to create_group_conversation, the Conversation record, and the conversation_created event; the backend threads them through the conversation model and exposes the current conversation's description as a property the thread header binds.
Adding a member gave no feedback: the roster only showed the peer ~a commit later, and only via a manual refresh button or a message from them. The status bar was the sole hint the invite was in flight.
Show the invite immediately and let the module tell us when it lands.
- A just-invited member appears at once as a busy row (spinner, dimmed, tooltip "waiting for commit"), tracked in a per-conversation pending set. A roster refresh that finds the member in the committed roster drops the pending marker; the set is cleared on conversation switch.
- The roster now auto-refreshes from the module's members_changed event (surfaced from libchat), so a remote join updates the list with no user action. Bump the chat_module pin to the rev that emits it.
- Remove the members refresh button and its signal; the event replaces manual refresh.
- First time a user adds a member, an explainer dialog describes the commit delay, with a "Don't show this again" checkbox persisted via QtCore Settings.
* refactor: rebuild the chat view as a design-system ChatUi module
Reworks the chat UI from two large hand-themed QML files into a pure-QML ChatUi component module on the platform design system. ChatView.qml drops from ~825 lines to thin composition; everything below it is standalone-instantiable and Theme-driven.
- Theme: moves onto Logos.Theme and Logos.Controls, dropping the duplicated dark palette and the inline colour literals.
- Structure: ChatStore is the sole reader of the injected logos context; panes, delegates and leaf components take their data and services through declared properties, so each instantiates on its own with mock data.
- Behaviour: i18n marking (qsTr, %1 args, Locale.ShortFormat times, disambiguation comments), Accessible roles, keyboard navigation on the conversation and member lists, per-conversation composer drafts, and an overlay-layer toast.
- Hardening: plain-text rendering on all peer- and backend-bound text as markup-injection defence, plus guarded dialog focus and close policy.
- Messages: the model is ordered newest-first in C++ and the thread uses ListView.BottomToTop, so the newest row pins to the visual bottom natively and all imperative scroll management is gone; switching conversations replaces the thread in a single model reset rather than clear-then-repopulate, so the view never flashes through an empty state.
- Clipboard: addresses copy through a small standalone ClipboardProxy, used directly by the members pane and the address dialog, since QML exposes no native clipboard type.
- doctests: the exchange and group inspector drivers and the walkthrough specs are updated to the new component object names and UI text (Chat and Members headers, New/Send/Copy address buttons).
C++ backend strings and the .ts/.qm translation pipeline are out of scope.
* test+ci: qml component tests, storybook, and qmllint CI
Adds a Qt Quick Test that instantiates every ChatUi component standalone and checks the key behaviours, a storybook harness for visual inspection, and a qml-checks workflow that runs qmllint and the tests.
The CHAT_UI_QML_DEVTOOLS CMake option registers the pure-QML module for qmllint and qmltypes generation; it is off by default and not shipped, so the packaged .lgx stays byte-identical.
* docs: update the module structure and UI labels for the ChatUi refactor
The QML frontend is no longer a single ChatView.qml but a pure-QML ChatUi component module, so the module-structure tree reflects the new layout.
The refactor renamed the conversation-header buttons to New and Group, the member invite to Add, and made the roster reload an icon button; the run docs are corrected from the old + new / + group / + / roster-refresh labels.
* docs: refresh the exchange screenshots for the restyled UI
Regenerated by the two-instance-exchange run against this branch, so the walkthrough images show the design-system UI (Chat header, New/Group buttons, Send composer, in-bubble timestamps) rather than the old hand-themed look.
* feat: group conversations UI
The UI surfaced only 1:1 conversations, but chat_module now exposes GroupV2
groups, so the UI needs to create them, display their roster, and grow them.
- ChatBackend gains create_group_conversation / add_group_member and exposes
the current conversation's group view state (currentIsGroup,
currentDisplayName, memberCount) as replicated backend properties: across
the QtRO boundary a model replica carries roles and data() but not the
source model's custom Q_INVOKABLE methods or a reliable rowCount(), so that
whole-model state is derived on the source side and pushed as properties.
refreshMembers reads the roster as [GroupMember] records.
- MemberListModel drives the members panel; a member with no confirmed
account renders as "unknown_account".
- ConversationListModel marks group rows; ChatView shows the members panel
and per-message sender labels only for groups; MembersPane hosts the roster
and the add-member field.
- Pin chat_module to the merged GroupV2 commit on chat-module master (#43): its
GroupV2 contract (create_group_conversation, add_group_member,
list_group_members, the conversation kind and message sender fields) isn't in
a released tag yet, so pin the exact commit and re-pin as chat_module advances.
* test: add a three-party group-chat doc-test and CI gate
Exercise the group UI end to end: three headless app instances form a group,
grow it member by member, and render the roster, captured as a screenshot.
- doctests/chat-ui-group.test.yaml plus doctests/group/ drive three instances
on ports distinct from the exchange spec, so both specs can run in one job
without their module hosts colliding; nix run .#group launches the
screenshot app.
- doctests.yml runs the group spec alongside exchange; group-chat.yml is a
dedicated Ubuntu gate that runs the three-party driver from the checkout.
* docs: document running multiple instances on one machine
Running side-by-side instances was only covered in the linked
two-instance-exchange doc. Add it to the README's How to Run directly: the
per-instance env vars (CHAT_MODULE_INSTANCE_PATH, CHAT_MODULE_DELIVERY_PORT, and
QML_INSPECTOR_PORT when driving an instance via the inspector), a copy-paste
two-window example and how to scale to more, with the exchange/group drivers
linked for the full flow.
* docs: correct delivery-module version in README
The Dependencies and Related Repositories tables listed the delivery-module
runtime pin as v0.1.2; the flake pins v0.1.3. Match them.
* feat: adapt to chat_module's account-addressed DirectV1 API
chat_module (pinned at 5c79789 on master) replaces intro bundles with
installation addresses and turns persistence off: create_intro_bundle()
is gone in favour of get_address() -> tstr,
create_conversation(peer_address) sends only the cryptographic invite
(the first message follows via send_message once the peer has joined),
and chats are ephemeral, so nothing survives an app restart.
- pin the chat_module flake input at 5c79789
- ChatBackend/.rep/QML: requestMyBundle/bundleReady and the bundle
dialogs become requestMyAddress/addressReady and address dialogs; the
new-conversation dialog takes only the peer address (the first
message is typed in the thread once the conversation opens)
- run-exchange.mjs: share the address, open the conversation with it,
and gate Bob's first message on Alice having joined, mirroring
chat-module's own two-instance doc-test ordering
- #exchange app: ephemerality invalidates the relaunch-from-history
display design, so run-exchange.sh gains KEEP_INSTANCES and
run-exchange-show.sh keeps both finished instances alive, exposing
the capture port only after the round-trip as a TCP proxy
(port-proxy.mjs) onto Alice's live inspector
- doctests, workflows, README, docs: bundle flow rewritten as the
address flow; docs/images/exchange screenshots are stale until
regenerated by the two-instance-exchange job's artifact
* fix: elide the status-bar ID so long installation names fit
The new chat_module reports a long hex installation name; rendered
unconstrained it overflows the status-bar row and the three status
texts draw on top of each other (visible in the regenerated exchange
screenshots). Cap the ID text's width and elide it in the middle.
* docs: refresh the exchange screenshots for the address flow
Captured by the two-instance-exchange CI job on this branch: the
address dialog replaces the intro-bundle dialog and the status bar
shows the elided installation id.
* test: dump instance logs on any failed exchange run
The exchange harness surfaced the app logs only when an inspector port
never bound. A run that reached both inspectors but timed out waiting for
Online, or never received the message, exited with no diagnostics. Move
the dump into cleanup so every failed run shows each instance's
boot/runtime output.
* ci: fix the exchange doc-test (ubuntu-only round-trip, buildable app)
The two-party exchange doc-test needs live P2P networking between two
nodes that GitHub's macOS runners can't provide, so it failed on macOS
while passing on ubuntu. It is already gated on ubuntu by
two-instance-exchange.yml, so run it on ubuntu only; macOS keeps the
single-instance chat-ui spec.
Also expose the exchange runner as packages.<sys>.exchange so the
doc-test runner's `nix build .#exchange` pre-build resolves, warming the
store and dropping the misleading "does not provide attribute" log line.
* chore(deps): bump chat_module and delivery module pins
- logos-delivery-module: raw commit 2577383 -> v0.1.3 release tag
(carries the zerokit/RLN nix build fix, delivery-module #49)
- chat_module: a58b5ff -> 4fe912a, current master carrying the same
delivery v0.1.3 pin (chat-module #41); re-pin to the v0.1.2 tag
once that release is published
Both delivery pins (top-level and nested under chat_module) now
resolve to the same rev, keeping the lockstep intact.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): re-pin chat_module to the v0.1.2 release tag
v0.1.2 is published (and repointed to include the metadata version
bump, chat-module #42), so the interim master-rev pin can go.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: bump module version to 0.1.2
A chat_ui v0.1.2 release follows once this PR merges.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Susanna Bekker <susanna.bekker@gmail.com>
* feat: consume chat_module over LIDL and adopt the universal typed-backend
Rework the chat UI app to talk to chat_module through the generated LIDL client,
and adopt logos-module-builder's universal typed-backend interface in place of
the hand-written plugin/interface glue.
ChatBackend now derives from the generated QtRO source and LogosUiPluginContext:
the builder generates the *Plugin/*Interface glue from ChatBackend.rep +
metadata.json, so src/chat_ui_plugin.{h,cpp}, src/chat_ui_interface.h, and the
unused ChatConfig.h are removed. Module calls go through the typed
modules().chat_module client (init, status, conversation and message calls), and
events are subscribed through the same client. Backend setup moves into
onContextReady() -- the moment the framework has wired modules() -- replacing the
QTimer::singleShot deferral.
Also: take the initial conversation snapshot before seeding delivery state so the
first render is consistent; defer module refetches out of event callbacks to
avoid a QtRO re-entrancy stall; clamp the error toast and derive the selection
tint from the theme; default the delivery preset to logos.test.
* build: wire the universal QML module, pin deps, bump to 0.1.1
metadata.json declares interface: universal with the codegen rep/backend
overrides, so mkLogosQmlModule generates the plugin glue from ChatBackend.rep;
CMakeLists.txt drops the removed glue sources. Pin logos-delivery-module to the
zerokit-fix commit and chat_module to an explicit rev, and set the version to
0.1.1.
* test: add the two-instance message-exchange doc-test and CI
Add an executable doc-test that drives two app instances through a full message
exchange (Alice publishes an intro bundle, Bob sends, Alice receives and replies,
Bob sees the round-trip), capturing a screenshot at each step. The exchange is
scripted in doctests/exchange/ (a CI driver and a display variant) and declared
in the chat-ui doc-test specs. Two workflows run it: doctests.yml renders the
screenshots, and two-instance-exchange.yml runs the Linux two-instance
round-trip; macOS exercises the same exchange spec through doctests.yml.
* docs: document the two-instance exchange and drop the stale spec
Add docs/two-instance-exchange.md walking through the message-exchange doc-test
with the captured screenshots, update README for the universal interface and the
one-time GitHub Pages setup, and remove the outdated docs/spec.md.
* docs: note resolveInstancePath returns the module's persistence dir
It's passed to chat_module via init() for identity.db + history.json; the UI
only picks the location and never reads it.
* build: re-pin chat_module and refresh the flake to builder lockstep
chat_module advances to the rebased rev; logos-module-builder moves to the
thread-safe inter-module-calls SDK, matching the builder chat_module consumes.
* build: re-pin chat_module to the merged master rev
feat/lp-protocol-migration merged to master as a58b5ff (#32); tree-identical to the prior 5380e9e pin, so this is a metadata-only bump.