Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions .github/workflows/doctests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ name: Doc-Tests
# drives it headless (offscreen Qt):
# - chat-ui build + connect + share-address GUI walkthrough (screenshots)
# - chat-ui-exchange runs the real two-party round-trip and captures the result
# - chat-ui-group forms a real three-party group and captures the result
#
# The dedicated two-instance-exchange.yml job stays the robust gate for the
# round-trip and regenerates docs/two-instance-exchange.md's screenshots.
Expand Down Expand Up @@ -102,14 +103,15 @@ jobs:
- name: Run doc-tests
shell: bash
run: |
# Spec list. The two-party exchange needs live P2P networking between two
# nodes that GitHub's macOS runners can't provide (it is gated on ubuntu
# by two-instance-exchange.yml), so it runs on ubuntu only; macOS keeps the
# single-instance chat-ui spec. Both paths stay listed for the workspace
# tests-and-doctests spec discovery.
# Spec list. The two-party exchange and three-party group specs need live
# P2P networking that GitHub's macOS runners can't provide (each has its
# own dedicated ubuntu gate: two-instance-exchange.yml and group-chat.yml),
# so they run on ubuntu only; macOS keeps the single-instance chat-ui spec.
# Every path stays listed for the workspace tests-and-doctests spec discovery.
specs=(
doctests/chat-ui.test.yaml
doctests/chat-ui-exchange.test.yaml
doctests/chat-ui-group.test.yaml
)
if [ "${{ matrix.os }}" = "macos-latest" ]; then
specs=(doctests/chat-ui.test.yaml)
Expand Down Expand Up @@ -145,7 +147,7 @@ jobs:

- name: Verify markdown generation
run: |
for spec in doctests/chat-ui.test.yaml doctests/chat-ui-exchange.test.yaml; do
for spec in doctests/chat-ui.test.yaml doctests/chat-ui-exchange.test.yaml doctests/chat-ui-group.test.yaml; do
out="/tmp/$(basename "$spec" .test.yaml).md"
nix run github:logos-co/logos-doctest -- generate \
"$spec" \
Expand Down
60 changes: 60 additions & 0 deletions .github/workflows/group-chat.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Group chat

# Forms a real three-party group conversation across three logos-chat-ui windows
# over logos-qt-mcp (doctests/group/run-group.sh): Alice creates the group,
# invites Bob and Carol, and once the roster converges sends a message that fans
# out to both. This doubles as an end-to-end GUI integration test — the job fails
# if the group does not form or the message does not arrive — and captures the
# screenshots as an artifact for review.
#
# This job is Linux only: it launches three standalone app instances on one host,
# which the script keeps apart via distinct data dirs, delivery ports, and
# inspector ports. macOS still exercises the group flow through the chat-ui-group
# spec in doctests.yml; this job is the dedicated, race-free gate for it (it runs
# the flow against the checked-out tree rather than a freshly pushed github ref).

on:
pull_request:
push:
branches: [master, main]
workflow_dispatch:

concurrency:
group: group-chat-${{ github.ref }}
cancel-in-progress: true

jobs:
group:
name: group chat (ubuntu-latest)
runs-on: ubuntu-latest
timeout-minutes: 90

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main

- name: Setup Cachix
uses: cachix/cachix-action@v15
with:
name: logos-co
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'

# The flow runs against the commit under test (FLAKE defaults to the
# checked-out tree) and needs a real network: the three delivery nodes find
# each other on the configured fleet, and init publishes each instance's key
# package to the key-package registry.
- name: Form the group and capture screenshots
run: |
nix shell nixpkgs#nodejs --command \
bash doctests/group/run-group.sh "$PWD/docs/images/group"

