13 Commits

Author SHA1 Message Date
5c76e12142 chore(prototype): state-model prototype for the realtime frontend
Throwaway, kept as a primary source rather than on main. Single self-contained
HTML file: free-play buttons plus six guided walkthroughs over a pure reducer.

It settled two things the spec had wrong or missing:

- The client must supply the message id. With a server-generated id there is
  nothing to reconcile the optimistically rendered message against when it
  comes back over the realtime channel, and it renders twice. Both cases are
  reproducible via the toggle at the top.
- An unread *count* cannot be derived without fetching, because seq counts
  events and not messages. Hence: fetch for DMs, a plain dot for channels.
2026-08-14 18:19:18 +02:00
b93ca7dcb6 ci: add Gitea workflow for Linux and Android release builds
Some checks failed
release / build (push) Failing after 2m6s
2026-08-14 12:04:34 +02:00
fb3bc6e93a docs: add README 2026-08-13 13:10:55 +02:00
dc7678d184 chore: add app icon 2026-08-12 18:11:43 +02:00
ee19de5f94 fix: reset scroll ref on conversation change
prevLengthRef.current was never zeroed when conversationId changed,
so the first batch of messages in a new (shorter) conversation would
not trigger scrollIntoView. Reset it in the polling useEffect cleanup.
2026-08-12 18:03:19 +02:00
455d304885 fix: address final review findings
- Finding 1: add AppState::authed_get/authed_post helpers that attach
  Bearer token when present; all 4 commands now use them
- Finding 2: MessageInput accepts onSent callback, calls it after
  successful send; ChatPage passes refreshKey bump as onSent
- Finding 3: MessageList useEffect uses cancellation flag so stale
  in-flight responses cannot overwrite a newly selected conversation
- Finding 4: scrollIntoView moved to separate effect keyed on
  messages.length so scroll only fires when new messages arrive
2026-08-12 17:58:02 +02:00
91260c8e30 feat: implement chat page with conversation list, message view, and polling 2026-08-12 17:51:00 +02:00
68c5024334 feat: implement workspace list page 2026-08-12 17:47:50 +02:00
b4e9d25b9f feat: implement login page 2026-08-12 17:44:21 +02:00
a432ac976b feat: add React app shell with routing 2026-08-12 17:27:40 +02:00
3109ac4c22 feat: add message Tauri commands 2026-08-12 17:21:44 +02:00
7c2e5ca9a9 feat: add workspace and conversation Tauri commands
Implement list_workspaces and list_conversations commands to enable
frontend access to workspace and conversation data via Tauri IPC.

- Add workspace.rs with list_workspaces command (GET /workspaces)
- Add conversation.rs with list_conversations command (GET /conversations?workspace_id=)
- Register both commands in lib.rs invoke_handler
- Update commands/mod.rs to expose new modules
2026-08-12 17:04:35 +02:00
d384465f2e feat: add login Tauri command 2026-08-12 16:59:55 +02:00
19 changed files with 1130 additions and 1 deletions

View File

