Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export abstract class ChatCollapsibleContentPart extends Disposable implements I

abstract hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean;

private updateAriaLabel(element: HTMLElement, label: string, expanded?: boolean): void {
protected updateAriaLabel(element: HTMLElement, label: string, expanded?: boolean): void {
element.ariaLabel = label;
element.ariaExpanded = String(expanded);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen
// Persistent title elements for shimmer
private titleShimmerSpan: HTMLElement | undefined;
private titleDetailContainer: HTMLElement | undefined;
// Inline model name label shown in the collapsed title (e.g. "GPT-4o")
private modelLabelSpan: HTMLElement | undefined;
private readonly _titleDetailRendered = this._register(new MutableDisposable<IRenderedMarkdown>());

/**
Expand Down Expand Up @@ -232,6 +234,8 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen
this.titleShimmerSpan = $('span.chat-thinking-title-shimmer');
this.titleShimmerSpan.textContent = initialTitle;
labelElement.appendChild(this.titleShimmerSpan);
this.updateModelLabel(labelElement);
this.updateAriaLabel(this._collapseButton.element, initialTitle, this.isExpanded());
}

// Note: wrapper is created lazily in initContent(), so we can't set its style here
Expand Down Expand Up @@ -508,8 +512,8 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen
descSpan.textContent = ` ${this.description}`;
labelElement.appendChild(descSpan);

this._collapseButton.element.ariaLabel = shimmerText;
this._collapseButton.element.ariaExpanded = String(this.isExpanded());
this.updateModelLabel(labelElement);
this.updateAriaLabel(this._collapseButton.element, shimmerText, this.isExpanded());
return;
}

Expand Down Expand Up @@ -545,8 +549,69 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen
}

const fullLabel = `${shimmerText}${toolCallText}`;
this._collapseButton.element.ariaLabel = fullLabel;
this._collapseButton.element.ariaExpanded = String(this.isExpanded());
this.updateModelLabel(labelElement);
this.updateAriaLabel(this._collapseButton.element, fullLabel, this.isExpanded());
}

/**
* Creates, updates, or removes the inline model name label in the collapsed
* title. The label is appended last so it appears after the description and
* any tool-call detail. It is updated in place to avoid duplication when the
* title is re-rendered, and removed when no model name is available.
*/
private updateModelLabel(labelElement: HTMLElement): void {
if (this.modelName) {
if (!this.modelLabelSpan || this.modelLabelSpan.parentElement !== labelElement) {
this.modelLabelSpan = $('span.chat-subagent-model-label');
labelElement.appendChild(this.modelLabelSpan);
} else if (labelElement.lastChild !== this.modelLabelSpan) {
// Ensure it stays the last child so it renders after the title detail.
labelElement.appendChild(this.modelLabelSpan);
}
this.modelLabelSpan.textContent = this.modelName;
} else if (this.modelLabelSpan) {
this.modelLabelSpan.remove();
this.modelLabelSpan = undefined;
}
}

/**
* Centralizes aria-label generation for the collapse button so that the
* model name is announced to screen readers alongside the visible title.
*
* The base label is derived from what is actually rendered in the title
* (excluding the inline model label) rather than the caller-provided
* `label`. This keeps the accessible name in sync with the visible title
* even when the base class re-applies its captured initial title from the
* expand/collapse autorun after `updateTitle()` has changed it.
*/
protected override updateAriaLabel(element: HTMLElement, label: string, expanded?: boolean): void {
const baseLabel = this.getRenderedTitleText() || label;
const ariaLabel = this.modelName
? localize('chat.subagent.ariaWithModel', '{0}, model {1}', baseLabel, this.modelName)
: baseLabel;
super.updateAriaLabel(element, ariaLabel, expanded);
}

/**
* Returns the text currently rendered in the collapse button's title,
* excluding the inline model label span (which is announced separately via
* the aria-label). Returns an empty string when the title has not been
* rendered yet.
*/
private getRenderedTitleText(): string {
const labelElement = this._collapseButton?.labelElement;
if (!labelElement) {
return '';
}
let text = '';
for (const child of labelElement.childNodes) {
if (child === this.modelLabelSpan) {
continue;
}
text += child.textContent ?? '';
}
return text.trim();
}

private updateHover(): void {
Expand Down Expand Up @@ -605,6 +670,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen
if (modelName && modelName !== this.modelName) {
this.modelName = modelName;
this.updateHover();
this.updateTitle();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@
overflow: visible;
}

/* Inline model name shown in the collapsed title (e.g. "GPT-4o") */
.chat-subagent-model-label {
margin-left: 6px;
color: var(--vscode-descriptionForeground);
font-size: var(--vscode-chat-font-size-body-s);
}

/* Prompt and result section styling */
.chat-subagent-section {
padding: 4px 12px 4px 18px;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1725,4 +1725,219 @@ suite('ChatSubagentContentPart', () => {
assert.ok(modelHover, 'Should set up hover with model name after it arrives');
});
});

suite('Model name in title', () => {
function getModelLabels(part: ChatSubagentContentPart): HTMLElement[] {
const button = getCollapseButton(part);
const label = button ? getCollapseButtonLabel(button) : undefined;
if (!label) {
return [];
}
return Array.from(label.querySelectorAll('.chat-subagent-model-label')).filter(isHTMLElement);
}

test('should display the model name inline in the title when completed', () => {
const serializedInvocation = createMockSerializedToolInvocation({
toolSpecificData: {
kind: 'subagent',
description: 'Completed task',
agentName: 'TestAgent',
prompt: 'Do the thing',
result: 'Done',
modelName: 'GPT-4o'
}
});
const context = createMockRenderContext(true);

const part = createPart(serializedInvocation, context);

const labels = getModelLabels(part);
assert.strictEqual(labels.length, 1, 'Should render exactly one model label');
assert.strictEqual(labels[0].textContent, 'GPT-4o', 'Model label should show the model name');
});

test('should display the model name inline in the title while active', () => {
const toolInvocation = createMockToolInvocation({
toolSpecificData: {
kind: 'subagent',
description: 'Working on task',
agentName: 'TestAgent',
prompt: 'Do stuff',
modelName: 'Claude Sonnet 4'
},
stateType: IChatToolInvocation.StateKind.Executing,
});
const context = createMockRenderContext(false);

const part = createPart(toolInvocation, context);

const labels = getModelLabels(part);
assert.strictEqual(labels.length, 1, 'Should render exactly one model label while active');
assert.strictEqual(labels[0].textContent, 'Claude Sonnet 4', 'Model label should show the model name');
});

test('should not display a model label when no model name is available', () => {
const serializedInvocation = createMockSerializedToolInvocation({
toolSpecificData: {
kind: 'subagent',
description: 'Completed task',
agentName: 'TestAgent',
prompt: 'Do the thing',
result: 'Done',
// no modelName
}
});
const context = createMockRenderContext(true);

const part = createPart(serializedInvocation, context);

assert.strictEqual(getModelLabels(part).length, 0, 'Should not render a model label without a model name');
});

test('should include the model name in the aria-label', () => {
const serializedInvocation = createMockSerializedToolInvocation({
toolSpecificData: {
kind: 'subagent',
description: 'Completed task',
agentName: 'TestAgent',
prompt: 'Do the thing',
result: 'Done',
modelName: 'GPT-4o'
}
});
const context = createMockRenderContext(true);

const part = createPart(serializedInvocation, context);

const button = getCollapseButton(part);
const ariaLabel = button?.getAttribute('aria-label') ?? '';
assert.ok(ariaLabel.includes('GPT-4o'), `aria-label should include the model name, got: "${ariaLabel}"`);
});

test('should not include a model name in the aria-label when none is available', () => {
const serializedInvocation = createMockSerializedToolInvocation({
toolSpecificData: {
kind: 'subagent',
description: 'Completed task',
agentName: 'TestAgent',
prompt: 'Do the thing',
result: 'Done',
// no modelName
}
});
const context = createMockRenderContext(true);

const part = createPart(serializedInvocation, context);

const button = getCollapseButton(part);
const label = button ? getCollapseButtonLabel(button) : undefined;
const ariaLabel = button?.getAttribute('aria-label') ?? '';
const renderedTitle = (label?.textContent ?? '').trim();
assert.strictEqual(ariaLabel, renderedTitle,
`aria-label should match the rendered title when no model is available, got: "${ariaLabel}" vs "${renderedTitle}"`);
assert.strictEqual(getModelLabels(part).length, 0, 'Should not render a model label without a model name');
});

test('should add the model name to title and aria-label when it arrives after render', () => {
// Agent host subagents start without a model name; it is reported
// later via the child turns' usage events.
const toolSpecificData: IChatSubagentToolInvocationData = {
kind: 'subagent',
description: 'Working on task',
agentName: 'TestAgent',
};

const toolInvocation = createMockToolInvocation({
toolSpecificData,
stateType: IChatToolInvocation.StateKind.Executing,
});
const context = createMockRenderContext(false);

const part = createPart(toolInvocation, context);

assert.strictEqual(getModelLabels(part).length, 0, 'Should not render a model label before one is reported');

// Model name arrives while the subagent is still running
toolSpecificData.modelName = 'Claude Sonnet 4';
const state = toolInvocation.state as ReturnType<typeof observableValue<IChatToolInvocation.State>>;
state.set(createState(IChatToolInvocation.StateKind.Executing), undefined);

const labels = getModelLabels(part);
assert.strictEqual(labels.length, 1, 'Should render exactly one model label after it arrives (no duplication)');
assert.strictEqual(labels[0].textContent, 'Claude Sonnet 4', 'Model label should show the new model name');

const button = getCollapseButton(part);
const ariaLabel = button?.getAttribute('aria-label') ?? '';
assert.ok(ariaLabel.includes('Claude Sonnet 4'), `aria-label should include the model name after it arrives, got: "${ariaLabel}"`);
});

test('should not duplicate the model label after expand and collapse', () => {
const serializedInvocation = createMockSerializedToolInvocation({
toolSpecificData: {
kind: 'subagent',
description: 'Completed task',
agentName: 'TestAgent',
prompt: 'Do the thing',
result: 'Done',
modelName: 'GPT-4o'
}
});
const context = createMockRenderContext(true);

const part = createPart(serializedInvocation, context);

const button = getCollapseButton(part);
assert.ok(button, 'Should have collapse button');
button.click(); // expand
button.click(); // collapse
part.finalizeTitle();

const labels = getModelLabels(part);
assert.strictEqual(labels.length, 1, 'Should still render exactly one model label after expand/collapse');
assert.strictEqual(labels[0].textContent, 'GPT-4o', 'Model label should still show the model name');
});

test('should keep the aria-label in sync with the updated title after expand/collapse', () => {
const toolSpecificData: IChatSubagentToolInvocationData = {
kind: 'subagent',
description: 'Initial description',
agentName: 'TestAgent',
prompt: 'Do stuff',
modelName: 'GPT-4o',
};

const toolInvocation = createMockToolInvocation({
toolSpecificData,
stateType: IChatToolInvocation.StateKind.Executing,
});
const context = createMockRenderContext(false);

const part = createPart(toolInvocation, context);

// Update the title with a running tool message (this is the more
// accurate, current label).
part.trackToolState(createMockToolInvocation({
toolSpecificData,
stateType: IChatToolInvocation.StateKind.Executing,
invocationMessage: 'Reading config.ts',
}));

const button = getCollapseButton(part);
assert.ok(button, 'Should have collapse button');

// Toggling expansion triggers the base class autorun, which previously
// re-applied the stale initial title to the aria-label.
button.click(); // expand
button.click(); // collapse

const ariaLabel = button.getAttribute('aria-label') ?? '';
assert.ok(ariaLabel.includes('Reading config.ts'),
`aria-label should reflect the updated title after expand/collapse, got: "${ariaLabel}"`);
assert.ok(ariaLabel.includes('GPT-4o'),
`aria-label should still include the model name after expand/collapse, got: "${ariaLabel}"`);
// The model name must not be duplicated in the aria-label.
assert.strictEqual(ariaLabel.indexOf('GPT-4o'), ariaLabel.lastIndexOf('GPT-4o'),
`aria-label should not duplicate the model name, got: "${ariaLabel}"`);
});
});
});