From 836fb6e864927153edf619b0732f562d10c48ac9 Mon Sep 17 00:00:00 2001 From: Kohei Date: Thu, 19 Feb 2026 11:00:24 +0900 Subject: [PATCH] feat: add real-time status indicators for agent processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show transient status labels (thinking, tool use, error) in the chat UI via a new `type` field on WebSocket messages. The server emits "思考中..." before LLM calls and tool-specific labels (e.g. "検索中...(query)") before each tool execution. The Android client renders these as an animated indicator above the message input without persisting to the DB. Co-Authored-By: Claude Opus 4.6 --- .../java/io/picoclaw/android/di/AppModule.kt | 4 +- .../core/data/remote/dto/WsOutgoing.kt | 5 +- .../data/repository/ChatRepositoryImpl.kt | 14 +- .../core/domain/repository/ChatRepository.kt | 1 + .../domain/usecase/ObserveStatusUseCase.kt | 8 + .../android/feature/chat/ChatUiState.kt | 3 +- .../android/feature/chat/ChatViewModel.kt | 8 + .../feature/chat/component/StatusIndicator.kt | 49 +++++ .../android/feature/chat/screen/ChatScreen.kt | 3 + pkg/agent/loop.go | 31 ++- pkg/agent/status.go | 199 ++++++++++++++++++ pkg/bus/types.go | 1 + pkg/channels/manager.go | 5 + pkg/channels/websocket.go | 3 +- 14 files changed, 327 insertions(+), 7 deletions(-) create mode 100644 android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/ObserveStatusUseCase.kt create mode 100644 android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/StatusIndicator.kt create mode 100644 pkg/agent/status.go diff --git a/android/app/src/main/java/io/picoclaw/android/di/AppModule.kt b/android/app/src/main/java/io/picoclaw/android/di/AppModule.kt index 3a0ff7059..02a13766e 100644 --- a/android/app/src/main/java/io/picoclaw/android/di/AppModule.kt +++ b/android/app/src/main/java/io/picoclaw/android/di/AppModule.kt @@ -14,6 +14,7 @@ import io.picoclaw.android.core.domain.usecase.DisconnectChatUseCase import io.picoclaw.android.core.domain.usecase.LoadMoreMessagesUseCase import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase +import io.picoclaw.android.core.domain.usecase.ObserveStatusUseCase import io.picoclaw.android.core.domain.usecase.SendMessageUseCase import io.picoclaw.android.feature.chat.ChatViewModel import kotlinx.coroutines.CoroutineScope @@ -71,10 +72,11 @@ val appModule = module { factory { SendMessageUseCase(get()) } factory { ObserveMessagesUseCase(get()) } factory { ObserveConnectionUseCase(get()) } + factory { ObserveStatusUseCase(get()) } factory { LoadMoreMessagesUseCase(get()) } factory { ConnectChatUseCase(get()) } factory { DisconnectChatUseCase(get()) } // ViewModel - viewModel { ChatViewModel(get(), get(), get(), get(), get(), get()) } + viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get()) } } diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/dto/WsOutgoing.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/dto/WsOutgoing.kt index 413a14402..f2c873ca6 100644 --- a/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/dto/WsOutgoing.kt +++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/dto/WsOutgoing.kt @@ -3,4 +3,7 @@ package io.picoclaw.android.core.data.remote.dto import kotlinx.serialization.Serializable @Serializable -data class WsOutgoing(val content: String) +data class WsOutgoing( + val content: String, + val type: String? = null +) diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/ChatRepositoryImpl.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/ChatRepositoryImpl.kt index 565b13127..3ce29a9a8 100644 --- a/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/ChatRepositoryImpl.kt +++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/ChatRepositoryImpl.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -27,6 +28,7 @@ class ChatRepositoryImpl( ) : ChatRepository { private val _displayLimit = MutableStateFlow(INITIAL_LOAD_COUNT) + private val _statusLabel = MutableStateFlow(null) @Suppress("OPT_IN_USAGE") override val messages: StateFlow> = @@ -38,11 +40,19 @@ class ChatRepositoryImpl( override val connectionState: StateFlow = webSocketClient.connectionState + override val statusLabel: StateFlow = _statusLabel.asStateFlow() + init { scope.launch { webSocketClient.incomingMessages.collect { dto -> - val entity = MessageMapper.toEntity(dto) - messageDao.insert(entity) + when (dto.type) { + "status" -> _statusLabel.value = dto.content + else -> { + _statusLabel.value = null + val entity = MessageMapper.toEntity(dto) + messageDao.insert(entity) + } + } } } } diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/repository/ChatRepository.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/repository/ChatRepository.kt index 31e76e962..a4095b6e9 100644 --- a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/repository/ChatRepository.kt +++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/repository/ChatRepository.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.StateFlow interface ChatRepository { val messages: StateFlow> val connectionState: StateFlow + val statusLabel: StateFlow suspend fun sendMessage(text: String, images: List = emptyList()) fun loadMore() fun connect() diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/ObserveStatusUseCase.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/ObserveStatusUseCase.kt new file mode 100644 index 000000000..a4fd0ae19 --- /dev/null +++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/ObserveStatusUseCase.kt @@ -0,0 +1,8 @@ +package io.picoclaw.android.core.domain.usecase + +import io.picoclaw.android.core.domain.repository.ChatRepository +import kotlinx.coroutines.flow.StateFlow + +class ObserveStatusUseCase(private val repository: ChatRepository) { + operator fun invoke(): StateFlow = repository.statusLabel +} diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatUiState.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatUiState.kt index 049c64d53..d0ac21a63 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatUiState.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatUiState.kt @@ -11,5 +11,6 @@ data class ChatUiState( val pendingImages: List = emptyList(), val isLoadingMore: Boolean = false, val canLoadMore: Boolean = true, - val error: String? = null + val error: String? = null, + val statusLabel: String? = null ) diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt index 381a41c10..a682444ad 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt @@ -7,6 +7,7 @@ import io.picoclaw.android.core.domain.usecase.DisconnectChatUseCase import io.picoclaw.android.core.domain.usecase.LoadMoreMessagesUseCase import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase +import io.picoclaw.android.core.domain.usecase.ObserveStatusUseCase import io.picoclaw.android.core.domain.usecase.SendMessageUseCase import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -20,6 +21,7 @@ class ChatViewModel( private val sendMessage: SendMessageUseCase, private val observeMessages: ObserveMessagesUseCase, private val observeConnection: ObserveConnectionUseCase, + private val observeStatus: ObserveStatusUseCase, private val loadMoreMessages: LoadMoreMessagesUseCase, private val connectChat: ConnectChatUseCase, private val disconnectChat: DisconnectChatUseCase @@ -45,6 +47,12 @@ class ChatViewModel( _uiState.update { it.copy(connectionState = state) } } } + + viewModelScope.launch { + observeStatus().collect { label -> + _uiState.update { it.copy(statusLabel = label) } + } + } } fun onEvent(event: ChatEvent) { diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/StatusIndicator.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/StatusIndicator.kt new file mode 100644 index 000000000..873bf481d --- /dev/null +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/StatusIndicator.kt @@ -0,0 +1,49 @@ +package io.picoclaw.android.feature.chat.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun StatusIndicator( + label: String?, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = label != null, + enter = fadeIn(), + exit = fadeOut() + ) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = label ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt index ae6003d09..6de475b54 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt @@ -32,6 +32,7 @@ import io.picoclaw.android.feature.chat.component.ConnectionBanner import io.picoclaw.android.feature.chat.component.ImagePreviewRow import io.picoclaw.android.feature.chat.component.MessageInput import io.picoclaw.android.feature.chat.component.MessageList +import io.picoclaw.android.feature.chat.component.StatusIndicator import org.koin.androidx.compose.koinViewModel import java.io.File @@ -134,6 +135,8 @@ fun ChatScreen( modifier = Modifier.weight(1f) ) + StatusIndicator(label = uiState.statusLabel) + ImagePreviewRow( images = uiState.pendingImages, onRemove = { viewModel.onEvent(ChatEvent.OnImageRemoved(it)) } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index fe7c2515d..0271cd5bd 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -188,6 +188,13 @@ func (al *AgentLoop) Run(ctx context.Context) error { response, err := al.processMessage(ctx, msg) if err != nil { response = fmt.Sprintf("Error processing message: %v", err) + al.bus.PublishOutbound(bus.OutboundMessage{ + Channel: msg.Channel, + ChatID: msg.ChatID, + Content: response, + Type: "error", + }) + continue } if response != "" { @@ -406,7 +413,17 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str // 3. Save user message to session al.sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) - // 4. Run LLM iteration loop + // 4. Emit thinking status + if !constants.IsInternalChannel(opts.Channel) { + al.bus.PublishOutbound(bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: "思考中...", + Type: "status", + }) + } + + // 5. Run LLM iteration loop finalContent, iteration, err := al.runLLMIteration(ctx, messages, opts) if err != nil { return "", err @@ -711,6 +728,18 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M } } + // Emit tool use status indicator + if !constants.IsInternalChannel(opts.Channel) { + if label := statusLabel(tc.Name, tc.Arguments); label != "" { + al.bus.PublishOutbound(bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: label, + Type: "status", + }) + } + } + toolResult := al.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback) // Send ForUser content to user immediately if not Silent diff --git a/pkg/agent/status.go b/pkg/agent/status.go new file mode 100644 index 000000000..43b5ba122 --- /dev/null +++ b/pkg/agent/status.go @@ -0,0 +1,199 @@ +package agent + +import ( + "fmt" + "net/url" + "path/filepath" + "unicode/utf8" +) + +// statusLabel generates a human-readable Japanese status label for a tool call. +func statusLabel(toolName string, args map[string]interface{}) string { + switch toolName { + case "web_search": + if q := strArg(args, "query"); q != "" { + return fmt.Sprintf("検索中...(%s)", truncLabel(q, 20)) + } + return "検索中..." + case "web_fetch": + if u := strArg(args, "url"); u != "" { + return fmt.Sprintf("ページ取得中...(%s)", hostFromURL(u)) + } + return "ページ取得中..." + case "read_file": + return fileStatusLabel("ファイル読み取り中...", args) + case "write_file": + return fileStatusLabel("ファイル書き込み中...", args) + case "edit_file": + return fileStatusLabel("ファイル編集中...", args) + case "append_file": + return fileStatusLabel("ファイル追記中...", args) + case "list_dir": + if p := strArg(args, "path"); p != "" { + return fmt.Sprintf("フォルダ確認中...(%s)", filepath.Base(p)+"/") + } + return "フォルダ確認中..." + case "exec": + if c := strArg(args, "command"); c != "" { + return fmt.Sprintf("コマンド実行中...(%s)", truncLabel(c, 30)) + } + return "コマンド実行中..." + case "memory": + return memoryStatusLabel(args) + case "skill": + return skillStatusLabel(args) + case "cron": + return cronStatusLabel(args) + case "message": + return "メッセージ送信中..." + case "spawn": + if l := strArg(args, "label"); l != "" { + return fmt.Sprintf("サブタスク開始中...(%s)", truncLabel(l, 20)) + } + return "サブタスク開始中..." + case "subagent": + if l := strArg(args, "label"); l != "" { + return fmt.Sprintf("サブタスク実行中...(%s)", truncLabel(l, 20)) + } + return "サブタスク実行中..." + case "i2c": + return i2cStatusLabel(args) + case "spi": + return spiStatusLabel(args) + default: + return "処理中..." + } +} + +func fileStatusLabel(base string, args map[string]interface{}) string { + if p := strArg(args, "path"); p != "" { + return fmt.Sprintf("%s(%s)", base, filepath.Base(p)) + } + return base +} + +func memoryStatusLabel(args map[string]interface{}) string { + switch strArg(args, "action") { + case "read_long_term": + return "メモリ読み込み中..." + case "read_daily": + return "今日のメモ読み込み中..." + case "write_long_term": + return "メモリ書き込み中..." + case "append_daily": + return "今日のメモ追記中..." + default: + return "メモリ操作中..." + } +} + +func skillStatusLabel(args map[string]interface{}) string { + switch strArg(args, "action") { + case "skill_list": + return "スキル一覧取得中..." + case "skill_read": + if n := strArg(args, "name"); n != "" { + return fmt.Sprintf("スキル読み込み中...(%s)", n) + } + return "スキル読み込み中..." + default: + return "スキル操作中..." + } +} + +func cronStatusLabel(args map[string]interface{}) string { + switch strArg(args, "action") { + case "add": + return "リマインダー設定中..." + case "list": + return "スケジュール一覧取得中..." + case "remove": + return "スケジュール削除中..." + default: + return "スケジュール変更中..." + } +} + +func i2cStatusLabel(args map[string]interface{}) string { + switch strArg(args, "action") { + case "detect": + return "I2Cバス検出中..." + case "scan": + if b := strArg(args, "bus"); b != "" { + return fmt.Sprintf("I2Cデバイススキャン中...(bus %s)", b) + } + return "I2Cデバイススキャン中..." + case "read": + if addr := intArg(args, "address"); addr > 0 { + return fmt.Sprintf("センサー読み取り中...(0x%02X)", addr) + } + return "センサー読み取り中..." + case "write": + if addr := intArg(args, "address"); addr > 0 { + return fmt.Sprintf("デバイス書き込み中...(0x%02X)", addr) + } + return "デバイス書き込み中..." + default: + return "I2C操作中..." + } +} + +func spiStatusLabel(args map[string]interface{}) string { + switch strArg(args, "action") { + case "list": + return "SPIデバイス一覧取得中..." + case "transfer": + if d := strArg(args, "device"); d != "" { + return fmt.Sprintf("SPI通信中...(%s)", d) + } + return "SPI通信中..." + case "read": + if d := strArg(args, "device"); d != "" { + return fmt.Sprintf("SPI読み取り中...(%s)", d) + } + return "SPI読み取り中..." + default: + return "SPI操作中..." + } +} + +// strArg extracts a string argument from a tool arguments map. +func strArg(args map[string]interface{}, key string) string { + if v, ok := args[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// intArg extracts an integer argument from a tool arguments map. +func intArg(args map[string]interface{}, key string) int { + if v, ok := args[key]; ok { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + } + } + return 0 +} + +// truncLabel truncates a string to maxRunes runes, appending "..." if truncated. +func truncLabel(s string, maxRunes int) string { + if utf8.RuneCountInString(s) <= maxRunes { + return s + } + runes := []rune(s) + return string(runes[:maxRunes]) + "..." +} + +// hostFromURL extracts the hostname from a URL string. +func hostFromURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return truncLabel(rawURL, 30) + } + return u.Host +} diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 44f9181a5..2e65d33c2 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -14,6 +14,7 @@ type OutboundMessage struct { Channel string `json:"channel"` ChatID string `json:"chat_id"` Content string `json:"content"` + Type string `json:"type,omitempty"` // "message" (default when empty), "status", "error" } type MessageHandler func(InboundMessage) error diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 11a6fc2c0..32791caf2 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -274,6 +274,11 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { continue } + // Status events are only supported by WebSocket clients + if msg.Type == "status" && msg.Channel != "websocket" { + continue + } + m.mu.RLock() channel, exists := m.channels[msg.Channel] m.mu.RUnlock() diff --git a/pkg/channels/websocket.go b/pkg/channels/websocket.go index 8dfbcb8c3..c3ecbf5cf 100644 --- a/pkg/channels/websocket.go +++ b/pkg/channels/websocket.go @@ -24,6 +24,7 @@ type wsIncoming struct { // wsOutgoing is the JSON message sent from picoclaw to APK. type wsOutgoing struct { Content string `json:"content"` + Type string `json:"type,omitempty"` } // WebSocketChannel is a server-side WebSocket channel that accepts @@ -132,7 +133,7 @@ func (c *WebSocketChannel) Send(ctx context.Context, msg bus.OutboundMessage) er return fmt.Errorf("no connection for chat %s", msg.ChatID) } - out := wsOutgoing{Content: msg.Content} + out := wsOutgoing{Content: msg.Content, Type: msg.Type} data, err := json.Marshal(out) if err != nil { return fmt.Errorf("failed to marshal response: %w", err)