# Reactフック

useSessionフックを使って、ReactでBeeuniセッションを実行します。

Canonical page: https://beeuni.ai/docs/ja/sdk/react

`@beeuni/sdk/react`はSDKにReactフックを追加します。このページでは、
セッションを最初から最後まで実行するフック`useSession`と、Reactアプリ向けに
安定性が確認されているその他のフックを説明します。

## useSession(projectId, sessionId, options?)

`useSession`はセッションを開始し、サーバーが選択したイベントトランスポートを開き、
メッセージ、ステータス、保留中のプロンプトを同期します。セッションビューごとに1回呼び出してください。

```tsx
import { useSession } from '@beeuni/sdk/react';

function Chat({ projectId, sessionId }: { projectId: string; sessionId: string }) {
  const s = useSession(projectId, sessionId);

  if (s.phase !== 'ready') return <Booting stage={s.stage} onRetry={s.retry} />;

  return (
    <>
      {s.messages.map(({ info, parts }) => (
        <Message key={info.id} info={info} parts={parts} />
      ))}
      <Composer busy={s.isBusy} onSend={s.send} onStop={s.cancel} />
    </>
  );
}
```

準備状態はサーバーの情報が正となります。`POST /start`が
`stage: 'ready'`を返すと、ランタイムは準備完了です。`useSession`はクライアント側で別途ヘルスチェックを実行しません。

<a id="returns"></a>

### 戻り値

| フィールド | 型 | 内容 |
|---|---|---|
| `phase` | `'starting' \| 'ready' \| 'error'` | 全体の状態。`ready`になるまで起動画面を表示します。 |
| `messages` | `{ info, parts }[]` | メッセージ一覧。各パートはリアルタイムでストリーミングされます。 |
| `savedTranscript` | `'loading' \| 'shown' \| 'none'` | コンピューターが起動する前に保存済みの会話を表示できるかどうか。`loading`：保存済みコピーを取得中。`shown`：メッセージが一覧に存在します。`none`：ランタイムが応答するまで何も表示できません。 |
| `conversationEmpty` | `boolean` | 保存済みコピーから会話が空であることが確認でき、それ以降に終了したターンがなく、進行中またはキュー内の処理もありません。起動画面ではなく入力欄を表示します。 |
| `status` | `SessionStatus` | セッションのステータス。 |
| `isBusy` | `boolean` | エージェントが返信を生成中です。 |
| `questions`, `permissions` | array | 保留中のエージェントの質問とツール承認リクエスト。ここでの`permission`はランタイムのツール承認1件であり、IAM権限ではありません。 |
| `diffs`, `todos` | array | リアルタイムのファイル差分とToDo項目。 |
| `sendError` | `BeeuniSendError \| null` | 直近の`send`の失敗：`billing`、`runtime-not-ready`、または`runtime-error`。 |
| `rewindMessageId` | `string \| null` | 元に戻せる巻き戻しが仮適用されている間の、選択されたユーザーメッセージ。 |
| `rewindPending` | `boolean` | 巻き戻しまたは復元リクエストが進行中です。 |
| `rewindError` | `BeeuniSendError \| null` | 直近の巻き戻しまたは復元の失敗。 |
| `models`, `agents`, `defaultAgent`, `commands` | — | 選択可能なモデル、選択可能なエージェント、デフォルトのエージェント、スラッシュコマンド。ランタイムの起動前から利用できます。 |
| `retry` | `() => void` | `/start`を強制的に再確認します。 |

<a id="actions"></a>

### アクション

| アクション | 動作 |
|---|---|
| `send(text, override?)` | プロンプトを送信します。`override`はこのメッセージに限り`{ model?, agent? }`を設定します。 |
| `sendParts(parts, override?)` | 選択されたトランスポート経由でテキストとランタイムURLのファイルパートを送信します。非公開のステージング済みハンドルはプラットフォームのプロンプトルートを使用します。 |
| `rewind(messageId)` | この正規セッションをユーザーメッセージまで巻き戻します。選択されたメッセージとそれ以降のパスは非表示になり、復元可能な状態になります。 |
| `restoreRewind()` | 別のプロンプトが置き換え後のパスを確定する前に、削除されたパスを復元します。 |
| `cancel()` | 現在の実行を停止し、保留中の質問と権限をクリアします。先にセッションのキュー内のプロンプトを保留するため、キュー内のプロンプトが新たな実行を開始することはありません。 |
| `runCommand(command, args)` | プロジェクトのスラッシュコマンドを実行します。 |
| `answerQuestion(id, answers)` | 保留中のエージェントの質問に回答します。 |
| `rejectQuestion(id)` | 保留中のエージェントの質問を拒否します。 |
| `answerPermission(id, reply, message?)` | ツール承認リクエストに回答します。`reply`は`'once'`、`'always'`、または`'reject'`です。 |