@@ -0,0 +1,83 @@
name: release
on:
push:
tags: ["v*"]
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: android-actions/setup-android@v3
- uses: nttld/setup-ndk@v1
id: ndk
with:
ndk-version: r26d
- name: System dependencies
run: |
sudo apt-get update
sudo apt-get install -y jq libgtk-3-dev libwebkit2gtk-4.1-dev \
libsoup-3.0-dev libayatana-appindicator3-dev librsvg2-dev
- run: npm ci
# Desktop: deb + rpm + AppImage (bundle.targets = "all")
- name: Build Linux desktop
run: npm run tauri build
- name: Build Android APK
env:
NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
run: |
npm run tauri android init
npm run tauri android build -- --apk
# ponytail: sign the finished APK with apksigner instead of patching
# gen/android/app/build.gradle.kts — keeps gen/ throwaway, no committed generated code.
- name: Sign APK
env:
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE }}
KS_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
run: |
echo "$KEYSTORE_B64" | base64 -d > /tmp/ks.jks
BT="$ANDROID_HOME/build-tools/$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -1)"
IN=src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk
"$BT/zipalign" -p -f 4 "$IN" /tmp/aligned.apk
"$BT/apksigner" sign --ks /tmp/ks.jks --ks-pass "env:KS_PASS" \
--out "rustification-${GITHUB_REF_NAME}.apk" /tmp/aligned.apk
- name: Publish release
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -eu
ID=$(curl -sSf -X POST "$API/releases" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\"}" | jq -r .id)
for f in src-tauri/target/release/bundle/deb/*.deb \
src-tauri/target/release/bundle/rpm/*.rpm \
src-tauri/target/release/bundle/appimage/*.AppImage \
rustification-${GITHUB_REF_NAME}.apk; do
[ -f "$f" ] || continue
curl -sSf -X POST "$API/releases/$ID/assets?name=$(basename "$f")" \
-H "Authorization: token $TOKEN" -F "attachment=@$f"
done

View File

@@ -0,0 +1,493 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Prototyp — Frontend-Zustandsmodell nach dem WebSocket-Umbau</title>
<style>
:root {
--akzent: #b4491f;
--grund: #fbfaf8;
--papier: #ffffff;
--linie: #e3ded6;
--text: #23201d;
--leise: #7d7469;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 2.5rem 1.5rem 6rem;
background: var(--grund); color: var(--text);
font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
.huelle { max-width: 66rem; margin: 0 auto; }
h1 { font-size: 1.55rem; margin: 0 0 .4rem; letter-spacing: -.01em; }
h2 { font-size: .82rem; text-transform: uppercase; letter-spacing: .09em;
color: var(--leise); margin: 2.4rem 0 .9rem; font-weight: 600; }
p { margin: 0 0 1rem; }
.frage { background: var(--papier); border: 1px solid var(--linie);
border-left: 3px solid var(--akzent); padding: 1.1rem 1.3rem;
border-radius: 3px; margin-bottom: .5rem; }
.frage strong { display: block; margin-bottom: .35rem; }
.frage p:last-child { margin-bottom: 0; }
.klein { font-size: .87rem; color: var(--leise); }
.schalter { display: flex; gap: 1.6rem; flex-wrap: wrap; margin: 1rem 0 0; }
.schalter label { display: flex; gap: .5rem; align-items: flex-start;
font-size: .88rem; cursor: pointer; max-width: 30rem; }
.schalter input { margin-top: .35rem; }
.raster { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; }
@media (max-width: 62rem) { .raster { grid-template-columns: 1fr; } }
.tafel { background: var(--papier); border: 1px solid var(--linie);
border-radius: 3px; padding: 1.1rem 1.3rem; }
.server { border-bottom: 1px solid var(--linie); padding: .7rem 0; }
.server:last-child { border-bottom: 0; padding-bottom: 0; }
.server:first-child { padding-top: 0; }
.serverkopf { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; }
.name { font-weight: 600; }
.zustand { font-size: .78rem; padding: .1rem .5rem; border-radius: 2rem;
border: 1px solid var(--linie); color: var(--leise); white-space: nowrap; }
.zustand.verbunden { color: #1f6b3a; border-color: #bfe0cb; background: #f2faf5; }
.zustand.neuverbinden { color: #96601a; border-color: #ecd8b4; background: #fdf7ec; }
.zustand.getrennt { color: #8f2f2f; border-color: #eec8c8; background: #fdf3f3; }
.zustand.abgemeldet { color: var(--leise); background: #f3f1ed; }
.conv { display: flex; justify-content: space-between; gap: .8rem;
padding: .28rem 0 .28rem .9rem; font-size: .9rem; }
.conv.offen { border-left: 2px solid var(--akzent); padding-left: .75rem; font-weight: 600; }
.marke { font-size: .78rem; color: var(--akzent); white-space: nowrap; }
.msgs { margin: .35rem 0 .2rem 1.6rem; padding: 0; list-style: none;
font-size: .84rem; color: var(--leise); }
.msgs li { padding: .1rem 0; }
.msgs .pending { opacity: .5; font-style: italic; }
.msgs .deleted { text-decoration: line-through; opacity: .45; }
.msgs .seq { display: inline-block; min-width: 2.4rem; color: #b3aa9d; }
button { font: inherit; font-size: .87rem; padding: .42rem .85rem;
border: 1px solid var(--linie); background: var(--papier);
border-radius: 3px; cursor: pointer; color: var(--text); }
button:hover:not(:disabled) { border-color: var(--akzent); color: var(--akzent); }
button:disabled { opacity: .35; cursor: default; }
button.jetzt { border-color: var(--akzent); background: var(--akzent); color: #fff; }
.knopfreihe { display: flex; flex-wrap: wrap; gap: .5rem; }
.reiter { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: 1rem; }
.reiter button.aktiv { border-color: var(--akzent); color: var(--akzent); font-weight: 600; }
ol.schritte { margin: 1rem 0 0; padding: 0; list-style: none; }
ol.schritte li { display: flex; gap: .7rem; align-items: baseline;
padding: .3rem 0; border-top: 1px dotted var(--linie); }
ol.schritte li:first-child { border-top: 0; }
ol.schritte .nr { color: #b3aa9d; font-size: .8rem; min-width: 1.2rem; }
ol.schritte .erledigt { color: var(--leise); text-decoration: line-through; }
.protokoll { margin-top: 1.4rem; font-size: .84rem; color: var(--leise);
max-height: 13rem; overflow-y: auto; }
.protokoll div { padding: .16rem 0; border-top: 1px dotted var(--linie); }
.protokoll div:first-child { border-top: 0; }
.protokoll .neu { color: var(--akzent); }
footer { margin-top: 3rem; font-size: .82rem; color: var(--leise);
border-top: 1px solid var(--linie); padding-top: 1rem; }
</style>
</head>
<body>
<div class="huelle">
<h1>Frontend-Zustandsmodell nach dem WebSocket-Umbau</h1>
<p class="klein">Wegwerf-Prototyp zu Ticket 10 der Map <em>websocket-realtime</em>. Nur zum Durchklicken — kein Produktionscode.</p>
<div class="frage">
<strong>Die Frage, die dieser Prototyp beantwortet</strong>
<p>Der Client hält Verbindungen zu <strong>mehreren unabhängigen Servern</strong>. Der Rust-Layer besitzt die Verbindungen, führt die Cursor und lädt selbst nach; das Frontend bekommt <strong>fertige Entitäten</strong> geliefert und hat keine Cursor-Logik. Trägt dieses Zustandsmodell die unangenehmen Fälle — Nachricht in einer nicht geöffneten Conversation, optimistisch gesendete Nachricht, die gleich nochmal vom Server kommt, Bearbeitung, Löschung, erzwungener Neuabgleich, und ein einzelner ausgefallener Server neben funktionierenden?</p>
<p class="klein">Die beiden Schalter unten sind die eigentlich offenen Entscheidungen. Stell sie um und fahr dieselben Durchläufe nochmal — der Unterschied ist der Punkt.</p>
<div class="schalter">
<label><input type="checkbox" id="s-immer">
<span><strong>Rust lädt auch für nicht geöffnete Conversations nach.</strong> Aus: es kommt nur ein Signal, der Inhalt wird erst beim Öffnen geholt.</span></label>
<label><input type="checkbox" id="s-clientid" checked>
<span><strong>Der Client vergibt die Nachrichten-ID.</strong> Aus: der Server vergibt sie, der Client muss die eigene Nachricht am Echo wiedererkennen.</span></label>
</div>
</div>
<div class="raster">
<div>
<h2>Aktueller Zustand</h2>
<div class="tafel" id="zustand"></div>
<h2>Was gerade passiert ist</h2>
<div class="tafel protokoll" id="protokoll"></div>
</div>
<div>
<h2>Freies Ausprobieren</h2>
<div class="tafel">
<div class="knopfreihe" id="freispiel"></div>
</div>
<h2>Geführte Durchläufe</h2>
<div class="tafel">
<div class="reiter" id="reiter"></div>
<p id="szenario-text" class="klein"></p>
<ol class="schritte" id="schritte"></ol>
</div>
</div>
</div>
<footer>
Prototyp — wird nicht ausgeliefert. Der Reducer im ersten <code>&lt;script&gt;</code>-Block ist der Teil, der in echten Code übernommen werden kann; die Seite drumherum nicht.
</footer>
</div>
<script>
/* ------------------------------------------------------------------ *
* REINER REDUCER — dieser Block ist der übernehmbare Teil.
* Kein DOM, keine Knöpfe, keine Seite. (Zustand, Aktion) => Zustand.
* ------------------------------------------------------------------ */
const A = "server-a", B = "server-b";
function anfang() {
return {
optionen: { rustLaedtImmer: false, clientVergibtId: true },
offen: { serverId: A, conversationId: "allgemein" },
naechsteSeq: { "server-a/allgemein": 3, "server-a/design": 8, "server-b/team": 5 },
server: {
[A]: {
name: "chat.example.org", verbindung: "verbunden",
conversations: {
allgemein: { name: "#allgemein", geladen: true, ungelesen: 0, nachrichten: [
{ id: "m1", seq: 1, autor: "Ida", text: "Moin.", zustand: "ok" },
{ id: "m2", seq: 2, autor: "Ruben", text: "Läuft der Server?", zustand: "ok" },
]},
design: { name: "#design", geladen: false, ungelesen: 0, nachrichten: [] },
},
},
[B]: {
name: "intern.firma.de", verbindung: "verbunden",
conversations: {
team: { name: "#team", geladen: false, ungelesen: 0, nachrichten: [] },
},
},
},
};
}
const istOffen = (s, srv, conv) =>
s.offen.serverId === srv && s.offen.conversationId === conv;
function sortiert(liste) {
// Anzeigereihenfolge ist ausschliesslich seq. Optimistische Nachrichten
// haben noch keine und hängen hinten an.
return [...liste].sort((x, y) => (x.seq ?? Infinity) - (y.seq ?? Infinity));
}
function reduce(zustand, aktion) {
const s = structuredClone(zustand);
const notiz = (t) => { s.letzteNotiz = t; };
switch (aktion.art) {
case "option": {
s.optionen[aktion.name] = aktion.wert;
notiz(`Option „${aktion.name}“ ist jetzt ${aktion.wert ? "an" : "aus"}.`);
return s;
}
case "conversationGeoeffnet": {
const { serverId, conversationId } = aktion;
s.offen = { serverId, conversationId };
const c = s.server[serverId].conversations[conversationId];
if (!c.geladen) {
// Beim Öffnen wird geholt, was noch fehlt.
c.geladen = true;
c.nachrichten = aktion.inhalt ?? c.nachrichten;
notiz(`${c.name} geöffnet — Inhalt wurde beim Öffnen nachgeladen.`);
} else {
notiz(`${c.name} geöffnet.`);
}
c.ungelesen = 0;
return s;
}
/* Rust hat ein Signal bekommen, nachgeladen und liefert Entitäten hoch.
Ob überhaupt nachgeladen wurde, entscheidet die Option. */
case "entitaetenEingetroffen": {
const { serverId, conversationId, entitaeten } = aktion;
const srv = s.server[serverId];
const c = srv.conversations[conversationId];
const sichtbar = istOffen(s, serverId, conversationId);
if (!sichtbar && !s.optionen.rustLaedtImmer) {
// Nur ein Signal: wir wissen DASS sich etwas geändert hat, nicht WAS.
c.ungelesen = "•";
notiz(`${srv.name} · ${c.name}: Signal ohne Inhalt — Markierung gesetzt, Anzahl unbekannt.`);
return s;
}
for (const e of entitaeten) {
const i = c.nachrichten.findIndex((m) => m.id === e.id);
if (i === -1) {
c.nachrichten.push({ ...e, zustand: e.zustand ?? "ok" });
} else {
// Ersetzen, nicht anhängen. seq der Nachricht bleibt, also
// bewegt sich eine bearbeitete Nachricht NICHT im Verlauf.
c.nachrichten[i] = { ...c.nachrichten[i], ...e };
}
}
c.geladen = true;
c.nachrichten = sortiert(c.nachrichten);
if (sichtbar) {
c.ungelesen = 0;
notiz(`${c.name}: ${entitaeten.length} Entität(en) übernommen — offen, also direkt sichtbar.`);
} else {
c.ungelesen = (typeof c.ungelesen === "number" ? c.ungelesen : 0) + entitaeten.length;
notiz(`${srv.name} · ${c.name}: nachgeladen im Hintergrund, Anzahl bekannt (${c.ungelesen}).`);
}
return s;
}
/* Optimistisches Senden. */
case "gesendet": {
const { serverId, conversationId, text } = aktion;
const c = s.server[serverId].conversations[conversationId];
const id = s.optionen.clientVergibtId ? aktion.clientId : null;
c.nachrichten.push({
id, clientId: aktion.clientId, seq: null,
autor: "ich", text, zustand: "pending",
});
notiz(`Gesendet: „${text}“ — erscheint sofort, noch ohne seq.`);
return s;
}
/* Die eigene Nachricht kommt über den Realtime-Weg zurück. */
case "eigeneNachrichtZurueck": {
const { serverId, conversationId, clientId, text } = aktion;
const c = s.server[serverId].conversations[conversationId];
const schluessel = `${serverId}/${conversationId}`;
const seq = s.naechsteSeq[schluessel]++;
// Zusammenführen: nur möglich, wenn es etwas gibt, woran man
// die eigene Nachricht wiedererkennt.
const i = s.optionen.clientVergibtId
? c.nachrichten.findIndex((m) => m.clientId === clientId)
: -1;
if (i === -1) {
c.nachrichten.push({
id: `srv-${seq}`, seq, autor: "ich", text, zustand: "ok",
});
notiz(s.optionen.clientVergibtId
? `Zurück vom Server — nichts zum Zusammenführen gefunden, angehängt.`
: `DOPPELT: Server vergab eine eigene ID, die optimistische Nachricht bleibt daneben stehen.`);
} else {
c.nachrichten[i] = { ...c.nachrichten[i], id: clientId, seq, zustand: "ok" };
notiz(`Zurück vom Server — über die Client-ID zusammengeführt, kein Duplikat.`);
}
c.nachrichten = sortiert(c.nachrichten);
return s;
}
case "bearbeitet": {
const { serverId, conversationId, id, text } = aktion;
const c = s.server[serverId].conversations[conversationId];
const m = c.nachrichten.find((x) => x.id === id);
if (m) { m.text = text; m.bearbeitet = true; }
notiz(`Bearbeitet — seq bleibt ${m ? m.seq : "?"}, die Nachricht springt also nicht.`);
return s;
}
case "geloescht": {
const { serverId, conversationId, id } = aktion;
const c = s.server[serverId].conversations[conversationId];
const m = c.nachrichten.find((x) => x.id === id);
if (m) { m.zustand = "deleted"; m.text = "(gelöscht)"; }
notiz(`Gelöscht — kommt als Grabstein mit, nicht als Auslassung.`);
return s;
}
/* Server kann kein Delta bilden: Zwischenspeicher wegwerfen. */
case "resync": {
const { serverId, conversationId, inhalt } = aktion;
const srv = s.server[serverId];
const c = srv.conversations[conversationId];
const sichtbar = istOffen(s, serverId, conversationId);
c.nachrichten = sichtbar ? inhalt : [];
c.geladen = sichtbar;
c.ungelesen = sichtbar ? 0 : "•";
notiz(sichtbar
? `${c.name}: Neuabgleich — Zwischenspeicher verworfen und frisch geladen.`
: `${srv.name} · ${c.name}: Neuabgleich — Zwischenspeicher verworfen, Laden beim Öffnen.`);
return s;
}
case "verbindungVerloren": {
s.server[aktion.serverId].verbindung = "neuverbinden";
notiz(`${s.server[aktion.serverId].name} getrennt — versucht neu zu verbinden. Andere Server unberührt.`);
return s;
}
case "verbindungZurueck": {
const srv = s.server[aktion.serverId];
srv.verbindung = "verbunden";
notiz(`${srv.name} wieder verbunden — Rust zieht das Manifest und gleicht ab.`);
return s;
}
case "verdraengt": {
s.server[aktion.serverId].verbindung = "abgemeldet";
notiz(`${s.server[aktion.serverId].name}: von neuerer Verbindung verdrängt (4002) — es wird NICHT neu verbunden.`);
return s;
}
case "zuruecksetzen":
return { ...anfang(), optionen: zustand.optionen, letzteNotiz: "Zurückgesetzt." };
default:
return s;
}
}
/* ------------------------------------------------------------------ *
* Ende des übernehmbaren Teils. Alles darunter ist Wegwerf-Seite.
* ------------------------------------------------------------------ */
</script>
<script>
let zustand = anfang();
let protokoll = [];
let aktivesSzenario = 0;
let schrittIndex = 0;
const el = (id) => document.getElementById(id);
function schicke(aktion) {
zustand = reduce(zustand, aktion);
if (zustand.letzteNotiz) protokoll.unshift(zustand.letzteNotiz);
protokoll = protokoll.slice(0, 40);
zeichne();
}
/* ---- Aktionen für das freie Ausprobieren ---- */
let zaehler = 0;
const neuerText = () => ["Kurze Frage.", "Bin gleich da.", "Sieht gut aus.", "Passt so.", "Melde mich später."][zaehler++ % 5];
const freispiel = [
{ titel: "#allgemein öffnen", tu: () => schicke({ art: "conversationGeoeffnet", serverId: A, conversationId: "allgemein" }) },
{ titel: "#design öffnen", tu: () => schicke({ art: "conversationGeoeffnet", serverId: A, conversationId: "design",
inhalt: [{ id: "d1", seq: 6, autor: "Ida", text: "Entwurf liegt bereit.", zustand: "ok" },
{ id: "d2", seq: 7, autor: "Nour", text: "Schaue ich mir an.", zustand: "ok" }] }) },
{ titel: "#team öffnen (Server B)", tu: () => schicke({ art: "conversationGeoeffnet", serverId: B, conversationId: "team",
inhalt: [{ id: "t1", seq: 4, autor: "Milan", text: "Standup um zehn.", zustand: "ok" }] }) },
{ titel: "Neue Nachricht in #allgemein", tu: () => schicke({ art: "entitaetenEingetroffen", serverId: A, conversationId: "allgemein",
entitaeten: [{ id: `x${++zaehler}`, seq: zustand.naechsteSeq["server-a/allgemein"]++, autor: "Ruben", text: neuerText() }] }) },
{ titel: "Neue Nachricht in #design", tu: () => schicke({ art: "entitaetenEingetroffen", serverId: A, conversationId: "design",
entitaeten: [{ id: `y${++zaehler}`, seq: zustand.naechsteSeq["server-a/design"]++, autor: "Nour", text: neuerText() }] }) },
{ titel: "Neue Nachricht in #team (Server B)", tu: () => schicke({ art: "entitaetenEingetroffen", serverId: B, conversationId: "team",
entitaeten: [{ id: `z${++zaehler}`, seq: zustand.naechsteSeq["server-b/team"]++, autor: "Milan", text: neuerText() }] }) },
{ titel: "Ich sende etwas", tu: () => { const cid = `c${++zaehler}`; letzteEigene = { cid, text: neuerText() };
schicke({ art: "gesendet", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, clientId: cid, text: letzteEigene.text }); } },
{ titel: "…und sie kommt zurück", tu: () => { if (!letzteEigene) return;
schicke({ art: "eigeneNachrichtZurueck", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, clientId: letzteEigene.cid, text: letzteEigene.text }); } },
{ titel: "Älteste Nachricht bearbeiten", tu: () => { const c = aktuelleConv(); const m = c.nachrichten.find(x => x.zustand === "ok");
if (m) schicke({ art: "bearbeitet", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, id: m.id, text: m.text + " (korrigiert)" }); } },
{ titel: "Älteste Nachricht löschen", tu: () => { const c = aktuelleConv(); const m = c.nachrichten.find(x => x.zustand === "ok");
if (m) schicke({ art: "geloescht", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, id: m.id }); } },
{ titel: "Server verlangt Neuabgleich", tu: () => schicke({ art: "resync", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId,
inhalt: [{ id: "r1", seq: 20, autor: "Ida", text: "Frisch geladen nach Neuabgleich.", zustand: "ok" }] }) },
{ titel: "Server A verliert Verbindung", tu: () => schicke({ art: "verbindungVerloren", serverId: A }) },
{ titel: "Server A ist zurück", tu: () => schicke({ art: "verbindungZurueck", serverId: A }) },
{ titel: "Server A: verdrängt (4002)", tu: () => schicke({ art: "verdraengt", serverId: A }) },
{ titel: "Alles zurücksetzen", tu: () => { protokoll = []; letzteEigene = null; schicke({ art: "zuruecksetzen" }); } },
];
let letzteEigene = null;
const aktuelleConv = () => zustand.server[zustand.offen.serverId].conversations[zustand.offen.conversationId];
/* ---- Geführte Durchläufe ---- */
const szenarien = [
{ name: "Nachricht kommt an",
text: "Der einfache Fall. Eine Nachricht trifft in der geöffneten Conversation ein. Achte darauf, dass das Frontend nur fertige Entitäten einsetzt — es rechnet nichts aus und fragt nichts nach.",
schritte: [freispiel[0], freispiel[3], freispiel[3]] },
{ name: "Nachricht anderswo",
text: "Die Nachricht kommt in einer Conversation an, die NICHT geöffnet ist. Fahr diesen Durchlauf zweimal — einmal mit dem Schalter „Rust lädt auch für nicht geöffnete Conversations nach“ aus, einmal an. Aus bekommst du nur einen Punkt, weil niemand die Anzahl kennt; an bekommst du eine Zahl, aber Requests für Inhalt, den vielleicht niemand ansieht.",
schritte: [freispiel[0], freispiel[4], freispiel[4], freispiel[1]] },
{ name: "Optimistisch senden",
text: "Die eigene Nachricht erscheint sofort und kommt gleich darauf über den Realtime-Weg zurück. Mit Client-ID wird zusammengeführt. Schalte „Der Client vergibt die Nachrichten-ID“ AUS und fahr den Durchlauf erneut — dann steht dieselbe Nachricht zweimal da.",
schritte: [freispiel[0], freispiel[6], freispiel[7]] },
{ name: "Bearbeiten und Löschen",
text: "Eine bereits sichtbare Nachricht wird geändert und dann gelöscht. Achte auf die Position: die Sortierung folgt der seq der Nachricht, nicht dem Änderungszeitpunkt — sie springt also nicht ans Ende. Und Löschen erzeugt einen Grabstein, keine Lücke.",
schritte: [freispiel[0], freispiel[8], freispiel[9]] },
{ name: "Neuabgleich",
text: "Der Server kann kein Delta bilden — Cursor zu alt, Limit überschritten, oder der Listener hatte einen Aussetzer. Der Zwischenspeicher dieser Conversation wird verworfen und frisch geladen. Das ist derselbe Pfad, den auch die Erstverbindung nimmt.",
schritte: [freispiel[0], freispiel[3], freispiel[10]] },
{ name: "Ein Server fällt aus",
text: "Server A verliert die Verbindung, während Server B weiterläuft — der Zustand ist pro Server, nicht global. Danach wird Server A verdrängt (Close-Code 4002): dieser Zustand ist endgültig, es wird bewusst NICHT neu verbunden, sonst verdrängen sich zwei Geräte im Kreis.",
schritte: [freispiel[0], freispiel[11], freispiel[5], freispiel[12], freispiel[13]] },
];
/* ---- Zeichnen ---- */
function zeichne() {
el("s-immer").checked = zustand.optionen.rustLaedtImmer;
el("s-clientid").checked = zustand.optionen.clientVergibtId;
el("zustand").innerHTML = Object.entries(zustand.server).map(([sid, srv]) => {
const zk = { verbunden: "verbunden", neuverbinden: "verbindet neu", getrennt: "getrennt", abgemeldet: "abgemeldet (4002)" }[srv.verbindung];
const convs = Object.entries(srv.conversations).map(([cid, c]) => {
const offen = istOffen(zustand, sid, cid);
const marke = c.ungelesen === "•" ? "• ungelesen"
: (typeof c.ungelesen === "number" && c.ungelesen > 0) ? `${c.ungelesen} neu` : "";
const liste = offen && c.nachrichten.length
? `<ul class="msgs">${sortiert(c.nachrichten).map(m =>
`<li class="${m.zustand === "pending" ? "pending" : m.zustand === "deleted" ? "deleted" : ""}">` +
`<span class="seq">${m.seq ?? "—"}</span>${m.autor}: ${m.text}${m.bearbeitet ? " ✎" : ""}</li>`).join("")}</ul>`
: (offen ? `<ul class="msgs"><li>— leer —</li></ul>` : "");
return `<div class="conv ${offen ? "offen" : ""}"><span>${c.name}${c.geladen ? "" : ' <span class="klein">(nicht geladen)</span>'}</span><span class="marke">${marke}</span></div>${liste}`;
}).join("");
return `<div class="server"><div class="serverkopf"><span class="name">${srv.name}</span>` +
`<span class="zustand ${srv.verbindung}">${zk}</span></div>${convs}</div>`;
}).join("");
el("protokoll").innerHTML = protokoll.length
? protokoll.map((t, i) => `<div class="${i === 0 ? "neu" : ""}">${t}</div>`).join("")
: `<div class="klein">Noch nichts passiert. Drück einen Knopf.</div>`;
el("freispiel").innerHTML = "";
freispiel.forEach((a, i) => {
const b = document.createElement("button");
b.textContent = a.titel;
b.onclick = a.tu;
el("freispiel").appendChild(b);
});
el("reiter").innerHTML = "";
szenarien.forEach((sz, i) => {
const b = document.createElement("button");
b.textContent = sz.name;
if (i === aktivesSzenario) b.className = "aktiv";
b.onclick = () => { aktivesSzenario = i; schrittIndex = 0; protokoll = [];
letzteEigene = null; schicke({ art: "zuruecksetzen" }); };
el("reiter").appendChild(b);
});
const sz = szenarien[aktivesSzenario];
el("szenario-text").textContent = sz.text;
el("schritte").innerHTML = "";
sz.schritte.forEach((schritt, i) => {
const li = document.createElement("li");
const nr = document.createElement("span");
nr.className = "nr"; nr.textContent = `${i + 1}.`;
const b = document.createElement("button");
b.textContent = schritt.titel;
b.disabled = i !== schrittIndex;
if (i === schrittIndex) b.className = "jetzt";
if (i < schrittIndex) b.classList.add("erledigt");
b.onclick = () => { schritt.tu(); schrittIndex = i + 1; zeichne(); };
li.append(nr, b);
el("schritte").appendChild(li);
});
}
el("s-immer").onchange = (e) => schicke({ art: "option", name: "rustLaedtImmer", wert: e.target.checked });
el("s-clientid").onchange = (e) => schicke({ art: "option", name: "clientVergibtId", wert: e.target.checked });
zeichne();
</script>
</body>
</html>

