Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
67 changes: 54 additions & 13 deletions packages/web-shell/client/components/ChatEditor.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
border-radius: 12px;
margin: 0;
cursor: text;
--chat-editor-min-height: 140px;
--chat-editor-max-height: min(350px, 40vh);
--chat-editor-input-min-height: 44px;
--chat-editor-input-max-height: 300px;
--chat-editor-attachments-max-height: 136px;
--dac-glow-on: 0;
--dac-glow-pulse: 0;
--dac-g1: #6a78ff;
Expand All @@ -27,8 +31,11 @@
position: relative;
z-index: 2;
display: flex;
height: 140px;
box-sizing: border-box;
min-height: var(--chat-editor-min-height, 140px);
max-height: var(--chat-editor-max-height, min(350px, 40vh));
flex-direction: column;
overflow: visible;
border: 1.5px solid var(--agent-gray-200);
border-radius: inherit;
background: var(--chat-editor-bg-primary);
Expand Down Expand Up @@ -709,8 +716,19 @@
}
}

.attachments {
display: flex;
min-height: 0;
max-height: var(--chat-editor-attachments-max-height, 136px);
flex: 0 1 auto;
flex-direction: column;
overflow-y: auto;
overscroll-behavior: contain;
}

.tags {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 6px;
padding: 0 0 8px;
Expand All @@ -731,14 +749,23 @@
line-height: 1.2;
}

.tagContent {
display: inline-flex;
min-width: 0;
max-width: 100%;
flex: 1 1 auto;
align-items: center;
}

.tagTooltip {
position: absolute;
z-index: calc(var(--web-shell-tooltip-z-index, 1000) + 1);
top: calc(100% + 6px);
left: 0;
display: none;
box-sizing: border-box;
min-width: 160px;
max-width: min(320px, 80vw);
max-height: min(
calc(100vh - 16px),
var(--radix-tooltip-content-available-height, calc(100vh - 16px))
);
padding: 8px 10px;
border: 1px solid var(--chat-editor-border-color);
border-radius: 6px;
Expand All @@ -748,14 +775,12 @@
font-family: var(--font-sans, system-ui, sans-serif);
font-size: 12px;
line-height: 1.5;
overflow-y: auto;
overscroll-behavior: contain;
pointer-events: auto;
white-space: normal;
}

.tag:hover .tagTooltip,
.tag:focus-within .tagTooltip {
display: block;
}

.tagLabel,
.tagValue {
color: var(--chat-editor-text-primary);
Expand Down Expand Up @@ -819,20 +844,34 @@
.editorArea {
display: flex;
flex: 1 1 auto;
align-items: flex-start;
align-items: stretch;
gap: 6px;
min-height: 0;
min-height: var(--chat-editor-input-min-height, 44px);
padding: 4px 0;
overflow: auto;
overflow: clip;
}

.editorArea > :last-child {
display: grid;
min-width: 0;
min-height: var(--chat-editor-input-min-height, 44px);
flex: 1;
overflow: clip;
}

.editorArea :global(.cm-editor) {
height: 100%;
min-height: 0;
}

.editorArea :global(.cm-scroller) {
height: 100%;
min-height: 0;
}

.shellPrefix {
flex: 0 0 auto;
align-self: flex-start;
padding-top: 1px;
color: var(--chat-editor-accent-color);
font-family: var(--font-mono);
Expand Down Expand Up @@ -870,6 +909,7 @@

.toolbar {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
min-width: 0;
Expand Down Expand Up @@ -1613,6 +1653,7 @@

.images {
display: flex;
flex: 0 0 auto;
gap: 6px;
padding: 4px 0 0;
flex-wrap: wrap;
Expand Down
199 changes: 184 additions & 15 deletions packages/web-shell/client/components/ChatEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,21 @@ import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { I18nProvider } from '../i18n';
import { ChatEditor, type ComposerToolbarAction } from './ChatEditor';
import type {
ComposerTagClickHandler,
ComposerTagRenderer,
WebShellComposerTag,
} from '../customization';
import { WebShellCustomizationProvider } from '../customization';
import { WebShellPortalRootContext } from '../portalRoot';

Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });

const mockComposerCoreState = vi.hoisted(() => ({
composerTags: [] as WebShellComposerTag[],
removeTopTag: vi.fn(),
}));

Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(() => ({
Expand Down Expand Up @@ -44,8 +56,8 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => {
},
pastedImages: [],
removeImage: vi.fn(),
composerTags: [],
removeTopTag: vi.fn(),
composerTags: mockComposerCoreState.composerTags,
removeTopTag: mockComposerCoreState.removeTopTag,
addTags: vi.fn(),
removeInlineTags: vi.fn(),
insertText: vi.fn(),
Expand Down Expand Up @@ -96,38 +108,58 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => {
};
});

const mounted: Array<{ root: Root; container: HTMLDivElement }> = [];
const mounted: Array<{
root: Root;
container: HTMLDivElement;
portalRoot: HTMLDivElement;
}> = [];

afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
for (const { root, container, portalRoot } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
portalRoot.remove();
}
mockComposerCoreState.composerTags = [];
mockComposerCoreState.removeTopTag.mockReset();
});

function renderChatEditor(props: {
gitBranch?: string;
workspaceName?: string;
workspaceTitle?: string;
visibleToolbarActions?: readonly ComposerToolbarAction[];
renderComposerTagTooltip?: ComposerTagRenderer;
onComposerTagClick?: ComposerTagClickHandler;
}) {
const { renderComposerTagTooltip, onComposerTagClick, ...chatEditorProps } =
props;
const container = document.createElement('div');
const portalRoot = document.createElement('div');
portalRoot.dataset.webShellPortalRoot = '';
document.body.appendChild(container);
document.body.appendChild(portalRoot);
const root = createRoot(container);
mounted.push({ root, container });
mounted.push({ root, container, portalRoot });

act(() => {
root.render(
<I18nProvider language="en">
<ChatEditor
onSubmit={() => undefined}
commands={[]}
showChatWidthToggle={false}
currentMode="default"
currentModel="qwen"
{...props}
/>
</I18nProvider>,
<WebShellPortalRootContext.Provider value={portalRoot}>
<WebShellCustomizationProvider
value={{ renderComposerTagTooltip, onComposerTagClick }}
>
<I18nProvider language="en">
<ChatEditor
onSubmit={() => undefined}
commands={[]}
showChatWidthToggle={false}
currentMode="default"
currentModel="qwen"
{...chatEditorProps}
/>
</I18nProvider>
</WebShellCustomizationProvider>
</WebShellPortalRootContext.Provider>,
);
});

Expand Down Expand Up @@ -222,3 +254,140 @@ describe('ChatEditor workspace toolbar integration', () => {
).toBeTruthy();
});
});

