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:
2026-01-03 19:35:26 +01:00
parent fb7aa922b9
commit 96998b328e

View File

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