From 19cbb0b56e90fa9edf57fa406d5727d219844897 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Fri, 28 Aug 2026 19:23:10 +0900 Subject: [PATCH] feat(ui): collapse either sidebar from a header toggle (config.rs, index.html, main.ts, styles.css, docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 部屋を挟む二つの面(左の「トピック」、右の「アカウント」)を、ヘッダのボタンからそれぞれ畳めるようにする。会話面を広く使いたいときに閉じられる。 - 左のボタンはヘッダ左端、アプリ名「Pullcept」が出ていた位置に置く。あの語は押しても何も起きず、その窓を見ている者に対してその窓が何かを言うだけだった。語は残さず置き換える(Master 判断)。右のボタンはヘッダ右端であり、どちらも自分が畳む列の上に立つ。 - アイコンは縦線の入った角丸の四角であり、縦線は自分が畳む面の側に立つ。二つのボタンは同じ図形を持ち、鏡像にしているのは CSS の一行である(決定4)。 - 畳んだ面は幅 0 で、細い帯は残さない(決定2)。開く手はヘッダに常に在るため、道標としての帯は要らない。 - 面は隠すだけであり、描画の元になる出来事(名簿、席、トピックの索引)は届き続ける。右の面を閉じている間にセッションが落ちても、開けばそう出ている(制約)。 - 開閉の状態は `config.json` の `panels` に残す(決定1)。この欄を持たない既存の設定は両方とも開いた状態として読む——それがこの欄より前のすべての画面が出していた割り付けである。押すたびに書くのは、行儀よく閉じなかった一回で割り付けを失わないためである。 - `Ctrl+B` は左の面だけを開閉する(決定3、AI 判断6)。端末に焦点がある間はこのキーを受け取らない(AI 判断5)——`attachCustomKeyEventHandler` は `Ctrl+V` 以外すべてに `true` を返すため、止めなければ面が畳まれると同時にシェルへ `Ctrl+B` が渡る。端末ペインはセッションのものである(#84)。 - ボタンの読み上げ名は状態で変えず、開閉は `aria-expanded` が言う。`#toggle-diagnostics` と同じ形である。 - `save_config` は設定を丸ごと書くため、アカウントと開閉状態を一本の `saveConfig` から書く。片方しか知らない保存が二本あれば、走るたびにもう片方を落とす。 要求仕様に「サイドバーの開閉」の節と、実機未確認の項目を追加した。 Closes #118 Co-Authored-By: Claude Opus 5 --- docs/0-requirements.md | 24 ++++++++ index.html | 54 +++++++++++++++++- src-tauri/src/config.rs | 39 +++++++++++++ src/main.ts | 123 +++++++++++++++++++++++++++++++++++++--- src/styles.css | 66 +++++++++++++++++++-- 5 files changed, 293 insertions(+), 13 deletions(-) diff --git a/docs/0-requirements.md b/docs/0-requirements.md index c2ea7ae..1b0e9f9 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -356,6 +356,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 画面の再読み込みに耐えるセッション(席の台帳が起動の記録を持ち、画面が席を見て端末を購読し直す。スクロールバックは戻らず、繋ぎ直した端末は切れた位置を 1 行書いてから開く) - 行のライフサイクル操作の記号表示(開始 ▶️ / 終了 ❌ / 編集 ⚙️、語は `aria-label` と `title`、幅を固定した列)と、終了の確認ダイアログ(webview 内の ``、取消に焦点、Escape は取消) - アプリの終了時のセッションの後始末(走っているセッションがあれば確認のダイアログ、承認で一括終了してから閉じる、取消で閉じない、走っているものが無ければ確認を出さずに閉じる) +- サイドバーの開閉(ヘッダ左右のボタン、同じ図形を CSS で裏返した鏡像のアイコン、幅 0 で畳む形、`config.json` への保持、`Ctrl+B` は左の面のみ、端末に焦点がある間はキーを端末へ渡す) - 診断面(アカウントごとの CLI の端末表示と入力) - 診断面のヘッダ(窓の名前「端末」、開いている端末のタブ、行との連動、面を畳む `✕`、端末の文字サイズ)と、面全体の角丸・一つの地・詰めた枠の余白。終了した端末はタブの `✕` で捨てる - Windows CI(`npm ci` と Rust コンパイル確認)、Release 公開時の CD @@ -448,6 +449,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 画面の再読み込みに耐えるところの実機確認。繋ぎ直した端末へ出力と入力が戻ること、繋ぎ直した端末の開始時刻・起動コマンド・作業ディレクトリが起動時の値であることは実装済みで、CI の型検査と Rust のコンパイルは通っているが、実機での操作は未確認である。席を持つ行が `❌` を出すことは 2026-08-24 に確認済みであり、端末の 1 行目の告知は同日に確認して**読めないことが分かった**(上記「画面の再読み込みに耐える」)。 - 走っているアカウントの様子のうち、パネル 16.5rem の中で本人の行の名前が押し出されないことの実機確認(#82、上記「走っているアカウントが何をしているか」)。実装済みで CI の型検査は通っているが、寸法の側は未確認である。「考え中…」「出力中」と沈黙 1 秒での消失は 2026-08-24 に確認済みである。Claude Code が確認プロンプトで待っている間に出力を止めるかどうかは依然として未計測である——止めないのであれば、待っている行に「出力中」が立ち続ける。「出力中」がその場合でも literal に正しい語であることがこの語を選んだ理由だが、それは待機と稼働を見分けられるという意味ではない。 - 起動から部屋に入るまでの行の語(#89)の窓の計測。**不具合そのものは修正済みである**(上記「参加者パネル」)。実機で見つかった窓であり(2026-08-24、AI が操作)、`▶️` を押した後、CLI が `--dangerously-load-development-channels` の確認プロンプトで止まっている間、行の note は「未起動」のままで、同じ行のライフサイクルの列は `❌` を出していた——終了できるものが未起動だと言っている状態である。行の「走っている」の定義が**部屋の名簿に居ること**であってプロセスが在ることではないために起きる窓であり、`row.participant` が null の間は上記「走っているアカウントが何をしているか」の二語も出ない。窓の長さは CLI の起動時間そのもので、確認プロンプトで止まれば数分続く。#82 より前から在る経路であり、#82 は空だった枝を埋めただけである。行の語そのものは 2026-08-25 に入口から出口まで確認済みである(上記「実機で確認済み」)——押す前の「未起動」、押した後の「起動中」と `❌` の並存、名簿に現れた後の消失という三段階を、起動していない行が据え置かれたまま辿っている。残る未計測は二つ。一つは窓の時間で、語が出るまでの時間も窓の長さも取っていない——窓の長さが CLI の起動時間であることは上の導出であって、実機で計った値ではない。もう一つは、一度部屋に入った CLI が接続を失った場合に note が「起動中」へ戻るかである。この経路は通っておらず、実機では見ていない。 +- サイドバーの開閉の実機確認。ヘッダ左右のボタン、鏡像のアイコンが Windows でその向きに出ること、畳んだときに帯が残らないこと、再起動後の保持、`Ctrl+B`、端末に焦点があるときにキーが端末へ渡ることはいずれも実装済みで CI の型検査と Rust のコンパイルは通っているが、実機での操作は未確認である。`Ctrl+B` を端末へ譲る側は特に未計測である——譲っていることは「アプリの面が動かない」という不在でしか見えず、シェル側がその一打をどう受けたかまで見て初めて確かめられる。 - クラッシュした場合の後始末(#85 の範囲外)。job object へ入れれば OS が落とすが、確認を挟む形とは別の軸であり、実装していない。正常終了の経路を閉じた後も、この経路からは孤児が生まれうる。 - 孤児の引き取り(#85 の範囲外、#84 関連)。起動時に前回の孤児を見つけて繋ぎ直す形。pty id が Rust のメモリにしか無いため持続化の話になる。 - CLI の出力から状態を読む形(「ツール使用中」の粒度)。#82 は段階 1 として出力を読まない二つの合図だけを採り、この形は実測してから別 issue で判断すると決めた。 @@ -627,6 +629,28 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre **語の長さはこの場所の制約を受ける。** 「考え中…」は 4 文字、「出力中」は 3 文字であり、既に出る「起動失敗」の幅を超えない。位置は note であって新しい列ではない——「未起動」と「考え中…」は排他であるため一つの場所で足り、#71 が固定した二列には触れない。 +### サイドバーの開閉 + +部屋を挟む二つの面——左の「トピック」と右の「アカウント」——を、それぞれ畳める。会話面を広く使いたいときに閉じる(#118)。 + +**操作はヘッダに置く。** 左の面のボタンはヘッダの左端、右の面のボタンは右端であり、どちらも自分が畳む列の上に立つ。左端はアプリ名「Pullcept」が出ていた位置である。あの語は、その窓を見ている者に対してその窓が何であるかを言うものであり、押しても何も起きなかった。語を残して隣にボタンを足す形は採らない(Master 判断)。 + +**ボタンは畳んだ状態でも残る。** 畳んだ面には押すものが残らないため、開く手はヘッダの側にしか無い。診断面の `端末` と `✕` が入口と出口を分けている形(上記「診断面」)とはここが違う——あちらは畳まれた面の外に入口があり、開いた面の中に出口がある。こちらは一つのボタンが両方を兼ねる。 + +**アイコンは同じ柄の鏡像である。** 縦線の入った角丸の四角であり、縦線は自分が畳む面の側に立つ。左右で別の絵にはしない——別の絵は、二つのボタンが別のことをすると言う(決定4)。二つのボタンは同じ図形を持ち、それを鏡像にしているのは CSS の一行である。左右で別々に描けば、以後は二つを揃え続けることになる。 + +**名前は状態で変えない。** 読み上げの名前は「トピックの開閉」「アカウントの開閉」で固定し、開いているかどうかは `aria-expanded` が言う。`端末` ボタンと同じ形である(上記「診断面」)。一つの問いに二つの答えを置かない。 + +**畳んだ面は幅 0 である。** 細い帯は残さない(決定2)。帯は面へ戻る道標になるが、道標はヘッダのボタンとして常に在るため要らない。 + +**畳んでも、その中で起きたことは失われない。** 面は隠すだけであり、描画の元になる出来事——名簿、席、トピックの索引——は届き続ける。右の面を閉じている間にセッションが落ちても、開けばそう出ている(制約)。診断面を `✕` で畳んでもセッションが止まらないのと同じ形である(上記「診断面」)。 + +**開閉の状態は `config.json` に残る(決定1)。** 閉じたまま次の起動でも閉じている。文字サイズの二軸(会話面 #60、端末 #68)が `localStorage` に在るのとは置き場が違う——あちらが答えるのは「読んでいる文が読める大きさか」であって読む側の問いであり、こちらは窓の割り付けである。押すたびに書く。終了時にまとめて書く形は、アプリが行儀よく閉じなかった一回で失われる。この欄を持たない設定は両方とも開いた状態として読む。それがこの欄より前のすべての画面が出していた割り付けである。 + +**キーは `Ctrl+B` であり、左の面だけを開閉する(決定3、AI 判断6)。** 柄の参照元である Claude Desktop がその側に割り当てている。右の面に第二の組み合わせは与えない——左右で非対称になるが、キーを一つ発明するよりは、要ると分かってから足すほうを採る。 + +**端末に焦点がある間、`Ctrl+B` は端末のものである(AI 判断5)。** `attachCustomKeyEventHandler` は `Ctrl+V` 以外のすべてに `true` を返すため、セッションはこのキーを受け取る。その上で同じキーがウィンドウにも届くため、止めなければ「面が畳まれ、同時にシェルへ `Ctrl+B` が渡る」形になる。`Ctrl+B` は端末側で意味を持つ(tmux の prefix、readline の backward-char)。端末ペインはセッションのものである(#84)。会話面の文字サイズのキーが既定動作を止めるのは webview 自身のズームを止めるためであって(上記「会話面の文字サイズ」)、セッションからキーを取り上げる根拠にはならない。面はボタンから開けるため、キーを譲っても失うものが無い。 + ### 会話面の文字サイズ 会話面(`#room`)の文字サイズを、読む側が変えられる。 diff --git a/index.html b/index.html index 6be2718..6847fda 100644 --- a/index.html +++ b/index.html @@ -10,7 +10,42 @@
- Pullcept + + @@ -39,6 +74,23 @@ title="会話の文字サイズ(Ctrl + = / - / 0)" > + +
diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 48077b3..a8367d5 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -121,6 +121,35 @@ pub struct Account { pub resume_command: Option, } +/// Which of the two panels flanking the room are open. +/// +/// Here rather than in the webview's own storage, which is where the two text +/// sizes live (#60 / #68). Those are properties of the screen reading the +/// conversation — two people reading one room have no reason to want the same +/// size — and this is a property of the window's layout, which the person who +/// folded a panel away expects to find folded when they open the app again +/// (#118, decision 1). +/// +/// Both open, for a screen that has never folded either. That is what every +/// screen showed before this field existed, so a config saved then opens the +/// way it closed. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct PanelState { + /// The topic list, on the left (`#history`). + pub history: bool, + /// The account list, on the right (`#participants`). + pub participants: bool, +} + +impl Default for PanelState { + fn default() -> Self { + PanelState { + history: true, + participants: true, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { /// Also read from `tabs`, the name this key had while an account was a @@ -130,6 +159,11 @@ pub struct AppConfig { /// and a hue is simply not declared yet. The next save writes `accounts`. #[serde(alias = "tabs")] pub accounts: Vec, + /// Absent in a config written before this field existed, which is every + /// config saved so far. `Default` fills it with both panels open — the + /// layout those screens have been showing. + #[serde(default)] + pub panels: PanelState, } // --------------------------------------------------------------------------- @@ -187,6 +221,7 @@ impl Default for AppConfig { // field they never filled in. resume_command: None, }], + panels: PanelState::default(), } } } @@ -237,6 +272,10 @@ pub fn load_config(app: AppHandle) -> Result { // before it existed declares no way of resuming, which is what every // account did then — there was no topic for a session to be resumed into. // + // `panels` is the same again: a config saved before it existed says nothing + // about which panels are folded, and both of them were open on every screen + // then (#118). + // // `character` is the same again, and its absence is the state it means: // an account saved before it existed declared no character, so its launch // reads whatever its working directory's own `settings.json` names — which diff --git a/src/main.ts b/src/main.ts index c583450..2fbbf20 100644 --- a/src/main.ts +++ b/src/main.ts @@ -199,8 +199,23 @@ interface Account { resume_command: string | null; } +/** + * Which of the two panels flanking the room are open. + * + * In the config rather than in this screen's own storage, where the two text + * sizes are kept (#60 / #68). Those answer "is what I am reading a readable + * size", which is the reader's own question; this is the window's layout, and + * a panel folded away is expected to still be folded the next time the app + * opens (#118, decision 1). + */ +interface PanelState { + history: boolean; + participants: boolean; +} + interface AppConfig { accounts: Account[]; + panels: PanelState; } interface StartedSession { @@ -359,6 +374,12 @@ const RESERVED_ARC = 25; const DERIVED_ARC = 360 - RESERVED_ARC * 2; const roomEl = document.getElementById("room") as HTMLElement; +const historyEl = document.getElementById("history") as HTMLElement; +const participantsEl = document.getElementById("participants") as HTMLElement; +const toggleHistoryEl = document.getElementById("toggle-history") as HTMLButtonElement; +const toggleParticipantsEl = document.getElementById( + "toggle-participants", +) as HTMLButtonElement; const rosterEl = document.getElementById("roster") as HTMLElement; const topicListEl = document.getElementById("topic-list") as HTMLElement; const topicNewEl = document.getElementById("topic-new") as HTMLButtonElement; @@ -413,6 +434,15 @@ const topicDeleteCancelEl = document.getElementById("topic-delete-cancel") as HT const topicDeleteCommitEl = document.getElementById("topic-delete-commit") as HTMLButtonElement; let accounts: Account[] = []; +/** + * Which panels are open, until the config says otherwise. + * + * Both, which is the layout every screen showed before either could be folded. + * A config that has never recorded a fold reads back the same pair, so the + * value here and the value on disk agree without either being the fallback for + * the other. + */ +let panels: PanelState = { history: true, participants: true }; /** * The accounts holding a seat in the room, by id. * @@ -708,6 +738,39 @@ function hideDiagnostics(): void { toggleEl.setAttribute("aria-expanded", "false"); } +/** + * Draw both panels the way `panels` says, and say so on their buttons. + * + * One function for both states and both panels, called from the restore and + * from every toggle. The button's `aria-expanded` is set from the same value + * that folds the panel, so there is no path where the bar says one thing and + * the window shows another. + * + * Folding is `hidden` and nothing else. Nothing is torn down: both panels are + * drawn from events that keep arriving whether or not they are on screen — the + * roster, the seats, the topic index — so a panel opened after a session ended + * behind it opens showing that it ended (#118, 制約). + */ +function applyPanels(): void { + historyEl.hidden = !panels.history; + participantsEl.hidden = !panels.participants; + toggleHistoryEl.setAttribute("aria-expanded", String(panels.history)); + toggleParticipantsEl.setAttribute("aria-expanded", String(panels.participants)); +} + +/** + * Fold one panel away, or bring it back, and remember which. + * + * The write goes to disk on every press rather than at the end of the session: + * the app is closed by the window's `✕` and by whatever ends it less politely, + * and a layout only saved on the way out is one the second of those loses. + */ +function togglePanel(which: keyof PanelState): void { + panels = { ...panels, [which]: !panels[which] }; + applyPanels(); + saveConfig(); +} + /** The terminal currently on the glass, or null when none is. */ function shownView(): SessionView | null { return shownAccount === null ? null : (views.get(shownAccount) ?? null); @@ -766,17 +829,23 @@ function localAccount(): Account | null { } /** - * Write the account list back to disk. + * Write the config back to disk: the accounts, and which panels are open. + * + * One function for the whole file, because that is what a save is — the command + * takes an `AppConfig` and writes it whole, so a second saver that knew about + * only one of the two fields would drop the other every time it ran. * * Called when an account is decided, deleted, or created for the person at this * screen — never from a field losing focus. An account is a thing that exists * whether or not it runs (#53), and the field-by-field save made every keystroke * on the way to a name into a state that existed: pressing + created an - * account, and from there the only way out was to delete it (#59). + * account, and from there the only way out was to delete it (#59). A panel is + * the other shape: folding one is the whole act, so it saves as it happens + * (#118, decision 1). */ -function saveAccounts(): void { - void invoke("save_config", { config: { accounts } }).catch(() => { - status("アカウントを保存できませんでした。", "error"); +function saveConfig(): void { + void invoke("save_config", { config: { accounts, panels } }).catch(() => { + status("設定を保存できませんでした。", "error"); }); } @@ -2177,7 +2246,7 @@ function resolveLocalAccount(): void { resume_command: null, }; accounts.push(account); - saveAccounts(); + saveConfig(); } localAccountId = account.id; @@ -3044,7 +3113,7 @@ async function commitAccountDialog(): Promise { } else { accounts.push(settled); } - saveAccounts(); + saveConfig(); renderPanel(); renderSessionFacts(); @@ -3099,7 +3168,7 @@ function deleteFromDialog(): void { // Its terminal goes with it. An account that no longer exists cannot be named // in the panel, and the row is the only way that pane could be reached. discardView(views.get(account.id)); - saveAccounts(); + saveConfig(); closeAccountDialog(); renderPanel(); renderSessionFacts(); @@ -3206,6 +3275,39 @@ async function main(): Promise { }); diagnosticsCloseEl.addEventListener("click", () => hideDiagnostics()); + // ── the two panels ───────────────────────────────────────────────────────── + // + // Each button folds the panel it stands over. Both stay on the bar in either + // state: a folded panel leaves nothing behind to press (see `#history[hidden]` + // in src/styles.css), so the way back has to be somewhere that is always + // there. + toggleHistoryEl.addEventListener("click", () => togglePanel("history")); + toggleParticipantsEl.addEventListener("click", () => togglePanel("participants")); + // `Ctrl+B`, the left panel only. Claude Desktop — which is where Master took + // the shape of these buttons from — puts the key on that side, and inventing + // a second combination for the panel opposite would be adding a key before + // anyone has reached for one (#118, AI 判断6). + // + // On the window, like the text size keys above, and stopped before the + // window's own handling for the same reason those are. What is different here + // is the terminal. `attachCustomKeyEventHandler` returns true for everything + // but `Ctrl+V`, so a session receives every other key it is sent — and this + // event still reaches the window afterwards, which would fold the panel and + // send `Ctrl+B` to the shell in one press. `Ctrl+B` means something there + // (tmux's prefix, readline's backward-char), and a session's pane is the + // session's (#84), so while the terminal holds focus this key is not the + // app's and the app does not take it (#118, AI 判断5). The panels are still + // reachable from the two buttons, which is why giving the key up costs + // nothing. + window.addEventListener("keydown", (event) => { + if (!event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) return; + if (event.isComposing) return; + if (event.key !== "b" && event.key !== "B") return; + if (terminalEl.contains(document.activeElement)) return; + event.preventDefault(); + togglePanel("history"); + }); + await listen("room-message", (event) => { appendMessage(event.payload); // The same post read twice: once as a line in the conversation, once for @@ -3316,6 +3418,11 @@ async function main(): Promise { try { const config = await invoke("load_config"); accounts = config.accounts; + // Before the panel is drawn below. A panel folded when the app was last + // closed is folded again here, and the config is the only place that + // knows it (#118, decision 1). + panels = config.panels; + applyPanels(); // Before the join below: the id this resolves goes into it, and an account // may have to be made here for it (#59). resolveLocalAccount(); diff --git a/src/styles.css b/src/styles.css index f239be9..81527e4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -79,10 +79,6 @@ body { font-size: 0.85rem; } -#titlebar .title { - font-weight: 600; -} - /* Takes the slack so the controls stay pinned to the right. */ #titlebar .spacer { flex: 1; @@ -119,6 +115,50 @@ body { opacity: 0.6; } +/* The two panel toggles. The bar's own button — the same ground, border and + rounding every control on it carries — squared off around a drawing rather + than a word (#118). The buttons beside these are wider than they are tall + because a word is, and that box around a square drawing reads as a button + with the picture pushed off centre. + + The vertical padding is the one the other buttons carry, put on all four + sides. With the size below it comes out at their height, so a toggle and the + `端末` next to it are two boxes of one height rather than two that nearly + agree. */ +#titlebar .panel-toggle { + display: flex; + align-items: center; + justify-content: center; + padding: 0.2rem; +} + +/* Drawn in the button's own colour, so the pair follows the theme the way the + words beside them do. Strokes, no fill: the shape is a panel seen edge-on, + and a filled rectangle at this size is a block rather than a frame. + + The size is the line the bar's words make — `1.125rem` against their + `0.85rem`, measured — rather than a round number picked on its own. That is + what puts the button at the height of the buttons beside it, since the box + round the drawing and the box round a word are then built on the same + height. */ +#titlebar .panel-toggle svg { + display: block; + width: 1.125rem; + height: 1.125rem; + fill: none; + stroke: currentColor; + stroke-width: 1.3; + stroke-linejoin: round; +} + +/* The mirror, and the whole of it. The two buttons hold the same drawing and + this rule turns one of them over, so the divider stands on the side the + panel it folds is on. Two drawings would be two shapes to keep in step, and + the pair being one shape is the decision (#118, decision 4). */ +#titlebar #toggle-participants svg { + transform: scaleX(-1); +} + /* ── body ────────────────────────────────────────────────────────────────── */ /* The conversation and the panel stand side by side, so the panel is read @@ -138,6 +178,24 @@ body { min-width: 0; } +/* A folded panel takes no width at all — no strip is left where it stood + (#118, decision 2). A strip would be a signpost back, and the way back is on + the title bar whether the panel is open or not, so the room takes the whole + width instead. + + `display: none` written out, for the reason `#diagnostics[hidden]` needs it + below: the `display: flex` these two carry outranks the user agent's rule + for the attribute, so the attribute on its own folds nothing. + + Nothing under it stops, the way nothing under the terminal pane stops when + that is folded. Both panels are drawn from events that keep arriving — the + roster, the seats, the topic index — so a panel opened after a session ended + behind it shows that it ended (#118, 制約). */ +#history[hidden], +#participants[hidden] { + display: none; +} + /* ── room ────────────────────────────────────────────────────────────────── */ /* The conversation's own text size, declared on the two elements that render