AI Composer SwiftUI ライブラリの設計書。Bob の MessageInputView 周辺を抽出し、現代的な AI 入力バーとして汎用化する。
旧 Toolbar (2017, UIKit) の責務は「複数アイテムを横に並べて高さを計算するコンテナ」だった。AI チャット時代の入力バーは責務が変質し、テキストに加えて画像・ファイル・音声・スラッシュコマンド・任意のカスタムアイコンを束ねて 1 リクエストとして送出する Composer が中核になった。
新 Toolbar は概念ごと作り直す。互換性は維持しない。
旧 Toolbar (2017) 新 Toolbar (2026)
責務: 責務:
複数 item を並べる AI Composer の primitive 群を提供
高さの計算 開発者が宣言的に組み立てる
プラットフォーム差分は Editor のみ
本質: container 本質: declarative composer kit
- iOS 26+ / iPadOS 26+ / macOS 26+ で動作する SwiftUI Composer 部品群
- Liquid Glass 全面採用 (
glassEffect,GlassEffectContainer,glassEffectID) - 宣言的 API: 開発者が
ToolbarContainerの中身を自由に組み立てる - 添付・音声・スラッシュコマンドを protocol で外部実装と接続
- 公開 API は SwiftUI のみ。内部の
NSViewRepresentable/UIViewRepresentableは隠蔽 - 単一モジュール (
import Toolbarで完結)
- 旧 UIKit Toolbar との互換維持
- 音声処理 (Whisper / Apple Speech) の内蔵実装
- 画像処理 / OCR / カメラ撮影の内蔵
- macOS 25 以下 / iOS 25 以下のサポート
- 一体型
Toolbar(text:onSend:)View や.toolbarMenu(...)のような環境値ベース設定 API(設計方針として採用しない)
新 Toolbar は monolithic な Toolbar View を提供しない。代わりに ToolbarContainer (Liquid Glass の親 surface) のみを提供し、その中に何を入れるかは開発者が決める。横並びの action 行は標準の HStack(alignment: .bottom, spacing: 8) で組む。SlashCommandPopup / AttachmentStrip / AttachmentChip / ToolbarEditor / SendButton / StopButton / VoiceButton / ToolbarMenuButton などはすべて public な独立コンポーネントで、自由に並べ替え・追加・差し替えができる。
旧設計案 新設計
───────────────────────────────── ─────────────────────────────
Toolbar(text:onSend:) ToolbarContainer {
.toolbarMenu { ... } label: { ... } HStack(alignment: .bottom, spacing: 8) {
.toolbarSlashProvider(...) ToolbarMenuButton { ... }
.toolbarVoiceProvider(...) ToolbarEditor(text: $text)
.toolbarAccessory { ... } SendButton(isEnabled: ...) { ... }
.toolbarStyle(.liquidGlass) }
}
.accessory {
AttachmentStrip(...)
// ephemeral state strip:
// VoiceWaveform / Transcribing /
// banners / suggestions / etc.
}
.popup(isPresented: text.hasPrefix("/")) {
SlashCommandPopup(...)
}
責務: モノリシック View が 責務: primitive を組み立てるのは
全 slot を内包し environment 開発者。Library は部品と
で挙動を構成する Liquid Glass surface のみ提供
宣言的アプローチを採用する理由:
- 拡張性: 新しい役割の slot (例: connected-folder chip, generate-image button) を追加するときに新しい environment modifier を生やす必要がない。
HStackの中にビューを 1 つ足すだけで済む。 - 一貫性:
HStack/Group/NavigationStackと同じ SwiftUI 流儀に従うので、初見の開発者でも構造が読める。 - 環境結合の排除: 旧設計では menu / voice / accessory / style がすべて environment 経由だったため、子 View で readable になる順序や上書き優先順位を覚える必要があった。declarative なら body から構造が見える。
+-----------------------------------------------------------+
| Public API (SwiftUI) |
| |
| ToolbarContainer { ... }.accessory { ... }.footer { ... }.popup { ... } |
| ToolbarEditor(text:contentHeight:isFocused:) |
| ToolbarMenuButton { ... } label: { ... } |
| AttachmentChip(attachment:onRemove:) |
| SlashCommandPopup(commands:selectedIndex:onSelect:) |
| VoiceButton(provider:onResult:onError:onStateChange:) |
| VoiceWaveform(amplitudes:) |
| TranscribingIndicator(label:) |
| SendButton(isEnabled:action:) / StopButton(action:) |
| GlassCircleButtonStyle |
| |
| View modifiers: |
| ToolbarContainer.popup { ... } |
| ToolbarContainer.accessory { ... } |
| .voiceAmplitudes(from:into:capacity:) |
| |
| Protocols: |
| ToolbarAttachment, AttachmentIcon |
| SlashCommandProvider, SlashCommand |
| VoiceInputProvider, VoiceState, VoiceResult |
| VoiceAmplitudeSource |
| InlineAttachmentRenderer |
+-----------------------------------------------------------+
|
+------------------+-------------------+
| Cross-platform internals |
| |
| EditorBacking |
| +-- AppKit (#if os(macOS)) |
| | NSScrollView { NSTextView } |
| +-- UIKit (#if !os(macOS)) |
| UITextView |
+--------------------------------------+
プラットフォーム分岐は EditorBacking のみ。それ以外はすべて純 SwiftUI で iOS / iPadOS / macOS 共通実装。
Toolbar/
Package.swift // swift-tools-version: 6.2
// platforms: [.macOS(.v26), .iOS(.v26)]
Sources/Toolbar/
ToolbarContainer.swift // public, GlassEffectContainer + slab + .popup/.accessory modifiers
Editor/
Editor.swift // public ToolbarEditor (SwiftUI facade)
EditorBacking+macOS.swift // internal NSTextView
EditorBacking+iOS.swift // internal UITextView
EditorKey.swift // public EditorKey enum
Attachments/
ToolbarAttachment.swift // public protocol
AttachmentIcon.swift // public enum
AttachmentTint.swift // public semantic tint enum
AttachmentFileCategory.swift // public broad file category enum
AttachmentResolver.swift // public URL classifier
AttachmentStrip.swift // public horizontal attachment row, unclipped scroll
AttachmentChip.swift // public fixed-height attachment preview
URLAttachment.swift // public, classified URL-based
FileAttachment.swift // public, URL-based
PathAttachment.swift // public, URL-based folder
ImageAttachment.swift // public, URL-based image
Slash/
SlashCommand.swift // public struct
SlashCommandProvider.swift // public protocol + StaticSlashCommandProvider
SlashCommandPopup.swift // public view
Voice/
VoiceInputProvider.swift // public protocol
VoiceAmplitudeSource.swift // public protocol + .voiceAmplitudes modifier
VoiceState.swift // public enum (.idle/.recording/.transcribing)
VoiceResult.swift // public enum
VoiceButton.swift // public view (with onStateChange)
VoiceWaveform.swift // public bar-graph visualization
TranscribingIndicator.swift // public progress view for .transcribing
Menu/
ToolbarMenu.swift // public ToolbarMenuButton
Buttons/
SendButton.swift // public
StopButton.swift // public
GlassCircleButton.swift // public ButtonStyle
Inline/
InlineAttachmentRenderer.swift // public protocol for [[marker]] support
Tests/ToolbarTests/ // Swift Testing
import SwiftUI
import Toolbar
struct ChatView: View {
@State private var text = ""
@State private var height: CGFloat = 36
@State private var isFocused = false
@State private var attachments: [any ToolbarAttachment] = []
@State private var slashMatches: [SlashCommand] = []
@State private var slashSelection: Int? = nil
@State private var isStreaming = false
// Voice state owned at the chat level. The accessory area shows a live
// waveform during recording, and a "transcribing…" indicator while the
// provider finalizes the result. The transcript itself flows into
// `text` directly — the editor renders it like normal typed input.
@State private var voiceAmplitudes: [Float] = []
@State private var voiceState: VoiceState = .idle
@State private var amplitudeSource: (any VoiceAmplitudeSource)? = nil
let slashProvider: any SlashCommandProvider
let voiceProvider: (any VoiceInputProvider)?
var body: some View {
ScrollView {
messageList
}
.safeAreaInset(edge: .bottom) {
ToolbarContainer {
HStack(alignment: .bottom, spacing: 8) {
ToolbarMenuButton {
Button("Add file...") { pickFile() }
Button("Add image...") { pickImage() }
} label: {
Image(systemName: "plus")
}
ToolbarEditor(
text: $text,
contentHeight: $height,
isFocused: $isFocused,
placeholder: "Message...",
onCommandReturn: send,
onKeyEvent: handleKey,
onPasteAttachment: addAttachment
)
.frame(minHeight: 36, maxHeight: max(36, min(height, 220)))
// Trailing slot: streaming → Stop, empty → Voice, otherwise → Send.
if isStreaming {
StopButton(action: cancel)
} else if text.isEmpty, let voiceProvider {
// While recording, the developer's pipeline pumps
// partial transcripts straight into `$text`, so the
// ToolbarEditor itself displays the recognized text.
VoiceButton(
provider: voiceProvider,
onResult: { _ in /* finalized text already in $text */ },
onStateChange: { state in
voiceState = state
// Wire the amplitude source for the waveform.
// The provider commonly conforms to both
// VoiceInputProvider and VoiceAmplitudeSource.
switch state {
case .recording:
amplitudeSource = voiceProvider as? any VoiceAmplitudeSource
case .transcribing, .idle:
amplitudeSource = nil
}
}
)
} else {
SendButton(isEnabled: !text.isEmpty, action: send)
}
}
}
.accessory {
VStack(alignment: .leading, spacing: 8) {
if !attachments.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(attachments, id: \.id) { attachment in
AttachmentChip(attachment: attachment) {
remove(attachment)
}
}
}
}
}
switch voiceState {
case .idle:
EmptyView()
case .recording:
VoiceWaveform(amplitudes: voiceAmplitudes)
.frame(maxWidth: .infinity)
.transition(.opacity.combined(with: .scale(scale: 0.96)))
case .transcribing:
TranscribingIndicator()
.transition(.opacity.combined(with: .scale(scale: 0.96)))
}
}
}
.popup(isPresented: text.hasPrefix("/")) {
SlashCommandPopup(
commands: slashMatches,
selectedIndex: slashSelection,
onSelect: commitSlash
)
}
.voiceAmplitudes(from: amplitudeSource, into: $voiceAmplitudes)
}
}
}onSend payload struct (ToolbarValue) は 提供しない。送信時に text / attachments / 直近の voice 結果を呼び出し側が直接組み立てる。
ToolbarContainer 自体は mode を持たない。accessory は .accessory { ... } modifier で差し込む空きスロットで、ライブラリ側がその中身を列挙しない。Wave / Transcribing 以外にもエラーバナー、ドラフトプレビュー、サジェストチップ列、ファイルアップロード進捗など "composer 本体の前に挟まる一時帯" すべてをここに置ける。footer は .footer { ... } modifier で差し込む空きスロットで、editor 下に常駐する添付メニュー、workspace/model selector、send/stop controls を置く。
Voice の場合: VoiceButton.onStateChange が .recording / .transcribing / .idle を順に発火するので、これをそのまま voiceState に保存し、.accessory { switch voiceState { ... } } で振り分ける。amplitudes は VoiceAmplitudeSource を実装したストリーム源を .voiceAmplitudes(from:into:) modifier に渡すと自動で sliding window として voiceAmplitudes に流れる。
.transcribing 状態は 解析が終わるまで Wave を消さないため に存在する。stopRecording() が完了する前に accessory を空に戻してしまうと、ユーザーには「停止 → 入力欄が空に戻った」ように見え、その後 transcript が遅れて流入するためチラつきが生じる。.transcribing の間は Wave の代わりに TranscribingIndicator (ProgressView + label) を表示し、結果を受け取ったタイミングで .idle に遷移して accessory が消える。
ToolbarContainer の内部は accessory + content + footer の 3 段構成。.accessory { } / .footer { } を付けない場合は該当 slot が EmptyView で、見た目上は content だけが slab に乗る。popup { } はこの slab の外側に描画される浮遊面で、slash command 候補のように composer 本体のレイアウトへ参加させたくない UI に使う。
contentInsets は slab の内側余白を制御する。外側の .padding(...) では glass 境界と content の距離を調整できないため、これは ToolbarContainer の API として持つ。
GlassEffectContainer { // morph domain
VStack(alignment: .leading, spacing: 8) {
accessory // ← .accessory { } で差し込まれた View
// 省略時は EmptyView
// 例: AttachmentChip row / VoiceWaveform / TranscribingIndicator
// / error banner / draft preview /
// suggestion chips / upload progress
content // ← 開発者が ToolbarContainer の trailing
// closure に書いた View 群
// (HStack { ToolbarMenuButton; ToolbarEditor;
// VoiceButton; SendButton }, ...)
footer // ← .footer { } で差し込まれた View
// 例: attachment menu / workspace chip
// / model selector / SendButton
}
.padding(ToolbarSurfaceMetrics.contentInset)
.glassEffect(.regular, in: .rect(cornerRadius: ToolbarSurfaceMetrics.cornerRadius)) // unified slab
}
ToolbarContainer { ... }
.popup(isPresented: text.hasPrefix("/")) {
SlashCommandPopup(...) // slab の外側、accessory とは別スロット
}
accessory は slab の中 に同居するので、波形やインジケータが Liquid Glass の上に乗っているように見える。content 側のインスタンスは accessory の有無に関係なく 同じツリーがマウントされ続ける ので、focus / cursor / 入力中テキストが保たれる。
accessory area は "composer 本体の前に一時的に乗る帯" を担う汎用スロット。voice 入力の Wave / 解析中インジケータが代表例だが、library が用途を列挙しない:
| 用途例 | 表示 view |
|---|---|
| 録音中 | VoiceWaveform |
| 解析中 | TranscribingIndicator |
| 添付 | AttachmentStrip / AttachmentChip row |
| エラー | (呼び出し側) banner View |
| ドラフト | (呼び出し側) preview View |
| 提案 | (呼び出し側) suggestion chips |
| アップロード進捗 | (呼び出し側) progress strip |
呼び出し側が .accessory { switch state { ... } } で振り分ける。accessory の差し替えアニメーションは呼び出し側責任 — withAnimation { state = ... } でトリガし、各分岐の View に .transition(.opacity.combined(with: .scale(0.96))) を付ける。Container 側は accessory に対する自動アニメーションを持たない (汎用なので何が出るか知らない)。
transcript は accessory ではなく ToolbarEditor.$text 越しに普段通り編集領域に描画される。別 Text を生やす必要がなく、フォントサイズも editor のものに揃う。
ToolbarContainer は 1 枚の統一 Liquid Glass slab を背景として描画する。GlassEffectContainer は morph domain として機能し、popup や glass circle ボタンなど "自前の .glassEffect を持つ子" は slab と同じドメイン内で連結 (morph) するため、popup 出現や send→stop 切り替えの形状アニメーションが滑らかに繋がる。
ToolbarEditor は自前の .glassEffect(...) を 持たない。slab の上にプレーンに乗る。Editor を独自のガラスカード化したい場合は呼び出し側で .glassEffect(.regular, in: .rect(cornerRadius: 22)) を付けることはできるが、デフォルトでは二重ガラス (slab + editor card) を避けるためプレーンにする。glass-circle 系のボタン (SendButton / StopButton / VoiceButton / ToolbarMenuButton) は円形なので slab 上に乗っても自然に morph する。
ToolbarContainer はチャット View の ScrollView に対して .safeAreaInset(edge: .bottom) で配置する。VStack { ScrollView; ToolbarContainer } のような直配置はしない。
理由:
ScrollViewのコンテンツインセットに toolbar 分が自動加算される- スクロール中にメッセージが toolbar 下に潜り、Liquid Glass の morph が効く
- iOS のキーボード回避が自動で効く
// MARK: - Attachment
public protocol ToolbarAttachment: Sendable, Identifiable where ID == String {
var id: String { get }
var displayName: String { get }
var icon: AttachmentIcon { get }
var tint: AttachmentTint { get }
var badgeLabel: String? { get }
var badgeSymbolName: String? { get }
}
public enum AttachmentIcon: Sendable, Hashable {
case system(String)
case fileURL(URL)
case image(URL)
}
public protocol ToolbarURLAttachment: ToolbarAttachment {
var url: URL { get }
var contentType: UTType? { get }
}
// MARK: - Slash command
public struct SlashCommand: Identifiable, Sendable, Hashable {
public let id: String
public let name: String
public let description: String
public let icon: String
}
public protocol SlashCommandProvider: Sendable {
func commands(matching query: String) async -> [SlashCommand]
}
public struct StaticSlashCommandProvider: SlashCommandProvider { ... }
// MARK: - Voice
public enum VoiceState: Sendable { case idle, recording, transcribing }
public enum VoiceResult: Sendable { case text(String), audio(URL) }
@MainActor
public protocol VoiceInputProvider: AnyObject {
var state: VoiceState { get }
func startRecording() async throws
func stopRecording() async throws -> VoiceResult
func cancel() async
}
/// Stream of normalized (0...1) audio amplitude samples used by
/// ``VoiceWaveform``. `VoiceInputProvider` and `VoiceAmplitudeSource` are
/// intentionally separate protocols — recording lifecycle vs. visualization
/// data — but a single concrete type may conform to both.
public protocol VoiceAmplitudeSource: AnyObject, Sendable {
func amplitudeStream() -> AsyncStream<Float>
}
extension View {
/// Subscribes to `source` and pumps each sample into `amplitudes`,
/// keeping the array bounded to `capacity` (newest-last sliding window).
/// Subscription lifetime follows `.task(id:)` semantics keyed off the
/// source's object identity. Passing `nil` clears the buffer.
public func voiceAmplitudes(
from source: (any VoiceAmplitudeSource)?,
into amplitudes: Binding<[Float]>,
capacity: Int = 200
) -> some View
}
// MARK: - Inline attachment marker (e.g. [[path:/Users/foo]])
public protocol InlineAttachmentRenderer: Sendable {
var markerPrefix: String { get }
var markerSuffix: String { get }
func marker(for url: URL) -> String?
#if os(macOS)
@MainActor func makeAttachment(payload: String, font: NSFont) -> NSTextAttachment?
#else
@MainActor func makeAttachment(payload: String, font: UIFont) -> NSTextAttachment?
#endif
}添付は View ではなくデータ契約にする。ToolbarAttachment は表示名、icon、badge label、badge symbol、semantic tint だけを持つ。AttachmentChip が標準 renderer であり、app 側は protocol に準拠したデータを渡す。
URLAttachment は drop / paste された URL 向けの標準実装で、AttachmentResolver が拡張子と UTType から category / tint / symbol を決定する。NSImage / UIImage を持たず、画像表示は SwiftUI の Image(nsImage:) / Image(uiImage:) を chip 内で必要時に解決する。
| Type | URL の意味 | チップ表現 |
|---|---|---|
URLAttachment |
任意 URL / file URL | resolver による thumbnail / extension badge |
FileAttachment |
任意ファイル | fixed-height extension badge |
PathAttachment |
フォルダ | fixed-height DIR badge |
ImageAttachment |
画像ファイル | fixed-height rounded thumbnail |
旧 Bob の PathAttachment (NSTextAttachment ベース、テキスト中にインライン) も ToolbarEditor 内で同等のインライン表示が可能 (InlineAttachmentRenderer 経由)。[[path:/Users/foo]] 形式の marker で text シリアライズと往復する。
public struct ToolbarEditor: View {
public init(
text: Binding<String>,
contentHeight: Binding<CGFloat>,
isFocused: Binding<Bool>,
placeholder: String = "Message...",
rightInset: CGFloat = 0,
onCommandReturn: @escaping () -> Void = {},
onKeyEvent: @escaping (EditorKey) -> Bool = { _ in false },
onPasteAttachment: @escaping (URL) -> Void = { _ in }
)
}NSScrollView { NSTextView } を NSViewRepresentable。
- 自動高さ計算 (
layoutManager.usedRect) Cmd+ReturnでonCommandReturn- 矢印 / Tab / Esc は
EditorKeyに変換してonKeyEventへ転送 registerForDraggedTypesでファイル URL drop →onPasteAttachmentNSTextAttachmentでインライン attachment 表示 (InlineAttachmentRenderer連携)
UITextView を UIViewRepresentable。
sizeThatFitsで高さ報告keyCommandsoverride でCmd+Return/ 矢印 / Esc / Tab をUIKeyCommand化- ペーストボード経由のファイル URL を
onPasteAttachmentに通知 NSTextAttachmentでインライン attachment 表示
プラットフォーム共通の enum で up / down / return / tab / escape を表現。Carbon 依存を Toolbar 内に閉じ込める。
public enum EditorKey: Sendable, Equatable {
case up
case down
case `return`
case tab
case escape
case other
}- 親 View は
ToolbarContainerを使う — 内部でGlassEffectContainer(morph domain) と統一 slab (.glassEffect(.regular, in: .rect(cornerRadius: ToolbarSurfaceMetrics.cornerRadius))) の二段構成を持つ - slab 内側余白は
ToolbarContainer(contentInsets:)で制御する。toolbar 全体の外側余白は呼び出し側の.padding(...)で制御する ToolbarEditorには呼び出し側で.glassEffect(...)を付けない — slab と二重ガラスになるため。プレーンに slab 上に乗せる- 円形ボタン (Send / Stop / Voice / Menu):
GlassCircleButtonStyleまたは内部で.glassEffect(.regular, in: .circle)。slab と morph 連結する - 添付チップ: fixed-height thumbnail / extension badge (
AttachmentChip内蔵)。角丸は slab と同じ数値ではなく、ToolbarSurfaceMetrics.cornerRadius(insetBy: ToolbarSurfaceMetrics.contentInset)で内側 inset 分を差し引く。horizontal row はAttachmentStripを使い、remove button overlay が ScrollView で clip されないようにする - Slash popup:
.glassEffect(.regular, in: .rect(cornerRadius: 12))(SlashCommandPopup内蔵)。ToolbarContainer.popup(isPresented:)に置き、slash 入力中だけ slab の外側の浮遊面として表示する。サイズや外側 padding は呼び出し側の.frame(...)/.padding(...)で調整する - カラーアクセント:
.glassEffect(.regular.tint(.accentColor.opacity(0.3)), ...) - hover / focus 状態で
.regular<->.clearを切り替えてユーザー操作のフィードバック
Toolbar 完成後、Bob 側で以下を実施する。本ドキュメントの範囲外。
BobKit/Sources/BobUI/Chat/MessageInputView.swift削除BobKit/Sources/BobUI/Chat/TextView.swift削除BobKit/Sources/BobUI/Chat/SlashCommand*.swift削除BobKit/Sources/BobUI/Chat/PathAttachment.swift削除BobKitに Toolbar を依存追加FloatingChatView内でToolbarContainerを組み立て、ConnectedAppViewを action 行 (HStack) 内の任意位置に挿入- Bob の
SkillManagerをSlashCommandProviderに適合させ、SlashCommandPopupの commands を直接バインド [[path:/...]]marker はPathInlineRenderer(built-in 予定) 経由でToolbarEditorに流す
実装中に判断が必要になりそうな項目:
- インライン attachment の marker 形式:
[[path:...]]互換にするか、汎用[[<type>:<id>]]にするか - Slash popup の最大行数 / 高さ: 既存 Bob 実装は 210pt
- 添付追加時のアニメーションカーブ: spring(0.35, bounce: 0.1) で統一
- iPadOS Stage Manager で複数 Toolbar が同居した場合の voice provider 競合
- 音声録音中の他操作 (送信ボタン押下など) の挙動: cancel? hold?