`useSession`は`removeQuestion`と`removePermission`も返します。これらは使用しないでください。
ローカル状態からプロンプトをクリアするだけでエージェントには通知しないため、
実行がブロックされたままになります。代わりに`answerQuestion`、`rejectQuestion`、または
`answerPermission`を使用してください。

<a id="options"></a>

### オプション

| オプション | デフォルト | 動作 |
|---|---|---|
| `waitMs` | `15000` | `/start`に送信するロングポーリングの時間枠。 |
| `replayStartStash` | `true` | セッションが存在する前に保存されたプロンプトを、セッションの準備完了後に再送します。 |
| `enabled` | `true` | `false`を設定すると、たとえば課金チェックに合格するまでフックの実行を遅らせることができます。 |
| `chatEngine` | `true` | アプリがこのセッション用の独自のチャット画面をマウントする場合、メッセージの二重同期を避けるために`false`を設定します。 |
| `initialRuntimeSessionId` | `null` | サーバーがこのセッション用にすでに保持しているランタイムセッションのピン。`/start`の実行中はこれを使って会話履歴を表示しますが、`/start`の応答が引き続き正となります。`initialOpenCodeSessionId`は非推奨の旧名です。 |
| `browserPresence` | `false` | ログイン中のユーザーがページを使用している間（過去10分以内に入力あり）、このタブのプレゼンスリースを保持します。リースはセッションのコンピューターを起動状態に保ちます。 |
| `presenceAlerts` | `false` | このタブが独自の通知を表示します。プレゼンスリースで送信され、通知するタブが使用中の間に限り、サーバーはスマートフォンとWeb Pushへの通知を省略します。変更は即座に送信されます。プロジェクトの`notification_center`フラグがオンの場合のみ`true`を渡してください。それ以外ではサーバーは無視します。`browserPresence`が必要です。 |
| `presencePageExit` | `false` | ページが閉じられるか戻る/進むキャッシュに入る際にプレゼンスリースを終了し、タブを閉じた直後にターンが終了しても通知されるようにします。オンの場合、`pagehide`が不在を報告し、すべての不在報告を`keepalive`付きで送信するため、ページ終了後も送信が継続します。オフの場合：`pagehide`の報告も`keepalive`もありません。閉じるタブは引き続き非表示になり不在を報告しますが、ページのアンロード時にブラウザーがそのリクエストをキャンセルする可能性があり、その場合リースは90秒の有効期限まで存続します。プロジェクトの`notification_center`フラグを渡してください。変更は即座に適用され、新たなプレゼンス報告は行われません。`browserPresence`が必要です。 |
| `repositoryMode` | — | テレメトリー専用。リポジトリの置き換え前に作成されたセッションも、これがなくてもプロジェクトの現在の設定で起動・実行されます。`/workspace`のクローンだけは引き続き旧リポジトリ由来となるため、そこからプッシュするには先にリベースが必要です。 |

送信は楽観的に処理されます。`send`はメッセージを即座に表示し、その後ストリーム
イベントがエージェントの返信を埋めていきます。

`rewind(messageId)`はセッションを作成しません。`POST /start`の正規セッションを使用します。
ランタイムはファイルの状態を復元し、削除された会話履歴のパスを
復元可能な状態に保ちます。次に受理されたプロンプトが置き換え後のパスを確定します。

<a id="other-stable-hooks"></a>

## その他の安定したフック

`@beeuni/sdk/react`は、実行中のセッションを必要としないデータ用のReact Queryフックも
エクスポートします。各フックは
[client](/docs/ja/sdk/reference)のメソッドに対応し、プロバイダーは不要です。

