diff --git a/README.md b/README.md index 0f47afe..1e79be7 100644 --- a/README.md +++ b/README.md @@ -54,15 +54,23 @@ npm run tauri dev 起動すると Tauri の窓が開き、部屋ソケットが待ち受けを始めます。フロントエンドの dev サーバは `vite.config.ts` で 1420 番に固定しています(`src-tauri/tauri.conf.json` の `devUrl` と一致させる必要があるため)。 -上の行が**アカウント**です。「+」で作り、「−」で削除します。アカウントは起動していなくても存在するもので、名前・色・作業ディレクトリ・起動オプションを持ちます。編集はその場で保存されます。 +上の行は**参加するアカウントを選んで参加するだけ**の行です。アカウントを作る・直す・消すのは、右の「参加者」の見出しの隣にある「+」と、各行の「編集」から開くウィンドウで行います。 -「名前」と「色」は、そのアカウントが部屋で名乗る名前と、発言に付く色です。**いつでも変えられます。** 同一性は名前ではなく内部の id にあるため、改名しても稼働中のセッションの登録は追随します。色を選ばずに参加することもでき、その場合は名前から色が決まります。タイトルバーの「名前」「色」は同じものの人間側です(人間はアカウントではなく、この画面の話者として参加します)。 +そのウィンドウには、名前・種別・色と、AI のアカウントなら作業ディレクトリ・起動オプションがあります。**決定を押すまで何も保存されません。** 「+」は空のフォームを開くだけで、取消せばアカウントは作られません。編集も同じで、取消せば元のままです。削除はそのウィンドウの中にあり、二度押しです。 -「起動オプション」には `--dangerously-skip-permissions` のように、CLI へ渡したいオプションをそのまま書けます。アプリは部屋の channel エントリ(`server:pullcept-room--`)をここへ統合するので、別の channel サーバを指定しても部屋の入力路は残ります。実際に起動する行は入力欄の右に表示されます。 +「名前」と「色」は、そのアカウントが部屋で名乗る名前と、発言に付く色です。**いつでも変えられます。** 同一性は名前ではなく内部の id にあるため、改名しても稼働中のセッションの登録は追随します。色を選ばずに参加することもでき、その場合は名前から色が決まります。 -作業ディレクトリの入力欄があります。初回はホームディレクトリが入っているので、セッションを動かしたいディレクトリへ変更してください。 +「種別」は `user`(人間)か `AI`(このアプリが起動する CLI)です。**作るときに選びます。** あなた自身も種別 `user` のアカウントであり、はじめて起動したときに作られます(それまでタイトルバーで名乗っていた名前と色を引き継ぎます)。種別 `user` のアカウントは起動しません——人間の下に動かす CLI は無いためです。 -右の参加者パネルに、部屋にいる参加者と、まだ起動していないアカウント(「未起動」)が並びます。その下に「端末」の列があり、この画面が端末を持っているアカウントが一行ずつ出ます。行を押すとその端末が診断面に出て、行の「終了」でそのアカウントのセッションを終了できます。終了は二度押しです——一度目でボタンが「本当に終了」に変わり、数秒で戻ります。さらに下にセッションの裏の値(部屋ソケット、セッションの生死、接続方法、起動コマンド、作業ディレクトリ、開始時刻、ウィンドウ)が出ます。部屋ソケット以外は、選んでいる端末のアカウントのものです。発言の色は参加者ごとに違い、名簿の点と同じ色です。色は選んだものが優先され、選んでいなければ自分は accent(青)、他の参加者は名前から決まります。名簿では自分の行に「(あなた)」が付きます。宛先に選べるのは部屋に居る参加者だけで、未起動のアカウントは出ません。 +「起動オプション」には `--dangerously-skip-permissions` のように、CLI へ渡したいオプションをそのまま書けます。アプリは部屋の channel エントリ(`server:pullcept-room--`)をここへ統合するので、別の channel サーバを指定しても部屋の入力路は残ります。実際に起動する行はその下に表示されます。 + +作業ディレクトリは初回だけホームディレクトリが入っているので、セッションを動かしたいディレクトリへ変更してください。 + +右の参加者パネルは**一つのリスト**です。部屋にいる参加者と、まだ起動していないアカウント(「未起動」)が、種別ごとのグループに分かれて並びます(`user — 1` / `AI — 2` のように、件数も出ます)。名前はその参加者の色で出ます。居ない相手は色を保ったまま薄くなり、点が塗りから輪郭に変わります。自分の行には「(あなた)」が付きます。 + +端末を持っているアカウントの行を押すと、その端末が診断面に出ます。行の「終了」でそのアカウントのセッションを終了できます。終了は二度押しです——一度目でボタンが「本当に終了」に変わり、数秒で戻ります。未起動のアカウントの行に端末の操作は出ません。 + +リストの下にセッションの裏の値(部屋ソケット、セッションの生死、接続方法、起動コマンド、作業ディレクトリ、開始時刻、ウィンドウ)が出ます。部屋ソケット以外は、選んでいる端末のアカウントのものです。宛先に選べるのは部屋に居る参加者だけで、未起動のアカウントは出ません。 「診断」を開くと**起動した CLI の端末**が出ます。端末はそのまま操作できます。CLI はフォルダごとに初回の信頼確認を出すため、最初の一回はここから答えてください。 diff --git a/crates/mcp-config/src/lib.rs b/crates/mcp-config/src/lib.rs index a2e33f8..740ccb2 100644 --- a/crates/mcp-config/src/lib.rs +++ b/crates/mcp-config/src/lib.rs @@ -98,6 +98,13 @@ pub struct RoomRegistration<'a> { pub token: &'a str, /// Id of the account being launched. The registration key derives from /// this, so the entry stays put across a rename of the account. + /// + /// It is also handed to the sidecar in the env, so the session can name it + /// in `hello` and the room can carry it on the seat. Carried, not consulted: + /// the room decides identity, self-suppression and the `speaker` stamp on + /// the connection, and an account id changes none of that (#39 / #40 / + /// #47). What it buys is the screen being able to say which of its accounts + /// a participant is, without matching on a name (#59). pub account_id: &'a str, /// Display name this session speaks under. Written into the env for the /// sidecar to declare in `hello`, and it is what says whose entry this is @@ -276,6 +283,11 @@ pub fn register_sidecar(dir: &Path, room: &RoomRegistration<'_>) -> Result 自分の accent > 名前からの導出) +- 部屋が運ぶアカウント id(`hello` / `room_join` の `account_id`、`Participant.account`。任意であり、判定には使わない) - チャットルーム UI(メッセージ一覧、発言者表示、入力欄、参加者から選ぶ宛先) -- 参加者パネル(名簿、参加者ごとの色、部屋ソケット、セッションの生死と終了コード、接続方法・起動コマンド・作業ディレクトリ・開始時刻・ウィンドウ) -- アカウントごとの端末(パネルの端末の列で選ぶ、行からの終了、切り替えで残るスクロールバック) +- 参加者パネル(一つのリスト、種別ごとのグループと件数、参加者ごとの色、オフラインの薄い表示、部屋ソケット、セッションの生死と終了コード、接続方法・起動コマンド・作業ディレクトリ・開始時刻・ウィンドウ) +- アカウントのモーダル(作成・編集・削除、決定と取消、2 クリックの削除、走る行の表示) +- アカウントごとの端末(リストの行で選ぶ、行からの終了、切り替えで残るスクロールバック) - 診断面(アカウントごとの CLI の端末表示と入力) - Windows CI(`npm ci` と Rust コンパイル確認)、Release 公開時の CD @@ -264,18 +272,39 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 順番の付与(弾くだけでなく「あなたは N 人め」を返す形)。戻り値の形は段差を残していないが、二重計算が痛むと測れてから判断する。 - アカウント面の実機確認。作成・改名・削除、オフライン表示、二重起動の拒否、改名後の `.mcp.json` 登録の追随はいずれも実装済みで、CI の型検査とテストは通っているが、実機での操作は未確認である。 - アカウントごとの端末と終了の実機確認。2 セッションでの出力の分離、入力の宛先、切り替え後のスクロールバック、終了後の席の解放と再起動はいずれも実装済みであり、CI の型検査は通っているが、実機での操作は未確認である。 +- 一つのリストとアカウントのモーダルの実機確認。種別ごとのグループと件数、オフラインの表示、行からの終了、モーダルの決定と取消、本人のアカウントへの移行はいずれも実装済みで、CI の型検査とテストは通っているが、実機での操作は未確認である。`hello` の `account_id` が実際の部屋で名簿へ届くところも未計測である。 - 会話ログの永続化と観測 UI - plugin としての allowlist 掲載(配布の第二段階) ### 参加者パネル -会話面の右に常時出る列を持つ。名簿と、その下にセッションの裏の値(部屋ソケット / セッションの生死と終了コード / 接続方法 / 起動コマンド / 作業ディレクトリ / 開始時刻 / ウィンドウ)を並べる。 +会話面の右に常時出る列を持つ。参加者のリストと、その下にセッションの裏の値(部屋ソケット / セッションの生死と終了コード / 接続方法 / 起動コマンド / 作業ディレクトリ / 開始時刻 / ウィンドウ)を並べる。 **読む値と、見る値を分ける。** ここに出るのは、詰まったときに読みに行く値である。折りたたみの下に置くと、開く操作を思いつくかどうかが読めるかどうかになる。CLI の端末だけは別で、あれは読む値ではなく映しているものであるから診断面に残る。 **宛先の選択肢の出所は部屋の名簿(`room-participants`)だけである。** 画面側に第二の一覧を持たない。持てば、宛先に選べる名前と画面に並ぶ名前が食い違う余地ができる。 -名簿の列にはこれに加えて、席を持たないアカウントが「未起動」として並ぶ(上記「アカウント」)。第二の一覧ではない——出所は config のアカウント一覧であり、部屋の名簿とはアカウント id で突き合わせる。宛先には出ないため、選べる名前と届く相手が食い違う余地も増えていない。オフラインの行は色を落とし、点を塗りから輪郭へ変える。 +#### リストは一つである + +部屋の名簿と、このアプリのアカウント一覧を、**アカウント id で突き合わせて一つのリストにする**。行は「誰が居るか」と「その相手に何ができるか」の両方に答える。 + +以前はここが二つに割れていた——名簿(鍵は接続)と端末の列(鍵はアカウント id)である。割れていた理由は、`Participant` がアカウント id を持たず、二つを結ぶ手段が名前しか無かったことである。名前で結ぶ形は #40 と #53 が却下しているため、#57 は結ばずに二つ並べる形を採った。部屋が id を運ぶようになったので(上記「部屋プロトコル」)、結べる。二つの見た目を揃えるのではなく、一つにする(#59)。 + +突き合わせは常に id であり、名前ではない。名前は編集できる属性であり、二つのアカウントが同じ名前を名乗ることもあれば、稼働中に改名されることもある。どちらの場合も名前での突き合わせは別の相手に当たる。 + +**リストは種別で分かれ、見出しが種別名と件数を示す。** `user — 1` / `AI — 2` の形である。アカウントを持たない接続は種別の宣言が無いため「ゲスト」に入る。これは種別ではなく、宣言の不在である。 + +**種別は作成時の宣言であり、接続の種類から推測しない。** 部屋は接続の種類しか知らず、別のクライアントから参加した人間はセッションと同じ種類の接続で来る。推測すれば取り違える。宣言できる唯一の時点はアカウントを作る瞬間であり、そこには既にフォームがある(下記「アカウント」)。 + +**起動していないアカウントも最初から並ぶ。** 一度起動することを条件にしない。 + +**行の見え方。** 名前はその参加者の色で表示する。オフラインの行は色を保ったまま薄くし、点を塗りから輪郭へ変える。灰色に落として色を捨てない——色はアカウントの同一性を担っており、居ないだけで別人に見えるのは誤りである。行が示す補足は、この画面の本人なら「(あなた)」、席の無いアカウントなら「未起動」、セッションが終了して出力だけ残っているなら「終了」である。アバターは持たない。 + +**行にアカウントの操作が乗る。** 端末を持つアカウントの行は、クリックで診断面の表示をその端末へ切り替える。行には「編集」(下記のモーダルを開く)が付き、稼働中であれば「終了」が付く。行の鍵がアカウント id であることが、行に操作を載せられる条件である——名前で突き合わせると、二つのアカウントが同じ名前を名乗った時点で別の行に当たる。 + +端末を持たないアカウントに端末の操作は出ない。未起動のアカウントに出せる端末の操作は無い(#57 の条件を維持)。 + +**宛先の選択肢は増えていない。** オフラインの行は部屋の名簿に居ないため、宛先には出ない。届かない相手を名指せる状態は、宛先を名簿から引くという判断(#43)の理由に反する。 **参加者ごとの色。** 発言の縁の色と名簿の点の色は同じであり、参加者ごとに異なる。明度と彩度は `--accent` の実測値に固定し、色相だけを振る。 @@ -298,15 +327,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre 名前から導出する色相は accent の周囲 50 度を空ける。宣言した色相は空けない。空けるのは名前がたまたま自分の色に当たることを避けるためであり、選んだ結果として当たるのは選択である。 -端末はこのパネルへ畳まない。設計キャンバスは右下へ同居させているが、パネルの幅では CLI が枠を描けない(下記「診断面」)。 - -**名簿の下に、端末の列を持つ。** この画面が端末を持っているアカウントが一行ずつ並び、行をクリックすると診断面の表示がその端末へ切り替わる。行には終了が付く。 - -名簿の第二の一覧ではない。答える問いが違う。名簿は「誰が部屋に居るか」であり、出所は部屋の名簿で、鍵は参加者が接続してきたコネクションである。端末の列は「この画面がどのセッションの端末を持っているか」であり、鍵はアカウント id である。集合も一致しない——別の場所から参加した参加者はここに端末を持たず、終了した直後のセッションは端末を持っていて席を持たない。 - -行の鍵をアカウント id に置くのは、行に操作を載せるための条件でもある。端末の表示・入力・終了はいずれも一つのアカウントに対する操作であり、名前で突き合わせると、二つのアカウントが同じ名前を名乗った時点で別の行に当たる(上記「アカウント」)。 - -端末を持たないアカウントは、この列に行を持たない。未起動のアカウントに出せる端末の操作は無い。 +端末そのものはこのパネルへ畳まない。設計キャンバスは右下へ同居させているが、パネルの幅では CLI が枠を描けない(下記「診断面」)。パネルに乗るのは端末を選ぶ操作と終了であって、端末の中身ではない。 パネル下部のセッションの値(接続方法・起動コマンド・作業ディレクトリ・開始時刻・ウィンドウ・生死)は、選択されている端末のものである。最後に起動したセッションのものではない。部屋ソケットだけは部屋自身の値であり、選択に依らない。 @@ -333,11 +354,11 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre 終了した端末は、その場では消さない。落ちる直前の出力が、なぜ落ちたかを読む唯一の材料である。別のアカウントが選ばれた時点で捨てる。同じアカウントを再起動した場合も、前回の端末は新しい端末に置き換わる。 -**終了はアカウントごとに行う。** 端末の列の行から、そのアカウントのセッションを終了する。席の解放は別途要らない——`RoomSeats` は稼働の有無を PTY へ問うため、終了すれば席は空き、そのまま再起動できる(上記「アカウント」)。 +**終了はアカウントごとに行う。** 参加者のリストのその行から、そのアカウントのセッションを終了する。席の解放は別途要らない——`RoomSeats` は稼働の有無を PTY へ問うため、終了すれば席は空き、そのまま再起動できる(上記「アカウント」)。 終了は取り消せない操作であり、表示を切り替えるだけの操作の隣に並ぶ。無地のクリック一つでは実行しない。一度目のクリックは武装するだけで、ボタンの文言と見た目が変わり、数秒で武装は解ける。二度目のクリックが実行する。 -`window.confirm` を使わないのは、ホストが答えなかった場合の失敗の向きがどちらも誤りであるためである。答えを待たない実装ではボタンが黙って何もしない側へ倒れ、答えを既定で肯定と読む実装(アカウント削除が取っている側)では一度のクリックで落ちる。画面側で武装状態を持てばどちらも起きず、どちらの状態にいるかをボタン自身が表示する。 +`window.confirm` を使わないのは、ホストが答えなかった場合の失敗の向きがどちらも誤りであるためである。答えを待たない実装ではボタンが黙って何もしない側へ倒れ、答えを既定で肯定と読む実装では一度のクリックで落ちる。画面側で武装状態を持てばどちらも起きず、どちらの状態にいるかをボタン自身が表示する。アカウントの削除も同じ形を取る(下記「アカウント」)。`window.confirm` はこのアプリのどこにも残っていない。 端末はセッションへの入力路でもある。キー入力はそのまま PTY へ渡り、ウィンドウのリサイズは CLI 側へ伝わる。 @@ -349,7 +370,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre ### 起動オプション -セッションの起動オプションはアカウントの属性であり、画面で編集したその場で保存する。 +セッションの起動オプションはアカウントの属性であり、アカウントのモーダルで編集する(下記「アカウント」)。決定するまで保存しない。 アプリ自身の channel エントリ(`server:pullcept-room--`)は、利用者が書いたものへ**統合**する。利用者が `--dangerously-load-development-channels` を書いていればその直後へ挿入し、書いていなければフラグごと足す。第二のフラグとしては足さない。同一フラグの二重指定は `--channels` 併記と同じ形であり、成立条件が壊れる。 @@ -357,13 +378,15 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre このエントリの名前はアカウントごとに異なる。`.mcp.json` の登録鍵と `server:` タグは同じ 1 つの事実であり、鍵が分かれればタグも分かれる(下記「`.mcp.json` の登録単位」)。 -実際に起動する行は画面に表示する。書いた行と走る行が違う以上、結果を見せるほうが説明より安い。表示は選んでいるアカウントに追従し、そのアカウントの名前を編集しても動かない。エントリ名がアカウント id から決まるためであり、これは要点である——起動しているのはアカウントであって、改名はそれを別のものにしない。 +実際に起動する行は画面に表示する。書いた行と走る行が違う以上、結果を見せるほうが説明より安い。表示は起動オプションの欄の隣、つまりモーダルの中に置く。編集している欄の隣であることが条件であり、欄が移れば表示も移る。名前の欄を編集しても表示は動かない。エントリ名がアカウント id から決まるためであり、これは要点である——起動しているのはアカウントであって、改名はそれを別のものにしない。 + +表示は省略記号で切らずに折り返す。切れる末尾はアプリが統合した channel エントリであり、そこが「書いた行と走る行が違う」ことの中身である。一行のランチャ行には縦の余裕が無かったが、モーダルにはある。 **実測済み(2026-08-21)**: 複数の channel サーバを同時に有効化した状態で往復が成立した。`--print` での確認は無意味である(channel の読み込み自体が走らないため)ことも実測しており、確認は対話セッションでのみ可能。 ### 作業ディレクトリ -セッションの作業ディレクトリは画面に表示し、変更でき、アカウントの属性として保存する。未設定のままアプリのプロセス cwd へ暗黙にフォールバックしない。 +セッションの作業ディレクトリはアカウントの属性であり、アカウントのモーダルで表示・変更する。未設定のままアプリのプロセス cwd へ暗黙にフォールバックしない。未設定のアカウントの起動は拒否し、モーダルを開く。 `npm run tauri dev` ではアプリのプロセス cwd が `src-tauri/` になるため、暗黙のフォールバックはセッションを `src-tauri/` で起動させ、`.mcp.json` もそこへ書く。作業ディレクトリはユーザーの選択であって、アプリのプロセスがたまたま居た場所ではない。作業ディレクトリを持たないアカウントにはホームディレクトリを prefill するが、これは既定値ではなく prefill である。画面に出ていないディレクトリでセッションを起動しない。 @@ -375,15 +398,37 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre **アカウントは #40 の機構を置き換え、目的を保つ。** #40 が直した欠陥は「タブが起動の手段でしかなく、同一性を持てなかった」ことである。同一性が無いところに名前を付ける唯一の形が「起動ごとに宣言する」だった。構造的な同一性ができたので、名前と色はそこへ移る——そして起動時宣言では持ちようがなかったものを得る。**動いていないアカウントにも名前と色がある。** #40 が守ろうとした「名指せること」は、より強い形で満たされている。 -**人間はアカウントではない。** 画面の話者は従来どおり `localStorage` の名前と色で参加する。アカウントへ統合するかは別の判断であり、統合しないことを選んだのではなく、ここでは決めていない。 +**アカウントは種別を持つ。** `user`(人間)と `ai`(このアプリが起動する CLI のセッション)である。種別は作成時の宣言であり、部屋も画面もそれを推測しない——部屋は接続の種類しか知らず、別のクライアントから参加した人間はセッションと同じ種類の接続で来るため、推測すれば取り違える。宣言できる唯一の時点はアカウントを作る瞬間であり、そこには既にフォームがある。 + +種別が決めるのは参加者のリストのグループだけである(上記「参加者パネル」)。部屋の参加者は依然として一種類であり、フレームにも名簿の判定にも種別は現れない。 + +`kind` を持たない既存の `config.json` は `ai` として読む。fallback ではなく移行である——当時保存できたアカウントは全て CLI の起動レシピであり、画面の本人にはそもそもアカウントが無かった。 + +**画面の本人は種別 `user` のアカウントである。** #53 が保留にした判断をここで決めた(#59)。本人が行を持てなかったことは、参加者のリストが二つに割れていた一因でもある。移行は `localStorage` の名前と色をそのアカウントの属性にするだけであり、どちらのキーもこれ以降は書かない。どのアカウントが本人かは `localStorage`(`pullcept.local-account`)が持つ——本人であることはこの画面の性質であってアカウントの属性ではなく、同じ config を別の画面で開けば本人は別になる。 + +本人のアカウントは削除できず、種別も `user` から変えられない。部屋に居ることが「ここに居る」ことそのものであり、削除すれば居るための何かが無くなる。種別を変えれば、本人が AI の見出しの下に並び、その名前で CLI を起動する選択肢が出る。 + +種別 `user` のアカウントは起動しない。人間の下に spawn する CLI は無く、`start_session` が拒否する。ランチャの選択肢にも出さない——拒否が唯一の知る手段になるのは、拒否の使い方として誤りである。稼働中のアカウントの種別は変更できない。そのセッションが起動した作業ディレクトリと起動オプションを、動いている最中に捨てることになるためである。 **名前は人間が読んで選ぶものである。** UUID などの機械的な一意化で置き換えない。宛先の選択肢に出るのはこの名前であり、そこから 1 名を選べることが目的である。id が機械的な一意性を担うのは、名前をその責務から解放するためである。新規アカウントの既定名は既存と重ならない番号を取る——既定値が同じ名前の参加者を並べるのでは、#40 の欠陥を既定で再現することになる。 -名前が空のままの起動は拒否する。 +名前が空のままの決定は拒否する。 -**アカウントの一覧と稼働中の名簿は、アカウント id で突き合わせる。** 名前で結ばない。名前は衝突しうるものになり、稼働中に改名されることもある。画面は部屋の名簿をそのまま並べ、席を持たないアカウントを「未起動」として足す。 +**アカウントの一覧と稼働中の名簿は、アカウント id で突き合わせる。** 名前で結ばない。名前は衝突しうるものになり、稼働中に改名されることもある。突き合わせの結果は一つのリストである(上記「参加者パネル」)。 -**オフラインのアカウントは名簿に出し、宛先の選択肢には出さない。** 宛先の選択肢の出所は部屋の名簿だけであり(上記「参加者パネル」)、席の無いアカウントはそこに存在しない。届かない相手を名指せる状態は、宛先を名簿から引くという判断(#43)の理由に反する。 +**オフラインのアカウントはリストに出し、宛先の選択肢には出さない。** 宛先の選択肢の出所は部屋の名簿だけであり(上記「参加者パネル」)、席の無いアカウントはそこに存在しない。届かない相手を名指せる状態は、宛先を名簿から引くという判断(#43)の理由に反する。 + +#### アカウントの窓は同一 webview 内のモーダルである + +作成・編集・削除はモーダルで行う。OS の別ウィンドウにはしない——一つの行為の途中の一段であり、別ウィンドウは探し、動かし、アプリの裏へ失くすものである。 + +**モーダルは明示的な決定と取消を持つ。欄の `change` では保存しない。** フォームは下書きを持ち、決定して初めてアカウント一覧へ入る。`+` は空のフォームを開くだけであり、決定するまでアカウントを作らない。取消は何も残さない——新規なら作られておらず、編集なら反映されていない。Escape も取消である。 + +これは形の移動ではない。以前は各欄が `change` で保存されており、`+` を押した瞬間にアカウントが実在し、以後はその場で直していく形だった。決定も取消も無かった。モーダルへ移すこと自体はこれを直さない——モーダルの中で `change` 保存を続ければ同じである。直しているのは保存の時点である。 + +削除はモーダルの中にあり、終了と同じ 2 クリックを取る。一度目は武装するだけで、二度目が実行する。稼働中のアカウントと、この画面の本人のアカウントは削除を拒否する。 + +**ランチャ行に残すのはアカウントの選択と参加のみである。** 「どのアカウントで参加するか」と「このアカウントの設定を編集する」は別の行為であり、種別が増えたことで分ける理由がさらに増えた。名前・色・作業ディレクトリ・起動オプションはモーダルにある。タイトルバーが持っていた本人の名前と色の欄も外した——本人がアカウントになった以上、同じ 2 値を編集する面が二つ並ぶことになる。`+` はアカウントが並ぶリストの見出しの隣に置く。作るものが現れる場所の隣であって、起動する行の上ではない。 #### 一つのアカウントは一つの部屋に一席まで @@ -397,7 +442,9 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre #### 既存設定からの移行 -`config.json` の `tabs` は `accounts` として読む。id・名前・起動コマンド・起動オプション・作業ディレクトリはそのまま持ち越し、読み手の居なかった `cli_kind` は未知フィールドとして落ちる(#17)。色はまだ宣言されていない状態になる。次回の保存で `accounts` として書き戻す。移行手続きは持たない——別名で読めることが移行のすべてであり、失われて困る値(作業ディレクトリと起動オプション)は形が変わっていない。 +`config.json` の `tabs` は `accounts` として読む。id・名前・起動コマンド・起動オプション・作業ディレクトリはそのまま持ち越し、読み手の居なかった `cli_kind` は未知フィールドとして落ちる(#17)。色はまだ宣言されていない状態になり、種別は `ai` になる。次回の保存で `accounts` として書き戻す。移行手続きは持たない——別名で読めることが移行のすべてであり、失われて困る値(作業ディレクトリと起動オプション)は形が変わっていない。 + +画面の本人のアカウントだけは、config 側では移行できない。元の値(名前と色)が webview の `localStorage` にあり、Rust 側からは見えないためである。画面が起動時に、種別 `user` のアカウントが無ければ作る。 ### `.mcp.json` の登録単位 @@ -437,8 +484,8 @@ CI が実行するもの: 上流から順に確認する。各段は下流の前提であるため、順序を飛ばさない。 1. **部屋ソケットが待ち受けているか。** 右の参加者パネルの部屋ソケットの行を見る。`127.0.0.1:` が accent 色で出ていなければ以降はすべて不成立。 -2. **セッションが生きているか。** 同じパネルのセッション行を見る。`終了(終了コード N)` なら CLI が落ちている。この行は端末の列で選択されているアカウントのものであるため、複数動かしている場合はまず対象の行を選ぶ。理由はタイトルバーの「診断」を開いた先の端末に残る。CLI が確認プロンプトで止まっている場合は、端末へ直接答える。 -3. **サイドカーが接続したか。** パネルの名簿で、そのアカウントの行から「未起動」が消える。名簿にはアカウントが起動していなくても並ぶため、名前が出ていること自体は接続の根拠にならない。自分(人間)も発言前から載っている。接続していない場合は端末に `room socket: connected as ""` があるかを見る。 +2. **セッションが生きているか。** 同じパネルのセッション行を見る。`終了(終了コード N)` なら CLI が落ちている。この行は参加者のリストで選択されている端末のアカウントのものであるため、複数動かしている場合はまず対象の行を選ぶ。理由はタイトルバーの「診断」を開いた先の端末に残る。CLI が確認プロンプトで止まっている場合は、端末へ直接答える。 +3. **サイドカーが接続したか。** パネルの参加者のリストで、そのアカウントの行から「未起動」が消え、薄い表示が戻る。リストにはアカウントが起動していなくても並ぶため、名前が出ていること自体は接続の根拠にならない。自分(人間)も発言前から載っている。接続していない場合は端末に `room socket: connected as ""` があるかを見る。 4. **サイドカー単体が壊れていないか。** `npm run sidecar:test` を実行する。偽の部屋ソケットを立てて両面を駆動するため、通ればサイドカーではなくアプリ側の問題に絞れる。 5. **`.mcp.json` の登録が効いているか。** セッションの作業ディレクトリの `.mcp.json` に `pullcept-room--` が入っているかを見る。鍵はアカウント id から決まるため名前では引けない。どのアカウントのものかはエントリ内の `PULLCEPT_AGENT_NAME` で読む。固定の `pullcept-room` は前回起動の残りとして掃除される側である。`--mcp-config` によるファイル渡しでは channel 側が名前を解決できないため、正式登録以外の経路は成立しない。 6. **起動フラグが単独指定になっているか。** `--dangerously-load-development-channels server:pullcept-room--` 以外に `--channels` が付いていると、同一サーバが二重登録され全体が不通になる。アカウントの起動オプションに `--channels` / `--print` / `--input-format` / `--output-format` がある場合、セッション起動はエラーで止まる(黙って外すと動いたように見えるため)。 @@ -450,7 +497,7 @@ CI が実行するもの: 2 枚のアートボードを並べ、その差分をロードマップとして読む形にしてある。 -- **次の一手** — 現在の実装に参加者パネルと宛先を足した、実装可能な範囲。両方とも実装済みである。この 1 枚と実装の差は、操作ボタンのうち未実装のリサイズ(端末と終了は実装済み。ただしキャンバスは 3 つを一組の行として描き、実装は端末の列の各行に置いている)、宛先の見た目(削除可能なチップ)、#40 でランチャとタイトルバーに入った名乗り(名前欄と色の選択)、そして #53 のアカウント(作成・編集・削除、名簿のオフライン表示)である。宛先は `` で機能的に等価であり、形の差は追っていない。アカウントの側はキャンバスが実装より大きく遅れており、差は #46 で追う。 - **全体像** — ルーム一覧 / ダイレクト / 参加者・セッション状態パネル 色と寸法は `src/styles.css` の実値をそのまま使っている(`--accent: #3a6ea5` / `--surface: #f4f4f7` / 角丸 4・6px / system-ui + Yu Gothic UI)。参加者ごとの色は accent と同じ彩度・明度で色相だけ変えたもの。絵と実装が同じトークンの上に乗るため、キャンバスの値をそのまま実装へ移せる。 @@ -463,8 +510,11 @@ CI が実行するもの: - 参加者パネルを持つ(実装済み。上記「参加者パネル」)。セッションの接続方法・起動コマンド・作業ディレクトリ・開始時刻を出す。操作のうち端末と終了は実装済みで、アカウントごとの行に載っている。ミュートは未実装であり、キャンバス側にのみある。 - 発言に宛先を持たせる(実装済み。入力欄の左で参加者から選ぶ。既定は全体宛)。 - 参加者ごとの色(実装済み。accent と同じ明度・彩度で色相のみ)。色相は宣言でき、宣言が導出に優先する(上記「参加者パネル」の梯子)。 -- 名前と色は「この部屋での自分は誰か」の宣言である(実装済み。人間はタイトルバーで参加の時点に、セッションはアカウントの属性として)。 +- 名前と色は「この部屋での自分は誰か」の宣言である(実装済み。人間もセッションも、アカウントの属性として)。 - アカウントは起動していなくても存在する(実装済み。上記「アカウント」)。同一性は id であり、名前は表示と宛先のための属性である。 +- **人間はアカウントである**(実装済み。種別 `user`。#53 が保留にし、#59 が決めた)。画面の本人も他の参加者と同じ行を持ち、名簿と宛先の出所は一本化された。人間の同一性を config のエントリとして持つことは受け入れる——本人であることは `localStorage` に残り、config に入るのは名前と色だけである。 +- 参加者のリストは一つである(実装済み。上記「参加者パネル」)。種別で分かれ、見出しが件数を示す。種別は作成時の宣言であり、接続の種類から推測しない。 +- アカウントの作成・編集・削除は同一 webview 内のモーダルで行い、明示的な決定と取消を持つ(実装済み。上記「アカウント」)。 ### 決まっていないこと @@ -475,7 +525,7 @@ CI が実行するもの: | 複数ルーム | サイドカーの `chat_id` は 1 つ固定。ルームごとにセッションを立てると利用枠がルーム数ぶん増え、1 セッションが複数ルームに属する形にすると push にルーム文脈を載せるプロトコル変更を伴う | | システム発言の出所 | エージェントが `say_to_room` で言うなら可。アプリがファイルや git を監視して言う形は「アプリは部屋であってランタイムではない」に当たる | | webhook を部屋へ流す | 未採用。判断材料は #32 に memo として残してある | -| 人間をアカウントにするか | 画面の話者は `localStorage` の名前と色で参加している。アカウントへ統合すると名簿と宛先の出所が一本化されるが、人間の同一性を config のエントリとして持つことの是非を決めていない(#53 で対象外とした) | +| ミュート | `design/Vision.dc.html` にあるが、まだ求められていない。#59 の対象外 | ## 配布 diff --git a/index.html b/index.html index 709718a..190cd22 100644 --- a/index.html +++ b/index.html @@ -15,58 +15,25 @@ participant panel's list now: two renderings of one roster is one surface too many, and the panel is always on screen. --> - - - + - +
- - - - - - +
-
- - -
-
@@ -111,18 +78,29 @@
- + **One list.** The room's roster and this app's accounts, joined on + the account id the room now carries (#59). It was two lists — a + roster and a terminal list — because a `Participant` had no account + id and the two halves could only have been matched by name, which + #40 and #53 ruled out (#57). With the id on the wire the two + answers are one row: who is here, and what can be done with them. + + Grouped by the kind declared when the account was made. An account + that is not running is still someone, so it is listed dimmed rather + than left out (#53); it is still not offered as an addressee, + because a name that cannot be reached is not one worth naming. -->
diff --git a/sidecar/src/index.ts b/sidecar/src/index.ts index bad3c2b..adbb036 100644 --- a/sidecar/src/index.ts +++ b/sidecar/src/index.ts @@ -54,7 +54,23 @@ function readHue(raw: string | undefined): number | null { return Number.isFinite(hue) ? hue : null; } -const PROTOCOL_VERSION = 4; +/** + * The account this session was launched as, or null when it was launched + * without one. + * + * Carried into `hello` and nothing else. It is not this session's identity in + * the room — the room mints that from the connection and keeps it there (#39 / + * #40 / #47) — and nothing in this file reads it to decide anything. It exists + * so the screen can say which of its accounts a participant is without + * matching on a name, which is the match #40 and #53 ruled out (#59). + * + * Null is a real state, not a launch that went wrong: a room does not presume + * an account exists behind a connection, and something joining from outside + * this app has none to declare. + */ +const ACCOUNT_ID = process.env.PULLCEPT_ACCOUNT_ID?.trim() || null; + +const PROTOCOL_VERSION = 5; /** * How long a post waits for the room to answer it. @@ -73,7 +89,7 @@ function log(line: string): void { // ── Room frames ────────────────────────────────────────────────────────────── // // Sidecar -> room: -// { type: "hello", protocol, name, hue? } +// { type: "hello", protocol, name, hue?, account_id? } // { type: "post", message_id, content, to?, ts, last_seen? } // Room -> sidecar: // { type: "post", message_id, speaker, content, to?, ts } @@ -94,6 +110,15 @@ function log(line: string): void { // this file decides, because both are the person's declaration, made at the // moment of joining. // +// `account_id` rides along on the same frame, and is the one field on it that +// is not a declaration about how to be shown. It says which account of the app +// launched this session, so the screen can join its own list against the room's +// roster by id instead of by name (#59). It is optional in both directions: a +// connection with no account behind it is a participant like any other, and the +// room presumes nothing about one. Nothing here or in the room reads it to +// decide identity, self-suppression or attribution — those stay on the +// connection (#39 / #40 / #47). +// // A participant never receives its own post. The room drops it on the way out, // judged on the connection it arrived on, so nothing here has to recognise // itself — and a name collision cannot make this side swallow someone else's @@ -466,13 +491,16 @@ function connectRoom(): void { retryCount = 0; lastError = ""; log(`room socket: connected as "${AGENT_NAME}"`); - // The hue key is omitted when none was declared, for the same reason `to` - // is: the room must be able to tell "declared nothing" from a value. + // The hue and account keys are omitted when there is none, for the same + // reason `to` is: the room must be able to tell "declared nothing" from a + // value. For the account that distinction is the whole of its optionality + // — a connection with no account is still a participant (#59). sendToRoom({ type: "hello", protocol: PROTOCOL_VERSION, name: AGENT_NAME, ...(AGENT_HUE === null ? {} : { hue: AGENT_HUE }), + ...(ACCOUNT_ID === null ? {} : { account_id: ACCOUNT_ID }), }); pingTimer = setInterval(() => { if (socket.readyState === WebSocket.OPEN) socket.ping(); diff --git a/sidecar/test/round-trip.test.mjs b/sidecar/test/round-trip.test.mjs index 1d84ea7..cfced7f 100644 --- a/sidecar/test/round-trip.test.mjs +++ b/sidecar/test/round-trip.test.mjs @@ -24,6 +24,12 @@ const REPO = join(HERE, "..", ".."); const TIMEOUT = 20_000; +/** + * The account this session is launched as. Opaque, as it is in the app: an id + * a name could be read out of would be the wrong thing to carry (#53). + */ +const TEST_ACCOUNT = "8f14e45f-ceea-467a-b160-6f14e45fceea"; + // The manners, whole. // // Asserted as complete literals rather than by a regex on the opening clause. @@ -189,6 +195,7 @@ test("a room post reaches the channel, and say_to_room reaches the room", async PULLCEPT_ROOM_URL: `ws://127.0.0.1:${port}`, PULLCEPT_AGENT_NAME: "test-agent", PULLCEPT_AGENT_HUE: "145", + PULLCEPT_ACCOUNT_ID: TEST_ACCOUNT, PULLCEPT_ROOM_ID: "test-room", }, stdio: ["pipe", "pipe", "pipe"], @@ -346,7 +353,12 @@ test("a room post reaches the channel, and say_to_room reaches the room", async // the same name (#40). assert.equal(hello.name, "test-agent"); assert.equal(hello.hue, 145); - assert.equal(hello.protocol, 4); + // The account this session was launched as, carried so the screen can join + // its own account list against the room's roster by id rather than by name + // (#59). It rides on `hello` and decides nothing: identity in the room is the + // connection, and this frame cannot set that (#39 / #40). + assert.equal(hello.account_id, TEST_ACCOUNT); + assert.equal(hello.protocol, 5); roomSocket.send( JSON.stringify({ @@ -558,11 +570,16 @@ test("a room post reaches the channel, and say_to_room reaches the room", async ); }); -test("a session launched without a declared hue says so by omission", async (t) => { +test("a session launched without a hue or an account says so by omission", async (t) => { // The undeclared state has to survive the wire. The room derives a colour // from the name for a participant who chose none, and it can only do that // while "chose none" is still distinguishable from a number a default put // there (#40). + // + // The account id is the same shape and a stronger case: a connection with no + // account behind it is a participant like any other, and the room must not + // presume one exists (#59). An empty string here would be an id no account + // has, offered to the screen as though someone had declared it. const http = createServer(); const wss = new WebSocketServer({ server: http }); await new Promise((r) => http.listen(0, "127.0.0.1", r)); @@ -605,4 +622,9 @@ test("a session launched without a declared hue says so by omission", async (t) const hello = await withTimeout(helloSeen.promise, "hello frame"); assert.equal(hello.name, "no-colour"); assert.equal("hue" in hello, false, "an undeclared hue must carry no key"); + assert.equal( + "account_id" in hello, + false, + "a connection with no account behind it must carry no account key", + ); }); diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index abde0c1..789e17a 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -3,6 +3,29 @@ use std::path::PathBuf; use tauri::AppHandle; use tauri::Manager; +/// What kind of participant an account is. +/// +/// **Declared when the account is made, never inferred.** The room knows only +/// what kind of connection someone arrived on, and a person joining from +/// another client arrives on the same kind of connection a session does — so +/// inferring this from the connection mistakes one for the other, which is +/// exactly the participant the room was built to stop treating differently +/// (#39). The one moment anyone can say which this is, is the moment the +/// account is created, and there is already a form there (#59). +/// +/// It sorts the participant list into groups and does nothing else. Nothing in +/// the room reads it; the room still has one kind of participant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum AccountKind { + /// A person. The one at this keyboard is one of these (#59, which is where + /// #53 left this open). + User, + /// A CLI session this app launches. + #[default] + Ai, +} + /// One account: someone who exists in this app whether or not they are running. /// /// The identity is `id`, and only `id`. It is minted once and never changes; @@ -39,6 +62,14 @@ pub struct Account { /// account is named and coloured while it is not running. #[serde(default)] pub hue: Option, + /// What kind of participant this account is, declared when it was made. + /// + /// Defaulted to `Ai` for an account written before this field existed, and + /// that is a migration rather than a fallback: every account that could + /// have been saved then was a launch recipe for a CLI. The person at the + /// keyboard had no account at all until now. + #[serde(default)] + pub kind: AccountKind, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -99,6 +130,7 @@ impl Default for AppConfig { args: vec![], cwd: None, hue: None, + kind: AccountKind::Ai, }], } } @@ -139,6 +171,13 @@ pub fn load_config(app: AppHandle) -> Result { // nothing left for one to do — the person's own working directory and // launch options are what would have been lost, and they carry over. // + // A config written before `kind` existed is the same shape of nothing: the + // field defaults to `Ai`, which every account saved then was. The account + // the person at the keyboard now has is made on the screen rather than + // migrated, because what it is made from — the name and hue they had been + // joining under — lives in the webview's own storage and never reached + // this file (#59). + // // No path exists for anything older than that. Pullcept has never // shipped a release, and its app data directory is keyed to its own // identifier (org.liplus-project.pullcept), so no config in the older diff --git a/src-tauri/src/room.rs b/src-tauri/src/room.rs index 51ad174..fb6a641 100644 --- a/src-tauri/src/room.rs +++ b/src-tauri/src/room.rs @@ -6,7 +6,7 @@ //! //! Frames on the wire are the room protocol: //! -//! sidecar -> room : { type: "hello", protocol, name, hue? } +//! sidecar -> room : { type: "hello", protocol, name, hue?, account_id? } //! both ways : { type: "post", message_id, speaker, content, to?, ts, //! last_seen? } //! room -> sidecar : { type: "post_result", message_id, delivered, missed } @@ -36,6 +36,21 @@ //! to one name; they are still two, and one of them leaving must not take the //! other off the roster (#40). //! +//! `account_id` is optional on `hello` and on `room_join`, and the room does no +//! more with it than put it on the seat and hand it back on the roster. It is +//! not an identity here and does not become one by being more stable than a +//! name: the roster is keyed on the connection, self-suppression is decided on +//! the connection, and `speaker` is stamped from the connection — all three +//! unchanged, and all three are what would break if any of them started reading +//! this field (#39 / #40 / #47). What it buys is on the screen: its own list of +//! accounts can be joined against the roster by id rather than by name, which +//! is the match #40 and #53 ruled out and the reason the panel had split into +//! two lists (#57, #59). +//! +//! A connection may carry none. The room does not presume an account exists +//! behind a participant — a person or a session that has one is not a different +//! kind of participant from one that has not. +//! //! `last_seen` on a post is the speaker's watermark: the `message_id` of the //! newest post they had actually seen when they composed. The room checks it //! against the floor (`room_floor::Floor`) and refuses the post outright when @@ -79,7 +94,10 @@ use uuid::Uuid; /// 3: `hello` carries the declared `hue` beside the name. /// 4: `post` carries the speaker's `last_seen` watermark, and the room answers /// every post with `post_result` (#47). -pub const PROTOCOL_VERSION: u32 = 4; +/// 5: `hello` may carry the `account_id` the session was launched as, and the +/// roster hands it back. Carried only — identity stays on the connection +/// (#59). +pub const PROTOCOL_VERSION: u32 = 5; /// One post of the room, as the frontend sees it. /// @@ -115,6 +133,15 @@ pub struct Participant { /// Declared at join; `None` when this participant declared none, which the /// screen answers by deriving one from the name. pub hue: Option, + /// The account this participant was launched as, or `None` when they + /// declared none. + /// + /// Handed back so the screen can join its own account list against this + /// roster by id. It is not the identity and is not what this entry is keyed + /// on — `id` above is both, and stays both however much more stable an + /// account id looks (#39 / #40 / #59). `None` is a participant like any + /// other, not a participant the screen may leave out. + pub account: Option, /// True for this screen's own person. Viewer-relative, like a message's /// `own`, and there is one screen. pub own: bool, @@ -162,6 +189,7 @@ struct IncomingFrame { message_id: Option, name: Option, hue: Option, + account_id: Option, content: Option, to: Option, ts: Option, @@ -174,6 +202,9 @@ struct IncomingFrame { struct Seat { name: String, hue: Option, + /// The account declared at join, or `None`. Held so the roster can hand it + /// back; nothing in this file branches on it. + account: Option, /// Where the floor stood when this connection took its seat. It is the /// watermark of a participant who declares none: what predates the seat /// was never delivered to them, so it is not theirs to have missed. @@ -247,6 +278,10 @@ impl RoomState { id: id.clone(), name: seat.name.clone(), hue: seat.hue, + account: seat.account.clone(), + // Decided on the connection, as it has to be: this is the one + // identity a shared name — or a shared account id arriving from + // somewhere this app did not launch — cannot blur (#40). own: *id == self.local_origin, }) .collect(); @@ -259,6 +294,19 @@ impl RoomState { self.inner.lock().participants.get(origin).and_then(|seat| seat.hue) } + /// The account whoever is on `origin` joined under. + /// + /// Read back for the same reason the hue is: `room_post` re-seats under the + /// name it was given, and passing `None` for what it was not told would + /// silently withdraw a declaration nobody withdrew. + fn account_of(&self, origin: &str) -> Option { + self.inner + .lock() + .participants + .get(origin) + .and_then(|seat| seat.account.clone()) + } + fn set_port(&self, port: u16) { self.inner.lock().port = Some(port); } @@ -270,14 +318,17 @@ impl RoomState { /// /// Returns true when the roster changed, so a declaration that declares /// nothing new does not emit a roster event. - fn seat(&self, origin: &str, name: &str, hue: Option) -> bool { + fn seat(&self, origin: &str, name: &str, hue: Option, account: Option<&str>) -> bool { let mut inner = self.inner.lock(); let current = inner .participants .get(origin) - .map(|seat| (seat.name.clone(), seat.hue, seat.since)); - if let Some((current_name, current_hue, _)) = ¤t { - if current_name == name && *current_hue == hue { + .map(|seat| (seat.name.clone(), seat.hue, seat.account.clone(), seat.since)); + if let Some((current_name, current_hue, current_account, _)) = ¤t { + if current_name == name + && *current_hue == hue + && current_account.as_deref() == account + { return false; } } @@ -286,7 +337,7 @@ impl RoomState { // being replaced keeps the position it started from — renaming does // not make a participant newly arrived. let since = match ¤t { - Some((_, _, since)) => *since, + Some((_, _, _, since)) => *since, None => inner.floor.seq(), }; inner.participants.insert( @@ -294,6 +345,7 @@ impl RoomState { Seat { name: name.to_string(), hue, + account: account.map(str::to_string), since, }, ); @@ -320,6 +372,15 @@ fn normalize_to(to: Option) -> Option { .filter(|name| !name.is_empty()) } +/// Absent is the key omitted, never an empty one, for the same reason `to` is: +/// the screen matches this against its own account ids, and an empty string +/// would be an id no account has while looking like a declared one. +fn normalize_account(account_id: Option) -> Option { + account_id + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) +} + /// The room's clock. /// /// One clock for everything the screen puts a time on. A session's start time @@ -563,8 +624,18 @@ async fn serve_participant( ); } // Seated on this connection. A second session answering to the - // same name is a second seat, not the same one. - room.seat(&origin, &name, normalize_hue(frame.hue)); + // same name is a second seat, not the same one — and so is a + // second session declaring the same account, which this room + // does not refuse: refusing a duplicate account belongs to the + // launcher's seat ledger, which knows what it started + // (`session::RoomSeats`), and a room that enforced it here + // would be treating the account as the identity. + room.seat( + &origin, + &name, + normalize_hue(frame.hue), + normalize_account(frame.account_id).as_deref(), + ); joined_as = Some(name); let _ = app.emit("room-participants", room.participants()); } @@ -640,22 +711,31 @@ pub fn room_participants(state: tauri::State) -> Vec { /// roster would list only sessions until the first utterance, and nobody could /// address someone who had not spoken yet. /// -/// `hue` is optional and the same declaration a session makes in its `hello`. -/// The screen's person and a session take one seat of the same kind, and there -/// is one path to it. +/// `hue` and `account_id` are optional and are the same declarations a session +/// makes in its `hello`. The screen's person and a session take one seat of the +/// same kind, and there is one path to it — which is why the account rides here +/// too: the person at the keyboard is an account of this app like any other +/// (#59), and a join path that could not say so would put them back outside the +/// one list this exists to make possible. #[tauri::command] pub fn room_join( app: AppHandle, state: tauri::State, name: String, hue: Option, + account_id: Option, ) -> Result<(), String> { let name = name.trim().to_string(); if name.is_empty() { return Err("name is empty".to_string()); } let local_origin = state.local_origin.clone(); - if state.seat(&local_origin, &name, normalize_hue(hue)) { + if state.seat( + &local_origin, + &name, + normalize_hue(hue), + normalize_account(account_id).as_deref(), + ) { let _ = app.emit("room-participants", state.participants()); } Ok(()) @@ -692,12 +772,13 @@ pub fn room_post( return Err("speaker is empty".to_string()); } // Speaking is being present. A post under a name the roster has not seen - // seats it, so the two cannot disagree. The hue is left as it stands: the - // composer declares nothing, and passing `None` here would silently - // withdraw a declaration the person made in the titlebar. + // seats it, so the two cannot disagree. The hue and the account are left as + // they stand: the composer declares neither, and passing `None` here would + // silently withdraw a declaration made at the join. let local_origin = state.local_origin.clone(); let seated_hue = state.hue_of(&local_origin); - if state.seat(&local_origin, &speaker, seated_hue) { + let seated_account = state.account_of(&local_origin); + if state.seat(&local_origin, &speaker, seated_hue, seated_account.as_deref()) { let _ = app.emit("room-participants", state.participants()); } diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index 8e7a504..65ebb55 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -8,7 +8,7 @@ //! //! The session must be interactive: `--print` never receives a push. -use crate::config::Account; +use crate::config::{Account, AccountKind}; use crate::pty::{self, PtyState}; use crate::room::RoomState; use mcp_config::{ @@ -241,6 +241,18 @@ pub fn start_session( ); } + // A person is not launched. Their account exists for the same reasons every + // account does — a name, a colour, a row in the list whether or not they + // are in the room — and there is no CLI under it to spawn (#59). Refused + // here rather than only hidden from the launcher, so the screen is not the + // only thing standing between a `user` account and a spawned `claude`. + if account.kind == AccountKind::User { + return Err(format!( + "Account \"{name}\" is a person, not a session. There is nothing to launch: a \ + person joins by being at the screen." + )); + } + if let Err(flag) = reject_incompatible_flags(&account.args) { return Err(format!( "Account \"{name}\" passes {flag}, which stops channel pushes from arriving. \ diff --git a/src/main.ts b/src/main.ts index 63e9667..dd3922b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -65,9 +65,31 @@ interface Participant { id: string; name: string; hue: number | null; + /** + * The account this participant joined as, or null when they joined with + * none. What the panel joins its own account list against (#59). + * + * Not the identity, and not what anything here decides on. `id` above is the + * identity, `own` below is the room's answer to self/other, and both are the + * connection. An account id looks like the more stable of the two and is not + * the one that was chosen: two connections could carry one account id — from + * somewhere this app did not launch — and they would still be two + * participants (#39 / #40 / #47). + */ + account: string | null; own: boolean; } +/** + * What kind of participant an account is, declared when it is made. + * + * Never inferred from the connection: the room sees only what kind of + * connection someone arrived on, and a person joining from another client + * arrives the same way a session does. The account form is the one moment + * anyone can say which this is (#59). + */ +type AccountKind = "user" | "ai"; + /** * One account: someone who exists whether or not they are running. * @@ -87,6 +109,9 @@ interface Account { /** The hue chosen for this account, or null when none was — the derived one * from the name is used then. */ hue: number | null; + /** Declared when the account was made. What the participant list groups on, + * and nothing else — the room still has one kind of participant. */ + kind: AccountKind; } interface AppConfig { @@ -100,8 +125,25 @@ interface StartedSession { started_at: string; } +/** + * Where the name and hue of this screen's person used to live, and the only + * thing still read out of them: the values to make their account from, once. + * + * They were the person's whole identity here while a person was not an account + * (#53 left that open). They are an account now (#59), so these two keys are a + * migration source and are not written to again. + */ const NAME_KEY = "pullcept.display-name"; const HUE_KEY = "pullcept.display-hue"; +/** + * Which account is the person at this screen. + * + * Held here rather than in the config, because it is a property of this screen + * rather than of the account list: the same config opened elsewhere would have + * a different person at it. The account itself is in the config like every + * other. + */ +const LOCAL_KEY = "pullcept.local-account"; /** * The hues a participant can declare. @@ -139,13 +181,8 @@ const DERIVED_ARC = 360 - RESERVED_ARC * 2; const roomEl = document.getElementById("room") as HTMLElement; const rosterEl = document.getElementById("roster") as HTMLElement; -const nameEl = document.getElementById("display-name") as HTMLInputElement; -const hueEl = document.getElementById("display-hue") as HTMLSelectElement; const accountEl = document.getElementById("account-select") as HTMLSelectElement; -const accountNameEl = document.getElementById("account-name") as HTMLInputElement; -const accountHueEl = document.getElementById("account-hue") as HTMLSelectElement; const accountNewEl = document.getElementById("account-new") as HTMLButtonElement; -const accountDeleteEl = document.getElementById("account-delete") as HTMLButtonElement; const startEl = document.getElementById("start-session") as HTMLButtonElement; const inputEl = document.getElementById("input") as HTMLTextAreaElement; const sendEl = document.getElementById("send") as HTMLButtonElement; @@ -161,11 +198,19 @@ const dirEl = document.getElementById("session-dir") as HTMLElement; const startedEl = document.getElementById("session-started") as HTMLElement; const windowEl = document.getElementById("session-window") as HTMLElement; const terminalEl = document.getElementById("terminal") as HTMLElement; -const terminalsEl = document.getElementById("terminals") as HTMLElement; -const terminalsSectionEl = document.getElementById("terminals-section") as HTMLElement; -const cwdEl = document.getElementById("session-cwd") as HTMLInputElement; -const optionsEl = document.getElementById("launch-options") as HTMLInputElement; -const previewEl = document.getElementById("launch-preview") as HTMLElement; +const dialogEl = document.getElementById("account-dialog") as HTMLDialogElement; +const dialogFormEl = document.getElementById("account-form") as HTMLFormElement; +const dialogTitleEl = document.getElementById("account-dialog-title") as HTMLElement; +const dialogNameEl = document.getElementById("dialog-name") as HTMLInputElement; +const dialogKindEl = document.getElementById("dialog-kind") as HTMLSelectElement; +const dialogHueEl = document.getElementById("dialog-hue") as HTMLSelectElement; +const dialogLaunchEl = document.getElementById("dialog-launch") as HTMLElement; +const dialogCwdEl = document.getElementById("dialog-cwd") as HTMLInputElement; +const dialogOptionsEl = document.getElementById("dialog-options") as HTMLInputElement; +const dialogPreviewEl = document.getElementById("dialog-preview") as HTMLElement; +const dialogErrorEl = document.getElementById("dialog-error") as HTMLElement; +const dialogDeleteEl = document.getElementById("dialog-delete") as HTMLButtonElement; +const dialogCancelEl = document.getElementById("dialog-cancel") as HTMLButtonElement; let accounts: Account[] = []; /** @@ -180,6 +225,14 @@ let accounts: Account[] = []; let seated = new Set(); /** Everyone in the room, this screen's person included. */ let participants: Participant[] = []; +/** + * The account the person at this screen is, or "" before it is resolved. + * + * They are an account like every other one — that is what #53 left open and + * this settles (#59). What is theirs alone is being the one at the keyboard, + * which is why the id is here and in `localStorage`, not a flag on the account. + */ +let localAccountId = ""; /** Prefill for an account that has never been given a working directory. */ let homeDir = ""; /** @@ -325,17 +378,36 @@ function joinArgs(args: string[]): string { return args.map((arg) => (arg === "" || arg.includes(" ") ? `"${arg}"` : arg)).join(" "); } +/** + * The accounts the launcher can start: the AI ones. + * + * A `user` account is a person, and there is no CLI under a person to spawn. + * The app refuses one as well and that refusal is the authority + * (`start_session`); leaving it out of the picker only means the refusal is + * never the way the person finds out (#59). + */ +function launchableAccounts(): Account[] { + return accounts.filter((account) => account.kind === "ai"); +} + /** The account the launcher is pointed at, or null when there is none. */ function selectedAccount(): Account | null { return accounts.find((candidate) => candidate.id === accountEl.value) ?? null; } +/** The account the person at this screen is, or null before it is resolved. */ +function localAccount(): Account | null { + return accounts.find((account) => account.id === localAccountId) ?? null; +} + /** * Write the account list back to disk. * - * Every edit persists as it is made rather than at the next launch: an account - * is a thing that exists whether or not it runs, so a name typed and never - * launched is not a draft (#53). + * 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). */ function saveAccounts(): void { void invoke("save_config", { config: { accounts } }).catch(() => { @@ -343,35 +415,6 @@ function saveAccounts(): void { }); } -/** - * Show the command that will actually run. - * - * The app merges its own channel entry into whatever is typed here, so the - * line the person wrote is not the line that launches. Showing the result is - * cheaper than explaining the merge. - */ -async function refreshPreview(): Promise { - const account = selectedAccount(); - if (!account) { - previewEl.textContent = ""; - return; - } - try { - const parsed = await invoke("parse_launch_options", { text: optionsEl.value }); - // The channel entry names this account's own server, which follows the - // account id. So the preview changes when another account is selected and - // holds still while this one is renamed — the identity being launched is - // the account, and renaming it does not make it something else (#53). - const merged = await invoke("preview_launch_args", { - args: parsed, - accountId: account.id, - }); - previewEl.textContent = `${account.command} ${joinArgs(merged)}`; - } catch { - previewEl.textContent = ""; - } -} - /** * Fill a hue picker, with "not declared" first. * @@ -502,15 +545,94 @@ function appendMessage(message: RoomMessage): void { } /** - * Draw one line of the panel's list. + * One line of the participant list: an account, whoever is in the room as it, + * or both. + * + * Both halves are optional, and each absence is a real state rather than a + * defect. An account with no participant is someone who exists and is not + * running (#53). A participant with no account is a connection that declared + * none — the room does not presume one exists, and something joining from + * outside this app has none to declare (#59). + */ +interface Member { + account: Account | null; + participant: Participant | null; +} + +/** Which group a row falls in, and the heading it is drawn under. */ +const GROUPS: { kind: AccountKind | "guest"; label: string }[] = [ + { kind: "user", label: "user" }, + { kind: "ai", label: "AI" }, + // Not a kind: the absence of one. A connection carrying no account has + // declared nothing, and inferring a kind from how it arrived is the mistake + // the declaration exists to avoid (#59). + { kind: "guest", label: "ゲスト" }, +]; + +/** + * Join the room's roster against this app's accounts, into one list. + * + * **On the account id**, which the room now carries on every seat (#59). Never + * on the name: a name is an editable attribute, two accounts may answer to one, + * and a running account may have been renamed since it joined — matching by + * name ties the wrong pair in all three cases, which is the failure #40 was + * about in another shape and the reason #53 refused it. + * + * The room's roster is the whole of the live half. The screen keeps no second + * list of who is present, so a name on a live row is a name a post can be + * addressed to. + */ +function members(): Member[] { + const rows: Member[] = []; + const placed = new Set(); + + for (const account of accounts) { + // Plural on purpose. One account holding two seats is refused by the app + // that launches it (`RoomSeats`), and this list is not the place to enforce + // that: something joining from elsewhere could carry the same id, and + // dropping the second one would hide a participant who is genuinely there. + const matches = participants.filter((one) => one.account === account.id); + for (const participant of matches) { + placed.add(participant.id); + rows.push({ account, participant }); + } + if (!matches.length) rows.push({ account, participant: null }); + } + + for (const participant of participants) { + if (!placed.has(participant.id)) rows.push({ account: null, participant }); + } + return rows; +} + +/** What a row is called: the room's name while it is in the room. */ +function memberName(row: Member): string { + return row.participant?.name ?? row.account?.name ?? ""; +} + +/** + * Draw one line of the participant list. * * The colour is the one that participant's lines carry in the room, which is * what makes the panel a legend for the conversation rather than a second copy - * of the same names. + * of the same names. It is on the name itself here, not only on the dot. + * + * An offline row keeps its colour and is dimmed; it does not fall to grey. The + * colour carries the account's identity, and someone merely absent must not + * read as someone else (#59). */ -function rosterEntry(name: string, hue: number | null, own: boolean, note?: string): HTMLLIElement { +function memberRow(row: Member): HTMLLIElement { + const name = memberName(row); + const hue = row.participant ? row.participant.hue : (row.account?.hue ?? null); + // From the room, decided on the connection. A name test here would mark every + // participant answering to this screen's name as oneself (#40). + const own = row.participant?.own ?? false; + const view = row.account ? views.get(row.account.id) : undefined; + const entry = document.createElement("li"); + entry.className = "member"; entry.style.setProperty("--speaker", speakerColor(name, hue, own)); + if (!row.participant) entry.classList.add("offline"); const dot = document.createElement("span"); dot.className = "dot"; @@ -520,28 +642,82 @@ function rosterEntry(name: string, hue: number | null, own: boolean, note?: stri who.textContent = name; who.title = name; - entry.append(dot, who); - if (note) { - const tag = document.createElement("span"); - tag.className = "note"; - tag.textContent = note; - entry.appendChild(tag); + // What this line says about itself beyond the name. Someone present and not + // oneself says nothing: being in the list is what it would have said. + let noteText = ""; + if (own) noteText = "(あなた)"; + else if (row.participant) noteText = ""; + else if (view?.ended != null) noteText = "終了"; + else if (row.account) noteText = "未起動"; + + // A row whose account has a terminal here picks that terminal; the whole line + // is the control, so choosing which session to watch is one click on the + // session rather than on something beside it. A row with no terminal is not a + // button at all — a disabled one would grey out a name whose colour is + // load-bearing. + let pick: HTMLElement; + if (view) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "pick"; + button.setAttribute("aria-pressed", String(view.accountId === shownAccount)); + button.title = `${name} の端末を見る`; + button.addEventListener("click", () => { + revealDiagnostics(); + showView(view.accountId); + view.term.focus(); + }); + if (view.accountId === shownAccount) entry.classList.add("shown"); + pick = button; + } else { + pick = document.createElement("div"); + pick.className = "pick static"; } + pick.append(dot, who); + if (noteText) { + const note = document.createElement("span"); + note.className = "note"; + note.textContent = noteText; + pick.appendChild(note); + } + entry.appendChild(pick); + + // The account's own operations ride on its row, which is what one list buys: + // the row is keyed on the account id, so 編集 and 終了 act on one account and + // cannot be tied to the wrong one by a shared name (#53, #59). + if (row.account) entry.appendChild(editButton(row.account)); + // No 終了 before the launch has returned an id: there is no session to end + // yet, and a kill aimed at an empty id reports success having done nothing. + // None for an account that is not running either (#57). + if (view && view.ended === null && view.ptyId !== "") entry.appendChild(endButton(view, name)); + return entry; } +/** The control that opens one account's form. */ +function editButton(account: Account): HTMLButtonElement { + const edit = document.createElement("button"); + edit.type = "button"; + edit.className = "edit"; + edit.textContent = "編集"; + edit.title = `${account.name} の設定`; + edit.addEventListener("click", () => openAccountDialog(account)); + return edit; +} + /** - * Draw the panel's list: who is in the room, and who exists but is not. + * Draw the participant list: everyone who is here, and everyone who exists. * - * The two halves are joined **on the account id**. The live half is the room's - * roster verbatim — the screen keeps no second list of who is present, so a - * name on a live line is a name a post can be addressed to. The offline half is - * every account with no seat, which the app answers by id (`seated_accounts`). + * One list. It was two — a roster keyed on the connection and a terminal list + * keyed on the account id — because a `Participant` carried no account id and + * the only join available was on the name, which #40 and #53 had ruled out + * (#57). The room carries the id now, so the two questions ("who is here" and + * "what can I do with them") are answered on one row. * - * By id and never by name. A name is an editable attribute now, so two accounts - * may answer to one name and a running account may have been renamed since it - * joined; matching the two halves by name would tie the wrong pair in both - * cases, which is the failure #40 was about in another shape. + * Grouped by the kind declared at creation, with the count in the heading. A + * participant with no account has declared no kind and is grouped as such; + * guessing one from the connection would mistake a person who joined from + * another client for a session (#59). * * An account that is not running is still someone, so it is listed rather than * left out — that is the whole point of an account existing while it is off @@ -550,32 +726,31 @@ function rosterEntry(name: string, hue: number | null, own: boolean, note?: stri */ function renderPanel(): void { rosterEl.replaceChildren(); + const rows = members(); - const offline = accounts.filter((account) => !seated.has(account.id)); - if (!participants.length && !offline.length) { + if (!rows.length) { const empty = document.createElement("li"); empty.className = "empty"; empty.textContent = "参加者なし"; rosterEl.appendChild(empty); + renderAddressees(); + return; } - for (const participant of participants) { - // `own` comes from the room, decided on the connection. A name test here - // would mark every participant answering to this screen's name as oneself. - rosterEl.appendChild( - rosterEntry( - participant.name, - participant.hue, - participant.own, - participant.own ? "(あなた)" : undefined, - ), - ); - } + for (const group of GROUPS) { + const inGroup = rows + .filter((row) => (row.account?.kind ?? "guest") === group.kind) + .sort((a, b) => memberName(a).localeCompare(memberName(b))); + if (!inGroup.length) continue; + + const heading = document.createElement("li"); + heading.className = "group"; + // Name and count, joined by an em dash. The count is what a heading buys + // over a divider: how many of this kind are here is read without counting. + heading.textContent = `${group.label} — ${inGroup.length}`; + rosterEl.appendChild(heading); - for (const account of offline) { - const entry = rosterEntry(account.name, account.hue, false, "未起動"); - entry.classList.add("offline"); - rosterEl.appendChild(entry); + for (const row of inGroup) rosterEl.appendChild(memberRow(row)); } renderAddressees(); @@ -600,37 +775,86 @@ async function refreshSeats(): Promise { seated = new Set(await invoke("seated_accounts")); } catch { // The panel keeps the last answer rather than declaring everyone offline - // on a failed read. - return; + // on a failed read. It still redraws: what failed is this one value, and + // whatever else moved since the last draw is not held back by it. } renderPanel(); } /** The name this screen posts under, and is listed in the roster under. */ function localName(): string { - return nameEl.value.trim() || "human"; + return localAccount()?.name.trim() || "human"; } /** - * Take a seat in the room under the current name and colour. + * Take a seat in the room as the account this screen's person is. * * Being in the room is not the same as having spoken in it: without this the * roster would list only the sessions, and nobody could address someone who * had not spoken yet. * - * The colour goes with the name because they are one declaration, and because - * a seat carries both — sending the name alone on a rename would withdraw a - * colour nobody withdrew. + * All three go together because a seat carries all three, and sending fewer + * would withdraw a declaration nobody withdrew. The account id rides for the + * same reason a session's does — so this screen's own row in the list is joined + * by id like every other, rather than by the name that #40 ruled out. It is not + * what makes this participant oneself: the room decides that on the connection, + * and `Participant.own` is its answer (#59). */ async function join(): Promise { + const account = localAccount(); try { - await invoke("room_join", { name: localName(), hue: declaredHue(hueEl) }); + await invoke("room_join", { + name: localName(), + hue: account?.hue ?? null, + accountId: account?.id ?? null, + }); } catch { // Failing to seat is not worth interrupting anything: the first post // seats the name anyway. } } +/** + * Find or make the account the person at this screen is. + * + * #53 left "is a human an account" open, and the two lists in the panel were + * one consequence: the person was a name and a colour in `localStorage`, so + * they had no row of the kind everyone else had. They are an account now, of + * kind `user`, and the migration is the obvious one — the name and colour they + * had been joining under become that account's (#59). + * + * Resolved before the room is joined, because the id goes into the join. + */ +function resolveLocalAccount(): void { + const stored = localStorage.getItem(LOCAL_KEY); + let account = + accounts.find((one) => one.id === stored && one.kind === "user") ?? + accounts.find((one) => one.kind === "user") ?? + null; + + if (!account) { + const savedHue = localStorage.getItem(HUE_KEY); + account = { + id: crypto.randomUUID(), + name: (localStorage.getItem(NAME_KEY) ?? "").trim() || "human", + // Carried so the shape of an account is one shape. Nothing launches a + // person, and `start_session` refuses a `user` account outright. + command: "claude", + args: [], + cwd: null, + hue: savedHue !== null && HUES.some(({ hue }) => String(hue) === savedHue) + ? Number(savedHue) + : null, + kind: "user", + }; + accounts.push(account); + saveAccounts(); + } + + localAccountId = account.id; + localStorage.setItem(LOCAL_KEY, account.id); +} + /** * Redraw the addressee list from the roster. * @@ -742,65 +966,6 @@ function renderSessionFacts(): void { showWindowSize(); } -/** - * Draw the panel's terminal list. - * - * Not the roster, and not a second copy of it. The roster answers who is in the - * room and is keyed on the connection each participant arrived on; this answers - * which sessions this screen is holding a terminal for, and is keyed on the - * account id. The two memberships genuinely differ: a participant who joined - * from somewhere else has no terminal here, and a session that has just exited - * has a terminal and no seat. Keying this list on the account id is also what - * lets a row carry an operation at all — a name match would tie the wrong row - * as soon as two accounts answer to one name (#53). - */ -function renderTerminalList(): void { - terminalsEl.replaceChildren(); - terminalsSectionEl.hidden = views.size === 0; - - for (const view of views.values()) { - const account = accounts.find((candidate) => candidate.id === view.accountId); - const name = viewName(view); - const row = document.createElement("li"); - row.className = "term-row"; - row.style.setProperty("--speaker", speakerColor(name, account?.hue ?? null, false)); - if (view.accountId === shownAccount) row.classList.add("shown"); - if (view.ended !== null) row.classList.add("ended"); - - // The whole line picks the terminal, so choosing which session to watch is - // one click on the name of it rather than a control beside the name. - const pick = document.createElement("button"); - pick.type = "button"; - pick.className = "pick"; - pick.setAttribute("aria-pressed", String(view.accountId === shownAccount)); - - const dot = document.createElement("span"); - dot.className = "dot"; - - const who = document.createElement("span"); - who.className = "who"; - who.textContent = name; - who.title = name; - - const note = document.createElement("span"); - note.className = "note"; - note.textContent = view.ended === null ? "稼働中" : "終了"; - - pick.append(dot, who, note); - pick.addEventListener("click", () => { - revealDiagnostics(); - showView(view.accountId); - view.term.focus(); - }); - row.appendChild(pick); - - // No 終了 before the launch has returned an id: there is no session to end - // yet, and a kill aimed at an empty id reports success having done nothing. - if (view.ended === null && view.ptyId !== "") row.appendChild(endButton(view, name)); - terminalsEl.appendChild(row); - } -} - /** * The 終了 control, which takes two clicks. * @@ -848,9 +1013,9 @@ function armEnd(accountId: string): void { // of a session is one that an unrelated click, minutes later, fires. armedTimer = window.setTimeout(() => { armedEnd = null; - renderTerminalList(); + renderPanel(); }, END_ARM_MS); - renderTerminalList(); + renderPanel(); } function disarmEnd(): void { @@ -872,7 +1037,7 @@ async function endSession(view: SessionView): Promise { try { await invoke("kill_pty", { id: view.ptyId }); } catch (err) { - renderTerminalList(); + renderPanel(); status(`${name} を終了できませんでした: ${err}`, "error"); return; } @@ -880,7 +1045,7 @@ async function endSession(view: SessionView): Promise { // The exit event marks the view ended and redraws the row; this call only // says the kill was delivered. await refreshSeats(); - renderTerminalList(); + renderPanel(); } /** @@ -988,7 +1153,7 @@ function showView(accountId: string | null): void { for (const view of views.values()) { view.host.hidden = view.accountId !== accountId; } - renderTerminalList(); + renderPanel(); renderSessionFacts(); fitShown(); // A pane that was `display: none` kept filling its buffer and painted @@ -1009,7 +1174,7 @@ function showView(accountId: string | null): void { async function followSession(view: SessionView, started: StartedSession): Promise { view.ptyId = started.pty_id; view.startedAt = started.started_at; - renderTerminalList(); + renderPanel(); renderSessionFacts(); // Both listeners are this view's, and are dropped with it. The shared @@ -1032,7 +1197,7 @@ async function followSession(view: SessionView, started: StartedSession): Promis // The seat this account held is free the moment its session ends, so the // panel says 未起動 again and the account can be started once more. void refreshSeats(); - renderTerminalList(); + renderPanel(); if (shownAccount === view.accountId) { renderSessionFacts(); revealDiagnostics(); @@ -1055,8 +1220,11 @@ async function startSession(): Promise { const name = account.name.trim(); if (!name) { - status("アカウントの名前を入力してください。部屋での名乗りになります。", "error"); - accountNameEl.focus(); + status( + "アカウントの名前を入力してください。部屋での名乗りになります。", + "error", + ); + openAccountDialog(account); return; } @@ -1071,18 +1239,18 @@ async function startSession(): Promise { return; } - const cwd = cwdEl.value.trim(); - if (!cwd) { - status("作業ディレクトリを入力してください。", "error"); - cwdEl.focus(); + // Read off the account, which is where the person set it — this row no longer + // carries a field of its own to read it out of (#59). Not defaulted to + // whatever directory the app process happens to sit in: that is what put a + // session in src-tauri (#20). + if (!(account.cwd ?? "").trim()) { + status( + `「${name}」に作業ディレクトリがありません。編集から設定してください。`, + "error", + ); + openAccountDialog(account); return; } - // The directory and the options are the person's choice, so they are the - // account's own and are saved. Falling back to whatever directory the app - // process happens to sit in is what put a session in src-tauri (#20). - account.cwd = cwd; - account.args = await invoke("parse_launch_options", { text: optionsEl.value }); - saveAccounts(); // Size the PTY to the terminal that will display it, so the CLI's first // paint is not laid out for a window it does not have. The pane is revealed @@ -1137,100 +1305,267 @@ function unusedAccountName(): string { } } -/** Fill the account picker, keeping `selected` selected when it still exists. */ +/** + * Fill the launcher's picker, keeping `selected` selected when it still exists. + * + * AI accounts only (`launchableAccounts`). The row's one question is which + * session to start, and a person is not one. + */ function renderAccountOptions(selected: string): void { + const launchable = launchableAccounts(); accountEl.replaceChildren(); - for (const account of accounts) { + for (const account of launchable) { const option = document.createElement("option"); option.value = account.id; option.textContent = account.name || "(名前未設定)"; accountEl.appendChild(option); } - accountEl.value = accounts.some((account) => account.id === selected) + accountEl.value = launchable.some((account) => account.id === selected) ? selected - : (accounts[0]?.id ?? ""); + : (launchable[0]?.id ?? ""); + const none = launchable.length === 0; + accountEl.disabled = none; + startEl.disabled = none; +} + +// ── the account dialog ─────────────────────────────────────────────────────── +// +// One form for making, editing and deleting an account. It holds a draft and +// writes nothing until 決定; 取消 leaves nothing behind, for a new account as +// much as for an edit. The fields used to save on `change`, which meant + +// created an account the instant it was pressed and every keystroke on the way +// to a name was a state that had existed — there was no deciding and no undoing +// (#59). + +/** The account being edited, or null while the form is making a new one. */ +let editing: Account | null = null; +/** The draft the form is filling in. Never the account itself. */ +let draft: Account | null = null; +/** True once 削除 has been armed, in the same shape 終了 uses. */ +let deleteArmed = false; + +/** Say why the form cannot be decided yet, or clear that. */ +function dialogError(text: string): void { + dialogErrorEl.textContent = text; +} + +/** Put 削除 back to resting. */ +function disarmDelete(): void { + deleteArmed = false; + dialogDeleteEl.textContent = "削除"; + dialogDeleteEl.classList.remove("armed"); } /** - * Show the selected account's own values in the row that edits them. + * Show only the fields that mean something for the kind being declared. * - * `homeDir` prefills a working directory an account has never had one for. A - * prefill, not a default: the app launches nothing in a directory the person - * has not seen on screen (#20). + * A person has no command under them, so a working directory and launch options + * would be two fields that never do anything. */ -function showAccount(): void { - const account = selectedAccount(); - accountNameEl.value = account?.name ?? ""; - accountHueEl.value = account?.hue === null || account === null ? "" : String(account.hue); - cwdEl.value = account?.cwd ?? homeDir; - optionsEl.value = joinArgs(account?.args ?? []); - const missing = account === null; - accountNameEl.disabled = missing; - accountHueEl.disabled = missing; - cwdEl.disabled = missing; - optionsEl.disabled = missing; - accountDeleteEl.disabled = missing; - startEl.disabled = missing; - void refreshPreview(); +function showDialogKind(): void { + const kind = dialogKindEl.value as AccountKind; + dialogLaunchEl.hidden = kind !== "ai"; + if (kind === "ai") void refreshDialogPreview(); +} + +/** + * Show the command this account's launch would actually run. + * + * The app merges its own channel entry into whatever is typed, so the line + * written here is not the line that launches; showing the result is cheaper + * than explaining the merge. The entry names this account's own server, which + * follows the account id — so the preview holds still while the name in the + * field above it is edited. Holding still is the point: the identity being + * launched is the account, and renaming it does not make it something else + * (#53). + */ +async function refreshDialogPreview(): Promise { + if (!draft) return; + const id = draft.id; + try { + const parsed = await invoke("parse_launch_options", { + text: dialogOptionsEl.value, + }); + const merged = await invoke("preview_launch_args", { args: parsed, accountId: id }); + // The form may have been closed or reopened during the round trip. + if (draft?.id !== id) return; + dialogPreviewEl.textContent = `${draft.command} ${joinArgs(merged)}`; + } catch { + dialogPreviewEl.textContent = ""; + } } -/** Make an account, select it, and leave the cursor in its name. */ -function newAccount(): void { - const account: Account = { - // Opaque and minted once. Nothing reads a name out of it — the key in - // `.mcp.json` derives from it precisely so that renaming is free (#53). - id: crypto.randomUUID(), - name: unusedAccountName(), - // The one vendor the room is built on. See src-tauri/src/config.rs. - command: "claude", - args: [], - cwd: homeDir || null, - hue: null, +/** + * Open the form on one account, or on a new one when given none. + * + * A new account's id is minted here so the launch preview has something to name + * a server after. That is all it is until 決定 — nothing is pushed into the + * account list, so 取消 leaves no account behind and no id in use. + */ +function openAccountDialog(account: Account | null): void { + editing = account; + draft = account + ? { ...account, args: [...account.args] } + : { + // Opaque and minted once. Nothing reads a name out of it — the key in + // `.mcp.json` derives from it precisely so renaming is free (#53). + id: crypto.randomUUID(), + name: unusedAccountName(), + // The one vendor the room is built on. See src-tauri/src/config.rs. + command: "claude", + args: [], + // A prefill, not a default: the app launches nothing in a directory the + // person has not seen on screen (#20). + cwd: homeDir || null, + hue: null, + kind: "ai", + }; + + dialogTitleEl.textContent = account ? "アカウントの編集" : "アカウントの追加"; + dialogNameEl.value = draft.name; + dialogKindEl.value = draft.kind; + dialogHueEl.value = draft.hue === null ? "" : String(draft.hue); + dialogCwdEl.value = draft.cwd ?? ""; + dialogOptionsEl.value = joinArgs(draft.args); + dialogDeleteEl.hidden = account === null; + disarmDelete(); + dialogError(""); + showDialogKind(); + dialogEl.showModal(); + dialogNameEl.focus(); + dialogNameEl.select(); +} + +/** + * Take what the form holds and put it into the account list. + * + * The one moment anything here reaches the list. Returns false when the form + * cannot be decided yet, so the dialog stays open on its own reason. + */ +async function commitAccountDialog(): Promise { + if (!draft) return false; + // Read before the await below. Escape closes the dialog on its own, and the + // close handler clears both — reading them afterwards would push a second + // copy of an account that was being edited. + const target = editing; + const settling = draft; + + const name = dialogNameEl.value.trim(); + if (!name) { + dialogError("名前を入力してください。部屋での名乗りになります。"); + dialogNameEl.focus(); + return false; + } + + const kind = dialogKindEl.value as AccountKind; + // A running account cannot change kind. Its session is in the room under this + // account, and turning it into a person would drop the working directory and + // options that session was launched from while it is still running. + if (target && kind !== target.kind && seated.has(target.id)) { + dialogError(`「${target.name}」は起動中です。種別を変えるには先に終了してください。`); + return false; + } + // The person at this screen is a person. Turning their account into an `ai` + // would list them under the wrong heading and offer to launch a CLI under + // their name, which is not a thing there is one of. + if (target && target.id === localAccountId && kind !== "user") { + dialogError("この画面の本人のアカウントは種別 user のままです。"); + return false; + } + const cwd = dialogCwdEl.value.trim(); + // Only for a session. A person's working directory and options would be two + // values nothing ever reads, kept alive by an edit that once set them. + const args = + kind === "ai" + ? await invoke("parse_launch_options", { text: dialogOptionsEl.value }) + : []; + + const settled: Account = { + ...settling, + name, + kind, + hue: declaredHue(dialogHueEl), + cwd: kind === "ai" ? cwd || null : null, + args, }; - accounts.push(account); - renderAccountOptions(account.id); - showAccount(); + + if (target) { + const at = accounts.findIndex((one) => one.id === target.id); + if (at >= 0) accounts[at] = settled; + } else { + accounts.push(settled); + } saveAccounts(); + + renderAccountOptions(target ? accountEl.value : settled.id); renderPanel(); - accountNameEl.focus(); - accountNameEl.select(); + renderSessionFacts(); + // The room holds this screen's person's name and colour on its seat, so a + // rename here has to be re-declared or the roster keeps the old pair. + if (settled.id === localAccountId) await join(); + status( + target + ? `アカウント「${settled.name}」を保存しました。` + : `アカウント「${settled.name}」を追加しました。`, + ); + return true; } /** - * Remove the selected account. + * Delete the account the form is open on, on the second click. + * + * Two clicks rather than `window.confirm`, for the reason 終了 does not use one + * either: a host that answers nothing makes the button either silently dead or + * — the bias `confirm` defaults to — destructive on one click (#57). * * Refused while it is running: the session in the room belongs to this account, * and deleting the account under it would leave a participant on the roster - * that nothing on this screen can name or account for. + * that nothing on this screen can name or account for. Refused for the person + * at this screen too — they are in the room by being here, and there would be + * nothing left to be here as. */ -function deleteAccount(): void { - const account = selectedAccount(); +function deleteFromDialog(): void { + const account = editing; if (!account) return; + if (seated.has(account.id)) { - status( - `「${account.name}」は起動中です。セッションを終了してから削除してください。`, - "error", - ); + dialogError(`「${account.name}」は起動中です。セッションを終了してから削除してください。`); + disarmDelete(); + return; + } + if (account.id === localAccountId) { + dialogError("この画面の本人のアカウントは削除できません。"); + disarmDelete(); return; } - // Only an explicit cancel stops this. A host that answers nothing would - // otherwise make the button silently do nothing at all. - if (window.confirm(`アカウント「${account.name}」を削除します。よろしいですか。`) === false) { + if (!deleteArmed) { + deleteArmed = true; + dialogDeleteEl.textContent = "本当に削除"; + dialogDeleteEl.classList.add("armed"); + dialogError("もう一度押すと削除します。"); return; } + accounts = accounts.filter((candidate) => candidate.id !== account.id); // 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)); - renderTerminalList(); - renderSessionFacts(); - renderAccountOptions(accounts[0]?.id ?? ""); - showAccount(); saveAccounts(); + closeAccountDialog(); + renderAccountOptions(accountEl.value); renderPanel(); + renderSessionFacts(); status(`アカウント「${account.name}」を削除しました。`); } +/** Drop the draft and close. Nothing it held reached the account list. */ +function closeAccountDialog(): void { + editing = null; + draft = null; + disarmDelete(); + if (dialogEl.open) dialogEl.close(); +} + function renderSocket(port: number | null, error?: string): void { if (error) { socketStateEl.textContent = error; @@ -1263,23 +1598,9 @@ async function main(): Promise { new ResizeObserver(() => fitShown()).observe(terminalEl); renderSessionFacts(); - nameEl.value = localStorage.getItem(NAME_KEY) ?? "human"; - fillHues(hueEl, localStorage.getItem(HUE_KEY)); // The account's colour is the account's, so nothing is restored into this - // picker — `showAccount` fills it from whichever account is selected. - fillHues(accountHueEl, null); - - // One handler for both halves of the declaration: a rename and a recolour are - // the same act on the same seat, and the room takes them together. - const redeclare = (): void => { - localStorage.setItem(NAME_KEY, localName()); - localStorage.setItem(HUE_KEY, hueEl.value); - // The roster entry follows the name, and the addressee list follows the - // roster: a rename must not leave the old name sitting in either. - void join().then(() => renderAddressees()); - }; - nameEl.addEventListener("change", redeclare); - hueEl.addEventListener("change", redeclare); + // picker — the form fills it from whichever account it was opened on. + fillHues(dialogHueEl, null); toggleEl.addEventListener("click", () => { if (diagnosticsEl.hidden) { @@ -1311,56 +1632,49 @@ async function main(): Promise { // Only the prefill is lost; the field is still typed into by hand. } - // Every field in this row edits the selected account. The name is written - // through as it is typed so the picker's label follows it, and persisted when - // the field is left — a rename is free, because the account's id is what the - // registration key and the seat are both keyed on (#53). - accountNameEl.addEventListener("input", () => { - const account = selectedAccount(); - if (!account) return; - account.name = accountNameEl.value; - const option = accountEl.selectedOptions[0]; - if (option) option.textContent = account.name || "(名前未設定)"; - renderPanel(); - // The terminal list is drawn from the account list, so a rename follows the - // pane it belongs to: the row is keyed on the id, and the name is only what - // it is called (#53). - renderTerminalList(); - renderSessionFacts(); - }); - accountNameEl.addEventListener("change", () => saveAccounts()); - accountHueEl.addEventListener("change", () => { - const account = selectedAccount(); - if (!account) return; - account.hue = declaredHue(accountHueEl); - saveAccounts(); - renderPanel(); - renderTerminalList(); - }); - cwdEl.addEventListener("change", () => { - const account = selectedAccount(); - if (!account) return; - account.cwd = cwdEl.value.trim() || null; - saveAccounts(); + // ── the account form ─────────────────────────────────────────────────────── + // + // Nothing here writes to an account. Every field edits the form's own draft, + // and only 決定 puts that draft into the list. + accountNewEl.addEventListener("click", () => openAccountDialog(null)); + dialogKindEl.addEventListener("change", () => showDialogKind()); + dialogOptionsEl.addEventListener("input", () => void refreshDialogPreview()); + // Anything but the second click of 削除 disarms it: an arm left standing is + // one that an unrelated click fires later. + for (const field of [dialogNameEl, dialogKindEl, dialogHueEl, dialogCwdEl, dialogOptionsEl]) { + field.addEventListener("input", () => disarmDelete()); + } + dialogDeleteEl.addEventListener("click", () => deleteFromDialog()); + dialogCancelEl.addEventListener("click", () => closeAccountDialog()); + // Escape closes the dialog itself, and it means 取消: the draft is dropped by + // the close handler below, so there is no path out of this form that leaves + // half of it applied. + dialogEl.addEventListener("close", () => { + editing = null; + draft = null; + disarmDelete(); }); - optionsEl.addEventListener("input", () => void refreshPreview()); - optionsEl.addEventListener("change", () => { - const account = selectedAccount(); - if (!account) return; - void invoke("parse_launch_options", { text: optionsEl.value }).then((args) => { - account.args = args; - saveAccounts(); + dialogFormEl.addEventListener("submit", (event) => { + // Always prevented: `method="dialog"` would close on submit, and the form + // may not be decidable yet. The commit closes it once it has succeeded. + event.preventDefault(); + void commitAccountDialog().then((done) => { + if (done) closeAccountDialog(); }); }); - accountEl.addEventListener("change", showAccount); - accountNewEl.addEventListener("click", () => newAccount()); - accountDeleteEl.addEventListener("click", () => deleteAccount()); try { const config = await invoke("load_config"); accounts = config.accounts; - renderAccountOptions(accounts[0]?.id ?? ""); - showAccount(); + // Before the join below: the id this resolves goes into it, and an account + // may have to be made here for it (#59). + resolveLocalAccount(); + renderAccountOptions(""); + // Drawn here, off the config alone. An account that is not running is + // listed from the moment the app opens rather than once the room has + // answered — being listed is not conditional on ever having been started + // (#59). + renderPanel(); } catch (err) { status(`設定を読み込めませんでした: ${err}`, "error"); } diff --git a/src/styles.css b/src/styles.css index 5b8bd8c..78fe317 100644 --- a/src/styles.css +++ b/src/styles.css @@ -290,24 +290,87 @@ body { font-size: 0.78rem; } +/* The list's own heading, with the control that makes an account beside it. + Making one belongs next to the list it appears in, not on the row that + launches one (#59). */ +#participants .panel-head { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 0.9rem 0.35rem; +} + #participants .panel-title { margin: 0; - padding: 0.6rem 0.9rem 0.35rem; + flex: 1; font-size: inherit; font-weight: 600; } +#participants .panel-head button { + font: inherit; + line-height: 1; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--line); + border-radius: 4px; + padding: 0.15rem 0.4rem; + cursor: pointer; +} + #participants .roster { margin: 0; - padding: 0; + padding: 0 0.3rem 0.35rem; list-style: none; } -#participants .roster li { +/* A group of one kind, and how many of them are here. The count is what a + heading buys over a divider — the label alone would say what these are, and + this says how many without counting rows (#59). + + The kind is the one declared when the account was made. A participant with no + account declared none, and their group is that absence rather than a guess + read off the connection they arrived on. */ +#participants .roster .group { + padding: 0.7rem 0.6rem 0.2rem; + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.02em; +} + +#participants .member { + display: flex; + align-items: center; + gap: 0.3rem; +} + +/* The name is the control that picks this account's terminal, so it takes the + width of the row: choosing which session to watch is a click on the session + itself. A row whose account has no terminal here is the same shape without + the button — the layout must not move when a session starts or ends. */ +#participants .member .pick { + flex: 1; + min-width: 0; display: flex; align-items: center; gap: 0.5rem; - padding: 0.45rem 0.9rem; + font: inherit; + text-align: left; + background: none; + border: 1px solid transparent; + border-radius: 4px; + padding: 0.3rem; +} + +#participants .member button.pick { + cursor: pointer; +} + +/* The pane on the glass. Lifted out of the panel's surface rather than tinted, + so which terminal is showing reads at a glance and in either theme. */ +#participants .member.shown .pick { + background: var(--bg); + border-color: var(--line); } /* The same colour the participant's lines carry in the room. */ @@ -319,30 +382,37 @@ body { background: var(--speaker, var(--line)); } +/* The name carries the participant's own colour, which is what makes this list + a legend for the conversation rather than a second copy of the same names. */ #participants .who { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--speaker, var(--fg)); } -/* What a line says about itself beyond the name: 「(あなた)」 for the name - this screen posts under, 「未起動」 for an account with no session in the - room. Colour alone would say the first of those, but only to someone who - already knows which colour is theirs. */ +/* What a line says about itself beyond the name: 「(あなた)」 for the person at + this screen, 「未起動」 for an account with no session in the room, 「終了」 for + one whose session has ended and whose output is still readable. Colour alone + would say the first of those, but only to someone who already knows which + colour is theirs. */ #participants .note { color: var(--muted); } -/* An account that exists but is not in the room. Dimmed rather than dropped: - it is still someone, and this list is where the person reads who they have - (#53). It is not an addressee — that list comes from the room's roster, so - an offline account cannot appear in it at all. */ -#participants .roster li.offline .who { - color: var(--muted); +/* An account that exists but is not in the room. Dimmed rather than dropped: it + is still someone, and this list is where the person reads who they have + (#53). Dimmed rather than greyed — the colour carries the account's identity, + and someone merely absent must not read as someone else (#59). The dot goes + from filled to outline, which is the part that says "not here" at a glance. + It is still not an addressee: that list comes from the room's roster, so an + offline account cannot appear in it at all. */ +#participants .member.offline .pick { + opacity: 0.55; } -#participants .roster li.offline .dot { +#participants .member.offline .dot { background: none; border: 1px solid var(--speaker, var(--line)); } @@ -352,83 +422,34 @@ body { color: var(--muted); } -/* ── panel: terminals ────────────────────────────────────────────────────── */ - -/* The sessions this screen holds a pane for. Its own block under the roster, - because it answers a different question: the roster says who is in the room, - this says which terminals are here to be watched and ended. */ -#participants .terminals-section { - border-top: 1px solid var(--line); - margin-top: 0.35rem; -} - -#participants .terminals { - margin: 0; - padding: 0 0.6rem 0.35rem; - list-style: none; -} - -#participants .term-row { - display: flex; - align-items: center; - gap: 0.3rem; -} - -/* The name is the control that picks the pane, so it takes the width of the - row: choosing which session to watch is a click on the session, not on a - separate button beside it. */ -#participants .term-row .pick { - flex: 1; - min-width: 0; - display: flex; - align-items: center; - gap: 0.5rem; +/* The account's own operations, riding on its row. This is what one list buys: + the row is keyed on the account id, so these act on one account and cannot be + tied to the wrong one by a shared name (#53, #59). */ +#participants .member .edit, +#participants .member .end { + flex: none; font: inherit; - text-align: left; - color: var(--fg); + font-size: 0.72rem; background: none; - border: 1px solid transparent; + border: 1px solid var(--line); border-radius: 4px; - padding: 0.3rem; + padding: 0.2rem 0.4rem; cursor: pointer; } -/* The pane on the glass. Lifted out of the panel's surface rather than tinted, - so which terminal is showing reads at a glance and in either theme. */ -#participants .term-row.shown .pick { - background: var(--bg); - border-color: var(--line); -} - -/* Dimmed, and the dot goes from filled to outline — the same pair the roster - uses for an account that is not in the room. A session that has ended is not - in it either; the row is still here only for what it printed. */ -#participants .term-row.ended .who { +#participants .member .edit { color: var(--muted); } -#participants .term-row.ended .dot { - background: none; - border: 1px solid var(--speaker, var(--line)); -} - /* Its resting state is a quiet outline in the danger colour: this is the one - control here that cannot be taken back, and it sits next to one that only - changes what is displayed. Armed, it fills — the button says which of its two - states it is in, and the second click is the one that acts (src/main.ts). */ -#participants .term-row .end { - flex: none; - font: inherit; - font-size: 0.72rem; + control here that cannot be taken back, and it sits next to two that can. + Armed, it fills — the button says which of its two states it is in, and the + second click is the one that acts (src/main.ts). */ +#participants .member .end { color: var(--danger); - background: none; - border: 1px solid var(--line); - border-radius: 4px; - padding: 0.2rem 0.4rem; - cursor: pointer; } -#participants .term-row .end.armed { +#participants .member .end.armed { color: var(--bg); background: var(--danger); border-color: var(--danger); @@ -480,8 +501,9 @@ body { /* ── launcher ────────────────────────────────────────────────────────────── */ -/* Its own row rather than the titlebar: the working directory is a path, and a - path squeezed into a title strip is a path nobody reads before launching. */ +/* Which account joins, and the join. It used to carry a field per attribute of + the selected account as well; those are in the dialog now, because choosing + who to join as and editing what that account is are two acts (#59). */ #launcher { display: flex; align-items: center; @@ -491,29 +513,6 @@ body { font-size: 0.8rem; } -/* The name and colour of the selected account. The same pair the titlebar - carries for this screen's person — the person declares theirs at the moment - of joining, and an account carries its own whether or not it is running. */ -#launcher .who { - display: flex; - align-items: center; - gap: 0.35rem; - color: var(--muted); -} - -#launcher .who input { - width: 7rem; -} - -#launcher .cwd { - display: flex; - align-items: center; - gap: 0.35rem; - flex: 1; - color: var(--muted); -} - -#launcher input, #launcher select, #launcher button { font: inherit; @@ -524,24 +523,15 @@ body { padding: 0.25rem 0.45rem; } -#launcher .cwd input { - flex: 1; - font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace; - font-size: 0.75rem; +#launcher select { + min-width: 12rem; } #launcher button { cursor: pointer; } -/* Add and remove an account. Square and unlabelled beside the picker they act - on, so the row keeps its width for the working directory. */ -#launcher #account-new, -#launcher #account-delete { - padding: 0.25rem 0.5rem; - line-height: 1; -} - +#launcher select:disabled, #launcher button:disabled { cursor: default; opacity: 0.6; @@ -572,42 +562,137 @@ body { height: 100%; } -/* ── launch options ──────────────────────────────────────────────────────── */ +/* ── the account dialog ──────────────────────────────────────────────────── */ -#launch-row { - display: flex; - align-items: center; - gap: 0.6rem; - border-bottom: 1px solid var(--line); - padding: 0.35rem 0.9rem 0.5rem; - font-size: 0.78rem; +/* In this webview, not an OS window: it is a step inside one act, and a second + window is a thing to find, move and lose behind the app. */ +#account-dialog { + width: min(30rem, calc(100vw - 3rem)); + color: var(--fg); + background: var(--bg); + border: 1px solid var(--line); + border-radius: 8px; + padding: 0; + font-size: 0.85rem; +} + +#account-dialog::backdrop { + background: rgb(0 0 0 / 0.45); } -#launch-row .options { +#account-form { display: flex; + flex-direction: column; + gap: 0.7rem; + padding: 1rem 1.1rem 0.9rem; +} + +#account-dialog h2 { + margin: 0; + font-size: 0.95rem; +} + +#account-dialog .field { + display: grid; + grid-template-columns: 8rem minmax(0, 1fr); align-items: center; - gap: 0.35rem; + gap: 0.6rem; color: var(--muted); } -#launch-row input { - width: 22rem; - font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace; - font-size: 0.75rem; +#account-dialog input, +#account-dialog select { + font: inherit; + width: 100%; color: var(--fg); background: var(--surface); border: 1px solid var(--line); border-radius: 4px; - padding: 0.25rem 0.45rem; + padding: 0.3rem 0.45rem; } -/* What will actually run, including the channel entry the app merges in. */ -#launch-row .preview { - flex: 1; +#account-dialog #dialog-cwd, +#account-dialog #dialog-options { + font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace; + font-size: 0.78rem; +} + +/* Only for a session. A person has no command under them, so these would be + fields that never do anything. */ +#account-dialog .launch { + display: flex; + flex-direction: column; + gap: 0.7rem; +} + +#account-dialog .launch[hidden] { + display: none; +} + +/* What will actually run, including the channel entry the app merges in. It + sits beside the field it explains rather than out on the launcher row, where + the field it explains no longer is. + + It wraps here, where the one-line row it came from had to clip it. The tail + is the channel entry the app merged in, which is the half the person did not + write and the reason this line is shown at all — an ellipsis would hide + exactly that. The dialog has the vertical room the row did not. */ +#account-dialog .preview { color: var(--muted); font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace; font-size: 0.72rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + line-height: 1.5; + overflow-wrap: anywhere; +} + +/* Why the form cannot be decided yet, or why a delete was refused. Empty most + of the time and holding its line, so the buttons do not jump when it fills. */ +#account-dialog .error { + margin: 0; + min-height: 1.2rem; + color: var(--danger); + font-size: 0.78rem; +} + +#account-dialog .actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + padding: 0; +} + +#account-dialog .actions .spacer { + flex: 1; +} + +#account-dialog .actions button { + font: inherit; + color: var(--fg); + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.35rem 0.9rem; + cursor: pointer; +} + +/* The decision. Filled, because it is the one control here that does anything + to the account list. */ +#account-dialog #dialog-commit { + color: var(--bg); + background: var(--accent); + border-color: var(--accent); +} + +/* Two clicks, in the same shape 終了 uses in the list: a quiet outline at rest, + filled once armed. Selected through `.actions` so it outranks the colour that + rule sets on every button in the row. */ +#account-dialog .actions .delete { + color: var(--danger); +} + +#account-dialog .actions .delete.armed { + color: var(--bg); + background: var(--danger); + border-color: var(--danger); }