diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 5d65206..567c5ee 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1,6 +1,7 @@
mod config;
mod pty;
mod room;
+mod room_log;
mod session;
use pty::PtyState;
@@ -46,6 +47,7 @@ pub fn run() {
room::room_participants,
room::room_join,
room::room_post,
+ room_log::room_log,
session::seated_accounts,
session::parse_launch_options,
session::preview_launch_args,
diff --git a/src-tauri/src/room.rs b/src-tauri/src/room.rs
index 10879d9..accc533 100644
--- a/src-tauri/src/room.rs
+++ b/src-tauri/src/room.rs
@@ -73,7 +73,14 @@
//!
//! Everything the frontend needs arrives as a `room-message` event. The room
//! never reads a CLI's terminal output; that is not a message source.
+//!
+//! What is admitted is also written to the room's log, inside the same
+//! acquisition it was judged under, so the file's order is the floor's order
+//! (`room_log`). A post is never held back on account of that write: it is in
+//! the room before the disk is touched, and a failure there costs the record,
+//! not the utterance (#48).
+use crate::room_log;
use futures_util::{SinkExt, StreamExt};
use parking_lot::Mutex;
use room_floor::{Admission, Floor, Missed, Post};
@@ -462,7 +469,7 @@ fn deliver(
// One acquisition, both halves. Concurrent speakers serialise here, so the
// loser's check runs against a floor the winner has already changed.
- let (admission, hue) = {
+ let (admission, hue, logged) = {
let mut inner = room.inner.lock();
let (since, hue) = match inner.participants.get(origin) {
Some(seat) => (seat.since, seat.hue),
@@ -476,7 +483,24 @@ fn deliver(
// refusal hands that copy back, and the screen draws it (#108).
post.hue = hue;
let admission = inner.floor.admit(origin, since, last_seen, post.clone());
- (admission, hue)
+ // Written here, inside the acquisition the floor was judged under, so
+ // the file's order is the floor's order. Appending after the lock is
+ // dropped would let two speakers the floor has already ordered reach
+ // the disk the other way round, and the conversation would then have
+ // two orderings — which is the one thing the room is the authority on
+ // (#48).
+ //
+ // Only what was admitted. A refused post is not in the room, so there
+ // is nothing about it for the room to have recorded.
+ //
+ // The failure is carried out rather than reported here: the room is
+ // stopped while this lock is held, and telling the screen is not work
+ // to do with everyone waiting.
+ let logged = match &admission {
+ Admission::Admitted { .. } => room_log::append(app, &post),
+ Admission::Unseen(_) => Ok(()),
+ };
+ (admission, hue, logged)
};
if let Admission::Unseen(missed) = admission {
@@ -487,6 +511,14 @@ fn deliver(
};
}
+ // The post is in the room either way. A log that cannot be written loses
+ // the record, never the utterance — so nothing below is conditional on
+ // this, and the one thing that must not happen is it passing unnoticed
+ // (#48).
+ if let Err(err) = logged {
+ room_log::report(app, err);
+ }
+
let mut frame = serde_json::json!({
"type": "post",
"message_id": post.message_id,
diff --git a/src-tauri/src/room_log.rs b/src-tauri/src/room_log.rs
new file mode 100644
index 0000000..fa3ddf8
--- /dev/null
+++ b/src-tauri/src/room_log.rs
@@ -0,0 +1,188 @@
+//! The room's log.
+//!
+//! What was said in the room, kept as one append-only jsonl file so a past
+//! exchange can be read back after the window that showed it has closed. The
+//! room itself keeps nothing: its lines live in the DOM, and closing the app
+//! took them (#48).
+//!
+//! The log is the room's, not a tab's. `config.rs` already holds a saving
+//! apparatus — `SavedChatMessage` / `SessionData` / `TabSessions` — and none of
+//! it is used here, deliberately. `SavedChatMessage` carries `role` and
+//! `content_type`, which is a chat assistant's vocabulary: the room has no axis
+//! `role` lands on, because a person and a session are one kind of participant
+//! and what separates two lines is the name on them (#39). `TabSessions` is
+//! keyed on a tab, and a tab is how something is launched, not the vessel a
+//! conversation happens in. Wiring either through would put the asymmetry #39
+//! removed back into the app from underneath.
+//!
+//! One line is one post, and it carries the five fields a post is:
+//! `message_id` / `speaker` / `content` / `to` / `ts`.
+//!
+//! `hue` is not among them. It is a declaration the speaker made at the moment
+//! of joining, and the seat that held it is gone by the time this file is read
+//! back; what a later reader would draw is a colour nobody is declaring any
+//! more. `own` is not among them either, and could not be: the type it comes
+//! from says so itself — it is a property of whoever is looking, not of whoever
+//! spoke (`room.rs`) — so a file that recorded it would be recording one
+//! viewer's position as if it were part of the utterance.
+//!
+//! The file name is `logs/{room}.jsonl`, which is where `design/Vision.dc.html`
+//! put it. The room's name occupies a position in that path and is fixed at
+//! `main`: several rooms are not implemented, and the path is shaped so that
+//! implementing them adds a value here rather than a directory level (#48).
+//!
+//! No rotation and no ceiling. Vision holds one `main.jsonl` carrying no date,
+//! and a log that drops its own oldest entries answers the question this exists
+//! for — what was actually said — with silence at exactly the distance that
+//! makes the question worth asking.
+
+use room_floor::Post;
+use serde::{Deserialize, Serialize};
+use std::io::Write;
+use std::path::PathBuf;
+use tauri::{AppHandle, Emitter, Manager};
+
+/// The room whose log this is, as it appears in the file name.
+///
+/// One room, and this constant is the seam rather than the omission: what a
+/// second room would need is another value here, not another shape of path.
+const ROOM_NAME: &str = "main";
+
+/// The event a failure on this surface reaches the screen on.
+///
+/// A post is never lost to a log failure — it is in the room before anything is
+/// written, and the write cannot take it back out. What can be lost is the
+/// record of it, and that is the thing this event exists to stop happening
+/// quietly (#48).
+const LOG_ERROR_EVENT: &str = "room-log-error";
+
+/// One post, as the log holds it.
+///
+/// The same five fields going in and coming out.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LoggedPost {
+ pub message_id: String,
+ pub speaker: String,
+ pub content: String,
+ /// The participant this was addressed to, or absent when it was said to the
+ /// room. Omitted rather than written as null, so the two states are the
+ /// field's presence.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub to: Option
,
+ pub ts: String,
+}
+
+impl LoggedPost {
+ /// The five fields of a post, taken off the post itself.
+ ///
+ /// A mapping rather than a `Serialize` on `Post`: `Post` carries `hue` as
+ /// well, and a derive would put it in the file. What is dropped here is
+ /// dropped on purpose, and this is where that is legible.
+ fn of(post: &Post) -> Self {
+ LoggedPost {
+ message_id: post.message_id.clone(),
+ speaker: post.speaker.clone(),
+ content: post.content.clone(),
+ to: post.to.clone(),
+ ts: post.ts.clone(),
+ }
+ }
+}
+
+fn log_path(app: &AppHandle) -> Result {
+ let dir = app
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("Failed to resolve app data dir: {e}"))?;
+ Ok(dir.join("logs").join(format!("{ROOM_NAME}.jsonl")))
+}
+
+/// Put one post at the end of the log.
+///
+/// Called from inside the room's own critical section, which is what makes the
+/// file's order the floor's order. The alternative — appending once the lock is
+/// dropped — leaves two speakers the floor has already ordered to reach the disk
+/// in whichever order the scheduler hands them, and the room would then hold two
+/// orderings of one conversation instead of one (`room.rs`).
+///
+/// Returns the failure rather than reporting it. The caller is holding the room
+/// lock at this point, and emitting from under it would be doing the screen's
+/// work with the room stopped; `room.rs` reports once it is out.
+///
+/// The content may contain newlines. It stays one line per post regardless,
+/// because what is written is JSON and JSON escapes them.
+pub fn append(app: &AppHandle, post: &Post) -> Result<(), String> {
+ let path = log_path(app)?;
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)
+ .map_err(|e| format!("Failed to create the room log dir: {e}"))?;
+ }
+
+ let mut line = serde_json::to_string(&LoggedPost::of(post))
+ .map_err(|e| format!("Failed to serialize the post: {e}"))?;
+ line.push('\n');
+
+ let mut file = std::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(&path)
+ .map_err(|e| format!("Failed to open the room log: {e}"))?;
+ // One write of the whole line, so no line is half put down by one call and
+ // finished by another's.
+ file.write_all(line.as_bytes())
+ .map_err(|e| format!("Failed to append to the room log: {e}"))
+}
+
+/// Say on the screen that the log failed.
+///
+/// The room went on without it, so nothing here stops anything. What it stops is
+/// the failure being invisible: a log that had quietly stopped recording would
+/// still look like a log, and would read as nothing having been said (#48).
+pub fn report(app: &AppHandle, message: String) {
+ eprintln!("[room] {message}");
+ let _ = app.emit(LOG_ERROR_EVENT, message);
+}
+
+/// The log, oldest first.
+///
+/// For the screen, and only for the screen. Nothing here is re-delivered to
+/// participants: a session that joined late missed what predates its seat, and
+/// that is the room's existing answer (`room.rs`). Feeding the log back into a
+/// channel would make the room start holding who has heard what, which is the
+/// shape #31 and #39 turned down.
+///
+/// A line that does not parse is skipped rather than failing the read. The case
+/// it covers is a torn tail from a run that ended mid-write, and refusing the
+/// whole history over the last line of it would lose everything to protect
+/// nothing. It is not skipped quietly — the count goes back to the screen on
+/// the same event a failed append does.
+#[tauri::command]
+pub fn room_log(app: AppHandle) -> Result, String> {
+ let path = log_path(&app)?;
+ // No file is no history, not a failure. It is what the first run of the app
+ // looks like.
+ if !path.exists() {
+ return Ok(Vec::new());
+ }
+
+ let content =
+ std::fs::read_to_string(&path).map_err(|e| format!("Failed to read the room log: {e}"))?;
+
+ let mut posts = Vec::new();
+ let mut skipped = 0usize;
+ for line in content.lines() {
+ if line.trim().is_empty() {
+ continue;
+ }
+ match serde_json::from_str::(line) {
+ Ok(post) => posts.push(post),
+ Err(_) => skipped += 1,
+ }
+ }
+
+ if skipped > 0 {
+ report(&app, format!("読めなかった記録が {skipped} 件あります"));
+ }
+
+ Ok(posts)
+}
diff --git a/src/main.ts b/src/main.ts
index 2a222a2..3d3260d 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -66,6 +66,29 @@ interface MissedPost {
ts: string;
}
+/**
+ * One post as the room's log kept it (src-tauri/src/room_log.rs).
+ *
+ * Five fields, and the two a live post also carries are absent by decision
+ * rather than by loss. `own` is a property of whoever is looking, so a file
+ * could only have recorded one viewer's position as if it were part of the
+ * utterance. `hue` was a declaration made at a seat that no longer exists by
+ * the time this is read, so the history derives a colour from the name instead
+ * — which means two participants who answered to one name are one colour here.
+ * That is the known cost of not storing a declaration nobody is making any
+ * more, and it is a panel of the past rather than the room's own attribution
+ * surface (#48).
+ */
+interface LoggedPost {
+ message_id: string;
+ speaker: string;
+ content: string;
+ /** Absent, not null, when it was said to the room: the field's presence is
+ * what carries the two states, in the file and on the way here alike. */
+ to?: string;
+ ts: string;
+}
+
/**
* One participant of the room, as the roster lists them.
*
@@ -282,6 +305,8 @@ const DERIVED_ARC = 360 - RESERVED_ARC * 2;
const roomEl = document.getElementById("room") as HTMLElement;
const rosterEl = document.getElementById("roster") as HTMLElement;
+const historyEl = document.getElementById("history") as HTMLElement;
+const historyListEl = document.getElementById("history-list") as HTMLElement;
const accountNewEl = document.getElementById("account-new") as HTMLButtonElement;
const inputEl = document.getElementById("input") as HTMLTextAreaElement;
const sendEl = document.getElementById("send") as HTMLButtonElement;
@@ -875,6 +900,21 @@ function shortTime(iso: string): string {
return at.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
+/**
+ * The day and the clock, for a stamp that is not from today.
+ *
+ * The room's own lines take `shortTime`, because every one of them was said
+ * during the run that is being watched and the day is not in question. The
+ * history is the other case by definition: everything in it predates this
+ * window, so the day is the part that places it (#48).
+ */
+function shortDateTime(iso: string): string {
+ const at = new Date(iso);
+ if (Number.isNaN(at.getTime())) return "";
+ const day = at.toLocaleDateString([], { month: "2-digit", day: "2-digit" });
+ return `${day} ${at.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
+}
+
function appendMessage(message: RoomMessage): void {
// The room is scrolled to the bottom only when it already was, so reading
// back through the log is not yanked away by an arriving message.
@@ -925,6 +965,93 @@ function appendMessage(message: RoomMessage): void {
if (atBottom) roomEl.scrollTop = roomEl.scrollHeight;
}
+/**
+ * Draw what was said before this window opened.
+ *
+ * Read once, at launch, out of the room's log. It never follows along after
+ * that, and the reason is the division the two surfaces are built on: the room
+ * starts empty every run (#48), so what is beside this strip is this run and
+ * what is in it is everything before. A strip that grew as posts arrived would
+ * be a second drawing of the conversation already on the glass next to it, and
+ * the boundary that tells the two apart would stop existing.
+ *
+ * Oldest first, as the file holds them and as the room draws them, and opened
+ * at the end. The two are one reading: the last line here was said just before
+ * the first line of the room beside it, so the bottom of this strip is where
+ * the conversation is continuous and the top is months back. A panel that
+ * opened at its oldest entry would put the far side of the log in front of the
+ * person every time, and the log has no ceiling to keep that distance short.
+ *
+ * The colour is derived from the name, because the log does not carry the
+ * declaration (see `LoggedPost`). `own` is passed false for the same reason —
+ * the file records what was said, not who was watching — so nothing here is
+ * drawn in this screen's accent.
+ */
+function renderHistory(posts: LoggedPost[]): void {
+ historyListEl.replaceChildren();
+
+ if (posts.length === 0) {
+ const empty = document.createElement("li");
+ empty.className = "empty";
+ // Not an error, and not "history unavailable". A room nobody has spoken in
+ // yet is the first run of the app, and it has a log that says so.
+ empty.textContent = "記録なし";
+ historyListEl.appendChild(empty);
+ return;
+ }
+
+ for (const post of posts) {
+ const entry = document.createElement("li");
+ entry.className = "entry";
+ entry.style.setProperty("--speaker", speakerColor(post.speaker, null, false));
+
+ const meta = document.createElement("div");
+ meta.className = "meta";
+
+ const speaker = document.createElement("span");
+ speaker.className = "speaker";
+ speaker.textContent = post.speaker;
+ meta.appendChild(speaker);
+
+ if (post.to) {
+ const to = document.createElement("span");
+ to.className = "to";
+ to.textContent = `→ ${post.to}`;
+ meta.appendChild(to);
+ }
+
+ const time = document.createElement("time");
+ time.className = "ts";
+ time.dateTime = post.ts;
+ // The date as well as the clock. In the room the day is the one being
+ // lived through and the clock alone reads; here it is whichever day this
+ // was said on, and a bare 14:32 could be any of them.
+ time.textContent = shortDateTime(post.ts);
+ meta.appendChild(time);
+
+ const body = document.createElement("div");
+ body.className = "body";
+ body.textContent = post.content;
+
+ entry.append(meta, body);
+ historyListEl.appendChild(entry);
+ }
+
+ // The scroller is the panel, not the list: the heading is inside it and stays
+ // where it is only because it scrolls off with everything else, which is the
+ // same thing the account panel does.
+ historyEl.scrollTop = historyEl.scrollHeight;
+}
+
+/** Say on the history strip why it has nothing to show. */
+function historyFailed(reason: string): void {
+ historyListEl.replaceChildren();
+ const line = document.createElement("li");
+ line.className = "empty";
+ line.textContent = `履歴を読めませんでした: ${reason}`;
+ historyListEl.appendChild(line);
+}
+
/**
* Put the posts a refusal handed back on the glass, and answer how many were
* new. Oldest first, in the order the room put them in.
@@ -2666,6 +2793,13 @@ async function main(): Promise {
trackAddress(event.payload);
});
await listen("room-participants", (event) => renderRoster(event.payload));
+ // The room went on without the log. Saying so is the whole of what this does
+ // — a log that had quietly stopped recording would still look like a log, and
+ // the next person to go looking would read the gap as nothing having been
+ // said (#48).
+ await listen("room-log-error", (event) => {
+ status(`記録に失敗しました: ${event.payload}`, "error");
+ });
// The socket binds after the frontend loads, so the event is the authority
// and the poll below is only for a listener that attached too late.
await listen("room-ready", (event) => renderSocket(event.payload));
@@ -2752,6 +2886,16 @@ async function main(): Promise {
status(`設定を読み込めませんでした: ${err}`, "error");
}
+ // The history, read once. Outside the room's try below and independent of it:
+ // the log is a file this app wrote, so a room that never answers is no reason
+ // for the strip to stay blank — what was said last week is readable whether
+ // or not anything is listening now (#48).
+ try {
+ renderHistory(await invoke("room_log"));
+ } catch (err) {
+ historyFailed(String(err));
+ }
+
// Before the room, and outside its try. A session running under a seat this
// screen has forgotten is reachable again from the seats alone (`adoptSeats`),
// and a room that fails to answer is no reason to leave it unreachable — the
diff --git a/src/styles.css b/src/styles.css
index 5b6c68c..7c48bbc 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -490,6 +490,112 @@ body {
padding: 0.15rem 0.4rem;
}
+/* ── history ─────────────────────────────────────────────────────────────── */
+
+/* The other column, and the pair to #participants below: same width, same
+ ground, and the border on the side that faces the conversation. Two panels
+ flanking the room read as two panels; one of them a hand's width narrower
+ would read as a mistake, and the width was measured for that panel's content
+ rather than picked, so it is the one to match (#48).
+
+ The rule below is the whole of what makes them a pair — `border-right` here
+ against `border-left` there. Nothing else needs saying twice, because the
+ parts inside are the panel's own vocabulary (`.panel-head`, `.panel-title`,
+ `.empty`) and those are shared. */
+#history {
+ width: 16.5rem;
+ flex: none;
+ border-right: 1px solid var(--line);
+ background: var(--surface);
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow-y: auto;
+ font-size: 0.78rem;
+}
+
+#history .panel-head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.6rem 0.9rem 0.35rem;
+}
+
+#history .panel-title {
+ margin: 0;
+ flex: 1;
+ font-size: inherit;
+ font-weight: 600;
+}
+
+#history .history {
+ margin: 0;
+ padding: 0 0.3rem 0.6rem;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+/* One past post. The same shape a line in the room has — a rule in the
+ speaker's colour down the left, the meta above the words — at the panel's
+ size rather than the conversation's. Reading it has to be reading the same
+ thing, or the two surfaces stop being one conversation seen from two
+ distances.
+
+ It does not carry `--room-font-size`. That size is declared on the two
+ elements rendering the conversation's own words and nowhere else (#81), and
+ this is a panel; it takes the panel's size like the roster beside it. */
+#history .entry {
+ border-left: 3px solid var(--speaker, var(--line));
+ padding: 0.1rem 0.4rem 0.1rem 0.5rem;
+}
+
+#history .entry .meta {
+ display: flex;
+ align-items: baseline;
+ gap: 0.4rem;
+ font-size: 0.9em;
+ color: var(--muted);
+}
+
+#history .entry .speaker {
+ font-weight: 600;
+ color: var(--fg);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Written in the speaker's colour, not the addressee's: it is part of what this
+ speaker said. Same reading as `.message .to`. */
+#history .entry .to {
+ color: var(--speaker, var(--accent));
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* The time takes what the two above leave, and never shrinks: a stamp that
+ ellipsed would be the one part of the row that cannot be guessed from the
+ rest of it. */
+#history .entry .ts {
+ margin-left: auto;
+ flex: none;
+}
+
+#history .entry .body {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ line-height: 1.5;
+ margin-top: 0.1rem;
+}
+
+#history .empty {
+ padding: 0.45rem 0.9rem;
+ color: var(--muted);
+}
+
/* ── participants ────────────────────────────────────────────────────────── */
#participants {