fix(web): align home and project composer controls

Restore shared add-menu resources and project context actions, prevent plugin preview clipping, and restore plus and attachment icons.

Use 4px padding for composer tags and match the design-system button to the adjacent plus button.
This commit is contained in:
wangchenglong
2026-09-11 19:56:05 +08:00
parent 03058b2534
commit 877980fb17
10 changed files with 567 additions and 125 deletions
+376 -83
View File
@@ -8,9 +8,17 @@ import {
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import type { SkillSummary, WorkspaceCollabContext } from '@open-design/contracts';
import type {
ConnectorDetail,
InstalledPluginRecord,
McpServerConfig,
SkillSummary,
WorkspaceCollabContext,
} from '@open-design/contracts';
import { useI18n, useT } from '../i18n';
import { LIBRARY_UI_VISIBLE } from '../features/libraryUi';
import { ComposerPluginPreview } from './ComposerPluginPreview';
import { localizePluginTitle } from './plugins-home/localization';
import { resolveFlyoutSide } from './composer-flyout-placement';
import { Icon, type IconName } from './Icon';
@@ -18,6 +26,12 @@ const PLUS_MENU_MARGIN = 12;
const PLUS_MENU_GAP = 8;
const PLUS_MENU_WIDTH = 190;
const PLUS_MENU_FLYOUT_WIDTH = 360;
// The Plugins flyout is wider than the others because it carries a
// side-by-side hover-preview column. This MUST match the rendered width of
// `.plus-menu__flyout--plugins` in styles/home/plus-menu.css — over-reserving
// here makes medium-width panes wrongly fall back to the contained layout and
// silently drop the preview column.
const PLUS_MENU_PLUGIN_FLYOUT_WIDTH = 466;
const PLUS_MENU_FLYOUT_MAX_HEIGHT = 320;
// Fallback "does the menu fit?" budget used only until the popup has been
// measured (first layout pass). Once `contentHeight` is known the real stack
@@ -27,13 +41,16 @@ export type PlusMenuPlacementPreference = 'auto' | 'down' | 'up';
type PlusMenuFlyoutPlacement = 'right' | 'left' | 'contained';
type PlusMenuFlyoutVerticalPlacement = 'down' | 'up';
type PlusMenuVerticalPlacement = 'down' | 'up';
export type PlusMenuSubmenu = 'skills' | 'workingDir';
export type PlusMenuSubmenu = 'connectors' | 'plugins' | 'skills' | 'mcp' | 'workingDir';
// Analytics mapping for the submenu flyouts: which resource list each
// submenu carries. `workingDir` is intentionally absent — its flyout carries
// actions, not an attachable resource list.
// submenu carries. `workingDir` is intentionally absent because its flyout
// carries actions, not an attachable resource list.
export const PLUS_SUBMENU_RESOURCE_KIND = {
connectors: 'connector',
plugins: 'plugin',
skills: 'skill',
mcp: 'mcp',
} as const;
type PlusMenuPopupStyle = CSSProperties & Record<'--plus-menu-flyout-max-height', string>;
@@ -146,6 +163,26 @@ function getFlyoutPlacement(
export interface ComposerPlusMenuProps {
workspaceContext?: WorkspaceCollabContext | null;
/** Connector context options shown under the "Connectors" submenu. */
connectors?: ConnectorDetail[];
onPickConnector?: (connector: ConnectorDetail) => void;
/** Opens the connector integration surface; omit to hide the add row. */
onAddConnector?: () => void;
/** Installed plugin options shown under the "Plugins" submenu. */
plugins?: InstalledPluginRecord[];
onPickPlugin?: (plugin: InstalledPluginRecord) => void;
/** Opens the plugin registry; omit to hide the add row. */
onAddPlugin?: () => void;
/** Hide the Plugins row when a caller provides a separate plugin picker. */
hidePluginsRow?: boolean;
/** Enabled MCP servers shown under the "MCP" submenu. */
mcpServers?: McpServerConfig[];
onPickMcp?: (server: McpServerConfig) => void;
/** Opens MCP settings; omit to hide the add row. */
onAddMcp?: () => void;
/**
* Accepted for API compatibility but no longer rendered as a "+" submenu:
* skills are picked through the composer's `@` mention popover on both the
@@ -198,19 +235,16 @@ export interface ComposerPlusMenuProps {
*/
onOpenDesignSystems?: () => void;
/** Test id for the trigger button. */
triggerTestId?: string;
/**
* Optional visible label beside the "+". Given one, the trigger stops being
* a lone disc and renders as a text control (Home's accessory row, where it
* sits next to the working-directory picker); left off, it stays the bare
* icon button the project composer uses.
*/
triggerLabel?: string;
/** Notified when the menu opens. */
/**
* Notified when the menu opens. The project composer uses this to latch its
* lazy plugin / MCP / connector fetches, so the Plugins / Connectors / MCP
* submenus aren't empty when the "+" menu is the first thing clicked on a
* cold composer.
*/
onOpen?: () => void;
/**
@@ -221,6 +255,13 @@ export interface ComposerPlusMenuProps {
*/
onSubmenuOpen?: (submenu: PlusMenuSubmenu) => void;
/**
* Notified once per submenu-open session when the user starts typing in
* that flyout's search box. Carries which list was searched, never the
* query text.
*/
onSearchUsed?: (submenu: 'plugins' | 'skills' | 'mcp') => void;
/**
* Home opens below the trigger like Claude Design's project picker, while
* the bottom project composer opens upward so it stays attached to the chat
@@ -237,6 +278,22 @@ export interface ComposerPlusMenuProps {
openRequest?: { nonce: number; submenu?: PlusMenuSubmenu } | null;
}
function pluginMatches(
plugin: InstalledPluginRecord,
needle: string,
localizedTitle: string,
): boolean {
if (!needle) return true;
// Match the localized title too, so a Chinese search hits a plugin whose
// raw `title` is English but whose `title_i18n` is the displayed name.
return `${localizedTitle} ${plugin.title} ${plugin.id}`.toLowerCase().includes(needle);
}
function mcpMatches(server: McpServerConfig, needle: string): boolean {
if (!needle) return true;
return `${server.label ?? ''} ${server.id}`.toLowerCase().includes(needle);
}
/**
* The composer "+" menu shared between the home hero and the project chat
* composer. Owns its own open / submenu / search state; callers supply the
@@ -244,6 +301,16 @@ export interface ComposerPlusMenuProps {
*/
export function ComposerPlusMenu({
workspaceContext = null,
connectors = [],
onPickConnector,
onAddConnector,
plugins = [],
onPickPlugin,
onAddPlugin,
hidePluginsRow,
mcpServers = [],
onPickMcp,
onAddMcp,
onAttachFiles,
attachLoading,
onReferenceProject,
@@ -259,6 +326,7 @@ export function ComposerPlusMenu({
triggerLabel,
onOpen,
onSubmenuOpen,
onSearchUsed,
placementPreference = 'auto',
openRequest,
}: ComposerPlusMenuProps) {
@@ -266,6 +334,11 @@ export function ComposerPlusMenu({
const { locale } = useI18n();
const [open, setOpen] = useState(false);
const [submenu, setSubmenu] = useState<PlusMenuSubmenu | null>(null);
const [query, setQuery] = useState('');
// Id of the plugin row the preview column is mirroring. Defaults to the
// first filtered row (see `hoveredPlugin`) so the panel is never blank
// while the menu is open.
const [hoveredPluginId, setHoveredPluginId] = useState<string | null>(null);
const [menuStyle, setMenuStyle] = useState<CSSProperties | null>(null);
const [flyoutPlacement, setFlyoutPlacement] = useState<PlusMenuFlyoutPlacement>('right');
const [flyoutVerticalPlacement, setFlyoutVerticalPlacement] = useState<PlusMenuFlyoutVerticalPlacement>('down');
@@ -278,6 +351,18 @@ export function ComposerPlusMenu({
const triggerRef = useRef<HTMLButtonElement | null>(null);
const popupRef = useRef<HTMLDivElement | null>(null);
const submenuCloseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Whether onSearchUsed already fired for the current submenu-open session.
const searchUsedRef = useRef(false);
// The plugin and MCP flyouts share one `query`, but it is scoped to whichever
// submenu is open. Reset it whenever the active submenu changes so a stale
// plugin search (e.g. "deck") never filters the MCP list — which would
// otherwise show the empty state even when servers exist.
useEffect(() => {
setQuery('');
setHoveredPluginId(null);
searchUsedRef.current = false;
}, [submenu]);
useEffect(() => () => {
if (submenuCloseTimer.current) clearTimeout(submenuCloseTimer.current);
@@ -300,8 +385,8 @@ export function ComposerPlusMenu({
// Typing into a flyout's search box narrows its list, which reflows rows
// out from under a stationary cursor — the browser then synthesizes a
// `mouseleave` on the flyout even though the pointer never moved. Honoring
// that close would yank the search box away mid-search, making the entry
// impossible to pick. Keep the submenu open
// that close would yank the search box (and its preview column) away
// mid-search, making the plugin impossible to pick. Keep the submenu open
// while its own search input still owns focus; the outside-click / Escape
// handlers remain the deliberate ways to dismiss it.
const active = document.activeElement;
@@ -369,6 +454,18 @@ export function ComposerPlusMenu({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openRequest]);
function handleQueryChange(value: string) {
if (
!searchUsedRef.current &&
value.trim() &&
(submenu === 'plugins' || submenu === 'mcp')
) {
searchUsedRef.current = true;
onSearchUsed?.(submenu);
}
setQuery(value);
}
useEffect(() => {
if (!open) return;
function onPointer(e: MouseEvent) {
@@ -416,7 +513,11 @@ export function ComposerPlusMenu({
if (next !== contentHeight) setContentHeight(next);
}
setMenuStyle(getPlusMenuStyle(anchor, placementPreference, measured));
setFlyoutPlacement(getFlyoutPlacement(anchor, PLUS_MENU_FLYOUT_WIDTH));
const flyoutWidth =
submenu === 'plugins'
? PLUS_MENU_PLUGIN_FLYOUT_WIDTH
: PLUS_MENU_FLYOUT_WIDTH;
setFlyoutPlacement(getFlyoutPlacement(anchor, flyoutWidth));
const activeRow = popupRef.current?.querySelector<HTMLDivElement>('.plus-menu__submenu-row.is-open') ?? null;
updateFlyoutGeometry(activeRow);
};
@@ -430,6 +531,22 @@ export function ComposerPlusMenu({
};
}, [open, submenu, placementPreference, contentHeight]);
const needle = query.trim().toLowerCase();
const filteredPlugins = needle
? plugins.filter((p) => pluginMatches(p, needle, localizePluginTitle(locale, p)))
: plugins;
const filteredMcp = needle
? mcpServers.filter((s) => mcpMatches(s, needle))
: mcpServers;
// The preview mirrors the hovered row, falling back to the first visible
// plugin so the panel is populated the moment the submenu opens. When a
// search prunes the hovered row out of view, the fallback re-anchors it.
const hoveredPlugin = useMemo(() => {
if (submenu !== 'plugins' || filteredPlugins.length === 0) return null;
return (
filteredPlugins.find((p) => p.id === hoveredPluginId) ?? filteredPlugins[0]
);
}, [submenu, filteredPlugins, hoveredPluginId]);
const popupStyle = menuStyle
? ({
...menuStyle,
@@ -437,6 +554,41 @@ export function ComposerPlusMenu({
} satisfies PlusMenuPopupStyle)
: undefined;
const contextActions = (
<>
{onReferenceProject ? (
<button
type="button"
role="menuitem"
className="plus-menu__item"
data-testid="composer-plus-reference-project"
onClick={() => {
close();
onReferenceProject();
}}
>
<Icon name="folder" size={15} className="plus-menu__item-icon" />
<span>{t('chat.plus.referenceProject')}</span>
</button>
) : null}
{onLinkLocalCode ? (
<button
type="button"
role="menuitem"
className="plus-menu__item"
data-testid="composer-plus-local-code"
onClick={() => {
close();
onLinkLocalCode();
}}
>
<Icon name="folder" size={15} className="plus-menu__item-icon" />
<span>{t('chat.plus.linkLocalCode')}</span>
</button>
) : null}
</>
);
return (
<div className="plus-menu" ref={rootRef}>
<button
@@ -461,11 +613,7 @@ export function ComposerPlusMenu({
aria-haspopup="menu"
aria-expanded={open}
>
{/* `od-icon` is what the stylesheet keys the 45° pivot off, so the bare
disc's glyph reads as a close × while the menu is open. The labeled
variant opts out of that pivot — its label already says what the
control does, and a tilted paperclip says nothing. */}
<Icon name="attach" size={16} className="od-icon" />
<Icon name="plus" size={16} className="od-icon" />
{triggerLabel ? (
<span className="plus-menu__trigger-label">{triggerLabel}</span>
) : null}
@@ -489,12 +637,90 @@ export function ComposerPlusMenu({
}}
>
<Icon
name={attachLoading ? 'spinner' : 'plus'}
name={attachLoading ? 'spinner' : 'attach'}
size={15}
className="plus-menu__item-icon"
/>
<span>{t('chat.attachAria')}</span>
</button>
{!onPickWorkingDir ? contextActions : null}
{hidePluginsRow || !onPickPlugin ? null : (
<PlusSubmenuRow
label={t('entry.navPlugins')}
icon="sparkles"
open={submenu === 'plugins'}
testId="composer-plus-plugins"
onOpen={(row) => openSubmenu('plugins', row)}
onClose={scheduleCloseSubmenu}
flyoutClassName={
filteredPlugins.length > 0 ? 'plus-menu__flyout--plugins' : undefined
}
>
<div className="plus-menu__plugin-pane">
<div className="plus-menu__plugin-main">
<div className="plus-menu__search">
<Icon name="search" size={14} />
<input
value={query}
onChange={(event) => handleQueryChange(event.target.value)}
placeholder={t('entry.navPlugins')}
aria-label={t('entry.navPlugins')}
/>
</div>
<div className="plus-menu__list">
{filteredPlugins.length === 0 ? (
<div className="plus-menu__empty">{t('homeHero.noPlugins')}</div>
) : (
filteredPlugins.map((plugin) => (
<button
key={plugin.id}
type="button"
role="menuitem"
className={`plus-menu__item${
plugin.id === hoveredPlugin?.id ? ' is-previewed' : ''
}`}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setHoveredPluginId(plugin.id)}
onFocus={() => setHoveredPluginId(plugin.id)}
onClick={() => {
close();
onPickPlugin(plugin);
}}
>
<Icon name="sparkles" size={15} className="plus-menu__item-icon" />
<span>{localizePluginTitle(locale, plugin)}</span>
</button>
))
)}
</div>
{onAddPlugin ? (
<>
<div className="plus-menu__divider" />
<button
type="button"
role="menuitem"
className="plus-menu__item"
onClick={() => {
close();
onAddPlugin();
}}
>
<Icon name="plus" size={15} className="plus-menu__item-icon" />
<span>{t('homeHero.addPlugin')}</span>
</button>
</>
) : null}
</div>
{hoveredPlugin ? (
<ComposerPluginPreview
record={hoveredPlugin}
locale={locale}
workspaceContext={workspaceContext}
/>
) : null}
</div>
</PlusSubmenuRow>
)}
{LIBRARY_UI_VISIBLE && onSelectFromLibrary ? (
<button
type="button"
@@ -525,21 +751,119 @@ export function ComposerPlusMenu({
<span>{t('chat.importFigma')}</span>
</button>
) : null}
{/* 附加文件 / 从 Figma 导入 both drop content straight into this
turn; the working directory below scopes what the agent may read
on disk for the whole session. Different question, own group.
Home omits the working-dir row entirely (it keeps its own footer
picker), so the rule is gated on the group it separates rather
than trailing the menu with nothing under it. */}
{onPickWorkingDir || onReferenceProject || onLinkLocalCode ? (
{onPickConnector ? (<PlusSubmenuRow
label={t('connectors.title')}
icon="link"
open={submenu === 'connectors'}
testId="composer-plus-connectors"
onOpen={(row) => openSubmenu('connectors', row)}
onClose={scheduleCloseSubmenu}
>
<div className="plus-menu__list">
{connectors.length === 0 ? (
<div className="plus-menu__empty">{t('homeHero.noConnectors')}</div>
) : (
connectors.map((connector) => (
<button
key={connector.id}
type="button"
role="menuitem"
className="plus-menu__item"
// Keep focus on the editor so the pick handler's
// insertMention lands at the caret, not the draft end.
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
close();
onPickConnector(connector);
}}
>
<Icon name="link" size={15} className="plus-menu__item-icon" />
<span>{connector.name}</span>
</button>
))
)}
</div>
{onAddConnector ? (
<>
<div className="plus-menu__divider" />
<button
type="button"
role="menuitem"
className="plus-menu__item"
onClick={() => {
close();
onAddConnector();
}}
>
<Icon name="plus" size={15} className="plus-menu__item-icon" />
<span>{t('homeHero.addConnectors')}</span>
</button>
</>
) : null}
</PlusSubmenuRow>) : null}
{onPickMcp ? (<PlusSubmenuRow
label="MCP"
icon="link"
open={submenu === 'mcp'}
testId="composer-plus-mcp"
onOpen={(row) => openSubmenu('mcp', row)}
onClose={scheduleCloseSubmenu}
>
<div className="plus-menu__search">
<Icon name="search" size={14} />
<input
value={query}
onChange={(event) => handleQueryChange(event.target.value)}
placeholder="MCP"
aria-label="MCP"
/>
</div>
<div className="plus-menu__list">
{filteredMcp.length === 0 ? (
<div className="plus-menu__empty">{t('homeHero.noMcp')}</div>
) : (
filteredMcp.map((server) => (
<button
key={server.id}
type="button"
role="menuitem"
className="plus-menu__item"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
close();
onPickMcp(server);
}}
>
<Icon name="link" size={15} className="plus-menu__item-icon" />
<span>{server.label || server.id}</span>
</button>
))
)}
</div>
{onAddMcp ? (
<>
<div className="plus-menu__divider" />
<button
type="button"
role="menuitem"
className="plus-menu__item"
onClick={() => {
close();
onAddMcp();
}}
>
<Icon name="plus" size={15} className="plus-menu__item-icon" />
<span>{t('homeHero.addMcp')}</span>
</button>
</>
) : null}
</PlusSubmenuRow>) : null}
{onPickWorkingDir ? (
<div className="plus-menu__divider" role="separator" />
) : null}
{/* Project reference + local code live INSIDE the working-dir group
below, not as siblings of 附加文件: all three answer the same
question (what may the agent read besides this thread), and the
flat list buried 附加文件 — the one thing this menu is named for
— under them. */}
{onPickWorkingDir || onReferenceProject || onLinkLocalCode ? (
{/* Callers with a working-directory picker keep context actions
in that group; other callers expose them directly. */}
{onPickWorkingDir ? (
<PlusSubmenuRow
label={t('homeWorkingDir.triggerShort')}
icon="folder"
@@ -549,22 +873,20 @@ export function ComposerPlusMenu({
onClose={scheduleCloseSubmenu}
>
<div className="plus-menu__list">
{onPickWorkingDir ? (
<button
type="button"
role="menuitem"
className="plus-menu__item"
data-testid="composer-plus-working-dir-pick"
onClick={() => {
close();
onPickWorkingDir();
}}
>
<Icon name="folder" size={15} className="plus-menu__item-icon" />
<span>{workingDir ? t('homeWorkingDir.replace') : t('homeWorkingDir.pick')}</span>
</button>
) : null}
{(onPickWorkingDir ? recentWorkingDirs ?? [] : []).map((dir) => (
<button
type="button"
role="menuitem"
className="plus-menu__item"
data-testid="composer-plus-working-dir-pick"
onClick={() => {
close();
onPickWorkingDir();
}}
>
<Icon name="folder" size={15} className="plus-menu__item-icon" />
<span>{workingDir ? t('homeWorkingDir.replace') : t('homeWorkingDir.pick')}</span>
</button>
{(recentWorkingDirs ?? []).map((dir) => (
<button
key={dir}
type="button"
@@ -580,7 +902,7 @@ export function ComposerPlusMenu({
<span>{dirBasename(dir)}</span>
</button>
))}
{onPickWorkingDir && workingDir && onClearWorkingDir ? (
{workingDir && onClearWorkingDir ? (
<button
type="button"
role="menuitem"
@@ -595,39 +917,10 @@ export function ComposerPlusMenu({
<span>{t('homeWorkingDir.clear')}</span>
</button>
) : null}
{onPickWorkingDir && (onReferenceProject || onLinkLocalCode) ? (
{onReferenceProject || onLinkLocalCode ? (
<div className="plus-menu__divider" role="separator" />
) : null}
{onReferenceProject ? (
<button
type="button"
role="menuitem"
className="plus-menu__item"
data-testid="composer-plus-reference-project"
onClick={() => {
close();
onReferenceProject();
}}
>
<Icon name="folder" size={15} className="plus-menu__item-icon" />
<span>{t('chat.plus.referenceProject')}</span>
</button>
) : null}
{onLinkLocalCode ? (
<button
type="button"
role="menuitem"
className="plus-menu__item"
data-testid="composer-plus-local-code"
onClick={() => {
close();
onLinkLocalCode();
}}
>
<Icon name="folder" size={15} className="plus-menu__item-icon" />
<span>{t('chat.plus.linkLocalCode')}</span>
</button>
) : null}
{contextActions}
</div>
</PlusSubmenuRow>
) : null}
+1
View File
@@ -1756,6 +1756,7 @@ export function EntryShell({
onDeleteProject,
onDuplicateProject,
onRenameProject,
onBrowseRegistry: () => changeView('plugins'),
onOpenIntegrations: () => openIntegrationTab('connectors'),
onOpenMcp: () => openIntegrationTab('mcp'),
onOpenNewProject: (tab: 'template') => {
+74 -9
View File
@@ -177,6 +177,7 @@ interface Props {
onRemoveConnectorContext?: (connectorId: string) => void;
onAddWorkspaceContext?: (item: WorkspaceContextItem) => void;
onRemoveWorkspaceContext?: (id: string) => void;
onAddPlugin?: () => void;
onAddConnector?: () => void;
onAddMcp?: () => void;
onOpenPluginDetails?: (record: InstalledPluginRecord) => void;
@@ -362,6 +363,7 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
onRemoveConnectorContext = () => undefined,
onAddWorkspaceContext = () => undefined,
onRemoveWorkspaceContext = () => undefined,
onAddPlugin,
onAddConnector = () => undefined,
onAddMcp = () => undefined,
onOpenPluginDetails = () => undefined,
@@ -2173,6 +2175,54 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
resource_kind: PLUS_SUBMENU_RESOURCE_KIND[submenu],
});
}}
onSearchUsed={(submenu) => {
trackHomeChatComposerClick(analytics.track, {
page_name: 'home',
area: 'chat_composer',
element: 'plus_search',
resource_kind: PLUS_SUBMENU_RESOURCE_KIND[submenu],
});
}}
connectors={connectorOptions}
onPickConnector={(connector) => {
trackHomeChatComposerClick(analytics.track, {
page_name: 'home',
area: 'chat_composer',
element: 'plus_pick',
resource_kind: 'connector',
resource_id: connector.id,
});
pickConnector(connector);
}}
onAddConnector={() => {
trackHomeChatComposerClick(analytics.track, {
page_name: 'home',
area: 'chat_composer',
element: 'plus_add',
resource_kind: 'connector',
});
onAddConnector();
}}
plugins={pluginOptions}
onPickPlugin={(record) => {
trackHomeChatComposerClick(analytics.track, {
page_name: 'home',
area: 'chat_composer',
element: 'plus_pick',
resource_kind: 'plugin',
resource_id: record.id,
});
pickPlugin(record);
}}
onAddPlugin={onAddPlugin ? () => {
trackHomeChatComposerClick(analytics.track, {
page_name: 'home',
area: 'chat_composer',
element: 'plus_add',
resource_kind: 'plugin',
});
onAddPlugin();
} : undefined}
skills={skillOptions}
onPickSkill={(skill) => {
trackHomeChatComposerClick(analytics.track, {
@@ -2184,6 +2234,26 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
});
pickSkill(skill);
}}
mcpServers={mcpOptions}
onPickMcp={(server) => {
trackHomeChatComposerClick(analytics.track, {
page_name: 'home',
area: 'chat_composer',
element: 'plus_pick',
resource_kind: 'mcp',
resource_id: server.id,
});
pickMcp(server);
}}
onAddMcp={() => {
trackHomeChatComposerClick(analytics.track, {
page_name: 'home',
area: 'chat_composer',
element: 'plus_add',
resource_kind: 'mcp',
});
onAddMcp();
}}
onAttachFiles={() => {
trackHomeChatComposerClick(analytics.track, {
page_name: 'home',
@@ -2192,8 +2262,8 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
});
fileInputRef.current?.click();
}}
onReferenceProject={onPickWorkingDir ? undefined : referenceProjectAction}
onLinkLocalCode={onPickWorkingDir ? undefined : linkLocalCodeAction}
onReferenceProject={referenceProjectAction}
onLinkLocalCode={linkLocalCodeAction}
onSelectFromLibrary={() => {
trackHomeChatComposerClick(analytics.track, {
page_name: 'home',
@@ -2396,13 +2466,8 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
});
onClearWorkingDir?.();
}}
// Analytics keep the original `plus_pick` element name so the
// funnel stays continuous across the move.
onReferenceProject={referenceProjectAction}
onLinkLocalCode={linkLocalCodeAction}
/* All four menu rows answer one question — what may the agent read
besides this thread — so they all report the same way: the
trigger takes the pick's name, and its hover × clears it (per
/* The trigger reflects the most recent attached context; its
hover × clears that selection (per
product: 工作目录会换成后边的文件名…和现在选择最近使用的文件夹的
逻辑一样). The LAST attached item is the one named; a directory
outranks it. */
+3
View File
@@ -256,6 +256,7 @@ interface Props {
onDeleteProject?: (id: string) => Promise<boolean | void> | boolean | void;
onDuplicateProject?: (id: string) => Promise<void> | void;
onRenameProject?: (id: string, name: string) => void;
onBrowseRegistry?: () => void;
onOpenIntegrations?: () => void;
onOpenMcp?: () => void;
// Stage B: optional callbacks the rail's migration chips need.
@@ -470,6 +471,7 @@ export function HomeView({
defaultDesignSystemId = null,
onSubmit,
onOpenProject,
onBrowseRegistry,
onOpenIntegrations,
onOpenMcp,
onOpenNewProject,
@@ -2871,6 +2873,7 @@ export function HomeView({
onRemoveConnectorContext={removeConnectorContext}
onAddWorkspaceContext={addWorkspaceContext}
onRemoveWorkspaceContext={removeWorkspaceContext}
onAddPlugin={onBrowseRegistry}
onAddConnector={onOpenIntegrations}
onAddMcp={onOpenMcp}
onOpenPluginDetails={setDetailsRecord}
@@ -67,8 +67,8 @@ import type {
import { buildVisualAnnotationAttachment, commentTargetDisplayName } from '../../../comments';
import { Icon, type IconName } from "./components/Icon";
import { ChatCloseIcon, ChatFileIcon, ChatSendArrowIcon } from "../primitives/icons";
import { ComposerPlusMenu } from '../../ComposerPlusMenu';
import { PLUS_SUBMENU_RESOURCE_KIND, type PlusMenuSubmenu } from './components/ComposerPlusMenu';
import { ComposerPlusMenu, PLUS_SUBMENU_RESOURCE_KIND } from '../../ComposerPlusMenu';
import type { PlusMenuSubmenu } from './components/ComposerPlusMenu';
import { LibraryPicker } from '../../LibraryPicker';
import { FigmaImportModal } from '../../FigmaImportModal';
import { FigmaHelpModal } from '../../FigmaHelpModal';
@@ -3660,11 +3660,69 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
workspaceContext={workspaceContext}
triggerTestId="chat-plus-trigger"
placementPreference="up"
openRequest={plusMenuOpenRequest ? { nonce: plusMenuOpenRequest.nonce } : null}
openRequest={plusMenuOpenRequest ? {
nonce: plusMenuOpenRequest.nonce,
submenu: plusMenuOpenRequest.submenu === 'toolbox'
? undefined
: plusMenuOpenRequest.submenu,
} : null}
onOpen={() => {
trackComposerBar({ element: 'plus_menu_open' });
setComposerEngaged(true);
}}
onSubmenuOpen={(submenu) => {
if (submenu === 'workingDir') return;
trackComposerBar({
element: 'plus_submenu_open',
resource_kind: PLUS_SUBMENU_RESOURCE_KIND[submenu],
});
}}
onSearchUsed={(submenu) => {
trackComposerBar({
element: 'plus_search',
resource_kind: PLUS_SUBMENU_RESOURCE_KIND[submenu],
});
}}
onReferenceProject={() => {
trackComposerBar({ element: 'plus_pick', resource_kind: 'workspace', resource_id: 'reference-project' });
trackProjectReferenceModalSurfaceView(analytics.track, {
page_name: 'chat_panel',
area: 'project_reference_modal',
...(projectId ? { project_id: projectId } : {}),
});
setProjectReferenceOpen(true);
}}
onLinkLocalCode={() => {
trackComposerBar({ element: 'plus_pick', resource_kind: 'workspace', resource_id: 'local-code' });
void handleLinkLocalCodeContext();
}}
plugins={pluginsForComposer}
onPickPlugin={(record) => {
trackComposerBar({ element: 'plus_pick', resource_kind: 'plugin', resource_id: record.id });
void insertPluginMention(record);
}}
onAddPlugin={onBrowsePlugins ? () => {
trackComposerBar({ element: 'plus_add', resource_kind: 'plugin' });
onBrowsePlugins();
} : undefined}
connectors={connectors}
onPickConnector={(connector) => {
trackComposerBar({ element: 'plus_pick', resource_kind: 'connector', resource_id: connector.id });
insertConnectorMention(connector);
}}
onAddConnector={onOpenConnectors ? () => {
trackComposerBar({ element: 'plus_add', resource_kind: 'connector' });
onOpenConnectors();
} : undefined}
mcpServers={enabledMcpServers}
onPickMcp={(server) => {
trackComposerBar({ element: 'plus_pick', resource_kind: 'mcp', resource_id: server.id });
insertMcpMention(server);
}}
onAddMcp={onOpenMcpSettings ? () => {
trackComposerBar({ element: 'plus_add', resource_kind: 'mcp' });
onOpenMcpSettings();
} : undefined}
onAttachFiles={() => fileInputRef.current?.click()}
attachLoading={uploading}
onSelectFromLibrary={() => setLibraryPickerOpen(true)}
@@ -1759,8 +1759,7 @@ body:has(.manual-edit-workspace) .chat-composer-fixed-layer:where([data-chat-roo
三层一起抬,少一层就会错位:真正写字的那层、Lexical 的静态占位符、
以及盖在占位符原位上的打字机轮播 —— 它们是同一行字的三种状态。
(行内 @芯片的 19px 方盒不跟着变:它按 `vertical-align: middle` 居中,
高度不是从行高推出来的,见下面 `.composer-inline-mention` 那段注释。) */
行内 @ 标签通过 `vertical-align: middle` 居中,高度由文字和内边距决定。 */
[data-chat-root] .composer-input-editor [contenteditable='true']:where([data-chat-root], [data-chat-root] *),
[data-chat-root] .composer-input-editor .composer-editable:where([data-chat-root], [data-chat-root] *),
[data-chat-root] .composer-input-placeholder:where([data-chat-root], [data-chat-root] *),
@@ -1787,15 +1786,11 @@ body:has(.manual-edit-workspace) .chat-composer-fixed-layer:where([data-chat-roo
display: inline-flex;
align-items: center;
gap: 4px;
/* Dify recipe: fixed-px box + vertical-align:middle, NOT an em-relative
height with a magic baseline nudge. A 19px box centers cleanly in the
editor's 21.6px line (13.5px × 1.6) and stays put under zoom / font
scaling because none of these metrics are derived from the pill's own
reduced font-size. */
height: 19px;
/* Let the text and padding determine the height so the label stays visible. */
height: auto;
max-width: min(240px, 25vw);
margin: 0 0.5px;
padding: 0 7px 0 6px;
padding: 4px;
border: 1px solid color-mix(in srgb, var(--m-hue) 26%, var(--border));
border-radius: var(--radius-sm);
background: var(--m-tint);
@@ -2853,6 +2848,12 @@ body:has(.manual-edit-workspace) .chat-composer-fixed-layer:where([data-chat-roo
background-color 140ms cubic-bezier(0.23, 1, 0.32, 1),
border-color 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
/* All tags above the composer share the same inset, including context,
skill, file, comment and applied-plugin variants. */
.composer-shell .staged-chip:where([data-chat-root], [data-chat-root] *) {
height: auto;
padding: 4px;
}
.staged-chip:hover:where([data-chat-root], [data-chat-root] *) {
background: var(--bg-panel);
border-color: var(--border-strong);
+9 -7
View File
@@ -1540,15 +1540,11 @@
display: inline-flex;
align-items: center;
gap: 4px;
/* Dify recipe: fixed-px box + vertical-align:middle, NOT an em-relative
height with a magic baseline nudge. A 19px box centers cleanly in the
editor's 21.6px line (13.5px × 1.6) and stays put under zoom / font
scaling because none of these metrics are derived from the pill's own
reduced font-size. */
height: 19px;
/* Let the text and padding determine the height so the label stays visible. */
height: auto;
max-width: min(240px, 25vw);
margin: 0 0.5px;
padding: 0 7px 0 6px;
padding: 4px;
border: 1px solid color-mix(in srgb, var(--m-hue) 26%, var(--border));
border-radius: var(--radius-sm);
background: var(--m-tint);
@@ -2547,6 +2543,12 @@
background-color 140ms cubic-bezier(0.23, 1, 0.32, 1),
border-color 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
/* All tags above the composer share the same inset, including context,
skill, file, comment and applied-plugin variants. */
.composer-shell .staged-chip:where(:not([data-chat-root], [data-chat-root] *)) {
height: auto;
padding: 4px;
}
.staged-chip:hover:where(:not([data-chat-root], [data-chat-root] *)) {
background: var(--bg-panel);
border-color: var(--border-strong);
+8 -7
View File
@@ -2078,11 +2078,11 @@
}
/* The picker is the upload disc's twin at the head of the card's foot row (per
product: 设计系统常驻在添加附件后面,样式和 icon 的大小和上传附件一致), so it
copies `.home-hero__foot-left .plus-menu__trigger` exactly: the same 36px
matches the adjacent `.plus-menu__trigger` on Home and in project chat: the same 36px
band, the same pale fill, no stroke and no shadow. `height: auto` undoes
primitives.css's blanket `button { height: 36px }`, which min-height alone
cannot override. */
.home-hero__foot-left .home-hero__ds-row-trigger {
:is(.home-hero__foot-left, .composer-row) .home-hero__ds-row-trigger {
box-sizing: border-box;
min-height: 36px;
height: auto;
@@ -2098,17 +2098,18 @@
box-shadow: none;
}
/* Same hover as the upload disc beside it — the pair lights up identically. */
.home-hero__foot-left .home-hero__ds-row-picker .home-hero__ds-row-trigger:hover,
.home-hero__foot-left .home-hero__ds-row-picker .home-hero__ds-row-trigger:focus-visible {
:is(.home-hero__foot-left, .composer-row) .home-hero__ds-row-picker .home-hero__ds-row-trigger:hover,
:is(.home-hero__foot-left, .composer-row) .home-hero__ds-row-picker .home-hero__ds-row-trigger:focus-visible,
:is(.home-hero__foot-left, .composer-row) .home-hero__ds-row-trigger[aria-expanded="true"] {
background: var(--bg-subtle);
color: var(--text-strong);
}
.home-hero__foot-left .home-hero__ds-row-trigger-icon {
:is(.home-hero__foot-left, .composer-row) .home-hero__ds-row-trigger-icon {
opacity: 1;
}
/* Nothing picked yet: the palette glyph alone (the field name lives on the
tooltip), so the control is the upload disc's exact 36px circle. */
.home-hero__foot-left .home-hero__ds-row-trigger.is-icon-only {
:is(.home-hero__foot-left, .composer-row) .home-hero__ds-row-trigger.is-icon-only {
width: 36px;
min-width: 36px;
height: 36px;
@@ -2118,7 +2119,7 @@
border-radius: var(--radius-circular, 50%);
}
/* The pill never gets squeezed by a long prompt-input row beside it. */
.home-hero__foot-left .home-hero__ds-row-picker {
:is(.home-hero__foot-left, .composer-row) .home-hero__ds-row-picker {
flex: 0 0 auto;
}
.home-hero__footer-options {
+23 -5
View File
@@ -322,11 +322,15 @@
font-size: 12px;
}
/* ---------- Plugin pane: list + hover-preview column ---------- */
/* Owned by the project composer's 插件 quick pill popover
(ChatComposer's ComposerPluginsPaneContent), which sizes itself through
`.composer-plugins-standalone-popup`. The left column is the familiar
search + list, the right column mirrors the hovered row with a live
preview hero and metadata. */
/* The Home submenu and project quick pill share these two columns.
Match the submenu width reserved by PLUS_MENU_PLUGIN_FLYOUT_WIDTH so
the list leaves room for the preview. */
.plus-menu__flyout--plugins {
min-width: 0;
width: 466px;
max-width: calc(100vw - 24px);
overflow: hidden;
}
.plus-menu__plugin-pane {
display: flex;
gap: 8px;
@@ -423,7 +427,21 @@
}
/* Narrow surfaces (contained flyout) have no room for a side column, so
the preview drops below the list and the hero height is capped. */
.plus-menu__popup--flyout-contained .plus-menu__flyout--plugins {
width: auto;
overflow-y: auto;
}
.plus-menu__popup--flyout-contained .plus-menu__plugin-pane {
flex-direction: column;
flex: 0 0 auto;
}
.plus-menu__popup--flyout-contained .plus-menu__plugin-main {
flex: 0 0 auto;
min-width: 0;
max-width: none;
}
.plus-menu__popup--flyout-contained .plus-menu__preview {
flex: 0 0 auto;
border-left: 0;
border-top: 1px solid var(--border);
/* Stacked layout: the side-column inset above doesn't apply — full-width
+2 -2
View File
@@ -590,14 +590,14 @@ select.plugin-inputs-form__input {
min-height: 24px;
max-width: 100%;
gap: 0;
padding: 0 3px 0 0;
padding: 4px;
background: color-mix(in srgb, var(--bg-panel) 88%, var(--c-tint));
border-color: color-mix(in srgb, var(--c-hue) 16%, var(--border-soft));
}
.composer-shell .context-chip-strip__body {
gap: 6px;
min-width: 0;
padding: 2px 6px 2px 8px;
padding: 0;
}
/* Drop the badge box — let a small, hue-tinted type icon lead, exactly
like a file context chip. */