diff --git a/README.md b/README.md index c1c82d9..429eace 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Pullcept は、人間と複数の独立した AI / Li+ セッションが、一 - 部屋ソケット(`127.0.0.1` の任意ポート、Bearer トークン必須) - `.mcp.json` への登録と、channel が成立する条件を満たした CLI 起動 - メッセージ一覧・発言者表示・入力欄を備えたチャットルーム UI +- 部屋のログ(`logs/main.jsonl` への追記)と、それを会話面の左の列に並べる履歴 - Windows 上で `npm ci` と Rust のコンパイル確認を行う CI - GitHub Release 公開時に Tauri バンドルを作成する CD @@ -43,7 +44,8 @@ Pullcept は、人間と複数の独立した AI / Li+ セッションが、一 ### 未実装 - 複数の AI セッションを同一の部屋へ参加させる運用(同時発話の抑制を含む)。経路は通っていますが、実機での往復は未確認です -- 会話ログの永続化と観測 UI +- 部屋のログと履歴の実機確認。追記と読み出しは実装済みで CI は通っていますが、実機での操作は未確認です +- 複数の部屋。ログのパスは部屋名を位置として持ちますが、部屋そのものは一つです - plugin としての allowlist 掲載(配布の第二段階) ## 部屋を動かす diff --git a/docs/0-requirements.md b/docs/0-requirements.md index 6bcc810..d5f212b 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -224,6 +224,20 @@ push の形(参照実装 `Liplus-Project/github-webhook-mcp` `local-mcp/src/in 他システムの調査(2026-08-23): 主要フレームワーク(AutoGen / AG2 GroupChat、Microsoft Agent Framework Group Chat)は orchestrator が次の話者を選ぶ turn-based であり、同時発話が構造上存在しない。並行が要る場合は共有ターンを持たない actor-model へ案内される。**この問題を解いたのではなく、起こらない形にしている。** 最も近い前例は Bounded Autonomy(ライブマルチプレイヤーの LLM キャラクター、2026-04)で、Talk state の原子的ロックにより発話中の割り当てを拒否する。調査は 2 回の検索に基づき、「他に例が無い」ことは主張しない。 +### 部屋のログ + +部屋で言われたことを追記のみの jsonl として残す。部屋の発言はウィンドウの DOM にしか無く、閉じれば消えていた——引用できないことは症状のひとつであり、実際に何が起きたかを根拠にする設計判断が参加者の記憶だけを頼りにしていたことが、これを持たせた理由である(#48)。 + +- **ログは部屋のものであってタブのものではない。** `config.rs` の `SavedChatMessage` / `SessionData` / `TabSessions` は流用しない。`SavedChatMessage` は `role` と `content_type` を持つチャットアシスタントの語彙であり、部屋には `role` の当たる軸が無い——人もセッションも一種類の参加者であり、二つの行を分けるのは名前だけである(#39)。`TabSessions` の鍵はタブであり、タブは起動の形であって会話の器ではない。どちらを配線しても、#39 が外した非対称が保存層から戻る。 +- **一行が一つの発言であり、載るのは五欄である。** `message_id` / `speaker` / `content` / `to` / `ts`。 +- **`hue` と `own` は載せない。** `own` は見る側の属性であって話者の属性ではなく(`room.rs` の型が自らそう言っている)、記録すれば一人の視点を発話の一部として残すことになる。`hue` は参加した時点の宣言であり、読み戻す頃にはそれを持っていた席が無い——引き直せば、誰も宣言していない色を描くことになる。 +- **判定と同じロックの内側で書く。** `deliver()` が床を判定した一回の取得の中で追記する。ロックを離してから書く形では、床が既に順序を付けた二人が逆順でディスクへ届きうるため、一つの会話に二つの順序が生まれる。並びの権威は部屋であるという既存の形(上記「床」)を、ファイルの側でも崩さない。 +- **追記の失敗で発言は失われない。失敗は画面に出す。** 発言はディスクに触れる前に部屋へ入っており、書き込みの失敗が取り消せるものではない。失われうるのは記録の側だけである。したがって配送も戻り値も追記の成否に依存させず、代わりに `room-log-error` イベントで画面の状況行に出す——黙って記録を止めたログは依然としてログに見え、後から見に行った者はその空白を「何も言われなかった」と読む。 +- **読み出しは画面のためのものであり、再配信ではない。** `room_log` コマンドは画面だけが呼ぶ。途中から参加したセッションへ過去の発言を流し込むことはしない(`room.rs` の現在の扱いを変えない)。流し込めば、部屋が「誰が何を聞いたか」を持ち始める——#31 と #39 が退けた形である。 +- **ローテーションも上限も持たない。** `design/Vision.dc.html` が日付を持たない `main.jsonl` を単数形で置いていることに従う。古い方から捨てるログは、「実際に何が言われたか」という、これが在る理由そのものの問いに、問う価値が出るちょうどその距離で沈黙する。 +- **ファイル名は `logs/{部屋名}.jsonl` であり、部屋名は `main` に固定する。** Vision の `main` を部屋名の位置と読む。複数部屋は実装しない——実装するときに増えるのはここの値であって、パスの階層ではない。 +- **読めない行は飛ばす。読み全体は失敗させない。** 途中で終わった実行が残す千切れた末尾がこの経路に当たり、その最後の一行のために全履歴を拒むのは、何も守らずにすべてを失う。飛ばした件数は追記の失敗と同じイベントで画面へ返す。 + ### MCP サーバの実装方式 参照実装は TypeScript / Node、本リポジトリは Rust + TypeScript であるため二択となる。 @@ -254,6 +268,8 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 順番の作法(先に届いた答えを読んでから決める、送信直前の読み直し。`instructions` のみで、protocol もツールも増やしていない) - 床(`say_to_room` の `last_seen` と `post_result`、既存ロック内での判定と刻印、見落としがあるときの拒否と返却。ツールは 1 本のまま) - 拒否が返した発言の描画(画面。`message_id` による重複除去、返ってきた最新への透かしの前進、発言に載せて運ぶ宣言色、描いた分だけを述べる状況表示) +- 部屋のログ(`logs/main.jsonl` への追記、床の判定と同じロック内での書き込み、保存する五欄、`hue` と `own` を持ち込まない写像、追記の失敗の画面への通知、読み出しコマンド、参加者への再配信をしないこと) +- 履歴(会話面の左の列、起動時の一回の読み出し、部屋の本文へ復元しないこと、参加者パネルと対の寸法、日付を含む時刻、名前から導く色) - 参加者モデル(統一 `post` フレーム、発言者以外の全参加者への配送、接続同一性による自分の発言の抑止と名簿の同一性、人間を含む名簿) - アカウント(作成・編集・削除、種別、名前と色と作業ディレクトリと起動オプション、リストでのオフライン表示、一つのアカウントは一つの部屋に一席まで、`config.json` からの移行) - アカウントのキャラクター(output style の `name:` を持つ欄、起動時の `--settings` による選択、ファイルを書き出さない形、起動オプションの `--settings` との併記の拒否、宣言しないアカウントは既定のまま) @@ -364,7 +380,9 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - クラッシュした場合の後始末(#85 の範囲外)。job object へ入れれば OS が落とすが、確認を挟む形とは別の軸であり、実装していない。正常終了の経路を閉じた後も、この経路からは孤児が生まれうる。 - 孤児の引き取り(#85 の範囲外、#84 関連)。起動時に前回の孤児を見つけて繋ぎ直す形。pty id が Rust のメモリにしか無いため持続化の話になる。 - CLI の出力から状態を読む形(「ツール使用中」の粒度)。#82 は段階 1 として出力を読まない二つの合図だけを採り、この形は実測してから別 issue で判断すると決めた。 -- 会話ログの永続化と観測 UI +- 部屋のログと履歴の実機確認。追記されること、再起動後に左の列へ並ぶこと、部屋の本文が空から始まること、二つの面の寸法が揃うことはいずれも実装済みで CI の型検査と Rust のコンパイルは通っているが、実機での操作は未確認である。追記の失敗の経路(`room-log-error`)はそもそも失敗を作り出す必要があるため、実機でも未観測である。 +- 複数の部屋。ログのパスは部屋名を位置として持つが(`logs/{部屋名}.jsonl`、現在は `main` に固定)、部屋そのものは一つである。 +- 履歴からの引用操作。左の列は読む面であり、行を選んで入力欄へ持っていく経路は持たない。 - plugin としての allowlist 掲載(配布の第二段階) ### 二つの地色 @@ -379,6 +397,28 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre この規則の下では、選択中の端末タブは地色で選択を言えなくなる。代わりに言うのは枠(未選択の `transparent` から `--line` へ)と文字色(`--muted` から参加者自身の色へ)である。参加者パネルの選択中の行と同じ形——枠のある窓の地の箱——になり、同じ選択を二つの面が同じ手で描く(#75)。それまでは逆を向いていた。パネルの地が `--surface` なので選択された行は `--bg` で浮き、端末ヘッダの地が `--bg` なので選択されたタブは `--surface` で沈んでいた。 +### 履歴 + +会話面の**左**に常時出る列を持つ。部屋のログを読み出して、このウィンドウが開く前に言われたことを並べる(上記「部屋のログ」)。 + +**起動時の部屋は新規である。** 過去ログを `#room` の本文へ復元しない。閉じれば部屋の中身は消えるという現在の振る舞いを変えない——変えたのは、消えた先に読みに行ける場所があるかどうかだけである。 + +**二つの面を分けるのは起動である。** 左に在るのはこのウィンドウが開く前、右の部屋に在るのはこのウィンドウの中。だから履歴は発言が届くたびに伸びない——伸びる帯は隣の会話面をもう一度描いたものであり、二つを見分けている境界そのものが無くなる。読み出しは起動時の一回である。 + +**部屋の本文と履歴は混ぜない。** 別のサーフェスであることがこの機能の形であり、同じものを二か所に出すことではない。 + +**参加者パネルと対にする。** 幅(16.5rem)と地色を揃え、枠は会話面に向く側に置く(左は `border-right`、右は `border-left`)。部屋を挟む二つの面が別々の寸法を持てば、揃っていないこと自体が読まれる。中の部品は同じ語彙(`.panel-head` / `.panel-title` / `.empty`)を使う。 + +**一件の見た目は部屋の行と同じ形である。** 話者の色の罫、その上に発言者・宛先・時刻。ただし寸法はパネルのものであり、会話面の文字サイズ(`--room-font-size`)は載せない——あの寸法は会話面の本文を描く二つの要素にだけ宣言されるものであり(#81)、ここはパネルである。 + +**古い方から並べ、末尾を開いた状態で出す。** 部屋の並びと同じ向きであり、二つは一続きに読める——この列の最後の行は、隣の部屋の最初の行の直前に言われたものである。だから下端が会話の連続する側であり、上端は何か月も前になる。上限を持たないログで一番古い行を毎回正面に出すのは、その距離が伸び続ける側を見せることになる。 + +**時刻には日付も出す。** 部屋の行は今この実行の中で言われたものなので時計だけで読めるが、履歴に在るものはすべてこのウィンドウより前であり、日付が置く側である。 + +**色は名前から導く。** ログは宣言色を持たないため(上記「部屋のログ」)、`hue` の梯子の最後の段——名前からの導出——を使う。同じ名前を名乗った二人はここでは一色になる。これは、誰も宣言していない色を保存しないことの既知の代償であり、履歴は過去の面であって部屋の帰属の面ではない。 + +**空とエラーを分ける。** 記録が無いのは初回の実行であって失敗ではないため「記録なし」と出す。読み出しが失敗したときだけ理由を添えて出す。 + ### 参加者パネル 会話面の右に常時出る列を持つ。参加者のリストと、その下にセッションの裏の値(部屋ソケット / セッションの生死と終了コード / 接続方法 / 起動コマンド / 作業ディレクトリ / 開始時刻 / ウィンドウ)を並べる。 @@ -789,6 +829,8 @@ slug は読みやすさのためだけにあり、ASCII 英数字と `-` に落 したがって `src-tauri/src/` に残すのは、tauri の `State` / `AppHandle` に触れる部分だけとする。純粋ロジックをそちらへ書き足すと、書いた時点で検証不能になる。床の順序付けは読んでも確かめられない性質のもの(二人が同時に呼んだとき片方だけが通る)であり、この方針が最も効く箇所である。`src-tauri/src/room.rs` が持つのは、その判定を既存ロックの内側で呼ぶことと、結果をフレームとイベントへ載せることだけである。 +部屋のログ(`src-tauri/src/room_log.rs`)はこの方針の例外ではなく、境界の側である。パスの解決は `AppHandle` の `app_data_dir` に依存し、追記も読み出しもその解決の上に載るため、tauri 非依存の crate へは出せない。ただし純粋な部分は残っている——発言から五欄への写像と、一行の解析である。**これらは `cargo test` の対象になっていない。** 隠さずここに書く。写像は落とす欄を明示するために手で書いてあり(`LoggedPost::of`)、解析は `serde_json` そのものであるため、読んで確かめられる範囲に収めてある。床の順序付けのように読んでも確かめられない性質のものは、この面には無い——追記の順序は床の判定と同じロックの内側で決まっており、その順序付け自体は既に `crates/room-floor/` が持っている。 + CI が実行するもの: | コマンド | 対象 | diff --git a/index.html b/index.html index 9afdf51..16a3ddc 100644 --- a/index.html +++ b/index.html @@ -42,6 +42,29 @@
+ + +
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 {