feat(chat): implement subagent input and compact boundary display items

- Added support for rendering 'subagent_input' and 'compact_boundary' types in the chat display components.
- Introduced a new `MarkdownViewer` for displaying content in both item types.
- Enhanced the `MetricsPill` and `SubagentItem` components to include phase breakdowns and isolated usage metrics.
- Updated the `AIGroupDisplayItem` type to accommodate new item types and their properties.
- Implemented logic to compute and display token consumption across multiple phases for subagents.
This commit is contained in:
matt 2026-02-16 22:13:24 +09:00
parent 21d4e1c98e
commit 056351b8a6
8 changed files with 530 additions and 20 deletions

View file

@ -1,11 +1,25 @@
import React, { useCallback, useState } from 'react';
import {
CODE_BG,
CODE_BORDER,
COLOR_TEXT_MUTED,
TOOL_CALL_BG,
TOOL_CALL_BORDER,
TOOL_CALL_TEXT,
} from '@renderer/constants/cssVariables';
import { formatTokensCompact } from '@renderer/utils/formatters';
import { format } from 'date-fns';
import { ChevronRight, Layers, MailOpen } from 'lucide-react';
import { BaseItem } from './items/BaseItem';
import { LinkedToolItem } from './items/LinkedToolItem';
import { SlashItem } from './items/SlashItem';
import { SubagentItem } from './items/SubagentItem';
import { TeammateMessageItem } from './items/TeammateMessageItem';
import { TextItem } from './items/TextItem';
import { ThinkingItem } from './items/ThinkingItem';
import { MarkdownViewer } from './viewers/MarkdownViewer';
import type { AIGroupDisplayItem } from '@renderer/types/groups';
import type { TriggerColor } from '@shared/constants/triggerColors';
@ -208,6 +222,103 @@ export const DisplayItemList = ({
break;
}
case 'subagent_input': {
itemKey = `input-${index}`;
const inputContent = item.content;
const inputTokenCount = item.tokenCount;
element = (
<BaseItem
icon={<MailOpen className="size-4" />}
label="Input"
summary={truncateText(inputContent, 80)}
tokenCount={inputTokenCount}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
>
<MarkdownViewer content={inputContent} copyable />
</BaseItem>
);
break;
}
case 'compact_boundary': {
itemKey = `compact-${index}`;
const compactContent = item.content;
const compactExpanded = expandedItemIds.has(itemKey);
element = (
<div>
<button
onClick={() => onItemClick(itemKey)}
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 transition-all duration-200"
style={{
backgroundColor: TOOL_CALL_BG,
border: `1px solid ${TOOL_CALL_BORDER}`,
}}
aria-expanded={compactExpanded}
>
<div
className="flex shrink-0 items-center gap-1.5"
style={{ color: TOOL_CALL_TEXT }}
>
<ChevronRight
size={14}
className={`transition-transform duration-200 ${compactExpanded ? 'rotate-90' : ''}`}
/>
<Layers size={14} />
</div>
<span className="shrink-0 text-xs font-medium" style={{ color: TOOL_CALL_TEXT }}>
Compacted
</span>
{item.tokenDelta && (
<span
className="min-w-0 truncate text-[11px] tabular-nums"
style={{ color: COLOR_TEXT_MUTED }}
>
{formatTokensCompact(item.tokenDelta.preCompactionTokens)} {' '}
{formatTokensCompact(item.tokenDelta.postCompactionTokens)}
<span style={{ color: '#4ade80' }}>
{' '}
({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
</span>
</span>
)}
<span
className="shrink-0 rounded px-1.5 py-0.5 text-[10px]"
style={{
backgroundColor: 'rgba(99, 102, 241, 0.15)',
color: '#818cf8',
}}
>
Phase {item.phaseNumber}
</span>
<span
className="ml-auto shrink-0 text-[11px]"
style={{ color: COLOR_TEXT_MUTED }}
>
{format(new Date(item.timestamp), 'h:mm:ss a')}
</span>
</button>
{compactExpanded && compactContent && (
<div
className="mt-1 overflow-hidden rounded-lg"
style={{
backgroundColor: CODE_BG,
border: `1px solid ${CODE_BORDER}`,
}}
>
<div
className="max-h-64 overflow-y-auto border-l-2 px-3 py-2"
style={{ borderColor: 'var(--chat-ai-border)' }}
>
<MarkdownViewer content={compactContent} copyable />
</div>
</div>
)}
</div>
);
break;
}
default:
return null;
}

View file

@ -1,9 +1,24 @@
import React, { useState } from 'react';
import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import {
CARD_ICON_MUTED,
CODE_BG,
CODE_BORDER,
COLOR_TEXT_MUTED,
TOOL_CALL_BG,
TOOL_CALL_BORDER,
TOOL_CALL_TEXT,
} from '@renderer/constants/cssVariables';
import { truncateText } from '@renderer/utils/aiGroupEnhancer';
import { formatTokensCompact } from '@renderer/utils/formatters';
import { format } from 'date-fns';
import { ChevronRight, Layers, MailOpen } from 'lucide-react';
import { MarkdownViewer } from '../viewers/MarkdownViewer';
import { BaseItem } from './BaseItem';
import { LinkedToolItem } from './LinkedToolItem';
import { TeammateMessageItem } from './TeammateMessageItem';
import { TextItem } from './TextItem';
import { ThinkingItem } from './ThinkingItem';
@ -142,6 +157,115 @@ export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
</div>
);
case 'subagent_input': {
const itemId = `subagent-input-${index}`;
const isExpanded = expandedItemId === itemId;
return (
<BaseItem
key={itemId}
icon={<MailOpen className="size-4" />}
label="Input"
summary={truncateText(item.content, 80)}
tokenCount={item.tokenCount}
onClick={() => handleItemClick(itemId)}
isExpanded={isExpanded}
>
<MarkdownViewer content={item.content} copyable />
</BaseItem>
);
}
case 'teammate_message': {
const itemId = `subagent-teammate-${item.teammateMessage.id}-${index}`;
const isExpanded = expandedItemId === itemId;
return (
<TeammateMessageItem
key={itemId}
teammateMessage={item.teammateMessage}
onClick={() => handleItemClick(itemId)}
isExpanded={isExpanded}
/>
);
}
case 'compact_boundary': {
const itemId = `subagent-compact-${index}`;
const isExpanded = expandedItemId === itemId;
return (
<div key={itemId}>
{/* Header — matches CompactBoundary.tsx amber styling */}
<button
onClick={() => handleItemClick(itemId)}
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 transition-all duration-200"
style={{
backgroundColor: TOOL_CALL_BG,
border: `1px solid ${TOOL_CALL_BORDER}`,
}}
aria-expanded={isExpanded}
>
<div
className="flex shrink-0 items-center gap-1.5"
style={{ color: TOOL_CALL_TEXT }}
>
<ChevronRight
size={14}
className={`transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
/>
<Layers size={14} />
</div>
<span className="shrink-0 text-xs font-medium" style={{ color: TOOL_CALL_TEXT }}>
Compacted
</span>
{item.tokenDelta && (
<span
className="min-w-0 truncate text-[11px] tabular-nums"
style={{ color: COLOR_TEXT_MUTED }}
>
{formatTokensCompact(item.tokenDelta.preCompactionTokens)} {' '}
{formatTokensCompact(item.tokenDelta.postCompactionTokens)}
<span style={{ color: '#4ade80' }}>
{' '}
({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
</span>
</span>
)}
<span
className="shrink-0 rounded px-1.5 py-0.5 text-[10px]"
style={{
backgroundColor: 'rgba(99, 102, 241, 0.15)',
color: '#818cf8',
}}
>
Phase {item.phaseNumber}
</span>
<span
className="ml-auto shrink-0 text-[11px]"
style={{ color: COLOR_TEXT_MUTED }}
>
{format(new Date(item.timestamp), 'h:mm:ss a')}
</span>
</button>
{/* Expanded content */}
{isExpanded && item.content && (
<div
className="mt-1 overflow-hidden rounded-lg"
style={{
backgroundColor: CODE_BG,
border: `1px solid ${CODE_BORDER}`,
}}
>
<div
className="max-h-64 overflow-y-auto border-l-2 px-3 py-2"
style={{ borderColor: 'var(--chat-ai-border)' }}
>
<MarkdownViewer content={item.content} copyable />
</div>
</div>
)}
</div>
);
}
default:
return null;
}

View file

@ -15,6 +15,7 @@ import { formatTokensCompact } from '@renderer/utils/formatters';
// =============================================================================
// Types
// =============================================================================
import type { PhaseTokenBreakdown } from '@renderer/types/data';
interface MetricsPillProps {
mainSessionImpact?: {
@ -30,6 +31,10 @@ interface MetricsPillProps {
};
/** Label override for the right segment (e.g. "Context Window" for team members) */
isolatedLabel?: string;
/** Override isolated total (for multi-phase total consumption) */
isolatedOverride?: number;
/** Phase breakdown for tooltip (shown when multiple phases exist) */
phaseBreakdown?: PhaseTokenBreakdown[];
}
// =============================================================================
@ -40,6 +45,8 @@ export const MetricsPill = ({
mainSessionImpact,
lastUsage,
isolatedLabel,
isolatedOverride,
phaseBreakdown,
}: Readonly<MetricsPillProps>): React.ReactElement | null => {
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({});
@ -47,14 +54,21 @@ export const MetricsPill = ({
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasMainImpact = mainSessionImpact && mainSessionImpact.totalTokens > 0;
const hasIsolated = lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0;
const hasIsolated =
isolatedOverride != null
? isolatedOverride > 0
: lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0;
const isolatedTotal = lastUsage
? lastUsage.input_tokens +
lastUsage.output_tokens +
(lastUsage.cache_read_input_tokens ?? 0) +
(lastUsage.cache_creation_input_tokens ?? 0)
: 0;
const isolatedTotal =
isolatedOverride ??
(lastUsage
? lastUsage.input_tokens +
lastUsage.output_tokens +
(lastUsage.cache_read_input_tokens ?? 0) +
(lastUsage.cache_creation_input_tokens ?? 0)
: 0);
const hasPhases = phaseBreakdown && phaseBreakdown.length > 1;
const clearHideTimeout = (): void => {
if (hideTimeoutRef.current) {
@ -109,7 +123,7 @@ export const MetricsPill = ({
const mainValue = hasMainImpact ? formatTokensCompact(mainSessionImpact.totalTokens) : null;
const isolatedValue = hasIsolated ? formatTokensCompact(isolatedTotal) : null;
const rightLabel = isolatedLabel ?? 'Isolated Usage';
const rightLabel = isolatedLabel ?? 'Subagent Context';
return (
<>
@ -160,6 +174,29 @@ export const MetricsPill = ({
</span>
</div>
)}
{hasPhases &&
phaseBreakdown.map((phase) => (
<div
key={phase.phaseNumber}
className="flex items-center justify-between gap-3 pl-2"
>
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
Phase {phase.phaseNumber}
</span>
<span
className="font-mono text-[10px] tabular-nums"
style={{ color: CARD_ICON_MUTED }}
>
{formatTokensCompact(phase.peakTokens)}
{phase.postCompaction != null && (
<span style={{ color: '#4ade80' }}>
{' '}
{formatTokensCompact(phase.postCompaction)}
</span>
)}
</span>
</div>
))}
<div
className="mt-1 pt-1.5 text-[10px]"
style={{ borderTop: `1px solid ${TAG_BORDER}`, color: CARD_ICON_MUTED }}

View file

@ -19,7 +19,8 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTabUI } from '@renderer/hooks/useTabUI';
import { useStore } from '@renderer/store';
import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer';
import { formatDuration } from '@renderer/utils/formatters';
import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers';
import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters';
import { getHighlightProps, type TriggerColor } from '@shared/constants/triggerColors';
import { getModelColorClass, parseModelString } from '@shared/utils/modelParser';
import {
@ -154,6 +155,12 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
return null;
}, [subagent.messages]);
// Multi-phase context breakdown (for subagents with compaction)
const phaseData = useMemo(() => {
if (!subagent.messages?.length) return null;
return computeSubagentPhaseBreakdown(subagent.messages);
}, [subagent.messages]);
// Search expansion
const searchExpandedSubagentIds = useStore((s) => s.searchExpandedSubagentIds);
const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId);
@ -196,12 +203,15 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
// Computed values for metrics
const hasMainImpact = subagent.mainSessionImpact && subagent.mainSessionImpact.totalTokens > 0;
const hasIsolated = lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0;
const isolatedTotal = lastUsage
? lastUsage.input_tokens +
lastUsage.output_tokens +
(lastUsage.cache_read_input_tokens ?? 0) +
(lastUsage.cache_creation_input_tokens ?? 0)
: 0;
const isMultiPhase = phaseData != null && phaseData.compactionCount > 0;
const isolatedTotal = isMultiPhase
? phaseData.totalConsumption
: lastUsage
? lastUsage.input_tokens +
lastUsage.output_tokens +
(lastUsage.cache_read_input_tokens ?? 0) +
(lastUsage.cache_creation_input_tokens ?? 0)
: 0;
// Shutdown-only team activations: minimal inline row (no metrics, no expand)
if (isShutdownOnly && teamColors && subagent.team) {
@ -338,6 +348,10 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
mainSessionImpact={subagent.team ? undefined : subagent.mainSessionImpact}
lastUsage={lastUsage ?? undefined}
isolatedLabel={subagent.team ? 'Context Window' : undefined}
isolatedOverride={
phaseData && phaseData.compactionCount > 0 ? phaseData.totalConsumption : undefined
}
phaseBreakdown={phaseData?.phases}
/>
{/* Duration */}
@ -453,7 +467,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
<div className="flex items-center gap-2">
<CircleDot className="size-3" style={{ color: 'rgba(56, 189, 248, 0.7)' }} />
<span className="text-xs" style={{ color: COLOR_TEXT_SECONDARY }}>
{subagent.team ? 'Context Window' : 'Isolated Usage'}
{subagent.team ? 'Context Window' : 'Subagent Context'}
</span>
</div>
<span
@ -464,6 +478,28 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
</span>
</div>
)}
{/* Per-phase breakdown when multi-phase */}
{isMultiPhase &&
phaseData.phases.map((phase) => (
<div key={phase.phaseNumber} className="flex items-center justify-between pl-5">
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
Phase {phase.phaseNumber}
</span>
<span
className="font-mono text-[11px] tabular-nums"
style={{ color: CARD_ICON_MUTED }}
>
{formatTokensCompact(phase.peakTokens)}
{phase.postCompaction != null && (
<span style={{ color: '#4ade80' }}>
{' '}
{formatTokensCompact(phase.postCompaction)}
</span>
)}
</span>
</div>
))}
</div>
</div>
)}

View file

@ -253,7 +253,15 @@ export type AIGroupDisplayItem =
| { type: 'subagent'; subagent: Process }
| { type: 'output'; content: string; timestamp: Date; tokenCount?: number }
| { type: 'slash'; slash: SlashItem }
| { type: 'teammate_message'; teammateMessage: TeammateMessage };
| { type: 'teammate_message'; teammateMessage: TeammateMessage }
| { type: 'subagent_input'; content: string; timestamp: Date; tokenCount?: number }
| {
type: 'compact_boundary';
content: string;
timestamp: Date;
tokenDelta?: CompactionTokenDelta;
phaseNumber: number;
};
/**
* The last output in an AI Group - what user sees as "the answer".

View file

@ -7,7 +7,7 @@
import { createLogger } from '@shared/utils/logger';
import { estimateTokens } from '@shared/utils/tokenFormatting';
import type { Process } from '../types/data';
import type { ParsedMessage, PhaseTokenBreakdown, Process } from '../types/data';
import type { LinkedToolItem } from '../types/groups';
const logger = createLogger('Util:aiGroupHelpers');
@ -98,3 +98,111 @@ export function attachMainSessionImpact(
}
return subagents;
}
/**
* Computes multi-phase context breakdown for a subagent session.
* Mirrors the algorithm in src/main/utils/jsonl.ts:500-576.
*
* Tracks assistant input tokens across compaction events to compute
* per-phase contribution and total consumption across all phases.
*
* @param messages - Subagent's ParsedMessages
* @returns Phase breakdown with total consumption, or null if no usage data
*/
export function computeSubagentPhaseBreakdown(messages: ParsedMessage[]): {
phases: PhaseTokenBreakdown[];
totalConsumption: number;
compactionCount: number;
} | null {
let lastMainAssistantInputTokens = 0;
let awaitingPostCompaction = false;
const compactionPhases: { pre: number; post: number }[] = [];
for (const msg of messages) {
// Track assistant input tokens.
// Unlike jsonl.ts, we don't filter by isSidechain here because subagent messages
// all have isSidechain=true (from the parent session's perspective).
if (msg.type === 'assistant' && msg.model !== '<synthetic>') {
const inputTokens =
(msg.usage?.input_tokens ?? 0) +
(msg.usage?.cache_read_input_tokens ?? 0) +
(msg.usage?.cache_creation_input_tokens ?? 0);
if (inputTokens > 0) {
if (awaitingPostCompaction && compactionPhases.length > 0) {
compactionPhases[compactionPhases.length - 1].post = inputTokens;
awaitingPostCompaction = false;
}
lastMainAssistantInputTokens = inputTokens;
}
}
// Detect compaction events
if (msg.isCompactSummary) {
compactionPhases.push({ pre: lastMainAssistantInputTokens, post: 0 });
awaitingPostCompaction = true;
}
}
if (lastMainAssistantInputTokens <= 0) {
return null;
}
let phaseBreakdown: PhaseTokenBreakdown[];
if (compactionPhases.length === 0) {
// No compaction: single phase
phaseBreakdown = [
{
phaseNumber: 1,
contribution: lastMainAssistantInputTokens,
peakTokens: lastMainAssistantInputTokens,
},
];
return {
phases: phaseBreakdown,
totalConsumption: lastMainAssistantInputTokens,
compactionCount: 0,
};
}
phaseBreakdown = [];
let total = 0;
// Phase 1: tokens up to first compaction
const phase1Contribution = compactionPhases[0].pre;
total += phase1Contribution;
phaseBreakdown.push({
phaseNumber: 1,
contribution: phase1Contribution,
peakTokens: compactionPhases[0].pre,
postCompaction: compactionPhases[0].post,
});
// Middle phases: contribution = pre[i] - post[i-1]
for (let i = 1; i < compactionPhases.length; i++) {
const contribution = compactionPhases[i].pre - compactionPhases[i - 1].post;
total += contribution;
phaseBreakdown.push({
phaseNumber: i + 1,
contribution,
peakTokens: compactionPhases[i].pre,
postCompaction: compactionPhases[i].post,
});
}
// Last phase: final tokens - last post-compaction
const lastPhase = compactionPhases[compactionPhases.length - 1];
const lastContribution = lastMainAssistantInputTokens - lastPhase.post;
total += lastContribution;
phaseBreakdown.push({
phaseNumber: compactionPhases.length + 1,
contribution: lastContribution,
peakTokens: lastMainAssistantInputTokens,
});
return {
phases: phaseBreakdown,
totalConsumption: total,
compactionCount: compactionPhases.length,
};
}

View file

@ -29,6 +29,9 @@ function getDisplayItemTimestamp(item: AIGroupDisplayItem): Date {
return toDate(item.slash.timestamp);
case 'teammate_message':
return toDate(item.teammateMessage.timestamp);
case 'subagent_input':
case 'compact_boundary':
return toDate(item.timestamp);
}
}
@ -320,10 +323,76 @@ export function buildDisplayItemsFromMessages(
subagents.map((s) => s.parentTaskId).filter((id): id is string => !!id)
);
// Track compaction events for compact_boundary display items
let compactionCount = 0;
// Helper to get the last assistant's total input tokens before a given index
// Note: don't filter by isSidechain — subagent messages all have isSidechain=true
function getLastAssistantInputTokens(idx: number): number {
for (let i = idx - 1; i >= 0; i--) {
const m = messages[i];
if (m.type === 'assistant' && m.usage && m.model !== '<synthetic>') {
return (
(m.usage.input_tokens ?? 0) +
(m.usage.cache_read_input_tokens ?? 0) +
(m.usage.cache_creation_input_tokens ?? 0)
);
}
}
return 0;
}
// Helper to get the first assistant's total input tokens after a given index
function getFirstAssistantInputTokens(idx: number): number {
for (let i = idx + 1; i < messages.length; i++) {
const m = messages[i];
if (m.type === 'assistant' && m.usage && m.model !== '<synthetic>') {
return (
(m.usage.input_tokens ?? 0) +
(m.usage.cache_read_input_tokens ?? 0) +
(m.usage.cache_creation_input_tokens ?? 0)
);
}
}
return 0;
}
// First pass: collect tool calls and tool results from messages
for (const msg of messages) {
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
const msg = messages[messageIndex];
const msgTimestamp = toDate(msg.timestamp);
// Detect compact boundary (before regular user message handling)
if (msg.isCompactSummary) {
const preTokens = getLastAssistantInputTokens(messageIndex);
const postTokens = getFirstAssistantInputTokens(messageIndex);
const rawText =
typeof msg.content === 'string'
? msg.content
: Array.isArray(msg.content)
? msg.content
.filter((b: { type: string; text?: string }) => b.type === 'text')
.map((b: { type: string; text?: string }) => b.text ?? '')
.join('\n\n')
: '';
displayItems.push({
type: 'compact_boundary',
content: rawText,
timestamp: msgTimestamp,
tokenDelta:
preTokens > 0
? {
preCompactionTokens: preTokens,
postCompactionTokens: postTokens,
delta: postTokens - preTokens,
}
: undefined,
phaseNumber: compactionCount + 2,
});
compactionCount++;
continue;
}
// Check for teammate messages (non-meta user messages with <teammate-message> content)
// One user message may contain multiple <teammate-message> blocks
if (msg.type === 'user' && !msg.isMeta) {
@ -354,6 +423,16 @@ export function buildDisplayItemsFromMessages(
}
continue;
}
// Plain-text user message (subagent input prompt)
if (rawText.trim()) {
displayItems.push({
type: 'subagent_input',
content: rawText.trim(),
timestamp: msgTimestamp,
tokenCount: estimateTokens(rawText),
});
}
continue;
}
if (msg.type === 'assistant' && Array.isArray(msg.content)) {

View file

@ -26,6 +26,8 @@ export function buildSummary(items: AIGroupDisplayItem[]): string {
subagent: 0,
slash: 0,
teammate_message: 0,
subagent_input: 0,
compact_boundary: 0,
};
const teammateNames = new Set<string>();
@ -62,6 +64,11 @@ export function buildSummary(items: AIGroupDisplayItem[]): string {
`${counts.teammate_message} teammate ${counts.teammate_message === 1 ? 'message' : 'messages'}`
);
}
if (counts.compact_boundary > 0) {
parts.push(
`${counts.compact_boundary} ${counts.compact_boundary === 1 ? 'compaction' : 'compactions'}`
);
}
return parts.length > 0 ? parts.join(', ') : 'No items';
}