describe('ChatEditor top composer tag tooltip', () => {
it('activates the plain tag from click and keyboard with the outer tag rect', () => {
mockComposerCoreState.composerTags = [
{ id: 'orders', label: 'Table', value: 'orders', removable: false },
];
const onComposerTagClick = vi.fn();
const container = renderChatEditor({
onComposerTagClick,
visibleToolbarActions: [],
});
const tag = container.querySelector<HTMLElement>(
'[data-web-shell-composer-tag]',
)!;
const trigger = tag.querySelector<HTMLElement>(
'[data-web-shell-composer-tag-trigger]',
)!;
const outerRect = { width: 200 } as DOMRect;
const innerRect = { width: 120 } as DOMRect;
tag.getBoundingClientRect = vi.fn(() => outerRect);
trigger.getBoundingClientRect = vi.fn(() => innerRect);

act(() => {
trigger.dispatchEvent(new MouseEvent('click', { bubbles: true }));
trigger.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
trigger.dispatchEvent(
new KeyboardEvent('keydown', { key: ' ', bubbles: true }),
);
});

expect(onComposerTagClick).toHaveBeenCalledTimes(3);
for (const [info] of onComposerTagClick.mock.calls) {
expect(info).toMatchObject({
tag: mockComposerCoreState.composerTags[0],
placement: 'composer',
readonly: false,
anchorRect: outerRect,
});
}
expect(container.querySelector('[role="tooltip"]')).toBeNull();
});

it('removes a tag without activating it', () => {
mockComposerCoreState.composerTags = [
{ id: 'orders', label: 'Table', value: 'orders' },
];
const onComposerTagClick = vi.fn();
const container = renderChatEditor({
onComposerTagClick,
visibleToolbarActions: [],
});
const remove = container.querySelector<HTMLButtonElement>(
'[aria-label="Remove orders"]',
)!;

act(() => {
remove.dispatchEvent(new MouseEvent('click', { bubbles: true }));
remove.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }),
);
remove.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }),
);
});

expect(mockComposerCoreState.removeTopTag).toHaveBeenCalledTimes(3);
expect(mockComposerCoreState.removeTopTag).toHaveBeenCalledWith('orders');
expect(onComposerTagClick).not.toHaveBeenCalled();
});

it('falls back to a plain tag when custom tooltip rendering throws', () => {
mockComposerCoreState.composerTags = [
{ id: 'orders', label: 'Table', value: 'orders' },
];
const error = new Error('bad composer tooltip');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const container = renderChatEditor({
renderComposerTagTooltip: () => {
throw error;
},
visibleToolbarActions: [],
});

expect(container.textContent).toContain('Table');
expect(container.textContent).toContain('orders');
expect(container.querySelector('[role="tooltip"]')).toBeNull();
expect(warn).toHaveBeenCalledWith(
'[WebShell] composer tag tooltip render failed',
error,
);
warn.mockRestore();
});

it('opens custom content from a top tag in the configured portal root', () => {
mockComposerCoreState.composerTags = [
{ id: 'orders', label: 'Table', value: 'orders' },
];
const container = renderChatEditor({
renderComposerTagTooltip: () => 'Table details',
visibleToolbarActions: [],
});
const portalRoot = document.body.querySelector<HTMLElement>(
'[data-web-shell-portal-root]',
);
const tag = container.querySelector<HTMLElement>(
'[data-web-shell-composer-tag]',
);
const trigger = tag?.querySelector<HTMLElement>(
'[data-web-shell-composer-tag-trigger]',
);
const removeButton = tag?.querySelector<HTMLButtonElement>('button');

expect(trigger).not.toBeNull();
expect(trigger?.getAttribute('role')).toBeNull();
expect(trigger?.tabIndex).toBe(0);
expect(removeButton).not.toBeNull();
expect(trigger?.contains(removeButton ?? null)).toBe(false);
act(() => trigger?.focus());

const content = portalRoot?.querySelector<HTMLElement>(
'[data-web-shell-composer-tag-tooltip]',
);
const accessibleTooltip =
portalRoot?.querySelector<HTMLElement>('[role="tooltip"]');
expect(content).not.toBeNull();
expect(content?.textContent).toContain('Table details');
expect(container.contains(content ?? null)).toBe(false);
expect(portalRoot?.contains(content ?? null)).toBe(true);
expect(accessibleTooltip).not.toBeNull();
expect(trigger?.getAttribute('aria-describedby')).toBe(
accessibleTooltip?.id,
);
expect(tag?.hasAttribute('aria-describedby')).toBe(false);
});
});
Loading
Loading