Skip to content

Commit c664fde

Browse files
authored
fix(frontend): label inline teammate transcript messages (#1229)
MessageContent currently derives user-row authorship from explicit subagent context and session ancestry only, so a complete inline `<teammate-message>` block inside a normal parent transcript still renders as `User/U`. This change recognizes complete teammate wrappers in parsed non-code text segments and routes those rows through the existing `Teammate/T` output. Explicit subagent context still wins first, true teammate child sessions keep their ancestry-based label, ordinary user messages remain `User/U`, and wrapper text inside fenced code blocks stays unclassified. The change stays frontend-only in `MessageContent.svelte` and its focused test surface. The transcript shape and reproduction steps came from @jklap's issue report. ![Inline teammate transcript row renders as Teammate](https://raw.githubusercontent.com/rodboev/agentsview/screenshots/agentsview-1223-after.png) Closes #1223 Co-authored-by: Rod Boev <rodboev@users.noreply.github.com>
1 parent 75b9e8b commit c664fde

2 files changed

Lines changed: 226 additions & 2 deletions

File tree

frontend/src/lib/components/content/MessageContent.svelte

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,19 @@
144144
return false;
145145
}
146146
147+
const INLINE_TEAMMATE_MESSAGE_RE =
148+
/<teammate-message\b[^>]*\bteammate_id\s*=\s*(?:"[^"]+"|'[^']+'|[^\s>]+)[^>]*>[\s\S]*?<\/teammate-message\s*>/;
149+
150+
let hasInlineTeammateMessage = $derived(
151+
isUser &&
152+
!isSubagentContext &&
153+
segments.some(
154+
(segment) =>
155+
segment.type === "text" &&
156+
INLINE_TEAMMATE_MESSAGE_RE.test(segment.content),
157+
),
158+
);
159+
147160
/** Classify the session kind, walking the parent chain. */
148161
let sessionKind = $derived.by((): "teammate" | "subagent" | "user" => {
149162
const s = owningSession;
@@ -158,16 +171,17 @@
158171
let roleLabel = $derived.by(() => {
159172
if (!isUser) return m.message_content_role_assistant();
160173
if (isSubagentContext) return m.message_content_role_agent();
161-
if (sessionKind === "teammate") return m.message_content_role_teammate();
162174
if (sessionKind === "subagent") return m.message_content_role_agent();
175+
if (sessionKind === "teammate" || hasInlineTeammateMessage)
176+
return m.message_content_role_teammate();
163177
return m.message_content_role_user();
164178
});
165179
166180
let roleIcon = $derived.by(() => {
167181
if (!isUser) return "A";
168182
if (isSubagentContext) return "S";
169-
if (sessionKind === "teammate") return "T";
170183
if (sessionKind === "subagent") return "S";
184+
if (sessionKind === "teammate" || hasInlineTeammateMessage) return "T";
171185
return "U";
172186
});
173187

frontend/src/lib/components/content/MessageContent.test.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,39 @@ function makeMessage(
125125
};
126126
}
127127

128+
function makeSession(
129+
overrides: Partial<Session> = {},
130+
): Session {
131+
return {
132+
id: "session-1",
133+
agent: "claude",
134+
project: "proj-a",
135+
machine: "test",
136+
first_message: "hello",
137+
started_at: "2026-02-20T12:30:00Z",
138+
ended_at: "2026-02-20T12:31:00Z",
139+
message_count: 3,
140+
user_message_count: 2,
141+
total_output_tokens: 0,
142+
peak_context_tokens: 0,
143+
is_automated: false,
144+
created_at: "2026-02-20T12:30:00Z",
145+
...overrides,
146+
} as Session;
147+
}
148+
149+
async function renderRole(
150+
message: MessageWithTokenFlags,
151+
props: Record<string, unknown> = {},
152+
) {
153+
const component = mount(MessageContent, {
154+
target: document.body,
155+
props: { message, ...props },
156+
});
157+
await tick();
158+
return component;
159+
}
160+
128161
afterEach(() => {
129162
setLocale("en");
130163
document.body.innerHTML = "";
@@ -140,6 +173,183 @@ beforeEach(() => {
140173
});
141174

142175
describe("MessageContent", () => {
176+
it("labels inline teammate transcript messages as Teammate", async () => {
177+
const content = `Another Claude session sent a message:
178+
<teammate-message teammate_id="batch-d-browser" color="pink" summary="Batch D complete; item 9 needs delegation">
179+
Batch D (browser/picker/tabs/media-monitor) is done...
180+
</teammate-message>`;
181+
sessionsState.sessions = [makeSession()];
182+
sessionsState.activeSession = sessionsState.sessions[0]!;
183+
184+
const component = await renderRole(
185+
makeMessage({
186+
id: 10,
187+
role: "user",
188+
content,
189+
content_length: content.length,
190+
}),
191+
);
192+
193+
expect(document.querySelector(".role-label")?.textContent?.trim()).toBe(
194+
"Teammate",
195+
);
196+
expect(document.querySelector(".role-icon")?.textContent?.trim()).toBe(
197+
"T",
198+
);
199+
unmount(component);
200+
});
201+
202+
it("keeps ordinary user prompts labeled as User", async () => {
203+
sessionsState.sessions = [makeSession()];
204+
const component = await renderRole(
205+
makeMessage({ id: 11, role: "user", content: "Please summarize this." }),
206+
);
207+
208+
expect(document.querySelector(".role-label")?.textContent?.trim()).toBe(
209+
"User",
210+
);
211+
expect(document.querySelector(".role-icon")?.textContent?.trim()).toBe(
212+
"U",
213+
);
214+
unmount(component);
215+
});
216+
217+
it("keeps teammate ancestry rows labeled as Teammate", async () => {
218+
sessionsState.sessions = [
219+
makeSession({
220+
id: "teammate-session",
221+
first_message: "<teammate-message>hello</teammate-message>",
222+
}),
223+
];
224+
const component = await renderRole(
225+
makeMessage({ id: 12, role: "user", session_id: "teammate-session" }),
226+
);
227+
228+
expect(document.querySelector(".role-label")?.textContent?.trim()).toBe(
229+
"Teammate",
230+
);
231+
expect(document.querySelector(".role-icon")?.textContent?.trim()).toBe(
232+
"T",
233+
);
234+
unmount(component);
235+
});
236+
237+
it("keeps subagent ancestry rows labeled as Agent when inline teammate markup is present", async () => {
238+
const content = '<teammate-message teammate_id="batch-d-browser">reply</teammate-message>';
239+
sessionsState.sessions = [
240+
makeSession({
241+
id: "subagent-session",
242+
relationship_type: "subagent",
243+
}),
244+
];
245+
const component = await renderRole(
246+
makeMessage({
247+
id: 13,
248+
role: "user",
249+
session_id: "subagent-session",
250+
content,
251+
content_length: content.length,
252+
}),
253+
);
254+
255+
expect(document.querySelector(".role-label")?.textContent?.trim()).toBe(
256+
"Agent",
257+
);
258+
expect(document.querySelector(".role-icon")?.textContent?.trim()).toBe(
259+
"S",
260+
);
261+
unmount(component);
262+
});
263+
264+
it("does not relabel teammate wrappers inside fenced code blocks", async () => {
265+
const content = "```xml\n<teammate-message teammate_id=\"batch-d-browser\">\nreply\n</teammate-message>\n```";
266+
sessionsState.sessions = [makeSession()];
267+
const component = await renderRole(
268+
makeMessage({
269+
id: 14,
270+
role: "user",
271+
content,
272+
content_length: content.length,
273+
}),
274+
);
275+
276+
expect(document.querySelector(".role-label")?.textContent?.trim()).toBe(
277+
"User",
278+
);
279+
expect(document.querySelector(".role-icon")?.textContent?.trim()).toBe(
280+
"U",
281+
);
282+
unmount(component);
283+
});
284+
285+
it("keeps inline teammate, ancestry, subagent, and code-fence rows separated", async () => {
286+
const cases = [
287+
{
288+
content: '<teammate-message teammate_id="t">reply</teammate-message>',
289+
session: makeSession(),
290+
props: {},
291+
label: "Teammate",
292+
icon: "T",
293+
},
294+
{
295+
content: "ordinary",
296+
session: makeSession({
297+
id: "teammate-session",
298+
first_message: "<teammate-message>hello</teammate-message>",
299+
}),
300+
props: {},
301+
label: "Teammate",
302+
icon: "T",
303+
},
304+
{
305+
content: '<teammate-message teammate_id="t">reply</teammate-message>',
306+
session: makeSession({
307+
id: "subagent-session",
308+
relationship_type: "subagent",
309+
}),
310+
props: {},
311+
label: "Agent",
312+
icon: "S",
313+
},
314+
{
315+
content: "ordinary",
316+
session: makeSession(),
317+
props: { isSubagentContext: true },
318+
label: "Agent",
319+
icon: "S",
320+
},
321+
{
322+
content: "```xml\n<teammate-message teammate_id=\"t\">reply</teammate-message>\n```",
323+
session: makeSession(),
324+
props: {},
325+
label: "User",
326+
icon: "U",
327+
},
328+
];
329+
330+
for (const [index, testCase] of cases.entries()) {
331+
document.body.innerHTML = "";
332+
sessionsState.sessions = [testCase.session];
333+
const component = await renderRole(
334+
makeMessage({
335+
id: 100 + index,
336+
role: "user",
337+
session_id: testCase.session.id,
338+
content: testCase.content,
339+
content_length: testCase.content.length,
340+
}),
341+
testCase.props,
342+
);
343+
expect(document.querySelector(".role-label")?.textContent?.trim()).toBe(
344+
testCase.label,
345+
);
346+
expect(document.querySelector(".role-icon")?.textContent?.trim()).toBe(
347+
testCase.icon,
348+
);
349+
unmount(component);
350+
}
351+
});
352+
143353
it("renders message controls in Simplified Chinese without translating content", async () => {
144354
setLocale("zh-CN");
145355
const component = mount(MessageContent, {

0 commit comments

Comments
 (0)