feat: add real-time status indicators for agent processing

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 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-19 11:00:24 +09:00
parent 98309d4b36
commit 836fb6e864
14 changed files with 327 additions and 7 deletions

View file

@ -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()) }
}

View file

@ -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
)

View file

@ -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<String?>(null)
@Suppress("OPT_IN_USAGE")
override val messages: StateFlow<List<ChatMessage>> =
@ -38,11 +40,19 @@ class ChatRepositoryImpl(
override val connectionState: StateFlow<ConnectionState> = webSocketClient.connectionState
override val statusLabel: StateFlow<String?> = _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)
}
}
}
}
}

View file

@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.StateFlow
interface ChatRepository {
val messages: StateFlow<List<ChatMessage>>
val connectionState: StateFlow<ConnectionState>
val statusLabel: StateFlow<String?>
suspend fun sendMessage(text: String, images: List<ImageAttachment> = emptyList())
fun loadMore()
fun connect()

View file

@ -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<String?> = repository.statusLabel
}

View file

@ -11,5 +11,6 @@ data class ChatUiState(
val pendingImages: List<ImageAttachment> = emptyList(),
val isLoadingMore: Boolean = false,
val canLoadMore: Boolean = true,
val error: String? = null
val error: String? = null,
val statusLabel: String? = null
)

View file

@ -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) {

View file

@ -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
)
}
}
}

View file

@ -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)) }

View file

@ -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

199
pkg/agent/status.go Normal file
View file

@ -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
}

View file

@ -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

View file

@ -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()

View file

@ -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)