feat: Parse and display status/queue control tags in chat
- Add parseStatusTags() to extract <status mode="..."/> tags - Add parseQueueTags() to extract <queue-next/> tags - StatusInfo component shows agent status changes as info badges - QueueInfo component indicates when reading queued messages - Control-only messages render as centered info lines - Control tags with content show tags above the message 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -130,6 +130,91 @@ function parseThinking(text) {
|
|||||||
return { content, thinking };
|
return { content, thinking };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to extract status tags from content
|
||||||
|
function parseStatusTags(text) {
|
||||||
|
if (!text || typeof text !== 'string') return { content: text || '', statusTags: [] };
|
||||||
|
|
||||||
|
const statusTags = [];
|
||||||
|
// Match self-closing <status .../> tags with attributes
|
||||||
|
const statusRegex = /<status\s+([^/>]*)\/?>/g;
|
||||||
|
let match;
|
||||||
|
while ((match = statusRegex.exec(text)) !== null) {
|
||||||
|
const attrsStr = match[1];
|
||||||
|
const attrs = {};
|
||||||
|
// Parse attributes like mode="working" project="claude-web-ui"
|
||||||
|
const attrRegex = /(\w+)="([^"]*)"/g;
|
||||||
|
let attrMatch;
|
||||||
|
while ((attrMatch = attrRegex.exec(attrsStr)) !== null) {
|
||||||
|
attrs[attrMatch[1]] = attrMatch[2];
|
||||||
|
}
|
||||||
|
if (Object.keys(attrs).length > 0) {
|
||||||
|
statusTags.push(attrs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = text.replace(/<status\s+[^/>]*\/?>/g, '').trim();
|
||||||
|
return { content, statusTags };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract queue-next tags from content
|
||||||
|
function parseQueueTags(text) {
|
||||||
|
if (!text || typeof text !== 'string') return { content: text || '', queueTags: 0 };
|
||||||
|
|
||||||
|
// Count <queue-next/> or <queue-next /> tags
|
||||||
|
const queueRegex = /<queue-next\s*\/?>/g;
|
||||||
|
const matches = text.match(queueRegex);
|
||||||
|
const queueTags = matches ? matches.length : 0;
|
||||||
|
|
||||||
|
const content = text.replace(/<queue-next\s*\/?>/g, '').trim();
|
||||||
|
return { content, queueTags };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status info display component (non-collapsible info badge)
|
||||||
|
// Rendered as a centered info line, similar to system messages
|
||||||
|
function StatusInfo({ statusTags }) {
|
||||||
|
if (!statusTags || statusTags.length === 0) return null;
|
||||||
|
|
||||||
|
// Mode display names and colors
|
||||||
|
const modeConfig = {
|
||||||
|
ready: { label: 'Ready', color: 'text-green-400' },
|
||||||
|
working: { label: 'Working', color: 'text-yellow-400' },
|
||||||
|
focus: { label: 'Focus', color: 'text-orange-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 justify-center message-enter">
|
||||||
|
{statusTags.map((tag, idx) => {
|
||||||
|
const config = modeConfig[tag.mode] || { label: tag.mode, color: 'text-dark-400' };
|
||||||
|
return (
|
||||||
|
<div key={idx} className="flex items-center gap-1.5">
|
||||||
|
<Info className="w-3.5 h-3.5 text-dark-500" />
|
||||||
|
<span className="text-xs text-dark-500">Status set to</span>
|
||||||
|
<span className={`text-xs font-medium ${config.color}`}>{config.label}</span>
|
||||||
|
{tag.project && (
|
||||||
|
<span className="text-xs text-dark-600">({tag.project})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue info display component
|
||||||
|
// Shows when agent is reading next queued message
|
||||||
|
function QueueInfo({ count }) {
|
||||||
|
if (!count || count === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 justify-center message-enter">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Info className="w-3.5 h-3.5 text-dark-500" />
|
||||||
|
<span className="text-xs text-dark-500">Reading next queued message</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Collapsible thinking block component
|
// Collapsible thinking block component
|
||||||
function ThinkingBlock({ thinking }) {
|
function ThinkingBlock({ thinking }) {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
@@ -535,10 +620,14 @@ const Message = memo(function Message({ message, onSendMessage, hostConfig }) {
|
|||||||
if (type === 'assistant') {
|
if (type === 'assistant') {
|
||||||
const withoutReminders = parseSystemReminders(content);
|
const withoutReminders = parseSystemReminders(content);
|
||||||
const withoutThinking = parseThinking(withoutReminders.content);
|
const withoutThinking = parseThinking(withoutReminders.content);
|
||||||
|
const withoutStatus = parseStatusTags(withoutThinking.content);
|
||||||
|
const withoutQueue = parseQueueTags(withoutStatus.content);
|
||||||
return {
|
return {
|
||||||
content: withoutThinking.content,
|
content: withoutQueue.content,
|
||||||
reminders: withoutReminders.reminders,
|
reminders: withoutReminders.reminders,
|
||||||
thinking: withoutThinking.thinking,
|
thinking: withoutThinking.thinking,
|
||||||
|
statusTags: withoutStatus.statusTags,
|
||||||
|
queueTags: withoutQueue.queueTags,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -593,7 +682,19 @@ const Message = memo(function Message({ message, onSendMessage, hostConfig }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 'assistant': {
|
case 'assistant': {
|
||||||
const { content: cleanContent, reminders, thinking } = parsedContent;
|
const { content: cleanContent, reminders, thinking, statusTags, queueTags } = parsedContent;
|
||||||
|
|
||||||
|
// If only control tags (no actual content), show just the info components
|
||||||
|
const hasOnlyControlTags = !cleanContent.trim() && (statusTags.length > 0 || queueTags > 0);
|
||||||
|
if (hasOnlyControlTags) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{statusTags.length > 0 && <StatusInfo statusTags={statusTags} />}
|
||||||
|
{queueTags > 0 && <QueueInfo count={queueTags} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3 message-enter">
|
<div className="flex gap-3 message-enter">
|
||||||
{assistantAvatar?.startsWith('/') ? (
|
{assistantAvatar?.startsWith('/') ? (
|
||||||
@@ -609,6 +710,9 @@ const Message = memo(function Message({ message, onSendMessage, hostConfig }) {
|
|||||||
)}
|
)}
|
||||||
<div className="max-w-[85%]">
|
<div className="max-w-[85%]">
|
||||||
<div className="text-xs text-dark-500 mb-1">{assistantName}</div>
|
<div className="text-xs text-dark-500 mb-1">{assistantName}</div>
|
||||||
|
{/* Control tags shown above the message if there's also content */}
|
||||||
|
{statusTags.length > 0 && cleanContent.trim() && <StatusInfo statusTags={statusTags} />}
|
||||||
|
{queueTags > 0 && cleanContent.trim() && <QueueInfo count={queueTags} />}
|
||||||
<div className="bg-dark-850 rounded-lg rounded-tl-none p-4 border border-dark-700 claude-message inline-block">
|
<div className="bg-dark-850 rounded-lg rounded-tl-none p-4 border border-dark-700 claude-message inline-block">
|
||||||
<ThinkingBlock thinking={thinking} />
|
<ThinkingBlock thinking={thinking} />
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
|
|||||||
Reference in New Issue
Block a user