WorkspaceShell.vue 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
  1. <script setup lang="ts">
  2. import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  3. import { useRoute, useRouter } from 'vue-router'
  4. import type { Profile } from '@shared/types'
  5. import AgentAvatar from '@/components/common/AgentAvatar.vue'
  6. import AppIcon from '@/components/common/AppIcon.vue'
  7. import BrandMark from '@/components/common/BrandMark.vue'
  8. import NodePairingDialog from '@/components/app/NodePairingDialog.vue'
  9. import SystemUpdateDialog from '@/components/app/SystemUpdateDialog.vue'
  10. import SystemManagementDialog from '@/components/app/SystemManagementDialog.vue'
  11. import AccountSecurityDialog from '@/components/app/AccountSecurityDialog.vue'
  12. import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
  13. type NavItem = {
  14. key: 'chat' | 'groups' | 'files'
  15. label: string
  16. path: string
  17. icon: 'chat' | 'groups' | 'files'
  18. }
  19. const SIDEBAR_COLLAPSED_KEY = 'hermes-yaoyao:sidebar-collapsed'
  20. const SIDEBAR_SEARCH_EVENT = 'hermes-yaoyao:sidebar-search'
  21. const SIDEBAR_SEARCH_CLOSE_EVENT = 'hermes-yaoyao:sidebar-search-close'
  22. const props = withDefaults(defineProps<{
  23. userName?: string
  24. pairingUserName?: string
  25. activeProfile?: Profile
  26. profiles?: Profile[]
  27. theme?: 'light' | 'dark'
  28. insecureTransport?: boolean
  29. sidebarTitle?: string
  30. sidebarSubtitle?: string
  31. sidebarContextTitle?: string
  32. sidebarFocusMode?: boolean
  33. inspectorOpen?: boolean
  34. inspectorCloseLabel?: string
  35. isAdmin?: boolean
  36. upstreamReady?: boolean
  37. upstreamError?: string
  38. }>(), {
  39. userName: '',
  40. pairingUserName: '',
  41. activeProfile: undefined,
  42. profiles: () => [],
  43. theme: 'light',
  44. insecureTransport: false,
  45. sidebarTitle: '',
  46. sidebarSubtitle: '',
  47. sidebarContextTitle: '',
  48. sidebarFocusMode: false,
  49. inspectorOpen: false,
  50. inspectorCloseLabel: '关闭预览',
  51. isAdmin: false,
  52. upstreamReady: false,
  53. upstreamError: '',
  54. })
  55. const emit = defineEmits<{
  56. logout: []
  57. toggleTheme: []
  58. selectProfile: [profile: string]
  59. editProfile: []
  60. closeInspector: []
  61. }>()
  62. const route = useRoute()
  63. const router = useRouter()
  64. const mobileDrawerOpen = ref(false)
  65. const profileMenuOpen = ref(false)
  66. const sidebarCollapsed = ref(false)
  67. const sidebarSearchOpen = ref(false)
  68. const nodePairingOpen = ref(false)
  69. const systemUpdateOpen = ref(false)
  70. const systemManagementOpen = ref(false)
  71. const accountSecurityOpen = ref(false)
  72. const desktopSidebarContext = ref<HTMLElement | null>(null)
  73. const mobileSidebarContext = ref<HTMLElement | null>(null)
  74. function profileTitle(profile?: Profile): string {
  75. return profile?.agentName || profile?.displayName || profile?.name || '未选择 Agent'
  76. }
  77. const navItems: NavItem[] = [
  78. { key: 'chat', label: '对话', path: '/chat', icon: 'chat' },
  79. { key: 'groups', label: '团队', path: '/groups', icon: 'groups' },
  80. { key: 'files', label: '文件库', path: '/files', icon: 'files' },
  81. ]
  82. // The active workspace is already represented by its main canvas and sidebar
  83. // context. Keep this strip focused on destinations the user can switch to.
  84. const featureNavItems = computed(() => navItems.filter(item => item.key !== activeNav.value.key
  85. && !(activeNav.value.key === 'groups' && item.key === 'files')))
  86. const activeNav = computed(() => {
  87. const item = navItems.find(entry => route.path.startsWith(entry.path))
  88. return item ?? navItems[0]
  89. })
  90. const contextHeading = computed(() => ({
  91. chat: '历史记录',
  92. groups: '团队列表',
  93. files: '历史记录',
  94. })[activeNav.value.key])
  95. const hasPrimaryAction = computed(() => ['chat', 'groups', 'files'].includes(activeNav.value.key))
  96. async function navigate(path: string) {
  97. mobileDrawerOpen.value = false
  98. await router.push(path)
  99. }
  100. function closeMenus(event: MouseEvent) {
  101. const target = event.target as HTMLElement
  102. if (!target.closest('.sidebar-account-switcher')) profileMenuOpen.value = false
  103. }
  104. function toggleSidebar() {
  105. sidebarCollapsed.value = !sidebarCollapsed.value
  106. try { localStorage.setItem(SIDEBAR_COLLAPSED_KEY, sidebarCollapsed.value ? '1' : '0') } catch { /* optional persistence */ }
  107. }
  108. function navIcon(key: NavItem['key']): 'chat' | 'folder' | 'people' {
  109. return key === 'groups' ? 'people' : key === 'files' ? 'folder' : 'chat'
  110. }
  111. async function openSidebarSearch(host: HTMLElement | null) {
  112. if (sidebarCollapsed.value) {
  113. sidebarCollapsed.value = false
  114. try { localStorage.setItem(SIDEBAR_COLLAPSED_KEY, '0') } catch { /* optional persistence */ }
  115. await nextTick()
  116. }
  117. sidebarSearchOpen.value = true
  118. document.dispatchEvent(new CustomEvent(SIDEBAR_SEARCH_EVENT, { detail: { section: activeNav.value.key } }))
  119. await nextTick()
  120. host?.querySelector<HTMLInputElement>('input[type="search"]')?.focus()
  121. }
  122. async function closeSidebarSearch(host: HTMLElement | null, clear = false, restoreFocus = false) {
  123. const input = host?.querySelector<HTMLInputElement>('input[type="search"]')
  124. if (clear && input?.value) {
  125. input.value = ''
  126. input.dispatchEvent(new Event('input', { bubbles: true }))
  127. }
  128. sidebarSearchOpen.value = false
  129. document.dispatchEvent(new CustomEvent(SIDEBAR_SEARCH_CLOSE_EVENT))
  130. input?.blur()
  131. if (restoreFocus) {
  132. await nextTick()
  133. host?.closest('aside')?.querySelector<HTMLButtonElement>('.sidebar-search-trigger')?.focus()
  134. }
  135. }
  136. function handleSidebarInput(event: Event) {
  137. const input = event.target as HTMLInputElement
  138. if (input.matches('input[type="search"]') && !input.value) void closeSidebarSearch(input.closest('.sidebar-context'))
  139. }
  140. function handleSidebarFocusout(event: FocusEvent) {
  141. const input = event.target as HTMLInputElement
  142. if (input.matches('input[type="search"]') && !input.value) void closeSidebarSearch(input.closest('.sidebar-context'))
  143. }
  144. function handleSidebarSearchClosed() { sidebarSearchOpen.value = false }
  145. watch(() => route.fullPath, () => { mobileDrawerOpen.value = false })
  146. watch(() => activeNav.value.key, () => {
  147. sidebarSearchOpen.value = false
  148. profileMenuOpen.value = false
  149. })
  150. onMounted(() => {
  151. try { sidebarCollapsed.value = localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1' } catch { /* optional persistence */ }
  152. document.addEventListener('mousedown', closeMenus)
  153. document.addEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSidebarSearchClosed)
  154. })
  155. onBeforeUnmount(() => {
  156. document.removeEventListener('mousedown', closeMenus)
  157. document.removeEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSidebarSearchClosed)
  158. })
  159. </script>
  160. <template>
  161. <div class="workspace-shell" :class="{ 'workspace-shell--collapsed': sidebarCollapsed, 'workspace-shell--sidebar-focused': sidebarFocusMode }">
  162. <header class="mobile-header">
  163. <button class="icon-button" type="button" aria-label="打开导航" @click="mobileDrawerOpen = true">
  164. <AppIcon name="menu" :size="20" />
  165. </button>
  166. <button class="mobile-brand" type="button" aria-label="返回对话" @click="navigate('/chat')">
  167. <BrandMark :size="28" compact />
  168. </button>
  169. <button class="icon-button" type="button" :aria-label="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
  170. <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" />
  171. </button>
  172. </header>
  173. <aside
  174. class="desktop-sidebar rail"
  175. :class="{ 'desktop-sidebar--collapsed': sidebarCollapsed }"
  176. aria-label="主导航"
  177. :inert="mobileDrawerOpen"
  178. >
  179. <div class="sidebar-brand-row">
  180. <button class="rail__brand sidebar-brand" type="button" aria-label="返回对话" title="夭夭 Web" @click="navigate('/chat')">
  181. <BrandMark :size="sidebarCollapsed ? 26 : 32" :label="false" compact bare />
  182. </button>
  183. </div>
  184. <button
  185. class="sidebar-collapse"
  186. :class="{ 'sidebar-collapse--collapsed': sidebarCollapsed }"
  187. type="button"
  188. :aria-label="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
  189. :title="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
  190. @click="toggleSidebar"
  191. >
  192. <AppIcon name="chevron-left" :size="18" />
  193. </button>
  194. <button
  195. v-if="activeNav.key !== 'files'"
  196. class="sidebar-search-trigger"
  197. :class="{ 'sidebar-search-trigger--searching': sidebarSearchOpen }"
  198. type="button"
  199. aria-label="搜索"
  200. title="搜索"
  201. :aria-expanded="sidebarSearchOpen"
  202. :aria-hidden="sidebarSearchOpen ? 'true' : undefined"
  203. :tabindex="sidebarSearchOpen ? -1 : 0"
  204. @click="openSidebarSearch(desktopSidebarContext)"
  205. >
  206. <YaoYaoSidebarIcon name="search" />
  207. <span>搜索</span>
  208. </button>
  209. <div v-if="hasPrimaryAction" class="sidebar-primary-action">
  210. <slot name="sidebar-action" />
  211. </div>
  212. <nav class="sidebar-feature-nav" aria-label="功能入口">
  213. <button
  214. v-for="item in featureNavItems"
  215. :key="item.key"
  216. type="button"
  217. :class="{ active: activeNav.key === item.key }"
  218. :aria-current="activeNav.key === item.key ? 'page' : undefined"
  219. :title="item.label"
  220. @click="navigate(item.path)"
  221. >
  222. <YaoYaoSidebarIcon :name="navIcon(item.key)" />
  223. <span>{{ item.label }}</span>
  224. </button>
  225. </nav>
  226. <section
  227. ref="desktopSidebarContext"
  228. class="sidebar-context"
  229. :class="{ 'sidebar-context--searching': sidebarSearchOpen }"
  230. @keydown.esc.capture="closeSidebarSearch(desktopSidebarContext, true, true)"
  231. @input.capture="handleSidebarInput"
  232. @focusout.capture="handleSidebarFocusout"
  233. >
  234. <slot name="sidebar-before-heading" />
  235. <header class="sidebar-context__heading">
  236. <strong>{{ sidebarContextTitle || contextHeading }}</strong>
  237. <span v-if="sidebarSubtitle">{{ sidebarSubtitle }}</span>
  238. </header>
  239. <div class="sidebar-context__body">
  240. <slot name="sidebar" />
  241. </div>
  242. </section>
  243. <div class="sidebar-footer">
  244. <div class="sidebar-account-switcher">
  245. <button class="sidebar-account-switcher__main" type="button" :title="`切换 Agent:${profileTitle(activeProfile)}`" @click="profileMenuOpen = !profileMenuOpen">
  246. <AgentAvatar :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="30" />
  247. <span class="account-copy">
  248. <strong>{{ profileTitle(activeProfile) }}</strong>
  249. <span>{{ activeProfile?.name || userName || '未选择 Agent' }}</span>
  250. </span>
  251. <AppIcon class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
  252. </button>
  253. <button class="sidebar-account__utility" type="button" :title="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
  254. <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" :size="17" />
  255. </button>
  256. <button class="sidebar-account__utility" type="button" title="手机登录与节点" aria-label="手机登录与节点" @click="nodePairingOpen = true">
  257. <AppIcon name="users" :size="17" />
  258. </button>
  259. <Transition name="menu-fade">
  260. <div v-if="profileMenuOpen" class="profile-menu">
  261. <button
  262. v-for="profile in profiles"
  263. :key="profile.name"
  264. type="button"
  265. :class="{ active: profile.name === activeProfile?.name }"
  266. @click="emit('selectProfile', profile.name); profileMenuOpen = false"
  267. >
  268. <AgentAvatar :name="profileTitle(profile)" :avatar="profile.agentAvatar || ''" :size="24" />
  269. <strong>{{ profileTitle(profile) }}</strong>
  270. <AppIcon v-if="profile.name === activeProfile?.name" name="check" :size="15" />
  271. </button>
  272. <button class="profile-menu__edit" type="button" :disabled="!activeProfile" @click="profileMenuOpen = false; emit('editProfile')"><AppIcon name="settings" :size="15" /><strong>Agent 管理</strong></button>
  273. <button class="profile-menu__edit" type="button" @click="profileMenuOpen = false; accountSecurityOpen = true"><AppIcon name="settings" :size="15" /><strong>账号安全</strong></button>
  274. <button v-if="isAdmin" class="profile-menu__edit" type="button" @click="profileMenuOpen = false; systemManagementOpen = true"><AppIcon name="settings" :size="15" /><strong>系统管理</strong></button>
  275. <button v-if="isAdmin" class="profile-menu__update" type="button" @click="profileMenuOpen = false; systemUpdateOpen = true"><AppIcon name="download" :size="15" /><strong>系统更新</strong></button>
  276. <button class="profile-menu__logout" type="button" @click="profileMenuOpen = false; emit('logout')">
  277. <AppIcon name="logout" :size="15" /><strong>退出登录</strong>
  278. </button>
  279. </div>
  280. </Transition>
  281. </div>
  282. </div>
  283. </aside>
  284. <Transition name="drawer-fade">
  285. <button v-if="mobileDrawerOpen" class="drawer-scrim" type="button" aria-label="关闭导航" @click="mobileDrawerOpen = false" />
  286. </Transition>
  287. <aside
  288. class="mobile-drawer"
  289. :class="{ 'mobile-drawer--open': mobileDrawerOpen, 'mobile-drawer--focused': sidebarFocusMode }"
  290. aria-label="移动导航"
  291. :aria-hidden="!mobileDrawerOpen"
  292. :inert="!mobileDrawerOpen"
  293. >
  294. <div class="mobile-drawer__header">
  295. <button class="sidebar-brand" type="button" aria-label="返回对话" @click="navigate('/chat')">
  296. <BrandMark :size="32" compact />
  297. </button>
  298. <button class="icon-button" type="button" aria-label="关闭导航" @click="mobileDrawerOpen = false">
  299. <AppIcon name="close" />
  300. </button>
  301. </div>
  302. <button
  303. v-if="activeNav.key !== 'files'"
  304. class="sidebar-search-trigger"
  305. :class="{ 'sidebar-search-trigger--searching': sidebarSearchOpen }"
  306. type="button"
  307. aria-label="搜索"
  308. title="搜索"
  309. :aria-expanded="sidebarSearchOpen"
  310. :aria-hidden="sidebarSearchOpen ? 'true' : undefined"
  311. :tabindex="sidebarSearchOpen ? -1 : 0"
  312. @click="openSidebarSearch(mobileSidebarContext)"
  313. >
  314. <AppIcon name="search" :size="18" />
  315. <span>搜索</span>
  316. </button>
  317. <div v-if="hasPrimaryAction" class="sidebar-primary-action">
  318. <slot name="sidebar-action" />
  319. </div>
  320. <nav class="sidebar-feature-nav" aria-label="功能入口">
  321. <button
  322. v-for="item in featureNavItems"
  323. :key="item.key"
  324. type="button"
  325. :class="{ active: activeNav.key === item.key }"
  326. :aria-current="activeNav.key === item.key ? 'page' : undefined"
  327. @click="navigate(item.path)"
  328. >
  329. <AppIcon :name="item.icon" :size="18" />
  330. <span>{{ item.label }}</span>
  331. </button>
  332. </nav>
  333. <section
  334. ref="mobileSidebarContext"
  335. class="sidebar-context mobile-drawer__context"
  336. :class="{ 'sidebar-context--searching': sidebarSearchOpen }"
  337. @keydown.esc.capture="closeSidebarSearch(mobileSidebarContext, true, true)"
  338. @input.capture="handleSidebarInput"
  339. @focusout.capture="handleSidebarFocusout"
  340. >
  341. <slot name="sidebar-before-heading" />
  342. <header class="sidebar-context__heading">
  343. <strong>{{ sidebarContextTitle || contextHeading }}</strong>
  344. <span v-if="sidebarSubtitle">{{ sidebarSubtitle }}</span>
  345. </header>
  346. <div class="sidebar-context__body">
  347. <slot name="mobile-sidebar" />
  348. </div>
  349. </section>
  350. <div class="sidebar-footer mobile-drawer__footer">
  351. <div class="sidebar-account-switcher">
  352. <button class="sidebar-account-switcher__main" type="button" :title="`切换 Agent:${profileTitle(activeProfile)}`" @click="profileMenuOpen = !profileMenuOpen">
  353. <AgentAvatar :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="30" />
  354. <span class="account-copy">
  355. <strong>{{ profileTitle(activeProfile) }}</strong>
  356. <span>{{ activeProfile?.name || userName || '未选择 Agent' }}</span>
  357. </span>
  358. <AppIcon class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
  359. </button>
  360. <button class="sidebar-account__utility" type="button" :title="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
  361. <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" :size="17" />
  362. </button>
  363. <button class="sidebar-account__utility" type="button" title="手机登录与节点" aria-label="手机登录与节点" @click="nodePairingOpen = true; mobileDrawerOpen = false">
  364. <AppIcon name="users" :size="17" />
  365. </button>
  366. <Transition name="menu-fade">
  367. <div v-if="profileMenuOpen" class="profile-menu">
  368. <button
  369. v-for="profile in profiles"
  370. :key="profile.name"
  371. type="button"
  372. :class="{ active: profile.name === activeProfile?.name }"
  373. @click="emit('selectProfile', profile.name); profileMenuOpen = false"
  374. >
  375. <AgentAvatar :name="profileTitle(profile)" :avatar="profile.agentAvatar || ''" :size="24" />
  376. <strong>{{ profileTitle(profile) }}</strong>
  377. <AppIcon v-if="profile.name === activeProfile?.name" name="check" :size="15" />
  378. </button>
  379. <button class="profile-menu__edit" type="button" :disabled="!activeProfile" @click="profileMenuOpen = false; emit('editProfile')"><AppIcon name="settings" :size="15" /><strong>Agent 管理</strong></button>
  380. <button class="profile-menu__edit" type="button" @click="profileMenuOpen = false; accountSecurityOpen = true; mobileDrawerOpen = false"><AppIcon name="settings" :size="15" /><strong>账号安全</strong></button>
  381. <button v-if="isAdmin" class="profile-menu__edit" type="button" @click="profileMenuOpen = false; systemManagementOpen = true; mobileDrawerOpen = false"><AppIcon name="settings" :size="15" /><strong>系统管理</strong></button>
  382. <button v-if="isAdmin" class="profile-menu__update" type="button" @click="profileMenuOpen = false; systemUpdateOpen = true; mobileDrawerOpen = false"><AppIcon name="download" :size="15" /><strong>系统更新</strong></button>
  383. <button class="profile-menu__logout" type="button" @click="profileMenuOpen = false; emit('logout')">
  384. <AppIcon name="logout" :size="15" /><strong>退出登录</strong>
  385. </button>
  386. </div>
  387. </Transition>
  388. </div>
  389. </div>
  390. </aside>
  391. <main class="workspace-main" :inert="mobileDrawerOpen">
  392. <slot />
  393. </main>
  394. <Transition name="inspector-slide">
  395. <aside v-if="inspectorOpen" class="workspace-inspector">
  396. <div class="workspace-inspector__close">
  397. <button class="icon-button" type="button" :aria-label="inspectorCloseLabel" :title="inspectorCloseLabel" @click="emit('closeInspector')">
  398. <AppIcon name="close" />
  399. </button>
  400. </div>
  401. <slot name="inspector" />
  402. </aside>
  403. </Transition>
  404. <NodePairingDialog :open="nodePairingOpen" :insecure-transport="insecureTransport" :user-name="pairingUserName || userName" :is-admin="isAdmin" @close="nodePairingOpen = false" />
  405. <SystemUpdateDialog :open="systemUpdateOpen" @close="systemUpdateOpen = false" />
  406. <SystemManagementDialog :open="systemManagementOpen" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @close="systemManagementOpen = false" />
  407. <AccountSecurityDialog :open="accountSecurityOpen" @close="accountSecurityOpen = false" />
  408. </div>
  409. </template>
  410. <style scoped>
  411. .workspace-shell {
  412. display: grid;
  413. grid-template-columns: 264px minmax(0, 1fr) auto;
  414. width: 100%;
  415. height: 100%;
  416. min-width: 0;
  417. overflow: hidden;
  418. background: var(--canvas);
  419. transition: grid-template-columns 180ms var(--ease-out);
  420. }
  421. .workspace-shell--collapsed { grid-template-columns: 68px minmax(0, 1fr) auto; }
  422. .mobile-header, .mobile-drawer, .drawer-scrim { display: none; }
  423. .desktop-sidebar {
  424. --sidebar-search-top: 58px;
  425. position: relative;
  426. z-index: 20;
  427. display: flex;
  428. min-width: 0;
  429. min-height: 0;
  430. flex-direction: column;
  431. overflow: hidden;
  432. border-right: 1px solid var(--line);
  433. background: var(--surface);
  434. color: var(--text-primary);
  435. }
  436. .sidebar-brand-row { display: flex; min-height: 58px; align-items: center; padding: 9px 20px 7px; }
  437. .sidebar-brand { display: flex; min-width: 0; align-items: center; padding: 0; border: 0; border-radius: 10px; background: transparent; cursor: pointer; }
  438. .sidebar-brand:hover { background: transparent; }
  439. .sidebar-collapse, .sidebar-account__utility {
  440. display: grid;
  441. width: 34px;
  442. height: 34px;
  443. flex: 0 0 34px;
  444. place-items: center;
  445. padding: 0;
  446. border: 0;
  447. border-radius: 9px;
  448. background: transparent;
  449. color: var(--text-muted);
  450. cursor: pointer;
  451. }
  452. .sidebar-collapse:hover, .sidebar-account__utility:hover { background: var(--surface-hover); color: var(--text-primary); }
  453. .sidebar-collapse { position: absolute; top: 12px; right: 11px; transition: transform 180ms var(--ease-out), color 140ms ease, background-color 140ms ease; }
  454. .sidebar-collapse--collapsed { transform: rotate(180deg); }
  455. .sidebar-search-trigger,
  456. .sidebar-feature-nav button {
  457. display: flex;
  458. width: calc(100% - 20px);
  459. min-height: 36px;
  460. align-items: center;
  461. gap: 11px;
  462. margin-inline: 10px;
  463. padding: 0 11px;
  464. border: 0;
  465. border-radius: 10px;
  466. background: transparent;
  467. color: var(--text-primary);
  468. cursor: pointer;
  469. text-align: left;
  470. font-size: 13px;
  471. font-weight: 580;
  472. }
  473. .sidebar-search-trigger:hover,
  474. .sidebar-search-trigger[aria-expanded="true"],
  475. .sidebar-feature-nav button:hover,
  476. .sidebar-feature-nav button.active { background: var(--surface-soft); }
  477. .sidebar-search-trigger[aria-expanded="true"] { box-shadow: 0 0 0 3px var(--focus-ring); }
  478. .sidebar-search-trigger--searching { visibility: hidden; pointer-events: none; }
  479. .desktop-sidebar .sidebar-context--searching :deep(.sidebar-search),
  480. .desktop-sidebar .sidebar-context--searching :deep(.library-search),
  481. .mobile-drawer .sidebar-context--searching :deep(.sidebar-search),
  482. .mobile-drawer .sidebar-context--searching :deep(.library-search) {
  483. position: absolute;
  484. z-index: 40;
  485. top: var(--sidebar-search-top);
  486. left: 10px;
  487. width: calc(100% - 20px);
  488. min-height: 36px;
  489. margin: 0;
  490. background: var(--surface);
  491. }
  492. .sidebar-primary-action { margin: 3px 10px; }
  493. .sidebar-primary-action :deep(button) {
  494. display: flex;
  495. width: 100%;
  496. min-height: 36px;
  497. align-items: center;
  498. justify-content: flex-start;
  499. gap: 11px;
  500. padding: 0 11px;
  501. border: 0;
  502. border-radius: 10px;
  503. background: transparent;
  504. color: var(--text-primary);
  505. cursor: pointer;
  506. font-size: 13px;
  507. font-weight: 580;
  508. }
  509. .sidebar-primary-action :deep(button:hover),
  510. .sidebar-primary-action :deep(button:focus-visible) { background: var(--surface-soft); }
  511. .sidebar-primary-action :deep(button:focus-visible) { outline: 0; box-shadow: inset 0 0 0 1px var(--line-strong); }
  512. .sidebar-primary-action :deep(button:active) { background: var(--surface-hover); }
  513. .sidebar-primary-action :deep(button > span) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  514. .sidebar-feature-nav { display: flex; flex-direction: column; gap: 1px; padding: 0 0 9px; }
  515. .sidebar-feature-nav button { margin-block: 0; }
  516. .sidebar-feature-nav button.active { font-weight: 680; }
  517. .sidebar-context { display: flex; min-height: 0; flex: 1; flex-direction: column; overflow: hidden; }
  518. .sidebar-context__heading { display: flex; min-height: 35px; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 20px 5px 21px; }
  519. .sidebar-context__heading strong { font-size: 12px; font-weight: 680; }
  520. .sidebar-context__heading span { overflow: hidden; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
  521. .sidebar-context__body { min-height: 0; flex: 1; overflow: hidden; }
  522. .sidebar-context:not(.sidebar-context--searching) :deep(.library-search) { display: none; }
  523. .sidebar-context__body :deep(.library-search) { margin-bottom: 10px; }
  524. .sidebar-context__body :deep(.sidebar-list) { padding-inline: 10px; }
  525. .sidebar-context__body :deep(.sidebar-item) { min-height: 42px; padding: 5px 9px; }
  526. .sidebar-context__body :deep(.sidebar-item.sidebar-item--single-line) { min-height: 31px; padding: 1px 7px; }
  527. .sidebar-context__body :deep(.sidebar-item--single-line .sidebar-item__row strong) { font-size: 11.5px; font-weight: 450; }
  528. .sidebar-context__body :deep(.sidebar-item__icon) { width: 25px; height: 25px; flex-basis: 25px; border: 0; border-radius: 7px; background: transparent; }
  529. .sidebar-context__body :deep(.sidebar-item__icon--avatar) { background: var(--surface-soft); color: var(--text-secondary); }
  530. .sidebar-context__body :deep(.sidebar-item__row strong) { font-size: 12px; }
  531. .sidebar-context__body :deep(.sidebar-item.sidebar-item--topic) { min-height: 28px; padding-block: 0; }
  532. .sidebar-context__body :deep(.sidebar-item.sidebar-item--topic .sidebar-item__icon) { width: 17px; height: 17px; flex-basis: 17px; }
  533. .sidebar-context__body :deep(.sidebar-item.sidebar-item--topic .sidebar-item__row strong) { font-size: 10.5px; font-weight: 450; }
  534. .sidebar-context__body :deep(.library-sidebar) { padding-top: 1px; }
  535. .sidebar-context__body :deep(.library-sidebar section) { margin-top: 2px; }
  536. .workspace-shell--sidebar-focused .desktop-sidebar .sidebar-search-trigger,
  537. .workspace-shell--sidebar-focused .desktop-sidebar > .sidebar-primary-action,
  538. .workspace-shell--sidebar-focused .desktop-sidebar .sidebar-feature-nav,
  539. .workspace-shell--sidebar-focused .desktop-sidebar .sidebar-footer { display: none; }
  540. .workspace-shell--sidebar-focused { grid-template-columns: 264px minmax(0, 1fr) auto; }
  541. .workspace-shell--sidebar-focused .desktop-sidebar .sidebar-context,
  542. .workspace-shell--sidebar-focused .desktop-sidebar--collapsed .sidebar-context { display: flex; flex: 1; }
  543. .sidebar-footer { flex: 0 0 auto; padding: 7px 10px 10px; background: var(--surface); }
  544. .sidebar-account-switcher { position: relative; z-index: 30; display: flex; min-height: 46px; align-items: center; gap: 6px; padding: 4px 7px; border-top: 1px solid var(--line); }
  545. .sidebar-account-switcher__main { display: flex; min-width: 0; min-height: 38px; flex: 1; align-items: center; gap: 9px; padding: 4px 1px; border: 0; border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }
  546. .sidebar-account-switcher__main:hover { background: var(--surface-soft); }
  547. .sidebar-account__avatar { display: grid; width: 30px; height: 30px; flex: 0 0 30px; place-items: center; border-radius: 50%; background: #c91e55; color: #fff; font-size: 12px; font-weight: 700; }
  548. .sidebar-account-switcher__chevron { flex: 0 0 auto; color: var(--text-muted); }
  549. .profile-menu { position: absolute; right: 0; bottom: calc(100% + 6px); left: 0; padding: 5px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
  550. .profile-menu button { display: flex; width: 100%; min-height: 38px; align-items: center; gap: 9px; padding: 5px 8px; border: 0; border-radius: 8px; background: transparent; cursor: pointer; text-align: left; }
  551. .profile-menu button:hover, .profile-menu button.active { background: var(--surface-hover); }
  552. .profile-menu button > span:not(.agent-avatar) { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 8px; background: var(--surface-soft); color: var(--text-secondary); font-size: 10px; }
  553. .profile-menu button strong { flex: 1; overflow: hidden; font-size: 12px; text-overflow: ellipsis; }
  554. .profile-menu__edit { margin-top: 4px; border-top: 1px solid var(--line) !important; color: var(--text-secondary); }
  555. .profile-menu__update { color: var(--text-secondary); }
  556. .profile-menu__logout { margin-top: 4px; border-top: 1px solid var(--line) !important; color: var(--danger); }
  557. .account-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; }
  558. .account-copy strong, .account-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  559. .account-copy strong { font-size: 12px; font-weight: 650; }
  560. .account-copy span { color: var(--text-muted); font-size: 10px; }
  561. .desktop-sidebar--collapsed .sidebar-brand-row { padding-inline: 21px; }
  562. .desktop-sidebar--collapsed .sidebar-brand { width: 26px; flex: 0 0 26px; overflow: hidden; }
  563. .desktop-sidebar--collapsed .sidebar-collapse { position: static; width: 28px; height: 28px; flex: 0 0 28px; order: 1; align-self: center; margin-top: auto; margin-bottom: 5px; }
  564. .desktop-sidebar--collapsed .sidebar-context { display: none; }
  565. .desktop-sidebar--collapsed .sidebar-search-trigger { width: 42px; justify-content: center; margin-inline: auto; padding: 0; }
  566. .desktop-sidebar--collapsed .sidebar-search-trigger span { display: none; }
  567. .desktop-sidebar--collapsed .sidebar-primary-action { width: 42px; margin-inline: auto; }
  568. .desktop-sidebar--collapsed .sidebar-primary-action :deep(button) { width: 42px; justify-content: center; padding: 0; }
  569. .desktop-sidebar--collapsed .sidebar-primary-action :deep(button > span) { display: none; }
  570. .desktop-sidebar--collapsed .sidebar-feature-nav { align-items: center; padding: 8px 0; }
  571. .desktop-sidebar--collapsed .sidebar-feature-nav button { width: 42px; justify-content: center; margin-inline: 0; padding: 0; }
  572. .desktop-sidebar--collapsed .sidebar-feature-nav button span,
  573. .desktop-sidebar--collapsed .sidebar-account-switcher__chevron,
  574. .desktop-sidebar--collapsed .account-copy { display: none; }
  575. .desktop-sidebar--collapsed .sidebar-footer { display: flex; order: 2; margin-top: 0; flex-direction: column; align-items: center; padding: 0 8px 8px; }
  576. .desktop-sidebar--collapsed .sidebar-account-switcher { width: 42px; flex-direction: column-reverse; gap: 3px; padding: 0; border-top: 0; }
  577. .desktop-sidebar--collapsed .sidebar-account-switcher__main { width: 42px; min-height: 42px; flex: 0 0 42px; justify-content: center; padding: 0; }
  578. .desktop-sidebar--collapsed .sidebar-account__utility { display: none; }
  579. .desktop-sidebar--collapsed .profile-menu { right: auto; bottom: 0; left: calc(100% + 8px); width: 220px; }
  580. .workspace-main { position: relative; display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; background: var(--canvas); }
  581. .workspace-inspector { position: relative; width: min(390px, 34vw); min-width: 310px; min-height: 0; overflow: hidden; border-left: 1px solid var(--line); background: var(--surface); }
  582. .workspace-inspector__close { position: absolute; z-index: 5; top: 10px; right: 10px; }
  583. .menu-fade-enter-active, .menu-fade-leave-active { transition: opacity 120ms ease, transform 120ms var(--ease-out); }
  584. .menu-fade-enter-from, .menu-fade-leave-to { opacity: 0; transform: translateY(4px); }
  585. .inspector-slide-enter-active, .inspector-slide-leave-active { overflow: hidden; transition: width 180ms var(--ease-out), opacity 150ms ease; }
  586. .inspector-slide-enter-from, .inspector-slide-leave-to { width: 0; min-width: 0; opacity: 0; }
  587. @media (max-width: 900px) {
  588. .workspace-shell, .workspace-shell--collapsed { grid-template-columns: minmax(0, 1fr); grid-template-rows: 52px minmax(0, 1fr); }
  589. .mobile-header { display: flex; z-index: 24; grid-row: 1; align-items: center; justify-content: space-between; padding: max(5px, env(safe-area-inset-top)) 10px 5px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--surface) 94%, transparent); backdrop-filter: blur(18px); }
  590. .mobile-brand { padding: 0; border: 0; background: transparent; cursor: pointer; }
  591. .desktop-sidebar { display: none; }
  592. .workspace-main { grid-row: 2; }
  593. .drawer-scrim { display: block; position: fixed; z-index: 50; inset: 0; padding: 0; border: 0; background: var(--scrim); backdrop-filter: blur(2px); }
  594. .mobile-drawer { --sidebar-search-top: calc(max(9px, env(safe-area-inset-top)) + 49px); display: flex; position: fixed; z-index: 51; inset: 0 auto 0 0; width: min(304px, 88vw); min-height: 0; flex-direction: column; overflow: hidden; padding: max(9px, env(safe-area-inset-top)) 0 max(9px, env(safe-area-inset-bottom)); background: var(--surface); box-shadow: var(--shadow-float); transform: translateX(-102%); transition: transform 190ms var(--ease-out); }
  595. .mobile-drawer--open { transform: translateX(0); }
  596. .mobile-drawer__header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; padding: 0 14px 5px 12px; }
  597. .mobile-drawer__context { margin-top: 2px; }
  598. .mobile-drawer--focused { padding-block: max(9px, env(safe-area-inset-top)) max(9px, env(safe-area-inset-bottom)); }
  599. .mobile-drawer--focused > .sidebar-search-trigger,
  600. .mobile-drawer--focused > .sidebar-primary-action,
  601. .mobile-drawer--focused > .sidebar-feature-nav,
  602. .mobile-drawer--focused > .sidebar-footer { display: none; }
  603. .mobile-drawer--focused .mobile-drawer__context { margin-top: 0; flex: 1; }
  604. .mobile-drawer__context .sidebar-context__body :deep(.resource-sidebar) { min-height: 0; flex: 1; }
  605. .mobile-drawer__footer { padding-inline: 10px; }
  606. .workspace-inspector { position: fixed; z-index: 45; inset: 52px 0 0 auto; width: min(440px, 100vw); min-width: 0; box-shadow: var(--shadow-float); }
  607. .inspector-slide-enter-from, .inspector-slide-leave-to { width: min(440px, 100vw); transform: translateX(100%); }
  608. }
  609. .drawer-fade-enter-active, .drawer-fade-leave-active { transition: opacity 160ms ease; }
  610. .drawer-fade-enter-from, .drawer-fade-leave-to { opacity: 0; }
  611. </style>