- name: Upload group screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: group-chat-screenshots
path: docs/images/group/*.png
if-no-files-found: warn
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ logos_module(
src/ConversationListModel.cpp
src/MessageListModel.h
src/MessageListModel.cpp
src/MemberListModel.h
src/MemberListModel.cpp
INCLUDE_DIRS
src
)
56 changes: 50 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,33 @@ Built with [`logos-module-builder`](https://github.com/logos-co/logos-module-bui

## What It Does

The application provides a two-panel chat interface with a dark, terminal-inspired theme:
The application provides a terminal-inspired chat interface with a conversation
list (left), a message thread (center), and a members panel (right) that appears
only for group conversations:

- **Conversation list** (left panel) — shows active conversations with timestamps and unread indicators
- **Chat panel** (right panel) — displays messages and a text input for the selected conversation
- **Conversation list** (left panel) — active conversations with timestamps and unread indicators; group rows are prefixed with `#`
- **Message thread** (center panel) — messages and a text input for the selected conversation; group messages show a short sender label above incoming bubbles
- **Members panel** (right panel, groups only) — the group's roster, with an add-member field

Core functionality:

- **Identity** — on startup, initializes a chat identity and displays the user's ID in the status bar
- **Addresses** — show your address (the **Show My Address** button) and share it with others to let them start a conversation with you
- **New conversations** — paste another user's address to open a private conversation
- **New conversations** — paste another user's address to open a private (1:1) conversation
- **Group conversations** — start a group with **+ group**, then invite peers by address from the members panel (see below)
- **Messaging** — send and receive messages in real-time over the Logos network
- **Chat lifecycle** — auto-initializes and starts on launch; status shown in the bottom bar

Conversations are **ephemeral** — messages and identity exist only while the app is running.

### Group conversations

- **+ group** creates a group with you as its only member (no dialog; the group opens immediately).
- Collect peers' addresses (each shares theirs via **Show My Address**), paste one into the members panel's add-member field, and press **+** to invite.
- Membership changes are asynchronous: on devnet the group's steward commits an add only after a ~60s commit-inactivity window, then the welcome is delivered, so a peer joins **minutes** after the invite. The roster refreshes on selection, a message from a new member, the **↻** button, or your own add.
- Any member can add another; the invite routes from whoever proposed it.
- During the brief windows while the group is finalizing a membership change, de-mls rejects sends; these surface as an error toast, so retry after a moment.

## How to Run

### Standalone (recommended for development)
Expand All @@ -38,6 +50,38 @@ nix run --override-input chat_module path:../logos-chat-module \

The standalone app starts Logos Core, loads `capability_module` and `chat_module`, then launches the QML UI via an isolated `ui-host` process.

### Running multiple instances on one machine

To try a real conversation or group locally, run two or more standalone apps
side by side on the same host. Each instance needs its own module data directory
and its own delivery-node port; the UI-to-backend QtRO socket name is randomized
per instance, so nothing else has to be set:

```bash
# window A
CHAT_MODULE_INSTANCE_PATH=~/.local/share/chat_a CHAT_MODULE_DELIVERY_PORT=60000 nix run
# window B
CHAT_MODULE_INSTANCE_PATH=~/.local/share/chat_b CHAT_MODULE_DELIVERY_PORT=60001 nix run
```

Add further windows the same way, giving each a fresh instance path and delivery
port (`chat_c` / `60002`, and so on).

| Variable | Purpose |
|---|---|
| `CHAT_MODULE_INSTANCE_PATH` | The `chat_module` instance's data directory. Two instances must not share one. Defaults to a directory under the app's data location. |
| `CHAT_MODULE_DELIVERY_PORT` | The local delivery (Waku) node's port, bound by the node, so it must be distinct per instance. Defaults to a compiled-in port. |
| `QML_INSPECTOR_PORT` | Only needed when attaching the [logos-qt-mcp](https://github.com/logos-co/logos-qt-mcp) inspector to drive an instance programmatically (default 3768); give each a distinct one then. Interactive use does not need it. |

Each node joins the `logos.test` Waku fleet and publishes its key package during
init, so this needs internet and ~5-20s per window to reach **Online**. Then
share one window's address (**Show My Address**) and paste it into another
(**+ new** for a 1:1, or **+ group** then the members panel for a group). For
the full walkthrough with screenshots, and the scripted drivers that automate it
(`doctests/exchange/run-exchange.sh` for a two-party exchange,
`doctests/group/run-group.sh` for a three-party group), see
[Two-instance message exchange](docs/two-instance-exchange.md).

### In Basecamp

Build the `.lgx` package and install it:
Expand Down Expand Up @@ -125,14 +169,14 @@ view.
| Qt6 Core, RemoteObjects, Declarative | UI framework + IPC |
| [`logos-module-builder`](https://github.com/logos-co/logos-module-builder) | Build system (mkLogosQmlModule) |
| [`logos-chat-module`](https://github.com/logos-co/logos-chat-module) | Chat backend module |
| [`logos-delivery-module`](https://github.com/logos-co/logos-delivery-module) | Transport (Waku) — runtime dependency, pinned at v0.1.2 |
| [`logos-delivery-module`](https://github.com/logos-co/logos-delivery-module) | Transport (Waku) — runtime dependency, pinned at v0.1.3 |

## Related Repositories

| Repository | Role |
|---|---|
| [`logos-chat-module`](https://github.com/logos-co/logos-chat-module) | Chat backend — this UI's required dependency |
| [`logos-delivery-module`](https://github.com/logos-co/logos-delivery-module) | Transport (Waku) — runtime dependency, pinned at v0.1.2 |
| [`logos-delivery-module`](https://github.com/logos-co/logos-delivery-module) | Transport (Waku) — runtime dependency, pinned at v0.1.3 |
| [`libchat`](https://github.com/logos-messaging/libchat) | Chat engine embedded by `chat_module` (E2EE, sessions) |
| [`logos-module-builder`](https://github.com/logos-co/logos-module-builder) | Module build system |
| [`logos-liblogos`](https://github.com/logos-co/logos-liblogos) | Logos Core platform |
81 changes: 81 additions & 0 deletions doctests/chat-ui-group.test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: "Tutorial: Run the automated group-chat test"
output: chat-ui-group.md
project_name: logos-chat-ui
release: ""

intro: |
A group conversation goes further than a 1:1 chat: one person creates it,
invites others by address, and every message fans out to the whole roster with
the sender attributed. That spans **three** app instances and grows by
membership commits over the live network — more than the single-window
walkthrough in *The Logos Chat UI* tutorial can express, and more than the
doc-test `ui_test` driver can orchestrate on its own (it works against one
instance and can't read one window's address out to invite it into another).

`logos-chat-ui` ships an automated test for exactly this. The `group` app
drives the real three-party flow over the live network between three headless
instances — Alice creates the group, invites Bob and Carol, and once the roster
converges sends a message that reaches both — then exposes the newest member's
window so the finished group can be captured.

what_you_build: "A verified three-member, end-to-end-encrypted group conversation across three chat UI instances, captured from a receiving member's window."

what_you_learn:
- "How the `group` app forms a three-member group and fans a message out to it, headless over the live network"
- "How the group UI adds a members pane and per-message sender attribution over a 1:1 chat"
- "Why a multi-window group needs a dedicated runner rather than the single-instance `ui_test` driver"

prerequisites:
- |
**Nix** with flakes enabled. Install from [nixos.org](https://nixos.org/download.html), then:

```bash
mkdir -p ~/.config/nix
echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf
```
- "**A Linux or macOS machine.** Nix provides every toolchain (Qt, CMake, Rust) during the builds."
- "**Network access.** The three delivery nodes discover each other over the configured network preset; an isolated sandbox won't complete the flow. Expect membership changes to take minutes."

sections:
- title: "Run the automated group chat"
step: true
text: |
The `group` app launches three instances offscreen and runs the real flow
over the live network — Alice creates the group, invites Bob and Carol,
and once all three are in she sends a message that fans out to both — then
opens the capture port onto Carol's live window so her copy of the group is
on screen. The whole flow happens while the launch is still coming up, so
the screenshot below captures the already-formed group: the creator's
message, received with its sender label, beside the three-member roster.
steps:
- title: "Run the group flow and show the result"
ui_test:
setup:
- "nix build 'github:logos-co/logos-qt-mcp{release}' -o result-mcp"
qt_mcp: "result-mcp"
# Opens the default inspector port 3768: the qt-mcp test framework
# always connects to QML_INSPECTOR_PORT (default 3768), so the captured
# window must be reachable there. The group flow runs on its own
# inspector ports (4768/4769/4770) during this (unbounded) launch wait;
# 3768 opens (proxied onto Carol's live window) only once the flow is
# done, so the capture below runs against the already-finished window.
# Larger than the exchange's 1800: forming a three-member group runs
# two sequential membership commits plus the fan-out before Carol's
# window opens on 3768, so the port-wait must cover a longer flow.
launch: "nix run 'github:logos-co/logos-chat-ui{release}#group'"
launch_timeout: 2700
tests:
- name: "Group message received"
action: wait_for
# Exact strings — the inspector's findByProperty matches the whole
# `text` property, not a substring (verified against logos-qt-mcp).
# "Hello team 👋" mirrors run-group.mjs's GROUP_MSG; "members" is
# the members-pane header, present only for a group conversation.
texts: ["Hello team 👋", "members"]
timeout: 90000
text: |
Carol's window after the group formed: Alice's message, received
with its sender label and end-to-end-encrypted over
`delivery_module`, beside the members pane listing the
three-member roster.
screenshot: "group.png"
34 changes: 34 additions & 0 deletions doctests/group/port-proxy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Expose an already-listening local TCP port on another one: accept
// connections on LISTEN_PORT and pipe each to 127.0.0.1:TARGET_PORT.
//
// Used by run-exchange-show.sh to gate the doc-test's capture port: the app
// binds its QML inspector the moment it starts, so the only way to make "the
// port is open" mean "the exchange is finished" is to run the exchange on a
// private inspector port and open the capture port afterwards, as this proxy
// onto the live window.
//
// Env:
// LISTEN_PORT port to listen on
// TARGET_PORT local port to forward to
import net from "node:net";

const listenPort = parseInt(process.env.LISTEN_PORT, 10);
const targetPort = parseInt(process.env.TARGET_PORT, 10);
if (!listenPort || !targetPort) {
console.error("port-proxy: LISTEN_PORT and TARGET_PORT are required");
process.exit(1);
}

const server = net.createServer((client) => {
const upstream = net.createConnection({ host: "127.0.0.1", port: targetPort });
client.pipe(upstream);
upstream.pipe(client);
const drop = () => { client.destroy(); upstream.destroy(); };
client.on("error", drop);
upstream.on("error", drop);
client.on("close", drop);
upstream.on("close", drop);
});
server.listen(listenPort, "127.0.0.1", () => {
console.log(`port-proxy: ${listenPort} -> ${targetPort}`);
});
Loading
Loading