Skip to content

2026-09-30 -- トランスクリプトの仮想化(タブ再表示時の数秒〜十数秒の固まり対策)

This content is not available in your language yet.

会話が長くなると AgentPanel の Repaint に時間がかかる。特に、ドッキングした パネルの前に Inspector などの別タブを表示し、AgentPanel のタブへ切り替え直した 瞬間に数秒〜十数秒の読み込みが入る。

Unity(2022.3 の DefaultEditorWindowBackend)は、ドックタブが隠れるときに ウィンドウの rootVisualElement をパネルの visualTree から外し (UnregisterWindow → RemoveFromHierarchy)、再表示で付け直す (RegisterWindow → visualTree.Add)。CreateGUI は Initialized プロパティで 1 回だけしか呼ばれないので、パネル側の再構築ではない。 だが UI Toolkit はツリーをパネルに付け直すと 全要素のスタイル再解決・ レイアウト・テキスト再計測を行う。トランスクリプトは最大 300 メッセージを そのまま生きた要素として保持しており(Markdown、ツールカード、コードブロック、 差分)、その数千〜数万のラベルの再計測が同期的に走るのが固まりの正体。 コストは生きている要素数に比例するので、リサイズのときの ScrollReflowThrottle(2026-09-16)のような「変更をまとめる」策では効かない。 再アタッチはまとめようがなく、要素数そのものを会話の長さから切り離す必要がある。

対策: ビューポート駆動の仮想化

Section titled “対策: ビューポート駆動の仮想化”

ListView は使わない(ARCHITECTURE.md D6: 2022.3 の DynamicHeight 仮想化は 不安定で、ストリーミングは最悪ケース)。既存の手動 ScrollView のまま、 MessageListController が自前で行の実体化と解放を行う。

  • コンテンツは常に [上スペーサー][生きている行...][下スペーサー]。行 i は 子 i+1。スペーサーの高さは、いったん実体化した行なら計測した外形高さ (_heights、マージン込み)、未計測の行は計測済みの平均(なければ 96px)。
  • ウィンドウパス(WindowPass): スクロール・コンテンツ/ビューポートの ジオメトリ変化・Refresh のたびにスケジューラで 1 フレームにまとめて走る。
    1. 可視範囲から ReleaseMarginViewports(2.5 ビューポート)より遠い行を 上下から解放する。解放時に計測高さを記録し、その分スペーサーが伸びる ので、コンテンツ高さもスクロール位置も動かない。末尾追従中は下側を 解放しない(追従する末尾が生きている必要がある)。
    2. 可視範囲の LoadMarginViewports(1 ビューポート)以内に生きている行の 端が来たら、その側に LoadChunk(8)行を実体化する。下側は位置に影響 しない。上側はスペーサーの推定分が実測に置き換わって高さがずれるので、 挿入前の scroller 値とコンテンツ高さを記録し、直後の GeometryChangedEvent で差分だけ scroller をずらす (ComputeAnchoredScrollValue)。
    3. 解放余白 > 実体化余白のヒステリシスにより、同じスクロールで作って すぐ捨てる往復は起きない(LoadAndRelease_AreDisjoint で固定)。
    4. 変更があれば新しいジオメトリが次のパスを呼ぶので、長いスクロールも 1 フレーム 1 チャンクで数フレームで収束する。
  • 新規ビルド(Rebuild)はアンカー(復元行または末尾)の周りに InitialRows (24)行だけ作る。それ以上はパスが必要に応じて足す。
  • 追記: 末尾が生きていれば従来通り行を追加する。利用者が上の方を読んでいて 末尾が解放済みなら下スペーサーが伸びるだけで、スクロールして近づいたときに 実体化する。AgentHub はリストを Refresh の合間にも変更するので、パスは Refresh が見た件数 _lastCount を境界にする。
  • 末尾追従に戻る(「最新」ピル、つまみを一気に末尾へ): 末尾が生きていなければ 間を全部作るのではなく末尾で Rebuild する。
  • 従来の「最新 300 件だけ描画+ N 件非表示の注記」は不要になった。どの メッセージにもスクロールで到達できる(ローダー側の読み込み上限は別)。

ピクセルオフセットはウィンドウ外が推定値なので再ビルド後は同じ行を指さない。 ChatView.SerializeState は「ビューポート上端にある行(末尾から数えた番号、 SessionStateBridge.ScrollAnchorFromEnd)」と「その行の上端からのオフセット (ScrollPosition)」を保存し、次の CreateGUI はその行の周りにビルドして、 行にレイアウトが付いた時点でオフセットを適用する(TryApplyRestore)。 末尾から数えるのは、リロード時にローダーが先頭を切り詰めても同じ行を指す ため。末尾追従中は従来通り -1。

  • 生きている行数は会話長ではなくビューポート高さで決まる(実測余白込みで 数十行)。タブ再表示・ドッキング変更・言語切替の再ビルド・リサイズ時の 再折り返しの全てがその範囲に収まる。
  • スクロールバーのつまみ位置は未計測部分の推定に依存し、実体化が進むにつれ 少し動く。一般的な可変高さ仮想リストと同じ挙動。
  • ストリーミング中の行が解放されても、StreamingLabelPump はパネルから 外れたラベルのエントリを自動で捨て、再実体化時に現在のテキストで 追跡し直す。折り畳み状態は ExpandStateMemory / 各カードが ID で記憶 しているので再実体化で戻る。
  • この環境では Unity を起動できないため、実測はサンドボックスでの確認待ち。 純粋な判断関数は MessageListControllerWindowTests で固定した。