mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-28 23:15:13 +08:00
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:
@@ -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}
|
||||
|
||||
@@ -1756,6 +1756,7 @@ export function EntryShell({
|
||||
onDeleteProject,
|
||||
onDuplicateProject,
|
||||
onRenameProject,
|
||||
onBrowseRegistry: () => changeView('plugins'),
|
||||
onOpenIntegrations: () => openIntegrationTab('connectors'),
|
||||
onOpenMcp: () => openIntegrationTab('mcp'),
|
||||
onOpenNewProject: (tab: 'template') => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user