diff --git a/docs/design/ctrl-o-detail-expand/design.md b/docs/design/ctrl-o-detail-expand/design.md index 217288cfa6..d929ba9e69 100644 --- a/docs/design/ctrl-o-detail-expand/design.md +++ b/docs/design/ctrl-o-detail-expand/design.md @@ -7,17 +7,17 @@ > **实现状态对照(当前 PR)**: > -> | 部分 | 状态 | -> | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | -> | 删除全局 compactMode(context/settings/toggle/i18n key、`mergeCompactToolGroups`) | ✅ 已实现 | -> | `fullDetail` 管线(`HistoryItemDisplay`/`ToolGroupMessage`,并入 `forceExpandAll`/`forceShowResult`/思考块 expanded) | ✅ 已实现 | -> | `fullDetail` 不被 `ToolGroupMessage` 两个 early return 绕过(纯并行/ memory-only 守 `!fullDetail`) | ✅ 已实现 + 回归测试 | -> | `TranscriptView` + alt-screen 接入(Ctrl+O 开关、Esc/q/Ctrl+C 关闭、双段冻结、退出重绘、后台确认自动关闭、消息队列守卫) | ✅ 已实现 | -> | 基于 #5661 type-based partition 的 rebase(已合入 main) | ✅ 已实现 | -> | `AlternateScreen` 的 `process.stdout.isTTY` guard(§4.2) | ✅ 已实现 + 测试 | -> | i18n 旧 compact 文案清理(9 语言)+ KeyboardShortcuts `ctrl+o → view transcript` 文案(§5) | ✅ 已实现 | -> | **read/search/list 完整明细透传到 transcript(§4.9:`detailedDisplay` 提取 helper + 渲染拆分 + live/resume/replay)** | ✅ **已实现 + 测试**(方案 Y:core `getToolResponseDisplayText` + live/resume 派生 + `ToolMessage` 数据源切换;ACP 经 `transformPartsToToolCallContent` 已带全文,无需新增协议字段;截图 §3.4 待重录) | -> | **鼠标点击工具 block 就地展开(§4.8,follow-up)** | ⏭️ **follow-up(独立 PR,不在本 PR)**——理由见 §4.8:type-based 下无 per-tool 点击目标、~250–400 行、SGR 选区风险 | +> | 部分 | 状态 | +> | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +> | 删除全局 compactMode(context/settings/toggle/i18n key、`mergeCompactToolGroups`) | ✅ 已实现 | +> | `fullDetail` 管线(`HistoryItemDisplay`/`ToolGroupMessage`,并入 `forceExpandAll`/`forceShowResult`/思考块 expanded) | ✅ 已实现 | +> | `fullDetail` 不被 `ToolGroupMessage` 两个 early return 绕过(纯并行/ memory-only 守 `!fullDetail`) | ✅ 已实现 + 回归测试 | +> | `TranscriptView` + alt-screen 接入(Ctrl+O 开关、Esc/q/Ctrl+C 关闭、双段冻结、退出重绘、后台确认自动关闭、消息队列守卫) | ✅ 已实现 | +> | 基于 #5661 type-based partition 的 rebase(已合入 main) | ✅ 已实现 | +> | `AlternateScreen` 的 `process.stdout.isTTY` guard(§4.2) | ✅ 已实现 + 测试 | +> | i18n 旧 compact 文案清理(9 语言)+ KeyboardShortcuts `ctrl+o → view transcript` 文案(§5) | ✅ 已实现 | +> | **read/search/list 完整明细透传到 transcript(§4.9:`detailedDisplay` 提取 helper + 渲染拆分 + live/resume/replay)** | ✅ **已实现 + 测试**(方案 Y:core `getToolResponseDisplayText` + live/resume 派生 + `ToolMessage` 数据源切换;ACP 经 `transformPartsToToolCallContent` 已带全文,无需新增协议字段;截图 §3.4 待重录) | +> | **鼠标点击工具 block 就地展开(§4.8,follow-up)** | ⏭️ **follow-up(独立 PR,不在本 PR)**——理由见 §4.8:type-based 下无 per-tool 点击目标、~250–400 行、SGR 选区风险 | --- @@ -386,7 +386,7 @@ claude code 的机制是"**存储层保留完整、显示层按 `verbose` 截断 | 文件 | 动作 | | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `packages/cli/src/config/keyBindings.ts` | 删 `TOGGLE_COMPACT_MODE`;加 `TOGGLE_TRANSCRIPT='toggleTranscript'`,默认绑 Ctrl+O;保留 `SHOW_MORE_LINES`(Ctrl+S) 不动。启动迁移检测**当前不适用**——代码库无用户可配置 keybinding 覆盖(`keyMatchers` 恒用硬编码默认值),无残留绑定可扫描(见 §6) | +| `packages/cli/src/config/keyBindings.ts` | 删 `TOGGLE_COMPACT_MODE`;加 `TOGGLE_TRANSCRIPT='toggleTranscript'`,默认绑 Ctrl+O;保留 `SHOW_MORE_LINES`(Ctrl+S) 不动。启动迁移检测**当前不适用**——代码库无用户可配置 keybinding 覆盖(`keyMatchers` 恒用硬编码默认值),无残留绑定可扫描(见 §6) | | `packages/cli/src/ui/keyMatchers.ts` | 同步增删 matcher | | `packages/cli/src/ui/AppContainer.tsx` | 删 `compactMode/compactInline/setCompactMode` 状态、`CompactModeProvider`、Ctrl+O 旧分支、`compactToggleHasVisualEffect` 调用;加 `isTranscriptOpen`(canonical useState,§4.2)+ `isTranscriptOpenRef` + `transcriptFreeze` + `toggleTranscript/openTranscript/closeTranscript`;全局 Ctrl+O→`toggleTranscript`;Esc/q/Ctrl+C **handleGlobalKeypress 第一分支**关闭(早于 QUIT/EXIT/ESCAPE 及 vim INSERT 守卫,§4.3);`useEffect` 监听**全部阻塞确认/对话框**自动关闭(§4.6 #1);消息队列 drain 守卫加 `\|\| isTranscriptOpenRef.current`(§4.6 #3);`refreshStatic`用`isTranscriptOpenRef`守卫、退出时重绘一次(§4.4)。**不并入 `dialogsVisible`,不改 `useDialogClose`** | | `packages/cli/src/ui/contexts/UIStateContext.tsx` | **不改**:transcript 态留 `AppContainer` 本地、顶层消费,**不 surface**(取证:ThinkingViewer / claude-code REPL-local 先例,§4.2)。实现代码已如此 | diff --git a/packages/cli/src/ui/components/TranscriptView.test.tsx b/packages/cli/src/ui/components/TranscriptView.test.tsx index b973bd3e92..08d02cc4c5 100644 --- a/packages/cli/src/ui/components/TranscriptView.test.tsx +++ b/packages/cli/src/ui/components/TranscriptView.test.tsx @@ -77,9 +77,7 @@ describe('', () => { it('enters and exits the alternate screen by default (useAlternateScreen defaults to true)', () => { setTTY(true); - const { unmount } = renderWithProviders( - , - ); + const { unmount } = renderWithProviders(); // The default path drives AlternateScreen with disabled=false, which writes // the enter-alt-screen escape on mount. expect(writeRaw).toHaveBeenCalledWith( diff --git a/packages/mobile-mcp/src/index.ts b/packages/mobile-mcp/src/index.ts index 12d88b4ae1..2ecc87c620 100644 --- a/packages/mobile-mcp/src/index.ts +++ b/packages/mobile-mcp/src/index.ts @@ -53,12 +53,10 @@ const startSseServer = async (host: string, port: number) => { app.get('/mcp', (req, res) => { if (transport) { - res - .status(409) - .json({ - error: - 'Another client is already connected. Disconnect the existing client first.', - }); + res.status(409).json({ + error: + 'Another client is already connected. Disconnect the existing client first.', + }); return; } diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 93144f96e3..63f1f2af87 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -174,6 +174,7 @@ import { type WebShellMarkdownCustomization, type ToolHeaderExtraRenderer, type UserMessageContentRenderer, + type UserMessageContentParser, type AssistantTurnFooterRenderer, type WelcomeHeaderRenderer, type WelcomeFooterRenderer, @@ -185,6 +186,9 @@ import { type MarkdownTableMode, type WebShellTaskInfo, type WebShellAtProvider, + type WebShellBuiltinAtProvidersConfig, + type ComposerTagClickHandler, + type ComposerTagRenderer, type WebShellComposerTagIconMap, type WebShellBottomStatusItem, } from './customization'; @@ -415,6 +419,8 @@ export interface WebShellProps { hiddenSlashCommands?: string[]; /** Slash command category order. Defaults to custom, skill, system. */ slashCommandCategoryOrder?: CommandDisplayCategoryOrder; + /** Built-in @ mention providers to enable. Defaults to all built-ins. */ + builtinAtProviders?: WebShellBuiltinAtProvidersConfig; /** Additional @ mention categories shown alongside built-in files/extensions. */ atProviders?: readonly WebShellAtProvider[]; /** Icon URLs for custom composer tag kinds used by @ mention chips. */ @@ -425,8 +431,16 @@ export interface WebShellProps { renderWelcomeHeader?: WelcomeHeaderRenderer; /** Custom renderer shown below the chat composer in the empty welcome state. */ renderWelcomeFooter?: WelcomeFooterRenderer; + /** Parse user-message text into display parts such as chips. */ + parseUserMessageContent?: UserMessageContentParser; /** Custom renderer for the inside of user chat bubbles. Defaults to plain text. */ renderUserMessageContent?: UserMessageContentRenderer; + /** Custom renderer for composer and user-message tags. */ + renderComposerTag?: ComposerTagRenderer; + /** Custom hover content for composer and user-message tags. */ + renderComposerTagTooltip?: ComposerTagRenderer; + /** Click handler for composer and user-message tags. */ + onComposerTagClick?: ComposerTagClickHandler; /** Custom renderer displayed after the final assistant message of each turn. */ renderAssistantTurnFooter?: AssistantTurnFooterRenderer; /** Custom renderer inserted before the built-in chat composer toolbar controls. */ @@ -839,12 +853,17 @@ export function App({ onBugReport, hiddenSlashCommands, slashCommandCategoryOrder, + builtinAtProviders, atProviders, composerTagIcons, renderToolHeaderExtra, renderWelcomeHeader, renderWelcomeFooter, + parseUserMessageContent, renderUserMessageContent, + renderComposerTag, + renderComposerTagTooltip, + onComposerTagClick, renderAssistantTurnFooter, renderComposerToolbarStart, renderComposerToolbarEnd, @@ -961,7 +980,12 @@ export function App({ renderToolHeaderExtra, renderWelcomeHeader, renderWelcomeFooter, + parseUserMessageContent, renderUserMessageContent, + composerTagIcons, + renderComposerTag, + renderComposerTagTooltip, + onComposerTagClick, renderAssistantTurnFooter, renderComposerToolbarStart, renderComposerToolbarEnd, @@ -977,7 +1001,12 @@ export function App({ renderToolHeaderExtra, renderWelcomeHeader, renderWelcomeFooter, + parseUserMessageContent, renderUserMessageContent, + composerTagIcons, + renderComposerTag, + renderComposerTagTooltip, + onComposerTagClick, renderAssistantTurnFooter, renderComposerToolbarStart, renderComposerToolbarEnd, @@ -5156,6 +5185,7 @@ export function App({ commands={commands} skills={loadedSkills} slashCommandCategoryOrder={slashCommandCategoryOrder} + builtinAtProviders={builtinAtProviders} atProviders={atProviders} composerTagIcons={composerTagIcons} queuedMessages={queuedTexts} diff --git a/packages/web-shell/client/components/AtMentionPanel.test.tsx b/packages/web-shell/client/components/AtMentionPanel.test.tsx index 692f61a9de..72a857adbd 100644 --- a/packages/web-shell/client/components/AtMentionPanel.test.tsx +++ b/packages/web-shell/client/components/AtMentionPanel.test.tsx @@ -4,6 +4,7 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { AtMentionPanel } from './AtMentionPanel'; import type { AtMentionMenuState } from '../hooks/useAtMentionMenu'; +import type { WebShellAtProvider } from '../customization'; import { I18nProvider } from '../i18n'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); @@ -20,20 +21,34 @@ let container: HTMLDivElement | null = null; let anchor: HTMLDivElement | null = null; let root: Root | null = null; +function providerView(provider: WebShellAtProvider) { + return { + id: provider.id, + provider, + label: provider.label, + textValue: + provider.textValue ?? + (typeof provider.label === 'string' ? provider.label : provider.id), + description: provider.description, + tabs: provider.tabs, + renderItem: provider.renderItem, + }; +} + function categoriesMenu(): AtMentionMenuState { + const provider: WebShellAtProvider = { + id: 'files', + label: 'Files', + description: 'Reference workspace files', + search: async () => [], + }; return { from: 0, to: 1, query: '', level: 'categories', selectedIndex: 0, - providers: [ - { - id: 'files', - label: 'Files', - description: 'Reference workspace files', - }, - ], + providers: [providerView(provider)], items: [], loading: false, }; @@ -54,6 +69,35 @@ function itemsMenu(): AtMentionMenuState { }; } +function tabbedItemsMenu(): AtMentionMenuState { + const provider: WebShellAtProvider = { + id: 'tables', + label: 'Tables', + tabs: [ + { id: 'mc', label: 'MaxCompute' }, + { id: 'hg', label: 'Hologres' }, + ], + search: async () => [], + }; + return { + ...categoriesMenu(), + level: 'items', + selectedProviderId: 'tables', + providers: [providerView(provider)], + tabs: provider.tabs, + selectedTabId: 'mc', + items: [ + { + id: 'orders', + label: 'orders', + subtitle: 'project_a', + description: 'daily order table', + insertText: '@orders ', + }, + ], + }; +} + function mount(menu: AtMentionMenuState, handlers = {}) { container = document.createElement('div'); anchor = document.createElement('div'); @@ -81,6 +125,7 @@ function mount(menu: AtMentionMenuState, handlers = {}) { onAccept={vi.fn()} onBack={vi.fn()} onSearch={vi.fn()} + onSelectTab={vi.fn()} {...handlers} /> , @@ -176,6 +221,97 @@ describe('AtMentionPanel', () => { expect(onSearch).toHaveBeenCalledWith('read'); }); + it('renders provider tabs and item subtitle fields', () => { + const onSelectTab = vi.fn(); + mount(tabbedItemsMenu(), { onSelectTab }); + + expect(document.body.textContent).toContain('MaxCompute'); + expect(document.body.textContent).toContain('Hologres'); + expect(document.body.textContent).toContain('orders'); + expect(document.body.textContent).toContain('project_a'); + expect(document.body.textContent).toContain('daily order table'); + + const tabs = [...document.body.querySelectorAll('[role="tab"]')]; + act(() => { + tabs[1]?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(onSelectTab).toHaveBeenCalledWith('hg'); + }); + + it('guards image icon sources', () => { + const menu = itemsMenu(); + menu.items = [ + { + id: 'safe', + label: 'Safe image', + icon: 'data:image/png;base64,iVBOR', + iconMode: 'image', + }, + { + id: 'unsafe', + label: 'Unsafe image', + icon: 'javascript:alert(1)', + iconMode: 'image', + }, + ]; + mount(menu); + + const images = [...document.body.querySelectorAll('img')]; + expect(images).toHaveLength(1); + expect(images[0]?.getAttribute('src')).toBe('data:image/png;base64,iVBOR'); + }); + + it('guards mask icon sources', () => { + const menu = itemsMenu(); + menu.items = [ + { + id: 'safe', + label: 'Safe mask', + icon: 'data:image/png;base64,iVBOR', + }, + { + id: 'unsafe', + label: 'Unsafe mask', + icon: 'javascript:alert(1)', + }, + ]; + mount(menu); + + const icons = [ + ...document.body.querySelectorAll('[style*="--at-item-icon-url"]'), + ]; + expect(icons).toHaveLength(1); + expect(icons[0]?.getAttribute('style')).toContain( + 'data:image/png;base64,iVBOR', + ); + expect(document.body.innerHTML).not.toContain('javascript:alert'); + }); + + it('falls back when a custom item renderer throws', () => { + const error = new Error('bad item'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const menu = itemsMenu(); + const provider: WebShellAtProvider = { + id: 'files', + label: 'Files', + renderItem: () => { + throw error; + }, + search: async () => [], + }; + menu.providers = [providerView(provider)]; + + mount(menu); + + expect(document.body.textContent).toContain('README.md'); + expect(warn).toHaveBeenCalledWith( + '[WebShell] at mention item render failed', + error, + ); + warn.mockRestore(); + }); + it('focuses search when explicitly requested', () => { vi.useFakeTimers(); mount({ ...itemsMenu(), inputMode: 'search' }); diff --git a/packages/web-shell/client/components/AtMentionPanel.tsx b/packages/web-shell/client/components/AtMentionPanel.tsx index 7b10bd34b0..3387073af2 100644 --- a/packages/web-shell/client/components/AtMentionPanel.tsx +++ b/packages/web-shell/client/components/AtMentionPanel.tsx @@ -5,6 +5,7 @@ import { useState, type CSSProperties, type RefObject, + type ReactNode, } from 'react'; import { createPortal } from 'react-dom'; import { useI18n } from '../i18n'; @@ -13,7 +14,9 @@ import { sanitizeDisplayText, type AtMentionMenuState, } from '../hooks/useAtMentionMenu'; +import { cssUrlVar } from '../utils/cssUrlVar'; import styles from './ChatEditor.module.css'; +import { isSafeImageSrc } from './messages/Markdown'; const AT_PANEL_THEME_VARS = [ '--chat-editor-accent-color', @@ -34,6 +37,7 @@ export function AtMentionPanel({ onAccept, onBack, onSearch, + onSelectTab, }: { menu: AtMentionMenuState; anchorRef: RefObject; @@ -42,14 +46,17 @@ export function AtMentionPanel({ onAccept: (index?: number) => boolean; onBack: () => boolean; onSearch: (query: string) => boolean; + onSelectTab: (tabId: string) => boolean; }) { const { t } = useI18n(); const itemRefs = useRef>([]); const searchInputRef = useRef(null); + const tabsRef = useRef(null); const [anchorRect, setAnchorRect] = useState<{ left: number; bottom: number; width: number; + maxHeight: number; } | null>(null); const [themeVars, setThemeVars] = useState({}); @@ -99,6 +106,12 @@ export function AtMentionPanel({ const updatePosition = () => { const rect = anchor.getBoundingClientRect(); const panelWidth = panelRef.current?.offsetWidth ?? 360; + const computedStyle = getComputedStyle(anchor); + const safeTop = + Number.parseFloat( + computedStyle.getPropertyValue('--web-shell-popover-safe-top'), + ) || 48; + const maxHeight = Math.max(96, Math.min(300, rect.top - safeTop - 8)); const next = { left: Math.max( 12, @@ -106,13 +119,15 @@ export function AtMentionPanel({ ), bottom: window.innerHeight - rect.top + 8, width: rect.width, + maxHeight, }; setAnchorRect((prev) => { if ( prev && prev.left === next.left && prev.bottom === next.bottom && - prev.width === next.width + prev.width === next.width && + prev.maxHeight === next.maxHeight ) { return prev; } @@ -147,18 +162,28 @@ export function AtMentionPanel({ ? menu.providers.map((provider) => ({ id: provider.id, label: provider.label, + labelTitle: provider.textValue, description: provider.description, trailing: '›', + provider, })) : menu.items.map((item) => ({ id: item.id, label: item.label, + labelTitle: item.label, + subtitle: item.subtitle, description: menu.selectedProviderId === FILE_PROVIDER_ID ? undefined : (item.description ?? item.detail), + icon: item.icon, + iconMode: item.iconMode, + iconColor: item.iconColor, + iconSpin: item.iconSpin, + iconTooltip: item.iconTooltip, trailing: item.kind === 'directory' || item.kind === 'mcp-server' ? '›' : '', + item, })); useEffect(() => { @@ -174,9 +199,13 @@ export function AtMentionPanel({ menu.itemMode === 'mcpResources' && menu.mcpServerName ? (sanitizeDisplayText(menu.mcpServerName) ?? '[invalid]') : (selectedProvider?.label ?? ''); + const panelTitleText = + menu.itemMode === 'mcpResources' && menu.mcpServerName + ? (sanitizeDisplayText(menu.mcpServerName) ?? '[invalid]') + : (selectedProvider?.textValue ?? ''); const listboxLabel = menu.level === 'items' - ? (selectedProvider?.label ?? t('at.menu')) + ? (selectedProvider?.textValue ?? t('at.menu')) : t('at.menu'); const listboxId = 'at-mention-listbox'; const activeOptionId = @@ -196,6 +225,7 @@ export function AtMentionPanel({ left: anchorRect.left, bottom: anchorRect.bottom, '--at-anchor-width': `${anchorRect.width}px`, + '--at-panel-max-height': `${anchorRect.maxHeight}px`, } as CSSProperties } role="region" @@ -224,7 +254,9 @@ export function AtMentionPanel({ > ‹ - {panelTitle} + + {panelTitle} + + {menu.tabs && menu.tabs.length > 0 && ( +
+ +
+ {menu.tabs.map((tab) => { + const selected = tab.id === menu.selectedTabId; + const tabText = + tab.textValue ?? + (typeof tab.label === 'string' ? tab.label : tab.id); + return ( + + ); + })} +
+ +
+ )} )}
) : ( - rows.map((row, index) => ( - - )) + rows.map((row, index) => { + const selected = index === menu.selectedIndex; + const provider = + 'item' in row + ? selectedProvider?.provider + : row.provider.provider; + let customItem: ReactNode | undefined; + if ('item' in row && provider?.renderItem) { + try { + customItem = provider.renderItem({ + item: row.item, + provider, + selected, + }); + } catch (error) { + console.warn( + '[WebShell] at mention item render failed', + error, + ); + } + } + const safeIcon = + 'icon' in row && row.icon && isSafeImageSrc(row.icon) + ? row.icon + : undefined; + return ( + + ); + }) )}
@@ -336,3 +505,42 @@ export function AtMentionPanel({ document.body, ); } + +function scrollTabs( + element: HTMLDivElement | null, + direction: 'previous' | 'next', +): void { + if (!element) return; + const delta = Math.max(96, element.clientWidth * 0.75); + element.scrollBy({ + left: direction === 'previous' ? -delta : delta, + behavior: 'smooth', + }); +} + +function getAtItemMainTemplate( + label: string, + subtitle: string | undefined, +): string { + if (!subtitle) return 'minmax(0, 1fr)'; + const labelLength = getDisplayLength(label); + const subtitleLength = getDisplayLength(subtitle); + if (subtitleLength <= 12) return 'minmax(0, 1fr) max-content'; + if (labelLength <= 18) return 'max-content minmax(0, 1fr)'; + return 'minmax(0, 3fr) minmax(0, 2fr)'; +} + +function getRowTextValue( + label: ReactNode, + fallback: string | undefined, +): string { + if (typeof label === 'string') return label; + if (typeof label === 'number') return String(label); + return fallback || ''; +} + +function getDisplayLength(value: string): number { + return Array.from(value).reduce((total, char) => { + return total + (char.charCodeAt(0) > 255 ? 2 : 1); + }, 0); +} diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index ccfbb66f8f..cd480b48d0 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -381,7 +381,7 @@ --at-anchor-width: 620px; display: flex; width: min(360px, calc(var(--at-anchor-width) - 32px), calc(100vw - 24px)); - max-height: min(300px, 42vh); + max-height: min(var(--at-panel-max-height, 300px), 42vh); box-sizing: border-box; flex-direction: column; padding: 6px; @@ -445,6 +445,73 @@ white-space: nowrap; } +.atTabsWrap { + display: grid; + min-width: 0; + grid-template-columns: 22px minmax(0, 1fr) 22px; + align-items: center; + gap: 4px; + margin-top: 4px; + margin-bottom: 0; +} + +.atTabs { + display: flex; + min-width: 0; + gap: 4px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; +} + +.atTabs::-webkit-scrollbar { + display: none; +} + +.atTab, +.atTabScrollButton { + display: inline-flex; + min-width: 0; + height: 22px; + align-items: center; + justify-content: center; + padding: 0 8px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + font: inherit; + font-size: 12px; + line-height: 18px; + white-space: nowrap; +} + +.atTab { + flex: 0 0 auto; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; +} + +.atTabScrollButton { + width: 22px; + padding: 0; + font-size: 16px; +} + +.atTab:hover, +.atTabActive, +.atTabScrollButton:hover { + background: var(--accent); + color: var(--foreground); +} + +.atTab:disabled { + opacity: 0.45; + cursor: default; +} + .atSearchInput { width: 100%; min-width: 0; @@ -524,17 +591,78 @@ padding-bottom: 5px; } -.atItemLabel { +.atItemMain { grid-area: label; + display: grid; + width: 100%; + min-width: 0; + grid-template-columns: var(--at-item-main-template, minmax(0, 1fr)); + column-gap: 8px; + align-items: end; + justify-self: stretch; +} + +.atItemLeading { + display: grid; + width: 100%; + min-width: 0; + grid-template-columns: auto minmax(0, 1fr); + align-items: end; + gap: 6px; + overflow: hidden; +} + +.atItemIcon { + display: inline-block; + width: 16px; + height: 16px; + flex: 0 0 auto; + background: currentColor; + color: var(--muted-foreground); + mask: var(--at-item-icon-url) center / contain no-repeat; + -webkit-mask: var(--at-item-icon-url) center / contain no-repeat; +} + +.atItemImageIcon { + display: block; + width: 16px; + height: 16px; + flex: 0 0 auto; + object-fit: contain; +} + +.atItemIconSpin { + animation: at-item-icon-spin 1.2s linear infinite; +} + +.atItemLabel, +.atItemSubtitle { min-width: 0; overflow: hidden; - color: var(--foreground); font-size: 13px; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; } +.atItemLabel { + display: block; + width: 100%; + color: var(--foreground); +} + +.atItemSubtitle { + color: var(--muted-foreground); + justify-self: end; + text-align: right; +} + +@keyframes at-item-icon-spin { + to { + transform: rotate(360deg); + } +} + .atItemDescription { grid-area: description; min-width: 0; @@ -585,6 +713,7 @@ } .tag { + position: relative; display: inline-flex; align-items: center; max-width: 100%; @@ -598,6 +727,31 @@ line-height: 1.2; } +.tagTooltip { + position: absolute; + z-index: calc(var(--web-shell-tooltip-z-index, 1000) + 1); + top: calc(100% + 6px); + left: 0; + display: none; + min-width: 160px; + max-width: min(320px, 80vw); + padding: 8px 10px; + border: 1px solid var(--chat-editor-border-color); + border-radius: 6px; + background: var(--background); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + color: var(--foreground); + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 12px; + line-height: 1.5; + white-space: normal; +} + +.tag:hover .tagTooltip, +.tag:focus-within .tagTooltip { + display: block; +} + .tagLabel, .tagValue { color: var(--chat-editor-text-primary); diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 81315a1e50..26e1364aa8 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -23,6 +23,7 @@ import { type WebShellComposerTag, type WebShellComposerTagIconMap, type WebShellAtProvider, + type WebShellBuiltinAtProvidersConfig, } from '../customization'; import { useComposerCore, @@ -35,6 +36,7 @@ import { import { AtMentionPanel } from './AtMentionPanel'; import { cssUrlVar } from '../utils/cssUrlVar'; import { getComposerTagIconUrl } from './composerTagIcons'; +import { isSafeImageSrc } from './messages/Markdown'; import { ModeIcon } from './ModeIcon'; import { planSlashSectionRows } from '../utils/slashSectionPlan'; import { getModelDisplayName } from '../utils/modelDisplay'; @@ -98,6 +100,7 @@ interface ChatEditorProps { sessionName?: string; composerInput?: WebShellComposerInput; composerInputVersion?: number; + builtinAtProviders?: WebShellBuiltinAtProvidersConfig; atProviders?: readonly WebShellAtProvider[]; composerTagIcons?: WebShellComposerTagIconMap; } @@ -924,10 +927,20 @@ export const ChatEditor = memo( sessionName, composerInput, composerInputVersion, + builtinAtProviders, atProviders, composerTagIcons, } = props; + const { + renderComposerToolbarStart: ToolbarStart, + renderComposerToolbarEnd: ToolbarEnd, + renderComposerToolbarRight: ToolbarRight, + renderComposerTag, + renderComposerTagTooltip, + onComposerTagClick, + } = useWebShellCustomization(); + const core = useComposerCore({ onSubmit, onCycleMode, @@ -948,17 +961,16 @@ export const ChatEditor = memo( sessionName, composerInput, composerInputVersion, + builtinAtProviders, atProviders, composerTagIcons, + renderComposerTag, + renderComposerTagTooltip, + onComposerTagClick, editorTheme: CHAT_EDITOR_THEME, }); const { t } = useI18n(); - const { - renderComposerToolbarStart: ToolbarStart, - renderComposerToolbarEnd: ToolbarEnd, - renderComposerToolbarRight: ToolbarRight, - } = useWebShellCustomization(); useImperativeHandle(ref, () => core.handle, [core.handle]); @@ -1274,19 +1286,30 @@ export const ChatEditor = memo( } = core.searchState; const renderComposerTagContent = (tag: WebShellComposerTag) => { + const custom = renderComposerTag?.({ + tag, + placement: 'composer', + readonly: false, + }); + if (custom !== undefined && custom !== null) { + return custom; + } const rawTagLabel = getComposerTagLabel(tag); const tagValue = getComposerTagValue(tag); const tagLabel = tag.kind ? '' : rawTagLabel; - const iconUrl = getComposerTagIconUrl(tag.kind, composerTagIcons); + const iconUrl = + tag.icon ?? getComposerTagIconUrl(tag.kind, composerTagIcons); + const safeIconUrl = + iconUrl && isSafeImageSrc(iconUrl) ? iconUrl : undefined; if (!tagLabel && !tagValue) { return {tag.id}; } return ( <> - {iconUrl && ( + {safeIconUrl && (