agent-Specialization/static/src/components/sidebar/WorkspaceSwitcher.vue
JOJO a900d6cc61 fix(sidebar): 修复删除工作区不生效与控制台 TDZ 报错
- ConversationSidebar: immediate watcher 在 setup 阶段同步执行,却引用了
  之后才声明的 isGroupByWorkspaceActive,触发 TDZ ReferenceError;
  调整声明顺序并加注释防回退
- WorkspaceSwitcher: onDocClick 为捕获阶段监听,未排除 teleport 到 body
  的二级菜单,点击菜单项(删除/重命名)会先关闭整个浮层,导致内联
  确认框永远不可见、删除流程无法走完;补充 .ws-menu-floating 排除
- WorkspaceSwitcher: 删除确认按钮区靠右对齐
2026-07-23 21:55:14 +08:00

923 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<Teleport to="body">
<transition name="ws-switcher">
<div
v-if="open"
ref="popoverEl"
class="ws-switcher"
:class="{ 'ws-switcher--project': isProject }"
:style="popoverStyle"
role="dialog"
:aria-label="noun"
@click.stop
>
<div class="ws-switcher__header">
<span class="ws-switcher__title">{{ noun }}</span>
<span class="ws-switcher__count">{{ workspaces.length }} 个</span>
</div>
<div class="ws-switcher__list" ref="listEl" @scroll="closeMenu">
<div
v-for="item in workspaces"
:key="item.workspace_id"
class="ws-row"
:data-ws-id="item.workspace_id"
:class="{
current: item.workspace_id === currentWorkspaceId,
'menu-open': menuOpenId === item.workspace_id
}"
role="option"
:aria-selected="item.workspace_id === currentWorkspaceId"
@click="handleRowClick(item)"
>
<!-- 删除确认内联 -->
<div v-if="deletingId === item.workspace_id" class="ws-confirm">
<div class="ws-confirm-text">
删除「{{ item.label }}」?{{ isProject ? '项目文件夹和对话记录将一并删除。' : '不会删除磁盘上的文件夹。' }}
</div>
<div class="ws-confirm-actions">
<button type="button" class="ws-btn ghost" :disabled="busy" @click.stop="deletingId = null">
取消
</button>
<button type="button" class="ws-btn danger" :disabled="busy" @click.stop="confirmDelete(item)">
删除
</button>
</div>
</div>
<template v-else>
<span class="ws-row-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
</span>
<span class="ws-row-text">
<input
v-if="renamingId === item.workspace_id"
ref="renameInputEl"
v-model="renameDraft"
class="ws-rename-input"
:disabled="busy"
@click.stop
@keydown.enter.prevent="commitRename(item)"
@keydown.esc.prevent="renamingId = null"
/>
<span v-else class="ws-row-name">{{ item.label }}</span>
<span v-if="!isProject" class="ws-row-path">{{ item.path || '(未配置路径)' }}</span>
</span>
<span class="ws-row-meta">
<span v-if="item.workspace_id === defaultWorkspaceId" class="ws-tag">默认</span>
<button
type="button"
class="ws-more"
:aria-expanded="menuOpenId === item.workspace_id"
aria-label="更多操作"
@click.stop="toggleMenu(item)"
>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="19" cy="12" r="1.6" />
</svg>
</button>
</span>
</template>
</div>
<div v-if="!workspaces.length" class="ws-empty">
{{ isProject ? '暂无项目' : '暂无工作区' }}
</div>
</div>
<!-- 底部:新建按钮 ⇄ 新建表单(高度动画容器) -->
<div class="ws-bottom" ref="bottomEl">
<div v-if="!createOpen" class="ws-footer" :class="{ fading: bottomFading }">
<button type="button" class="ws-create-btn" :disabled="busy" @click.stop="openCreateForm">
<span class="ws-create-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="M12 5v14" />
</svg>
</span>
<span>新建{{ noun }}</span>
</button>
</div>
<form v-else class="ws-create-form" :class="{ fading: bottomFading }" @submit.prevent="submitCreate">
<input
ref="createNameEl"
v-model="createLabel"
type="text"
:placeholder="isProject ? '项目名称' : '工作区名称(可选)'"
autocomplete="off"
:disabled="createSubmitting"
/>
<input
v-if="!isProject"
v-model="createPath"
type="text"
placeholder="路径:绝对路径或相对仓库路径"
autocomplete="off"
:disabled="createSubmitting"
/>
<div v-if="errorMessage" class="ws-create-error">{{ errorMessage }}</div>
<div class="ws-create-actions">
<button type="button" class="ws-btn ghost" :disabled="createSubmitting" @click.stop="closeCreateForm">
取消
</button>
<button type="submit" class="ws-btn primary" :disabled="createSubmitting">
{{ createSubmitting ? '创建中...' : '创建' }}
</button>
</div>
</form>
</div>
</div>
</transition>
<!-- 二级菜单fixed 浮层逃逸容器裁切同个人空间二级菜单方案 -->
<transition name="ws-menu">
<div
v-if="menuOpenId"
class="ws-menu-floating"
:style="menuStyle"
role="menu"
@click.stop
>
<button
type="button"
:disabled="menuOpenId === defaultWorkspaceId || busy"
@click="handleMenuAction('set-default')"
>
设为默认
</button>
<button type="button" :disabled="busy" @click="handleMenuAction('rename')">重命名</button>
<button type="button" :disabled="isProject || busy" @click="handleMenuAction('reveal')">
在文件夹中打开
</button>
<div class="ws-menu-sep"></div>
<button type="button" class="danger" :disabled="busy" @click="handleMenuAction('delete')">删除</button>
</div>
</transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
defineOptions({ name: 'WorkspaceSwitcher' });
type WorkspaceItem = {
workspace_id: string;
label: string;
path?: string;
running_task_count?: number;
};
type AnchorRect = { left: number; top: number; right: number; bottom: number };
const props = withDefaults(
defineProps<{
open: boolean;
anchorRect?: AnchorRect | null;
workspaces?: WorkspaceItem[];
currentWorkspaceId?: string;
defaultWorkspaceId?: string;
workspaceKind?: 'workspace' | 'project';
busy?: boolean;
createSubmitting?: boolean;
errorMessage?: string;
}>(),
{
anchorRect: null,
workspaces: () => [],
currentWorkspaceId: '',
defaultWorkspaceId: '',
workspaceKind: 'workspace',
busy: false,
createSubmitting: false,
errorMessage: ''
}
);
const emit = defineEmits<{
(event: 'close'): void;
(event: 'switch', workspaceId: string): void;
(event: 'create', payload: { path: string; label: string }): void;
(event: 'rename', payload: { workspace_id: string; label: string }): void;
(event: 'delete', item: WorkspaceItem): void;
(event: 'set-default', workspaceId: string): void;
(event: 'reveal', workspaceId: string): void;
}>();
const isProject = computed(() => props.workspaceKind === 'project');
const noun = computed(() => (isProject.value ? '项目' : '工作区'));
const popoverEl = ref<HTMLElement | null>(null);
const listEl = ref<HTMLElement | null>(null);
const bottomEl = ref<HTMLElement | null>(null);
const createNameEl = ref<HTMLInputElement | null>(null);
const renameInputEl = ref<HTMLInputElement[] | null>(null);
const menuOpenId = ref<string | null>(null);
const renamingId = ref<string | null>(null);
const deletingId = ref<string | null>(null);
const renameDraft = ref('');
const createOpen = ref(false);
const createPath = ref('');
const createLabel = ref('');
const bottomFading = ref(false);
/* ---------- 浮层定位:锚定入口按钮,夹紧在视口内 ---------- */
const POPOVER_WIDTH = 300;
const popoverStyle = ref<Record<string, string>>({});
const updatePosition = () => {
const anchor = props.anchorRect;
if (!anchor || typeof window === 'undefined') return;
const vw = window.innerWidth;
const vh = window.innerHeight;
const popH = popoverEl.value?.offsetHeight || 360;
let left = anchor.right + 8;
let top = anchor.top - 6;
/* 右侧空间不足(如移动端全屏侧边栏)→ 改为在入口下方展开 */
if (left + POPOVER_WIDTH > vw - 8) {
left = Math.max(8, Math.min(anchor.left, vw - POPOVER_WIDTH - 8));
top = anchor.bottom + 6;
}
if (top + popH > vh - 8) top = Math.max(8, vh - popH - 8);
popoverStyle.value = {
left: `${Math.round(left)}px`,
top: `${Math.round(top)}px`
};
};
/* ---------- 「…」浮动菜单定位 ---------- */
const menuStyle = ref<Record<string, string>>({});
const closeMenu = () => {
menuOpenId.value = null;
};
const toggleMenu = async (item: WorkspaceItem) => {
if (props.busy) return;
if (menuOpenId.value === item.workspace_id) {
closeMenu();
return;
}
renamingId.value = null;
deletingId.value = null;
menuOpenId.value = item.workspace_id;
await nextTick();
const id = item.workspace_id;
const rowEl = listEl.value?.querySelector(`.ws-row[data-ws-id="${CSS.escape(id)}"]`);
const btn = rowEl?.querySelector('.ws-more') as HTMLElement | null;
if (!btn) {
closeMenu();
return;
}
const rect = btn.getBoundingClientRect();
const menuEl = document.querySelector('.ws-menu-floating') as HTMLElement | null;
const mw = menuEl?.offsetWidth || 150;
const mh = menuEl?.offsetHeight || 150;
let left = rect.right - mw;
let top = rect.bottom + 4;
left = Math.max(8, Math.min(left, window.innerWidth - mw - 8));
if (top + mh > window.innerHeight - 8) top = rect.top - mh - 4;
menuStyle.value = { left: `${Math.round(left)}px`, top: `${Math.round(top)}px` };
};
const handleMenuAction = (act: 'set-default' | 'rename' | 'reveal' | 'delete') => {
const id = menuOpenId.value;
const item = props.workspaces.find((w) => w.workspace_id === id);
closeMenu();
if (!item) return;
if (act === 'set-default') {
emit('set-default', item.workspace_id);
} else if (act === 'rename') {
renamingId.value = item.workspace_id;
renameDraft.value = item.label;
nextTick(() => {
const el = renameInputEl.value?.[0];
el?.focus();
el?.select();
});
} else if (act === 'reveal') {
emit('reveal', item.workspace_id);
} else if (act === 'delete') {
deletingId.value = item.workspace_id;
}
};
/* ---------- 行行为 ---------- */
const handleRowClick = (item: WorkspaceItem) => {
if (props.busy) return;
if (renamingId.value || deletingId.value) return;
if (menuOpenId.value) {
closeMenu();
return;
}
if (item.workspace_id !== props.currentWorkspaceId) {
emit('switch', item.workspace_id);
}
};
const commitRename = (item: WorkspaceItem) => {
const label = renameDraft.value.trim();
if (label && label !== item.label) {
emit('rename', { workspace_id: item.workspace_id, label });
}
renamingId.value = null;
};
const confirmDelete = (item: WorkspaceItem) => {
deletingId.value = null;
emit('delete', item);
};
/* ---------- 新建表单:底部高度动画 + 交叉淡入淡出 ---------- */
let bottomAnimating = false;
const swapBottom = (toForm: boolean, done?: () => void) => {
if (bottomAnimating) return;
bottomAnimating = true;
const bottom = bottomEl.value;
if (!bottom) {
createOpen.value = toForm;
bottomAnimating = false;
if (done) done();
return;
}
const h0 = bottom.offsetHeight;
bottom.style.height = `${h0}px`;
bottomFading.value = true;
window.setTimeout(() => {
createOpen.value = toForm;
nextTick(() => {
/* scrollHeight 至少等于 clientHeight固定高度下会被旧高度污染
切 auto 测真实目标高度(此时新内容仍在淡出态,视觉无跳变) */
bottom.style.height = 'auto';
const h1 = bottom.offsetHeight;
bottom.style.height = `${h0}px`;
void bottom.offsetHeight; /* 强制 reflow确保过渡从 h0 开始 */
bottom.style.height = `${h1}px`;
bottomFading.value = false;
window.setTimeout(() => {
bottom.style.height = '';
bottomAnimating = false;
if (done) done();
}, 210);
});
}, 130);
};
const openCreateForm = () => {
if (createOpen.value || props.busy) return;
closeMenu();
createPath.value = '';
createLabel.value = '';
swapBottom(true, () => createNameEl.value?.focus());
};
const closeCreateForm = () => {
if (!createOpen.value) return;
swapBottom(false);
};
const submitCreate = () => {
emit('create', {
path: createPath.value.trim(),
label: createLabel.value.trim()
});
};
/* 创建成功后由父级刷新列表;成功后收起表单 */
watch(
() => props.createSubmitting,
(submitting, prev) => {
if (prev && !submitting && createOpen.value && !props.errorMessage) {
closeCreateForm();
}
}
);
/* ---------- 全局关闭 ---------- */
const onDocClick = (event: MouseEvent) => {
const target = event.target as Node | null;
if (!target) return;
/* 二级菜单 teleport 到 body、与 popoverEl 是兄弟节点capture 阶段会先于此处
触发,若不排除会导致点击菜单项(删除/重命名)时整个浮层先被关闭,
菜单动作设置的内联状态deletingId/renamingId永远不可见。 */
if ((target as Element).closest?.('.ws-menu-floating')) return;
if (popoverEl.value && !popoverEl.value.contains(target)) {
/* 点击入口按钮:交给按钮自身的 toggle 处理(已展开则收起),
避免 capture 阶段先 close、按钮 click 又重新打开导致重播动画 */
const anchor = props.anchorRect;
if (anchor) {
const x = event.clientX;
const y = event.clientY;
if (x >= anchor.left && x <= anchor.right && y >= anchor.top && y <= anchor.bottom) {
return;
}
}
emit('close');
}
};
const onDocKeydown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (menuOpenId.value) {
closeMenu();
return;
}
if (renamingId.value || deletingId.value) {
renamingId.value = null;
deletingId.value = null;
return;
}
if (createOpen.value) {
closeCreateForm();
return;
}
emit('close');
};
const onWindowResize = () => {
closeMenu();
updatePosition();
};
watch(
() => props.open,
async (open) => {
if (open) {
menuOpenId.value = null;
renamingId.value = null;
deletingId.value = null;
await nextTick();
updatePosition();
document.addEventListener('click', onDocClick, true);
document.addEventListener('keydown', onDocKeydown);
window.addEventListener('resize', onWindowResize);
} else {
if (createOpen.value) createOpen.value = false;
document.removeEventListener('click', onDocClick, true);
document.removeEventListener('keydown', onDocKeydown);
window.removeEventListener('resize', onWindowResize);
}
}
);
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick, true);
document.removeEventListener('keydown', onDocKeydown);
window.removeEventListener('resize', onWindowResize);
});
</script>
<style scoped>
/* ---------- 浮层主体:单层扁平容器 ---------- */
.ws-switcher {
--ws-row-h: 50px;
position: fixed;
z-index: 1500;
width: 300px;
display: flex;
flex-direction: column;
border: 1px solid var(--border-default);
border-radius: 12px;
background: var(--surface-raised);
box-shadow: var(--theme-shadow-soft);
overflow: hidden;
}
.ws-switcher--project {
--ws-row-h: 38px;
}
.ws-switcher-enter-active,
.ws-switcher-leave-active {
transition: opacity 120ms ease, transform 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.ws-switcher-enter-from,
.ws-switcher-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
/* 头部 */
.ws-switcher__header {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
height: 42px;
padding: 0 14px;
border-bottom: 1px solid var(--border-default);
}
.ws-switcher__title {
font-size: 12.5px;
font-weight: 600;
color: var(--text-secondary);
}
.ws-switcher__count {
font-size: 11px;
color: var(--text-tertiary);
}
/* 列表:刚好 7 行7h + 6gap + 上下padding超出内部滚动 */
.ws-switcher__list {
flex: none;
max-height: calc(var(--ws-row-h) * 7 + 18px);
overflow-y: auto;
padding: 6px;
display: flex;
flex-direction: column;
gap: 1px;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
.ws-switcher__list::-webkit-scrollbar {
width: 8px;
}
.ws-switcher__list::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 4px;
border: 2px solid var(--surface-raised);
}
.ws-switcher__list::-webkit-scrollbar-track {
background: transparent;
}
/* 工作区行 */
.ws-row {
position: relative;
/* flex 子项默认 shrink:1内容超出容器 max-height 时会被压到 min-content
(双行文字 35px导致行高失效、可见行数虚多必须禁止压缩 */
flex: none;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: var(--ws-row-h);
padding: 0 8px;
border-radius: 8px;
text-align: left;
cursor: pointer;
}
.ws-row:hover {
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
}
.ws-row.current {
background: color-mix(in srgb, var(--text-primary) 6%, transparent);
}
.ws-row.current:hover {
background: color-mix(in srgb, var(--text-primary) 7%, transparent);
}
.ws-row-icon {
flex: none;
width: 17px;
height: 17px; /* 行容器 align-items: center整行上下居中 */
color: var(--text-tertiary);
}
.ws-row-icon svg {
width: 100%;
height: 100%;
display: block;
}
.ws-row.current .ws-row-icon {
color: var(--text-secondary);
}
.ws-row-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.ws-row-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws-row-path {
font-size: 11.5px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws-row-meta {
flex: none;
display: flex;
align-items: center;
gap: 8px;
}
.ws-tag {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
}
.ws-more {
flex: none;
width: 24px;
height: 24px;
display: none;
align-items: center;
justify-content: center;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
}
.ws-more svg {
width: 15px;
height: 15px;
display: block;
}
.ws-more:hover {
color: var(--text-primary);
background: color-mix(in srgb, var(--text-primary) 7%, transparent);
}
.ws-row:hover .ws-more,
.ws-row.menu-open .ws-more {
display: flex;
}
.ws-empty {
padding: 18px 8px;
font-size: 12px;
color: var(--text-tertiary);
text-align: center;
}
/* 重命名内联输入 */
.ws-rename-input {
font: inherit;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
background: transparent;
border: 0;
border-bottom: 1px solid var(--accent);
outline: none;
padding: 1px 0;
width: 100%;
}
/* 删除确认内联 */
.ws-confirm {
display: flex;
flex-direction: column;
gap: 5px;
width: 100%;
padding: 2px 0;
}
.ws-confirm-text {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
.ws-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* 通用小按钮 */
.ws-btn {
height: 26px;
padding: 0 10px;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: transparent;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
}
.ws-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
}
.ws-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ws-btn.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--on-accent);
}
.ws-btn.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.ws-btn.danger {
background: var(--state-danger);
border-color: var(--state-danger);
color: var(--on-accent);
}
.ws-btn.danger:hover:not(:disabled) {
background: var(--state-danger-strong);
}
.ws-btn.ghost {
border-color: var(--border-default);
}
/* 底部区域:高度动画容器 */
.ws-bottom {
flex: none;
border-top: 1px solid var(--border-default);
overflow: hidden;
transition: height 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.ws-footer {
padding: 6px;
transition: opacity 130ms ease;
}
.ws-footer.fading {
opacity: 0;
}
.ws-create-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 34px;
padding: 0 8px;
border: 0;
border-radius: 8px;
background: transparent;
font-size: 12.5px;
color: var(--text-secondary);
cursor: pointer;
}
.ws-create-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
color: var(--text-primary);
}
.ws-create-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ws-create-icon {
flex: none;
width: 15px;
height: 15px;
color: var(--text-tertiary);
}
.ws-create-icon svg {
width: 100%;
height: 100%;
display: block;
}
.ws-create-btn:hover:not(:disabled) .ws-create-icon {
color: var(--text-secondary);
}
/* 新建表单 */
.ws-create-form {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
transition: opacity 150ms ease;
}
.ws-create-form.fading {
opacity: 0;
}
.ws-create-form input {
height: 32px;
padding: 0 10px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-soft);
color: var(--text-primary);
font: inherit;
font-size: 12.5px;
outline: none;
}
.ws-create-form input:focus {
border-color: var(--accent);
}
.ws-create-form input::placeholder {
color: var(--text-muted);
}
.ws-create-error {
font-size: 12px;
color: var(--state-danger);
}
.ws-create-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* 「…」浮动菜单Teleport 到 bodyfixed 定位逃逸裁切) */
.ws-menu-floating {
position: fixed;
z-index: 1600;
min-width: 136px;
padding: 4px;
border: 1px solid var(--border-default);
border-radius: 10px;
background: var(--surface-raised);
box-shadow: var(--theme-shadow-soft);
display: flex;
flex-direction: column;
}
.ws-menu-enter-active,
.ws-menu-leave-active {
transition: opacity 110ms ease, transform 140ms cubic-bezier(0.22, 1, 0.36, 1);
}
.ws-menu-enter-from,
.ws-menu-leave-to {
opacity: 0;
transform: translateY(-3px) scale(0.98);
}
.ws-menu-floating button {
display: flex;
align-items: center;
width: 100%;
height: 30px;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
font-size: 12.5px;
color: var(--text-secondary);
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.ws-menu-floating button:hover:not(:disabled) {
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
color: var(--text-primary);
}
.ws-menu-floating button.danger {
color: var(--state-danger);
}
.ws-menu-floating button.danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--state-danger) 8%, transparent);
}
.ws-menu-floating button:disabled {
color: var(--text-muted);
cursor: default;
}
.ws-menu-sep {
height: 1px;
margin: 4px 6px;
background: var(--border-default);
}
</style>