Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,7 @@ export function App({
}, []);
const customization = useMemo(
() => ({
composerTagIcons,
renderToolHeaderExtra,
renderWelcomeHeader,
renderWelcomeFooter,
Expand All @@ -956,6 +957,7 @@ export function App({
loadingPhrases,
}),
[
composerTagIcons,
renderToolHeaderExtra,
renderWelcomeHeader,
renderWelcomeFooter,
Expand Down
12 changes: 4 additions & 8 deletions packages/web-shell/client/components/ChatEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,10 @@ import {
type EditorHandle,
type SlashMenuState,
getComposerTagDisplay,
getComposerTagLabel,
getComposerTagValue,
} from '../hooks/useComposerCore';
import { AtMentionPanel } from './AtMentionPanel';
import { cssUrlVar } from '../utils/cssUrlVar';
import { getComposerTagIconUrl } from './composerTagIcons';
import { getComposerTagViewModel } from '../utils/composerTag';
import { ModeIcon } from './ModeIcon';
import { planSlashSectionRows } from '../utils/slashSectionPlan';
import { getModelDisplayName } from '../utils/modelDisplay';
Expand Down Expand Up @@ -1269,12 +1267,10 @@ export const ChatEditor = memo(
} = core.searchState;

const renderComposerTagContent = (tag: WebShellComposerTag) => {
const rawTagLabel = getComposerTagLabel(tag);
const tagValue = getComposerTagValue(tag);
const tagLabel = tag.kind ? '' : rawTagLabel;
const iconUrl = getComposerTagIconUrl(tag.kind, composerTagIcons);
const { tagLabel, tagValue, iconUrl, fallback } =
getComposerTagViewModel(tag, composerTagIcons);
if (!tagLabel && !tagValue) {
return <span className={styles.tagLabel}>{tag.id}</span>;
return <span className={styles.tagLabel}>{fallback}</span>;
}
return (
<>
Expand Down
24 changes: 0 additions & 24 deletions packages/web-shell/client/components/composerTagIcons.test.ts

This file was deleted.

38 changes: 0 additions & 38 deletions packages/web-shell/client/components/composerTagIcons.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,57 @@
word-break: break-word;
}

/* Keep visual styles in sync with ComposerTagWidget in useComposerCore.ts. */
.referenceChip {
display: inline-flex;
max-width: min(44ch, 100%);
min-height: 20px;
align-items: center;
margin: 0 0.25ch;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--secondary);
color: var(--foreground);
font-family: var(--font-mono, monospace);
font-size: 12px;
line-height: 1.2;
vertical-align: baseline;
}

.referenceIcon {
display: block;
width: 12px;
height: 12px;
flex: 0 0 auto;
margin-left: 7px;
background: currentColor;
mask: var(--composer-tag-icon-url) center / contain no-repeat;
-webkit-mask: var(--composer-tag-icon-url) center / contain no-repeat;
}

.referenceLabel,
.referenceValue {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.referenceLabel {
padding: 3px 0 3px 7px;
color: var(--agent-blue-500);
}

.referenceValue {
max-width: 32ch;
padding: 3px 7px 3px 0.5ch;
color: var(--foreground);
}

.referenceIcon + .referenceValue {
padding-left: 0.5ch;
}

.chatContentCollapsed {
max-height: 400px;
overflow: hidden;
Expand Down
53 changes: 53 additions & 0 deletions packages/web-shell/client/components/messages/UserMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,59 @@ describe('UserMessage', () => {
expect(container.textContent).toContain('hello world');
});

it('renders file references as chips by default', () => {
const container = render(<UserMessage content="list @.qwen/ files" />);
const chip = container.querySelector('[title="@.qwen/"]');

expect(chip).not.toBeNull();
expect(chip?.textContent).toBe('.qwen/');
expect(container.textContent).toContain('list .qwen/ files');
});

it('uses configured composer tag icons for default chips', () => {
const container = render(
<WebShellCustomizationProvider
value={{
composerTagIcons: { file: '/custom-file.svg' },
}}
>
<UserMessage content="list @.qwen/ files" />
</WebShellCustomizationProvider>,
);
const icon = container.querySelector<HTMLElement>(
'[title="@.qwen/"] [aria-hidden="true"]',
);

expect(icon).not.toBeNull();
expect(icon?.style.getPropertyValue('--composer-tag-icon-url')).toBe(
'url("/custom-file.svg")',
);
});

it('renders extension and MCP references as chips by default', () => {
const container = render(
<UserMessage content="@ext:browser and @mcp:docs" />,
);

expect(container.querySelector('[title="@ext:browser"]')).not.toBeNull();
expect(container.querySelector('[title="@mcp:docs"]')).not.toBeNull();
expect(container.textContent).toContain('browser and docs');
});

it('does not render inline email addresses as chips', () => {
const container = render(<UserMessage content="mail me at a@b.test" />);

expect(container.querySelector('[title="@b.test"]')).toBeNull();
expect(container.textContent).toContain('a@b.test');
});

it('keeps custom provider references as text by default', () => {
const container = render(<UserMessage content="open @dataset:users" />);

expect(container.querySelector('[title="@dataset:users"]')).toBeNull();
expect(container.textContent).toContain('open @dataset:users');
});

it('renders images when provided', () => {
const container = render(
<UserMessage
Expand Down
80 changes: 75 additions & 5 deletions packages/web-shell/client/components/messages/UserMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import {
Fragment,
memo,
useMemo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
getComposerTagViewModel,
splitComposerTagContent,
} from '../../utils/composerTag';
import { isSafeImageSrc } from './Markdown';
import { useWebShellCustomization } from '../../customization';
import {
useWebShellCustomization,
type WebShellComposerTag,
type WebShellComposerTagIconMap,
} from '../../customization';
import { useI18n } from '../../i18n';
import { cssUrlVar } from '../../utils/cssUrlVar';
import flashStyles from '../MessageLocateFlash.module.css';
import styles from './UserMessage.module.css';

Expand All @@ -24,19 +34,79 @@ interface UserMessageProps {
isLocateFlashing?: boolean;
}

function UserMessageReferenceChip({
composerTagIcons,
tag,
}: {
composerTagIcons?: WebShellComposerTagIconMap;
tag: WebShellComposerTag;
}) {
const { tagLabel, tagValue, iconUrl, fallback } = getComposerTagViewModel(
tag,
composerTagIcons,
);
return (
<span className={styles.referenceChip} title={tag.serialized}>
{iconUrl && (
<span
className={styles.referenceIcon}
style={cssUrlVar('--composer-tag-icon-url', iconUrl)}
aria-hidden="true"
/>
)}
{tagLabel && <span className={styles.referenceLabel}>{tagLabel}</span>}
<span className={styles.referenceValue}>{tagValue || fallback}</span>
</span>
);
}

function DefaultUserMessageContent({
composerTagIcons,
content,
}: {
composerTagIcons?: WebShellComposerTagIconMap;
content: string;
}) {
// The default user renderer upgrades serialized @ references to chips while
// still allowing hosts to replace message rendering entirely.
const segments = useMemo(() => splitComposerTagContent(content), [content]);
return (
<>
{segments.map((segment, index) =>
segment.type === 'text' ? (
<Fragment key={index}>{segment.text}</Fragment>
) : (
<UserMessageReferenceChip
composerTagIcons={composerTagIcons}
key={`${segment.tag.id}:${index}`}
tag={segment.tag}
/>
),
)}
</>
);
}

export const UserMessage = memo(function UserMessage({
content,
images,
isLocateFlashing = false,
}: UserMessageProps) {
const { t } = useI18n();
const { renderUserMessageContent } = useWebShellCustomization();
const { composerTagIcons, renderUserMessageContent } =
useWebShellCustomization();
const contentRef = useRef<HTMLDivElement>(null);
const [expanded, setExpanded] = useState(false);
const [heightOverflowing, setHeightOverflowing] = useState(false);
const renderedContent = useMemo(
() => renderUserMessageContent?.({ content, images }) ?? content,
[content, images, renderUserMessageContent],
() =>
renderUserMessageContent?.({ content, images }) ?? (
<DefaultUserMessageContent
composerTagIcons={composerTagIcons}
content={content}
/>
),
[composerTagIcons, content, images, renderUserMessageContent],
);

const measureOverflow = useCallback(() => {
Expand Down
1 change: 1 addition & 0 deletions packages/web-shell/client/customization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ export type LoadingPhrasesResolver = (
) => readonly string[] | undefined | null;

export interface WebShellCustomization {
composerTagIcons?: WebShellComposerTagIconMap;
renderToolHeaderExtra?: ToolHeaderExtraRenderer;
renderWelcomeHeader?: WelcomeHeaderRenderer;
renderWelcomeFooter?: WelcomeFooterRenderer;
Expand Down
18 changes: 11 additions & 7 deletions packages/web-shell/client/hooks/useComposerCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ import {
} from '../extensions/inputHighlight';
import { isEditableTarget } from '../utils/dom';
import { cssUrlValue } from '../utils/cssUrlVar';
import { getComposerTagIconUrl } from '../components/composerTagIcons';
import {
getComposerTagIconUrl,
getComposerTagViewModel,
} from '../utils/composerTag';
import type {
WebShellComposerApi,
WebShellComposerInput,
Expand Down Expand Up @@ -557,16 +560,17 @@ class ComposerTagWidget extends WidgetType {
const chip = document.createElement('span');
chip.style.cssText =
'display:inline-flex;align-items:center;max-width:min(44ch,100%);min-height:20px;margin:0 0.25ch;border:1px solid var(--border);border-radius:4px;background:var(--secondary);color:var(--foreground);font-family:var(--font-mono,monospace);font-size:12px;line-height:1.2;vertical-align:baseline;';
const rawTagLabel = getComposerTagLabel(this.tag);
const tagValue = getComposerTagValue(this.tag);
const tagLabel = this.tag.kind ? '' : rawTagLabel;
const iconUrl = this.tag.iconUrl ?? getComposerTagIconUrl(this.tag.kind);
const { tagLabel, tagValue, iconUrl } = getComposerTagViewModel(this.tag);
const resolvedIconUrl = this.tag.iconUrl ?? iconUrl;

if (iconUrl) {
if (resolvedIconUrl) {
const icon = document.createElement('span');
icon.style.cssText =
'display:block;width:12px;height:12px;flex:0 0 auto;margin-left:7px;background:currentColor;mask:var(--composer-tag-icon-url) center / contain no-repeat;-webkit-mask:var(--composer-tag-icon-url) center / contain no-repeat;';
icon.style.setProperty('--composer-tag-icon-url', cssUrlValue(iconUrl));
icon.style.setProperty(
'--composer-tag-icon-url',
cssUrlValue(resolvedIconUrl),
);
chip.appendChild(icon);
}

Expand Down
Loading
Loading