| フック | 読み取る内容 |
|---|---|
| `useProjectModels(projectId)` | プロジェクトで選択可能なモデル。 |
| `useProjectModelPickerCatalog(projectId)` | 各 wire モデル（`reasoning_options`、`temperature`、`limit`）の生の `/model-picker` レコードと、対象となる各マネージドルートの `managedPricingRoutes` 顧客向け料金見積もり。最終的なクレジット請求額は、処理を提供するルートによって決まります。 |
| `useProjectSessions(projectId, { parent, startedBy, q, limit })` | 1つのプロジェクトのセッション（ページ分割）。`parent: 'root'` は `child_count` を持つ最上位セッションを返します。`startedBy: 'me' \| 'others' \| 'automated'` は実行を開始したユーザーで絞り込み、`q` は読み込み済みのページだけでなく、閲覧者が開けるすべてのセッションを検索します。フィルターの組み合わせごとに専用のキャッシュエントリがあります。 |
| `useSessionChildren(projectId, parentSessionId, { q, limit, enabled })` | 1つの親が生成したセッション（`parent=<id>`）。新しい順で、ページ分割されます。親が開かれるまで `enabled: false` を設定します。 |
| `useVisibleAgents({ projectId })` | プロジェクトの表示可能なエージェント。 |
| `useProjectConfig(projectId)` | プロジェクトのランタイム設定：デフォルトのエージェント、コマンド。 |
| `useProjectSecrets(projectId)` | シークレット：一覧、追加、削除、個人用の上書き。 |
| `useAccountSecretResources(accountId)` | アカウントのプロバイダーシークレットのメタデータと使用許可。値は返されません。 |
| `useSessionProviderSecretPools(projectId, sessionId)` | 空の選択も含む設定済みプール。`setPool` は1つのプロバイダーを更新し、セッションキャッシュを更新します。 |
| `useProjectTriggers(projectId)` | トリガー：一覧、作成、更新、削除、発火。 |
| `useProjectTriggerEventTypes(projectId, connector)` | コネクターがトリガーにできるアプリイベント。`connector` が設定されるまで待機します。 |
| `useChangeRequests(projectId, status?)` | 変更リクエスト：一覧、オープン、マージ、クローズ、変更の要求。 |
| `useRuntimeSupports(capability)` | 開いているセッションのランタイムが機能（`session.rewind`、`session.compact`、…）を提供するかどうか。ランタイム再接続ポーラーが読み取る `/beeuni/health` の応答に基づきます。最初の応答までは `true`。[ランタイムがサポートする機能](/docs/ja/sdk/sessions#what-the-runtime-supports)を参照してください。 |
| `useNotificationInbox({ userId, limit?, enabled? })` | 呼び出し元の受信トレイの最新ページ（`limit` のデフォルトは20）、`unreadCount`、および `markRead(ids)`、`markAllRead()`、`markSessionRead(sessionId)`。ページが表示されている間は60秒ごとにポーリングし、フォーカス時に再取得します。書き込み時はキャッシュを即座に変更し、失敗時は元に戻してから、受信トレイの全エントリを再取得します。`userId` がなければリクエストしません。 |
| `useNotificationPreferences({ userId, enabled? })` | 種類ごとの呼び出し元のプッシュ通知とメールの選択、`email_available`、および `update(patch)`（楽観的更新）。 |
| `useSessionWatch({ userId, projectId, sessionId, enabled? })` | 1つのセッションの `{ watching }` と、それをミュートまたはウォッチする `setWatching(watching)`（楽観的更新）。 |
| `resetRuntimeQueries(queryClient)` | フックではありません。キャッシュされたすべてのランタイムクエリ（セッション、メッセージ、エージェント、コマンド、差分）を破棄します。再起動や設定の再読み込みによってセッションのランタイムが置き換わった後に呼び出すと、次の読み取りで新しいランタイムから取得されます。 |

3つの通知フックは `userId`（`qk.notifications`）をキャッシュのキーにするため、
同じタブでユーザーを切り替えても、前のユーザーの受信トレイが表示されることはありません。
サインイン中のユーザーのIDを渡してください。

通知はプロジェクトごとの `notification_center`
[機能フラグ](/docs/ja/feature-flags)で制御され、デフォルトではオフです。各フックを
`enabled: useFeatureFlag(projectId, 'notification_center').enabled` で制御し、
フラグがオフのプロジェクトでは通知リクエストが送信されないようにしてください。

<a id="connector-setup-links"></a>

## コネクター設定リンク

エージェントはワンクリック接続リンク（`/connect/<token>`）を発行できます。フローのデータと
ランタイムポリシーはSDK内にあり、ホストはそれらを使って独自のダイアログを表示し、
アダプターを注入します。

| エクスポート | 役割 |
|---|---|
| `useConnectorSetup(token, options)` | フロー全体：リンクが指定する内容を読み込み、`POST /start` を実行し、注入された `openPopup` を通じてプロバイダーがホストするページを開き、3秒 / 5秒 / 5分のスケジュールで `POST /finalize` をポーリングします。さらに、`phase`（`loading`、`ready`、`starting`、`opened`、`connected`、`error`）、アプリ情報、エラー、アカウントが誰として認可されたかを報告します。`options` はホストの `backendUrl`、`storage` アダプター、`openPopup` を保持します。`onOpened` はポップアップが開くたびに発火します。 |
| `useConnectorLinkInfo(token, options)` | リンクトークンごとに、ストレージを使用する共有キャッシュを1つ用意します。これにより、すべてのカードとモーダルが、レート制限付き公開ルートへの1回のGETを共有し、最初のフレームでアプリのロゴを描画します。`backendUrl`/`storage` のオプションは同じです。 |
| `createConnectorLinkInfoCache(fetchInfo, { storage })` | 独自のインスタンスを必要とするホスト向けの、キャッシュファクトリー自体（フレームワーク非依存、ストレージは注入）。 |
| `resolveConnectorStart({ start, finalize })` | `/start` の結果ルール：ホストされたURL → ポップアップ、接続済み → 一度だけ完了処理、それ以外 → エラーメッセージ。 |
| `nextConnectorPollDelay(attempt, elapsedMs)` | 上限付きポーリングスケジュール（初回は3秒、その後は5秒、5分で停止）。 |

<a id="connector-data"></a>

## コネクターデータ

`useConnectorQuery(projectId, slug, action, args, { account, enabled, staleTime })` は
1つのコネクターアクションの出力をキャッシュされたクエリとして読み取ります。`error` は
`ConnectorCallError` です。`error.connectUrl` から接続ボタンを、または
`error.availableAccounts` からアカウント選択UIを描画します。読み取りを行うアクションにのみ使用してください。
取得のたびにアクションが実行され、監査イベントが書き込まれます。出力は
30秒間最新として扱われ、フォーカス時に再取得されることはなく、上流の `429` または `503` のみが
再試行されます。[コードからコネクターを使う](/docs/ja/sdk/reference#pconnectorslug--one-connector)を参照してください。

<a id="start-the-open-read-early"></a>

## セッションを開く読み取りを早めに開始する

`prefetchSessionOpen(queryClient, projectId, sessionId)` はセッションビューがマウントされる前に、セッションを開くための
読み取りを開始します。1回の `GET .../snapshot` リクエスト
（セッション行、ターン、キュー、トランスクリプトの最初の40メッセージ）を送信し、その結果で
セッション行キャッシュを初期化します。その後、`useSession` は独自のリクエストを
開始する代わりに、そのリクエストを使用します。

ルートがセッションを指定するとき、またはセッションリンクのホバー、フォーカス、タッチ時に
呼び出してください。読み取り専用で、`/start` を呼び出すことも、コンピューターを起動することもありません。
拒否されることはありません。30秒以内に同じセッションに対して再度呼び出しても、
リクエストは送信されません。

```tsx
import { prefetchSessionOpen } from '@beeuni/sdk/react';

<a
  href={`/projects/${projectId}/sessions/${sessionId}`}
  onFocus={() => void prefetchSessionOpen(queryClient, projectId, sessionId)}
/>;
```

<a id="render-from-cache-on-a-reload"></a>

## 再読み込み時にキャッシュから描画する

2つのストアがユーザーがすでに見た内容を保持するため、再読み込みやアプリのコールドスタートでも
最初のフレームにそれを描画し、その場で更新できます。どちらもユーザーごとに管理され、上限があり、
ホストはサインアウト時に両方をクリアします。

- `createPersistedQueryCache` は、ユーザーが移動に使うReact Queryの結果を保持します：
  アカウント、プロジェクト、プロジェクト詳細、ページ分割されたセッション一覧、セッション行
  （`isPersistableQueryKey`）。復元されたエントリは元の経過時間を維持するため、マウント時に
  再取得されます。デフォルトの上限は1,000,000文字、7日間です。
- `createSavedCopyStore` は、サーバーが送信したトランスクリプトの最後に保存されたコピーを
  保持します。`setSavedCopyStore` で登録すると、`useSession` が最初のフレームの前に
  保持されたコピーを描画します。その後、サーバーのコピーがメッセージIDで照合・統合され、
  SDKは各ターンの終了から3秒後に再読み取りします。デフォルトの上限は12
  セッション、1,500,000文字、14日間です。300,000文字を超えるコピーでは、
  上限に収まる最新のメッセージを保持します。古いメッセージは、ユーザーが
  上にスクロールしたときにサーバーから読み込まれます。

```tsx
import {
  browserKeyValueStorage,
  createPersistedQueryCache,
  createSavedCopyStore,
  isPersistableQueryKey,
  setSavedCopyStore,
} from '@beeuni/sdk';

const storage = browserKeyValueStorage(); // null where there is no localStorage
if (storage) {
  const cache = createPersistedQueryCache({ storage, userId, shouldPersist: isPersistableQueryKey });
  cache.restore(queryClient); // synchronous with localStorage: no pending frame
  const stop = cache.persist(queryClient);
  setSavedCopyStore(createSavedCopyStore({ storage, userId }));
}
```

React Nativeでは、AsyncStorageを `storage` として渡してください。その場合、`restore` はPromiseを返します。

保存コピーのストアは、サーバーによる記録のみを保持します。これは、ターン終了時にAPIが
書き込むトランスクリプトのミラーです。ライブのトランスクリプトはデバイスに書き込まれないため、
停止したターンが実行中として復元されることはありません。ランタイムの読み取りでメッセージが返されない場合も、
保存済みメッセージは画面に残ります。これは会話が空であることではなく、コンピューターが状態を失ったことを
意味します。メッセージを含む最初のランタイム読み取りで、それらを照合・統合します。

<a id="sign-out-and-account-switching"></a>

## サインアウトとアカウントの切り替え

SDKはユーザーごとのセッション状態をメモリに保持します：トランスクリプト、保留中の質問
と権限、ターンの受領記録、モデルの選択。サインアウトや別のユーザーのサインインなど、
IDが変わるたびに `resetIdentityState()` を呼び出してください。これには、
別のタブでのサインインにより、ページを読み込まずにユーザーが変わる場合も含まれます。

認証フローでユーザーがサインインまたはサインアウトしたときに、このハンドラーを呼び出します。

```tsx
import { resetIdentityState } from '@beeuni/sdk/react';

let previousUserId: string | null = null;

function onIdentityChange(userId: string | null) {
  if (userId !== previousUserId) {
    queryClient.clear();
    resetIdentityState();
  }
  previousUserId = userId;
}
```

`resetIdentityState()` はモデルストアの `localStorage` エントリも削除します。
React Query などのホスト自身のキャッシュや、`createBeeuni` に渡されたトークンソースは
クリアしません。同じハンドラー内でリセットしてください。例外をスローすることはありません。

[再読み込み時にキャッシュから描画する](#render-from-cache-on-a-reload) のストアを使う場合は、
同じハンドラー内でクリアしてください。`await cache.clear()`、続いて
`await savedCopies.clear()` と `setSavedCopyStore(null)` の順です。どちらのストアも別のユーザーのデータを
復元することはありませんが、サインアウトしたデバイスにデータを残すべきではありません。

<a id="next"></a>

## 次のステップ

- [セッション](/docs/ja/sdk/sessions) — `useSession` がラップするセッションハンドルと、`BeeuniSendError` の種類。
- [リファレンス](/docs/ja/sdk/reference) — これらのフックが読み取る REST API の全体。

<a id="saved-history-during-startup"></a>

## 起動中の保存済み履歴

`useSession(projectId, sessionId)` はコンピューターの起動中に保存済みのチャット履歴を表示します。
SDK はランタイムの準備状態とは独立してデータベースを読み取ります。その後、ライブメッセージは
元の ID に基づいて整合されます。ホストに別途の履歴リクエストやメッセージストアは不要です。

保存済み履歴はライブの会話記録とまったく同じように描画されます。テキスト、推論、そしてすべてのツール呼び出しの
入力、出力、エラーが含まれます。サブエージェントの完全なビューは、そのサブエージェント自身の保存済み
会話記録を読み取ります。親のスコープと `savedChild: true` を
`useSessionSync(subAgentSessionId, { beeuniSessionScope, savedChild: true })` に渡してください。初期ウィンドウには最新の40件のメッセージが含まれ、
最大1,000,000文字です。上にスクロールすると、コンピューターがオフの間も保存済み履歴から
古いウィンドウを読み込みます。コンピューターを必要とする添付ファイルは、
ランタイムの準備が整うと利用可能になります。

`savedTranscript` は、コンピューターの起動中に何を描画するかをホストに伝えます。保存済みコピーが
まだ届く可能性がある間は `loading`、メッセージが `messages` に入ると `shown`、ランタイムが応答するまで
何も表示できない場合は `none` です。不明な状態は `loading` であり、`none` にはなりません。

`conversationEmpty` は、待つものが何もない場合に true になります。保存済みコピーによって
会話が空であることが証明されています。セッションのランタイムを完全に読み取った結果、メッセージが見つからなかった状態です。その後に終了したターンはなく、
開いているものやキューに入っているものもありません。レコードの欠落によって true になることはないため、履歴のある古いセッションが
空として表示されることはありません。セッションは、履歴保存を有効にして初めて起動したときに
この証明を得ます。

```tsx
if (s.messages.length === 0) {
  // Nothing saved and nothing to wait for: the user can type now.
  if (s.conversationEmpty) return <Composer />;
  // One round trip for a saved copy; the computer can take minutes.
  return s.savedTranscript === 'none' ? <Booting stage={s.stage} /> : <MessageSkeleton />;
}
```

2026-09-28 より前に保存されたツール呼び出しでは、入力と出力が失われていました。サーバーは各呼び出しを、
保持されたタイトルやメタデータから確認できる入力とともに提供します。コマンドのコマンドと出力、ファイル呼び出しのパス、
検索のパターン、サブエージェントの説明、フェッチの URL、Todo リスト、スキルの名前です。
セッションの次回起動時に、履歴全体が再び1対1で保存されます。
<a id="pending-prompts"></a>

### 保留中のプロンプト

`useSessionPrompts(projectId, sessionId)` は永続的なセッション受信トレイに送信します。
サーバーは保留中のプロンプトを、それぞれ独立したターンとして自動的に実行します。

会話内に保留中のメッセージを表示するには、`placement: 'transcript'` を渡してください。
これはすべての `composer` エントリより先に実行され、現在のツール呼び出しが完了すると
進行中の応答を終了します。
入力欄の上の編集可能なリストに保持するには、`placement: 'composer'` を渡してください。
これは進行中の応答が終わるまで待機します。各配置は送信順を維持します。
実行中のターンにプロンプトをどう届けるかを指定するには、`delivery: 'steer' | 'queue' | 'interrupt'` を
渡してください。`steer` は次のステップでターンに渡し、ターンは
停止しません。`queue` はターンの終了を待ちます。`interrupt` は実行中の
ツールの後にターンを終了します。`delivery` だけを指定すると、SDK はそれが意味する配置を送信します。
`interrupt` は `transcript`、それ以外は `composer` です。誘導できない
steer 行は `delivery: 'queue'` となり、`steer_fallback` が `unsupported`、
`not_prompter`、または `turn_ended` に設定されます。`interrupt(promptId)`（「Stop and send」）は
待機中の行を会話記録のエントリに変えます。すでに送信中の行は `409` を返します。

Web の入力欄では、ターンの実行中は Enter で `steer` として送信し、セッションがアイドル状態なら
会話記録への送信になります。Command+Enter（Windows/Linux では Control+Enter）は
入力欄への配置です。Shift+Enter は改行を挿入します。

リストの行には、再読み込み後に完全なプレビューを表示するための `full_text` が含まれます。`text` は
古いクライアント向けのサイズ制限付きプレビューとして残ります。添付ファイルのバイトデータはリストの
レスポンスに含まれません。削除時には、情報を失わずに元に戻せるよう、完全なプロンプトが返されます。

<a id="pending-delivery-and-active-responses"></a>

### 保留中の配信と進行中の応答

`useSessionWorking()` は、プロンプトが永続的な受信トレイで待機している間、`state: 'working'` を
維持します。これにより Stop を利用できます。`pendingDelivery: true` は、まだ
ランタイムに向かっている途中のプロンプトを示します。Web アプリは、そのフラグの値にかかわらず、`state` が
`working` のときに動作中インジケーターを1つ表示します。進行中のランタイムターンと新たなランタイムの
アクティビティによって、この状態はクリアされます。キャンセルがタイムアウトしても、実行が停止したことは確認できません。

ワーカーによる取得は、受け入れ可否を確認するだけで、プロンプトは待機したままです。配信は
受け入れが成功してから始まります。進行中のターンが確認されると、前の受信トレイのスナップショットにそのプロンプトがまだ載っていても、
保留中の表示はクリアされます。この引き継ぎ中、ランタイムのアクティビティは
進行中のターンのメッセージ ID を維持します。

Web の入力欄では、Command/Ctrl+Enter を **Queue List**、行の「Stop and
send」を **Quick Queue** と呼びます。Quick Queue のエントリは先に実行されます。待機中の Quick Queue エントリは、
配信済みターンの後に色付きの吹き出しとして表示され、ステータステキストはありません。Queue List のエントリは配信が始まるまで編集可能です。
サーバーは、再読み込み後も含め、正確なランタイムの証拠から取り逃した完了イベントを復元します。
到達できないランタイムを完了したターンとして扱うことはありません。

個々の Enter 操作は、先の受け入れリクエストが未解決でも、その保留中のプロンプトを
即座に表示します。テキストが追加されるのは配信に失敗した場合だけです。原因、
Retry、Remove が表示されます。実行順は引き続きサーバーの受信トレイが管理します。

フックはセッションの制御ストリーム
（`GET .../sessions/:id/events?channels=control`）からキューの変更を読み取ります。マウントするコンポーネントの数にかかわらず、
接続はセッションごとに1つです。受信トレイの行への書き込みはすべて
`beeuni.control.queue` フレームとして届きます。`GET .../prompts` のポーリングは、その
ストリームが接続されていない間だけ実行されます。

<a id="why-a-turn-ended"></a>

### ターンが終了した理由

中断されたターンの会話記録には `MessageAbortedError` だけが含まれます。
コントロールプレーンは、各ターンがどのように終了したかについて把握している情報を保持します。サンドボックスが
示した原因（たとえば `SandboxMemoryGuard`）や、原因が示されずに終了したターンです。
誰かが要求した停止（Stop ボタン、`abort()`、ビジー状態のセッションに
送信されたプロンプト）はリクエストとして記録され、失敗として報告されることは
ありません。

`useSessionTurnOutcome(projectId, sessionId)` は、`useSessionWorking()` が最新に保つキャッシュから
`last_ended`、`recent_failures`、読み取り時刻 `atMs` を
返します。リクエストは行いません。その結果を、会話記録がターンについて示す情報とともに
`turnEndNotice` に渡してください。

```tsx
const outcome = useSessionTurnOutcome(projectId, sessionId);
const notice = turnEndNotice(outcome, turn.userMessage.info.id, {
  hasError: Boolean(turnError),
  isAbort: turnErrorIsAbort,
});
// notice: { kind: 'sandbox-memory', usedPct: 97, detail: 'sandbox memory at 97% …' }
//       | { kind: 'cause', name: 'SomeGuard', message: '…' }
//       | { kind: 'unexplained' }
//       | null
```

ホストは各 `kind` を自身の表示文言に対応付け、サンドボックスの
メッセージを解析することはありません。`null` は「会話記録に表示されているものを表示する」という意味です。実際のエラーは残り、
中止の場合は何も描画しません。原因不明の失敗は、読み取り時刻が
ターン終了の数秒後になってから報告されます。原因は中止の1フレーム後に届くことが
多いためです。`turnEndCause(outcome, messageId)` は記録された生の
原因、または `null` を返します。
