Skip to content
This repository was archived by the owner on Mar 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions frontend/src/components/ConversationsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,22 @@ const props = defineProps<{
preselectAgent?: string
}>()

// Derive the operator name from agent_type — avoids hardcoding 'boss' string.
// Fallback to 'operator' while the space loads.
// Collect ALL human agents (spaces may have both 'boss' legacy and 'operator' canonical).
// Using a Set lets isOperatorConversation match any of them for categorization.
const humanAgentNames = computed(() =>
new Set(
Object.entries(props.space.agents ?? {})
.filter(([, a]) => a.agent_type === 'human')
.map(([name]) => name),
),
)

// Canonical operator name used for composing messages.
// Prefer 'operator' (the current canonical name) over legacy 'boss' if both exist.
const operatorName = computed(() => {
const entry = Object.entries(props.space.agents ?? {}).find(([, a]) => a.agent_type === 'human')
return entry?.[0] ?? 'operator'
if (humanAgentNames.value.has('operator')) return 'operator'
const [first] = humanAgentNames.value
return first ?? 'operator'
})

interface ConversationMessage {
Expand Down Expand Up @@ -273,25 +284,25 @@ const selectedConversation = computed((): Conversation | null => {
const readKeys = ref(new Set<string>())

function isOperatorConversation(conv: Conversation): boolean {
return conv.participants.includes(operatorName.value)
return conv.participants.some(p => humanAgentNames.value.has(p))
}

function unreadCount(conv: Conversation): number {
// Agent-to-agent conversations never show unread badges
if (!isOperatorConversation(conv)) return 0
if (readKeys.value.has(conv.key)) return 0
// Only count messages directed at the operator that haven't been acknowledged on the backend
return conv.messages.filter(m => m.recipient === operatorName.value && !m.read).length
// Count messages directed at ANY human agent that haven't been acknowledged on the backend
return conv.messages.filter(m => humanAgentNames.value.has(m.recipient) && !m.read).length
}

// ACK all unread messages to the operator in a conversation so the backend persists read state.
// This clears the sidebar badge (which reads msg.read from live space data) and ensures
// the conversation stays read after navigate-away + return.
function ackOperatorMessages(conv: Conversation) {
if (!isOperatorConversation(conv)) return
const unread = conv.messages.filter(m => m.recipient === operatorName.value && !m.read)
const unread = conv.messages.filter(m => humanAgentNames.value.has(m.recipient) && !m.read)
for (const msg of unread) {
api.ackMessage(props.space.name, operatorName.value, msg.id, operatorName.value).catch(() => {})
api.ackMessage(props.space.name, msg.recipient, msg.id, msg.recipient).catch(() => {})
}
}

Expand Down Expand Up @@ -518,12 +529,12 @@ const inlineSending = ref(false)
const inlineSendError = ref<string | null>(null)
const composeRef = ref<HTMLTextAreaElement | null>(null)

// Operator can compose to the other participant (only if operator is in the conversation)
// Operator can compose to the other participant (only if a human agent is in the conversation)
const composeRecipient = computed(() => {
if (!selectedConversation.value) return null
const { participants } = selectedConversation.value
if (!participants.includes(operatorName.value)) return null
return participants.find(p => p !== operatorName.value) ?? null
if (!participants.some(p => humanAgentNames.value.has(p))) return null
return participants.find(p => !humanAgentNames.value.has(p)) ?? null
})

async function sendInlineCompose() {
Expand Down
3 changes: 3 additions & 0 deletions internal/coordinator/handlers_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,9 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, spac

s.mu.Lock()
canonical := resolveAgentName(ks, agentName)
// Canonicalize sender name so "Cto" and "cto" resolve to the same conversation thread.
senderName = resolveAgentName(ks, senderName)
messageReq.Sender = senderName

var recipients []string
if scope == "subtree" {
Expand Down
3 changes: 3 additions & 0 deletions internal/coordinator/mcp_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,9 @@ func (s *Server) addToolSendMessage(srv *mcp.Server) {

s.mu.Lock()
canonical := resolveAgentName(ks, targetName)
// Canonicalize sender so "Cto" and "cto" produce the same conversation thread.
senderName = resolveAgentName(ks, senderName)
msgReq.Sender = senderName
ag := ks.agentStatus(canonical)
if ag == nil {
ag = &AgentUpdate{
Expand Down
Loading