123
README.md Normal file
View File

@@ -0,0 +1,123 @@
# rustification-client
Desktop chat client for Rustification. Built with Tauri v2 (Rust backend) and React + TypeScript (frontend).
## Tech stack
| Layer | Technology |
|-------|-----------|
| Desktop shell | [Tauri](https://tauri.app) v2 |
| Frontend | React 18 + TypeScript, Vite |
| HTTP | reqwest (Rust) via Tauri commands |
| Persistence | tauri-plugin-store (JWT token, user ID) |
| Build (Linux) | nix-shell |
## Features
- **Login / Register** — authenticates against the rustification-server, stores JWT via tauri-plugin-store
- **Workspace list** — browse workspaces the current user belongs to
- **Chat page** — conversation list, message history, and real-time polling for new messages
- **Message input** — send messages to any conversation
- **Member panel** — view conversation members
- **Settings page** — user preferences
## Prerequisites
- [Rust](https://rustup.rs/) + Cargo
- [Node.js](https://nodejs.org/) 18+ and npm
- Tauri v2 system dependencies (see below)
- A running [rustification-server](../rustification-server)
### System dependencies (Linux)
The project ships a `shell.nix`. If you use Nix, enter the dev shell first:
```bash
nix-shell
```
This provides: `cargo`, `rustc`, `pkg-config`, `glib`, `gtk3`, `webkitgtk_4_1`, `libsoup_3`, `openssl`, `librsvg`.
Without Nix, install the equivalent packages via your system package manager. On Debian/Ubuntu:
```bash
sudo apt install libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev \
libayatana-appindicator3-dev librsvg2-dev
```
## Getting started
```bash
# Install frontend dependencies
npm install
# Start in development mode (hot-reload frontend + Tauri window)
# Must be run inside nix-shell on NixOS / Nix systems
npm run tauri dev
```
The client defaults to `http://localhost:3000/api/v1` as the server base URL (configurable at runtime via Settings).
## Build
```bash
# Must be run inside nix-shell on NixOS / Nix systems
npm run tauri build
```
Output bundles (`.deb`, `.AppImage`, etc.) are written to `src-tauri/target/release/bundle/`.
## Project structure
```
src/ # React frontend
├── main.tsx # Entry point
├── App.tsx # Router + AuthContext provider
├── theme.ts # MUI / global theme
├── styles.css
├── contexts/
│ └── AuthContext.tsx # Auth state (token, user ID, login/logout)
├── pages/
│ ├── LoginPage.tsx
│ ├── WorkspacePage.tsx
│ ├── ChatPage.tsx
│ └── SettingsPage.tsx
└── components/
├── ConversationList.tsx
├── MessageList.tsx
├── MessageInput.tsx
├── MemberPanel.tsx
└── Toast.tsx
src-tauri/ # Tauri Rust backend
├── Cargo.toml
└── src/
├── lib.rs # Tauri builder, plugin + command registration
├── main.rs
├── state.rs # AppState: reqwest client, token, base URL
├── models.rs # Shared response types
└── commands/
├── user.rs # login, register, refresh_session, logout
├── workspace.rs # list_workspaces
├── conversation.rs # list_conversations
└── message.rs # list_messages, send_message
```
## Tauri commands
The frontend calls these via `invoke()`:
| Command | Description |
|---------|-------------|
| `login` | Authenticates and stores JWT + user ID |
| `register` | Registers a new account |
| `refresh_session` | Refreshes the JWT using the stored refresh token |
| `logout` | Clears stored credentials |
| `list_workspaces` | Returns workspaces for the current user |
| `list_conversations` | Returns conversations for a workspace |
| `list_messages` | Returns messages for a conversation |
| `send_message` | Posts a new message |
## Server connection
The default base URL is `http://localhost:3000/api/v1`. To point the client at a different server, update the URL in Settings — it is persisted in the Tauri store across sessions.

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -0,0 +1,17 @@
use crate::models::Conversation;
use crate::state::{AppState, BASE_URL};
#[tauri::command]
pub async fn list_conversations(
state: tauri::State<'_, AppState>,
workspace_id: String,
) -> Result<Vec<Conversation>, String> {
state
.authed_get(&format!("{}/conversations?workspace_id={}", BASE_URL, workspace_id))
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,47 @@
use crate::models::Message;
use crate::state::{AppState, BASE_URL};
#[tauri::command]
pub async fn list_messages(
state: tauri::State<'_, AppState>,
conversation_id: String,
) -> Result<Vec<Message>, String> {
state
.authed_get(&format!("{}/conversations/{}/messages", BASE_URL, conversation_id))
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn send_message(
state: tauri::State<'_, AppState>,
conversation_id: String,
content: String,
) -> Result<Message, String> {
let user_id = state
.user_id
.lock()
.unwrap()
.clone()
.ok_or_else(|| "Not logged in".to_string())?;
let body = serde_json::json!({
"conversation_id": conversation_id,
"user_id": user_id,
"content": content,
});
state
.authed_post(&format!("{}/messages", BASE_URL))
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,4 @@
pub mod conversation;
pub mod message;
pub mod user;
pub mod workspace;

View File

@@ -0,0 +1,42 @@
use crate::models::{AuthTokenResponse, User};
use crate::state::{AppState, BASE_URL};
#[tauri::command]
pub async fn login(state: tauri::State<'_, AppState>, email: String) -> Result<User, String> {
let users: Vec<User> = state
.client
.get(format!("{}/users", BASE_URL))
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
let user = users
.into_iter()
.find(|u| u.email == email)
.ok_or_else(|| "User not found".to_string())?;
let body = serde_json::json!({
"user_id": user.id,
"kind": "refresh_token",
"expires_at": "2027-01-01T00:00:00Z"
});
let token_resp: AuthTokenResponse = state
.client
.post(format!("{}/auth-tokens", BASE_URL))
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
*state.token.lock().unwrap() = Some(token_resp.token);
*state.user_id.lock().unwrap() = Some(user.id.clone());
Ok(user)
}

View File

@@ -0,0 +1,14 @@
use crate::models::Workspace;
use crate::state::{AppState, BASE_URL};
#[tauri::command]
pub async fn list_workspaces(state: tauri::State<'_, AppState>) -> Result<Vec<Workspace>, String> {
state
.authed_get(&format!("{}/workspaces", BASE_URL))
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())
}

View File

@@ -1,11 +1,20 @@
mod commands;
mod models;
mod state;
use state::AppState;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(AppState::new())
.invoke_handler(tauri::generate_handler![
commands::user::login,
commands::workspace::list_workspaces,
commands::conversation::list_conversations,
commands::message::list_messages,
commands::message::send_message,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -16,4 +16,21 @@ impl AppState {
user_id: Mutex::new(None),
}
}
pub fn authed_get(&self, url: &str) -> reqwest::RequestBuilder {
let builder = self.client.get(url);
// ponytail: omit header when no token — server may not require auth yet
match self.token.lock().unwrap().clone() {
Some(t) => builder.bearer_auth(t),
None => builder,
}
}
pub fn authed_post(&self, url: &str) -> reqwest::RequestBuilder {
let builder = self.client.post(url);
match self.token.lock().unwrap().clone() {
Some(t) => builder.bearer_auth(t),
None => builder,
}
}
}

14
src/App.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { Routes, Route } from "react-router-dom";
import LoginPage from "./pages/LoginPage";
import WorkspacePage from "./pages/WorkspacePage";
import ChatPage from "./pages/ChatPage";
export default function App() {
return (
<Routes>
<Route path="/" element={<LoginPage />} />
<Route path="/workspaces" element={<WorkspacePage />} />
<Route path="/workspaces/:workspaceId" element={<ChatPage />} />
</Routes>
);
}

View File

@@ -0,0 +1,47 @@
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
interface Conversation {
id: string;
name: string | null;
kind: string;
}
interface Props {
workspaceId: string;
onSelect: (id: string) => void;
selectedId: string | null;
}
export default function ConversationList({ workspaceId, onSelect, selectedId }: Props) {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
invoke<Conversation[]>("list_conversations", { workspaceId })
.then(setConversations)
.catch((e) => setError(String(e)));
}, [workspaceId]);
return (
<div style={{ width: 220, borderRight: "1px solid #ccc", overflowY: "auto" }}>
<div style={{ padding: "12px 16px", fontWeight: "bold", borderBottom: "1px solid #ccc" }}>
Conversations
</div>
{error && <p style={{ color: "red", padding: 8 }}>{error}</p>}
{conversations.map((c) => (
<div
key={c.id}
onClick={() => onSelect(c.id)}
style={{
padding: "10px 16px",
cursor: "pointer",
background: selectedId === c.id ? "#e8f0fe" : "transparent",
}}
>
{c.name ?? `(${c.kind})`}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
interface Props {
conversationId: string;
onSent: () => void;
}
export default function MessageInput({ conversationId, onSent }: Props) {
const [content, setContent] = useState("");
const [error, setError] = useState<string | null>(null);
async function handleSend() {
if (!content.trim()) return;
try {
await invoke("send_message", { conversationId, content });
setContent("");
setError(null);
onSent();
} catch (e) {
setError(String(e));
}
}
return (
<div style={{ padding: 16, borderTop: "1px solid #ccc" }}>
{error && <p style={{ color: "red", margin: "0 0 8px" }}>{error}</p>}
<input
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Type a message and press Enter…"
style={{ width: "100%", padding: 10, boxSizing: "border-box", fontSize: 14 }}
/>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { useState, useEffect, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
interface Message {
id: string;
user_id: string;
content: string;
created_at: string;
}
interface Props {
conversationId: string;
refreshKey: number;
}
export default function MessageList({ conversationId, refreshKey }: Props) {
const [messages, setMessages] = useState<Message[]>([]);
const [error, setError] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const prevLengthRef = useRef(0);
// Fix 3: cancellation flag prevents stale responses from overwriting new conversation
useEffect(() => {
let cancelled = false;
const load = () =>
invoke<Message[]>("list_messages", { conversationId })
.then((msgs) => {
if (!cancelled) setMessages(msgs);
})
.catch((e) => { if (!cancelled) setError(String(e)); });
load();
// ponytail: polling every 3s — replace with WebSocket when server supports it
const id = setInterval(load, 3000);
return () => { cancelled = true; clearInterval(id); prevLengthRef.current = 0; };
// Fix 2: refreshKey in deps triggers immediate reload after send
}, [conversationId, refreshKey]);
// Fix 4: scroll only when message count increases, not on every poll
useEffect(() => {
if (messages.length > prevLengthRef.current) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
prevLengthRef.current = messages.length;
}
}, [messages.length]);
return (
<div style={{ flex: 1, overflowY: "auto", padding: 16 }}>
{error && <p style={{ color: "red" }}>{error}</p>}
{messages.map((m) => (
<div key={m.id} style={{ marginBottom: 12 }}>
<strong style={{ marginRight: 8 }}>{m.user_id}</strong>
<span style={{ color: "#888", fontSize: 12, marginRight: 8 }}>
{new Date(m.created_at).toLocaleTimeString()}
</span>
<div>{m.content}</div>
</div>
))}
<div ref={bottomRef} />
</div>
);
}

View File

@@ -1,8 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<div>Loading…</div>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

36
src/pages/ChatPage.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import ConversationList from "../components/ConversationList";
import MessageList from "../components/MessageList";
import MessageInput from "../components/MessageInput";
export default function ChatPage() {
const { workspaceId } = useParams<{ workspaceId: string }>();
const [conversationId, setConversationId] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
return (
<div style={{ display: "flex", height: "100vh" }}>
<ConversationList
workspaceId={workspaceId!}
onSelect={setConversationId}
selectedId={conversationId}
/>
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
{conversationId ? (
<>
<MessageList conversationId={conversationId} refreshKey={refreshKey} />
<MessageInput
conversationId={conversationId}
onSent={() => setRefreshKey((k) => k + 1)}
/>
</>
) : (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#888" }}>
Select a conversation
</div>
)}
</div>
</div>
);
}

41
src/pages/LoginPage.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useNavigate } from "react-router-dom";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
async function handleLogin() {
setError(null);
setLoading(true);
try {
await invoke("login", { email });
navigate("/workspaces");
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}
return (
<div style={{ maxWidth: 400, margin: "100px auto", padding: 24 }}>
<h1>Rustification</h1>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
placeholder="Email address"
style={{ display: "block", width: "100%", marginBottom: 12, padding: 8, boxSizing: "border-box" }}
/>
<button onClick={handleLogin} disabled={loading || !email}>
{loading ? "Logging in…" : "Login"}
</button>
{error && <p style={{ color: "red" }}>{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useNavigate } from "react-router-dom";
interface Workspace {
id: string;
name: string;
slug: string;
}
export default function WorkspacePage() {
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
useEffect(() => {
invoke<Workspace[]>("list_workspaces")
.then(setWorkspaces)
.catch((e) => setError(String(e)));
}, []);
return (
<div style={{ maxWidth: 600, margin: "60px auto", padding: 24 }}>
<h1>Workspaces</h1>
{error && <p style={{ color: "red" }}>{error}</p>}
{workspaces.map((ws) => (
<div
key={ws.id}
onClick={() => navigate(`/workspaces/${ws.id}`)}
style={{ padding: 16, border: "1px solid #ccc", marginBottom: 8, cursor: "pointer", borderRadius: 4 }}
>
<strong>{ws.name}</strong>
<span style={{ marginLeft: 8, color: "#888" }}>/{ws.slug}</span>
</div>
))}
{workspaces.length === 0 && !error && <p>No workspaces found.</p>}
</div>
);
}