WorkspaceShell.vue 34 KB

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