diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue
index 9d1d1957..505f06df 100644
--- a/mateclaw-ui/src/components/chat/MessageBubble.vue
+++ b/mateclaw-ui/src/components/chat/MessageBubble.vue
@@ -10,14 +10,16 @@
-
+
@@ -444,6 +446,7 @@ import ToolCallSegment from './ToolCallSegment.vue'
import ThinkingSegment from './ThinkingSegment.vue'
import ContentSegment from './ContentSegment.vue'
import GoalAvatarRing from '@/components/goal/GoalAvatarRing.vue'
+import { useGoalStore } from '@/stores/useGoalStore'
import PlanStepsPanel from './PlanStepsPanel.vue'
import UserMessageContent from './UserMessageContent.vue'
import type { BrowserAction } from './BrowserTimeline.vue'
@@ -487,6 +490,19 @@ const avatarIcon = computed(() => {
return role.value === 'user' ? props.userIcon : props.assistantIcon
})
+// Followup attribution: an assistant message that opened right after a
+// `goal_followup` SSE event belongs to an auto-followup turn. The chat
+// composable stamps the message via goalStore on `message_start`; this
+// computed reads it back so the ↻ glyph renders on exactly those turns.
+const goalStore = useGoalStore()
+const isFollowupTurn = computed(() => {
+ if (role.value !== 'assistant') return false
+ const cid = props.message.conversationId
+ const mid = props.message.id
+ if (!cid || mid == null) return false
+ return goalStore.isFollowupMessage(String(cid), String(mid))
+})
+
// --- 错误卡片 ---
const errorInfo = computed(() => props.message.errorInfo)
diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts
index 8ed603f7..60503ebc 100644
--- a/mateclaw-ui/src/composables/chat/useChat.ts
+++ b/mateclaw-ui/src/composables/chat/useChat.ts
@@ -345,6 +345,12 @@ export function useChat(options: UseChatOptions): UseChatReturn {
headers: streamHeaders,
})
+ // Goal store is referenced from several stream handlers (message_start
+ // for followup attribution, message_complete for the evaluating halo,
+ // plus the dedicated goal_* events below). Resolve once up front so
+ // the handlers don't each pull their own copy.
+ const goalStore = useGoalStore()
+
// ===== Async-task lifecycle bridge =====
// Generative tools (music / video / image) return a taskId synchronously and
// finish asynchronously via `async_task_completed`. If the upstream provider
@@ -456,6 +462,13 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const assistantMessage = createAssistantMessage('', streamConversationId)
;(assistantMessage as any)._turnId = activeTurnId
currentAssistantId.value = assistantMessage.id as string
+
+ // Auto-followup attribution: if the goal evaluator just decided to
+ // inject a followup, the message that just opened belongs to that
+ // turn. Stamp it so MessageBubble can render the small ↻ glyph.
+ if (streamConversationId && goalStore.consumePendingFollowup(streamConversationId)) {
+ goalStore.markFollowupMessage(streamConversationId, String(assistantMessage.id))
+ }
})
stream.on('warning', (data) => {
@@ -529,6 +542,19 @@ export function useChat(options: UseChatOptions): UseChatReturn {
triggerAutoTts(streamConversationId, msg.content)
}
}
+
+ // Goal-evaluator breathing halo: when an assistant message finishes
+ // and this conversation has an active goal, the backend's evaluation
+ // node runs next. Flip the per-conv flag so GoalAvatarRing paints the
+ // breathing halo until `goal_evaluated` resets it. Skip when no goal
+ // is active — the halo should be quiet for ordinary turns.
+ if (
+ data.status === 'completed'
+ && streamConversationId
+ && goalStore.activeGoal(streamConversationId)
+ ) {
+ goalStore.markEvaluating(streamConversationId, true)
+ }
})
stream.on('done', (data) => {
@@ -1590,11 +1616,10 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
})
- // ===== Goal events (RFC 48) =====
- // Forward GoalEvaluationNode emissions to the goal store. The store
- // owns active-goal cache + the per-conv "evaluating" flag that drives
+ // ===== Goal events =====
+ // Forward goal evaluator emissions to the goal store. The store owns
+ // the active-goal cache + the per-conv "evaluating" flag that drives
// the avatar ring's breathing halo.
- const goalStore = useGoalStore()
stream.on('goal_evaluated', (data) => {
if (isStaleEvent(data)) return
diff --git a/mateclaw-ui/src/stores/useGoalStore.ts b/mateclaw-ui/src/stores/useGoalStore.ts
index 97cd159b..d0650548 100644
--- a/mateclaw-ui/src/stores/useGoalStore.ts
+++ b/mateclaw-ui/src/stores/useGoalStore.ts
@@ -41,6 +41,19 @@ export const useGoalStore = defineStore('goal', () => {
at: number
} | null>>({})
+ // Per-conversation flag: "the goal evaluator just chose to inject a
+ // followup prompt, and the next assistant message that opens belongs
+ // to that followup turn." Consumed (cleared) by the chat composable's
+ // `message_start` handler so the message gets stamped exactly once.
+ const pendingFollowupByConv = ref>({})
+
+ // Assistant message IDs that came from auto-followup turns, grouped by
+ // conversation. MessageBubble reads this to show the small ↻ glyph on
+ // the avatar — the only visible signal that a turn was auto-triggered.
+ // Kept in memory only; on refetch the metadata persists server-side via
+ // the message's `metadata.fromFollowup` flag (handled by ChatHistory).
+ const followupMessageIdsByConv = ref>>({})
+
const loading = ref(false)
async function loadActiveForConversation(conversationId: string) {
@@ -152,7 +165,12 @@ export const useGoalStore = defineStore('goal', () => {
break
}
case 'goal_followup': {
- // The next assistant turn will land soon; nothing to do for the ring.
+ // The next assistant turn will land soon. Flag the conversation
+ // so the chat composable can stamp the upcoming message as a
+ // followup turn when its `message_start` arrives. The ring keeps
+ // its evaluating state until message_complete fires for that
+ // followup turn — so the user sees breathe → still → breathe.
+ pendingFollowupByConv.value[conversationId] = true
break
}
case 'goal_completed': {
@@ -246,12 +264,47 @@ export const useGoalStore = defineStore('goal', () => {
recentTerminalByConv.value[conversationId] = null
}
+ // ==================== Followup attribution helpers ====================
+
+ /**
+ * Consume the pending-followup flag for this conversation if it's
+ * set, returning true when the caller should stamp the just-opened
+ * assistant message as a followup turn. Idempotent — calling twice
+ * returns false the second time.
+ */
+ function consumePendingFollowup(conversationId: string): boolean {
+ if (!conversationId) return false
+ const pending = pendingFollowupByConv.value[conversationId]
+ if (pending) {
+ pendingFollowupByConv.value[conversationId] = false
+ return true
+ }
+ return false
+ }
+
+ function markFollowupMessage(conversationId: string, messageId: string) {
+ if (!conversationId || !messageId) return
+ let set = followupMessageIdsByConv.value[conversationId]
+ if (!set) {
+ set = new Set()
+ followupMessageIdsByConv.value[conversationId] = set
+ }
+ set.add(messageId)
+ }
+
+ function isFollowupMessage(conversationId: string, messageId: string): boolean {
+ if (!conversationId || !messageId) return false
+ return followupMessageIdsByConv.value[conversationId]?.has(messageId) ?? false
+ }
+
return {
activeGoalByConv,
evaluatingByConv,
eventsByGoal,
dismissedPromptByConv,
recentTerminalByConv,
+ pendingFollowupByConv,
+ followupMessageIdsByConv,
loading,
loadActiveForConversation,
create,
@@ -269,6 +322,9 @@ export const useGoalStore = defineStore('goal', () => {
clearDismissedPrompt,
recentTerminal,
clearRecentTerminal,
+ consumePendingFollowup,
+ markFollowupMessage,
+ isFollowupMessage,
}
})
diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue
index 3d94c231..2650600d 100644
--- a/mateclaw-ui/src/views/ChatConsole.vue
+++ b/mateclaw-ui/src/views/ChatConsole.vue
@@ -1065,7 +1065,9 @@ const goalTerminalForCurrent = computed(() =>
const goalSystemLineTitle = computed(() => {
const t = goalTerminalForCurrent.value
if (!t) return ''
- return t.status === 'completed' ? `🎉 ${t.title}` : `⚠ ${t.title}`
+ // The leading icon is owned by GoalSystemLine (✦ / ⚠) so we don't
+ // prepend one here — doing so produced "✦ 🎉 …" double-glyph titles.
+ return t.status === 'completed' ? `目标达成 · ${t.title}` : `这次的预算用完了 · ${t.title}`
})
const goalSystemLineDetail = computed(() => {
const t = goalTerminalForCurrent.value