📚 Copilot Cowork 開発シリーズ
- なぜ作るのか — 調査と設計判断
- Electron + React + SDK で土台を組む
- C# で業務向けに拡張する設計
- 途中状態の見せ方と入れ子実行 ← 今ここ
AI チャットアプリを作るとき、「AI を呼ぶ部分」は意外とすぐ動きます。SDK を使えば数行でチャットが機能し始めます。
難しかったのは、その後でした。「今 AI が何をしているか」「なぜ使えないのか」 を正確に伝えること、これが想像以上に厄介でした。
📋 この記事の前提
- TypeScript / React の基本文法がわかる
- Electron アプリの main / renderer 構成を知っている
- Copilot Cowork の構成を把握している(第2回で解説)
- 「AI アプリの UX 設計」に興味がある方向け
今回は、Copilot Cowork という Electron + React 製の AI デスクトップアプリで取り組んだ 2 つの改善について書きます。
Copilot Cowork とは: GitHub Copilot SDK を使って、チャットとツール実行(ファイル操作・subagent 呼び出しなど)を組み合わせたワークフローを組めるデスクトップアプリです。開発自体も GitHub Copilot と一緒に進めています。
今回解決した 2 つの問題
問題 1: 「使えない」の原因が分からない
AI アプリを起動したとき、こんな経験はないでしょうか。
「何も動かない」「でも何が原因なのか分からない」
GitHub Copilot SDK ベースのアプリでは、「使えない状態」が少なくとも 3 種類あります。
| 状態 | 原因 | 対処法 |
|---|---|---|
| 未認証 | GitHub Copilot にサインインしていない | サインインする |
| CLI 不在 |
gh コマンド(GitHub CLI)が入っていない |
GitHub CLI をインストールする |
| SDK 接続エラー | ネットワーク障害・一時的な問題 | しばらく待って再試行する |
これを「使えません」の 1 文だけで表示すると、ユーザーは何をすべきか分かりません。原因の分類だけで体験が大きく変わります。
問題 2: AI が入れ子で処理していても追いかけられない
GitHub Copilot SDK のような AI ランタイムでは、あるツール実行の中で別のツールが走ることがよくあります。
ユーザー: 「このリポジトリの概要を教えて」
└─ run_subagent が起動
├─ list_dir を実行
├─ read_file を実行
└─ subagent が結果をまとめて返答
この入れ子構造を「チャット履歴のリスト」に平らに並べると、どの処理が何のために動いているのか、なぜ止まっているのかが見えなくなります。特に途中で「ファイルを削除してよいですか?」という確認が割り込んできたとき、それがどのツールのどの段階で発生した確認なのかが分からなくなります。
この 2 つを解決するために、起動ガイダンス と 入れ子実行の視覚化 を実装しました。
先に結論
改善の全体像をまとめます。
| 変更内容 | 何が変わるか |
|---|---|
| 未認証・CLI 不在・SDK 不達を区別して表示 | ユーザーが原因を自分で特定できる |
未認証時は New Chat を無効化 + Retry ボタン追加 |
認証後すぐにアプリを使い始められる |
| 入れ子ツール実行を親の会話ターンに束ねるデータモデルを追加 | 会話の流れとツール実行の流れが対応する |
| 権限確認・質問をチャット内に inline 表示 | 「どの処理に対して許可しているか」が分かる |
| 一時的な中間イベントを履歴から除外 | 再読み込みしても表示が崩れない |
やってみて分かったのは、難しいのは AI を呼ぶことよりも途中状態を文脈付きで見せること でした。
起動ガイダンスの実装
認証状態に「種類」を持たせる
以前は isAuthenticated: boolean だけで状態を管理していました。これを細かく分類した型に拡張します。
type AuthStatusHealth =
| 'ready' // 正常
| 'auth-required' // 未サインイン
| 'cli-unavailable' // GitHub CLI が見つからない
| 'sdk-unavailable' // SDK に接続できない
| 'error'; // その他のエラー
interface AuthStatus {
health: AuthStatusHealth;
recommendedActions: string[]; // 「次に何をすればよいか」のリスト
}
recommendedActions を持たせることで、UI 側はそのまま「次のアクション」をユーザーに提示できます。
UI での表示切り替え
Sidebar と EmptyState コンポーネントで health の値に応じて表示を切り替えます。
{status.health === 'auth-required' && (
<EmptyState
message="GitHub Copilot にサインインしてください"
action={<button onClick={retryStatusCheck}>サインイン後に再確認</button>}
/>
)}
{status.health === 'cli-unavailable' && (
<EmptyState
message="GitHub CLI が見つかりません"
action={<a href="https://cli.github.com">インストール方法を確認する</a>}
/>
)}
未認証時は New Chat ボタンを disabled にし、Retry status check で認証後すぐ再確認できるようにしています。
これは地味に見えますが、AI アプリは「使えないときにどう見えるか」が弱いとすぐ不信感につながります。 ChatGPT や Copilot が落ちているとき、エラーの理由は必ずはっきり書いてあります。
入れ子実行(Nested Execution)の実装
UI の前にデータモデルが必要
最初に試みたのは「入れ子のツール実行を画面上にかっこよく表示すること」でした。しかし途中で気づきました。表示より先に「どれがどれの子か」を構造として持つ必要がある ということに。
中心になる型はシンプルです。
interface ToolExecution {
toolCallId: string;
parentToolCallId: string | null; // 親ツールの ID(なければ null)
rootToolCallId: string | null; // 会話ターンの起点となるツールの ID
status: 'running' | 'complete' | 'waiting-permission' | 'waiting-input';
childEvents: ChildEvent[];
}
parentToolCallId で「誰の子か」、rootToolCallId で「どの会話ターンの流れに属するか」が分かります。この 2 つさえあれば、任意の深さの入れ子を正しく木構造として再構成できます。
イベントをまとめる
Copilot SDK から流れてくるイベントをこの構造にまとめるイメージは次のとおりです。
function attachToExecution(event: CopilotEvent, executions: Map<string, ToolExecution>) {
if (event.parentToolCallId) {
// 既存の親実行に子イベントとして追加
const parent = executions.get(event.parentToolCallId);
parent?.childEvents.push(event);
} else {
// 新しいルート実行として登録
executions.set(event.toolCallId, createNewExecution(event));
}
}
このモデルがあると、UI 側は childEvents を再帰的に展開するだけです。SDK から流れてくるイベントの種類(tool.execution_start・tool.execution_complete・permission_request など)がいくつあっても、同じしくみで束ねられます。
権限確認をチャットの流れの中で応答できるようにする
以前は権限確認も質問もすべて別ウィンドウ(モーダル)で表示していました。入れ子ツールが増えると、モーダルが割り込むたびに「今どの処理に対して許可を求められているのか」が分からなくなります。
今回は、対応する toolCallId が特定できる場合に限り、チャットパネル内に inline で表示するようにしました。
ユーザー: ファイルを整理して
└─ run_subagent が実行中...
├─ list_dir: 完了
├─ read_file: 完了
└─ ⚠️ 確認が必要
不要ファイルを削除してよいですか?
[今回だけ許可] [このセッションは許可] [キャンセル]
ツールの流れの中で応答できるので、「何のために許可しているか」が直感的に分かります。特定できない確認(SDK 側から起点が不明なもの)は従来どおりモーダルに残します。
中間イベントを履歴に残さない
「今見せる情報」と「後から復元する情報」は違う
入れ子処理を豊かに見せようとすると、逆にノイズが増えます。ストリーミング中の中間状態など「実行中にしか意味のない」イベント(ephemeral event)をそのまま SQLite に保存すると、再読み込み時に余計な情報が増えます。
今回は次のルールに統一しました。
| イベントの種類 | 画面表示 | トレースパネル | セッション履歴(SQLite) |
|---|---|---|---|
| 確定した結果イベント | ✅ | ✅ | ✅ |
| 中間状態(ephemeral)イベント | ✅ | ❌ | ❌ |
「今見せる情報」と「後から復元する情報」の境界を明確にしただけですが、これで再読み込み後の表示が生成時と大きくずれなくなりました。
SQLite への保存
-- messages テーブルに入れ子実行の情報を追加
ALTER TABLE messages ADD COLUMN nested_executions TEXT; -- JSON として格納
-- 送信後、最終的な assistant の返答を確定して保存
UPDATE messages
SET content = ?, nested_executions = ?
WHERE id = ?;
AI アプリっぽい派手さはありませんが、壊れやすいのは AI の生成よりセッション状態の整合性 だと実感しています。セッション再読み込みで会話内容が崩れると、ツールとして使い物にならなくなります。
テスト戦略
AI 機能は「手動で確認すればよい」と思いがちですが、状態の組み合わせが多くてすぐ崩れます。
今回は Playwright 用の fake manager に 3 種類のシナリオを追加しました。
// 未認証の状態でアプリを起動
await launchApp({ mode: 'auth-required' });
// GitHub CLI が見つからない状態をシミュレート
await launchApp({ mode: 'cli-unavailable' });
// 途中で権限確認が必要なツールフローをスクリプトで再現
await launchApp({ mode: 'scripted', script: permissionRequiredFlow });
これで「未認証のとき New Chat が無効になっているか」「権限確認が正しいツールに紐づいているか」を E2E で検証できます。
Vitest 側でも ChatPanel・client-manager・ipc-handlers・session-history・permission-manager・Sidebar のテストを広げ、入れ子実行と認証ガイダンスの回帰を固定しました。
ここまで作って感じたこと
GitHub Copilot SDK を土台にする利点は、AI ランタイムを自前で実装せずに済むことです。ただその分、アプリ側で問われるのは別のスキルでした。
- 「使えないとき」をどう説明するか — ここが弱いとすぐに不信感につながる
- 入れ子処理をどう文脈付きで見せるか — 情報が多くても文脈がないと役に立たない
- どこで確認を求めるか — モーダルより処理の流れの中に置いた方が自然なことがある
- 表示と保存の整合性 — 再読み込みで崩れないことは最低ライン
一言まとめ: AI アプリの難しさは「AI を呼ぶこと」ではなく、「AI の状態を人間が扱えるかたちで見せること」にある。
次にやること
今回の改善で、起動直後の不安定さと入れ子ツール実行の追跡性はかなり良くなりました。次は予定していた Structured Settings UX(設定画面の再設計)に戻ります。
残っている大きなテーマは次の 3 つです。
- Structured Settings UX — 設定画面の再設計
- live custom tool registry の wiring — カスタムツールの動的登録
- .NET worker scaffold — C# 側 worker との接続
設定面を整えたうえで、今回の入れ子実行基盤をカスタムツール側にも広げていく流れです。
まとめ
-
未認証と CLI 不在は分けて表示するだけで体験が変わる —
boolean1 つで管理していた認証状態を細かく分類するだけで、ユーザーが自力で解決できるようになる -
入れ子実行は UI より先にデータモデルが必要 —
parentToolCallIdとrootToolCallIdを持つだけで、任意の深さの入れ子を正しく再構成できる - 権限確認はツールの流れの中に置く — モーダルよりチャット内 inline の方が「何のために許可しているか」が伝わる
- 中間イベントは保存しない — 「今見せる情報」と「後から復元する情報」を分ける原則が再読み込み時の整合性を守る
次は設定 UI を進めつつ、今回整えた入れ子実行基盤をカスタムツールプラットフォーム側に広げていく予定です。
よくある質問
Q: 入れ子実行の中間イベントは全て SQLite に保存していますか?
A: いいえ。ストリーミング中にしか意味のない一時的なイベント(ephemeral event)は SQLite に保存せず、確定した結果イベントのみを保存しています。これにより、セッション再読み込み時の表示崩れを防いでいます。
Q: Playwright テストで AI の応答はどうシミュレートしていますか?
A: Copilot SDK の代わりに fake manager を使い、認証状態やツール実行フローをスクリプトで再現しています。launchApp({ mode: 'auth-required' }) のようにシナリオを指定することで、E2E テストで各状態を検証できます。
Q: 認証状態は何種類に分類していますか?
A: 5 種類です。ready(正常)、auth-required(未サインイン)、cli-unavailable(GitHub CLI 不在)、sdk-unavailable(SDK 接続エラー)、error(その他)。それぞれに recommendedActions を持たせることで、UI がユーザーに適切な対処法を提示できます。
この記事が参考になったら「いいね」で応援お願いします!
📝 この記事は Zenn で最初に公開されました。
最新版はZennをご覧ください。