WorkspaceShell.vue 55 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007
  1. <script setup lang="ts">
  2. import UnreadPanel from './UnreadPanel.vue'
  3. import { useUnreadStore } from '@/stores/unread'
  4. import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  5. import { useRoute, useRouter } from 'vue-router'
  6. import type { Profile } from '@shared/types'
  7. import AgentAvatar from '@/components/common/AgentAvatar.vue'
  8. import AccountInitialAvatar from '@/components/common/AccountInitialAvatar.vue'
  9. import type { ProfileIdentityInput } from '@/api/profiles'
  10. import AppIcon from '@/components/common/AppIcon.vue'
  11. import BrandMark from '@/components/common/BrandMark.vue'
  12. import SettingsCenterDialog from '@/components/app/SettingsCenterDialog.vue'
  13. import { rememberInterfacePath } from '@/utils/interfaceMode'
  14. import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
  15. const AboutDialog = defineAsyncComponent(() => import('./AboutDialog.vue'))
  16. const UpdateCheckDialog = defineAsyncComponent(() => import('./UpdateCheckDialog.vue'))
  17. const BotPluginsDialog = defineAsyncComponent(() => import('@/components/workspace/BotPluginsDialog.vue'))
  18. const BotSettingsDialog = defineAsyncComponent(() => import('@/components/workspace/BotSettingsDialog.vue'))
  19. type NavItem = {
  20. key: 'chat' | 'history' | 'groups' | 'kanban' | 'files'
  21. label: string
  22. path: string
  23. icon: 'chat' | 'history' | 'groups' | 'board' | 'files'
  24. }
  25. type SettingsPage =
  26. | 'agent-identity'
  27. | 'agent-models'
  28. | 'account-profile'
  29. | 'account-security'
  30. | 'credential-vault'
  31. | 'account-mobile'
  32. | 'appearance'
  33. | 'system-overview'
  34. | 'system-users'
  35. | 'system-connection'
  36. | 'system-push'
  37. | 'system-voice'
  38. | 'system-update'
  39. const SIDEBAR_COLLAPSED_KEY = 'hermes-yaoyao:sidebar-collapsed'
  40. const SIDEBAR_SEARCH_EVENT = 'hermes-yaoyao:sidebar-search'
  41. const SIDEBAR_SEARCH_CLOSE_EVENT = 'hermes-yaoyao:sidebar-search-close'
  42. const props = withDefaults(defineProps<{
  43. serverName?: string
  44. userName?: string
  45. userAvatar?: string
  46. pairingUserName?: string
  47. activeProfile?: Profile
  48. profiles?: Profile[]
  49. theme?: 'light' | 'dark'
  50. themePreference?: 'light' | 'dark' | 'system'
  51. insecureTransport?: boolean
  52. sidebarTitle?: string
  53. sidebarSubtitle?: string
  54. sidebarContextTitle?: string
  55. sidebarFocusMode?: boolean
  56. inspectorOpen?: boolean
  57. inspectorCloseLabel?: string
  58. isAdmin?: boolean
  59. upstreamReady?: boolean
  60. upstreamError?: string
  61. identityBusy?: boolean
  62. identityError?: string
  63. identityResetVersion?: number
  64. }>(), {
  65. serverName: '',
  66. userName: '',
  67. userAvatar: '',
  68. pairingUserName: '',
  69. activeProfile: undefined,
  70. profiles: () => [],
  71. theme: 'light',
  72. themePreference: 'system',
  73. insecureTransport: false,
  74. sidebarTitle: '',
  75. sidebarSubtitle: '',
  76. sidebarContextTitle: '',
  77. sidebarFocusMode: false,
  78. inspectorOpen: false,
  79. inspectorCloseLabel: '关闭预览',
  80. isAdmin: false,
  81. upstreamReady: false,
  82. upstreamError: '',
  83. identityBusy: false,
  84. identityError: '',
  85. identityResetVersion: 0,
  86. })
  87. const emit = defineEmits<{
  88. logout: []
  89. toggleTheme: []
  90. setTheme: [theme: 'light' | 'dark' | 'system']
  91. selectProfile: [profile: string]
  92. saveIdentity: [input: ProfileIdentityInput]
  93. closeInspector: []
  94. createAgent: []
  95. createGroup: []
  96. botSettingsChanged: []
  97. }>()
  98. const unread=useUnreadStore(),unreadOpen=ref(false)
  99. const route = useRoute()
  100. const router = useRouter()
  101. const mobileDrawerOpen = ref(false)
  102. const profileMenuOpen = ref(false)
  103. const settingsOpen = ref(false)
  104. const standalone = ref<'plugins' | 'about' | 'update' | 'bot-settings' | null>(null)
  105. const botSettingsPage = ref<'projects' | 'vm'>('projects')
  106. const standaloneReturnFocus = ref<HTMLElement>()
  107. const settingsPage = ref<SettingsPage>('agent-identity')
  108. const settingsReturnFocus = ref<HTMLButtonElement>()
  109. const mobileNavigationTrigger = ref<HTMLButtonElement>()
  110. const mobileDrawerClose = ref<HTMLButtonElement>()
  111. const profileMenuTrigger = ref<HTMLButtonElement>()
  112. const sidebarCollapsed = ref(false)
  113. const sidebarSearchOpen = ref(false)
  114. const createMenuOpen = ref(false)
  115. const createMenu = ref<HTMLElement | null>(null)
  116. let createTrigger: HTMLElement | null = null
  117. const createPosition = ref({ left: '8px', top: '58px' })
  118. const settingsMenuOpen = ref(false)
  119. const canCheckUpdates = computed(() => Boolean(window.yaoyaoDesktop?.openUpdates) || props.isAdmin)
  120. const openingUpdate = ref(false)
  121. const updateError = ref('')
  122. const settingsMenu = ref<HTMLElement | null>(null)
  123. let settingsTrigger: HTMLButtonElement | null = null
  124. const settingsMenuPosition = ref({ left: '8px', top: '8px' })
  125. const toolsMenuOpen = ref(false), toolsMenu = ref<HTMLElement | null>(null)
  126. const toolsPosition = ref({ left: '8px', top: '8px' })
  127. let toolsTrigger: HTMLButtonElement | null = null
  128. const desktopSidebarContext = ref<HTMLElement | null>(null)
  129. const mobileSidebarContext = ref<HTMLElement | null>(null)
  130. function profileTitle(profile?: Profile): string {
  131. return profile?.agentName || profile?.displayName || profile?.name || '未选择机器人'
  132. }
  133. const navItems: NavItem[] = [
  134. { key: 'chat', label: '聊天', path: '/chat', icon: 'chat' },
  135. { key: 'history', label: '历史记录', path: '/history', icon: 'history' },
  136. { key: 'groups', label: '聊天', path: '/conversations', icon: 'groups' },
  137. { key: 'kanban', label: '看板', path: '/kanban', icon: 'board' },
  138. { key: 'files', label: '文件库', path: '/files', icon: 'files' },
  139. ]
  140. // The active workspace is already represented by its main canvas and sidebar
  141. // context. Keep this strip focused on destinations the user can switch to.
  142. const featureNavItems = computed(() => !props.isAdmin ? [] : navItems.filter(item => item.key !== 'groups' && item.key !== activeNav.value.key
  143. ))
  144. const applicationWorkspace = computed(() => route.path.startsWith('/conversations'))
  145. const activeNav = computed(() => {
  146. const item = navItems.find(entry => route.path.startsWith(entry.path))
  147. return item ?? navItems[0]
  148. })
  149. const contextHeading = computed(() => ({
  150. chat: '聊天',
  151. history: '历史记录',
  152. groups: '聊天列表',
  153. kanban: '看板列表',
  154. files: '历史记录',
  155. })[activeNav.value.key])
  156. const hasPrimaryAction = computed(() => ['chat', 'groups', 'kanban', 'files'].includes(activeNav.value.key))
  157. async function navigate(path: string) {
  158. mobileDrawerOpen.value = false
  159. rememberInterfacePath(path)
  160. await router.push(path)
  161. }
  162. function openMobileDrawer() {
  163. mobileDrawerOpen.value = true
  164. void nextTick(() => mobileDrawerClose.value?.focus())
  165. }
  166. function closeMobileDrawer() {
  167. mobileDrawerOpen.value = false
  168. void nextTick(() => mobileNavigationTrigger.value?.focus())
  169. }
  170. function closeMenus(event: MouseEvent) {
  171. const target = event.target as HTMLElement
  172. if (!target.closest('.sidebar-account-switcher')) profileMenuOpen.value = false
  173. if (!target.closest('.workspace-create-menu, .sidebar-create-trigger')) createMenuOpen.value = false
  174. if (!target.closest('.workspace-settings-menu, .sidebar-settings-trigger')) settingsMenuOpen.value = false
  175. if (!target.closest('.workspace-tools-menu, .sidebar-tools-trigger')) toolsMenuOpen.value = false
  176. }
  177. function closeCreateMenu() {
  178. createMenuOpen.value = false
  179. void nextTick(() => { if (createTrigger?.isConnected) createTrigger.focus() })
  180. }
  181. async function openCreateMenu(event: Event) {
  182. if (createMenuOpen.value) { closeCreateMenu(); return }
  183. createTrigger = event.currentTarget as HTMLElement
  184. const rect = createTrigger.getBoundingClientRect()
  185. createPosition.value = { left: `${Math.max(8, Math.min(rect.left, window.innerWidth - 192))}px`, top: `${Math.max(8, Math.min(rect.bottom + 6, window.innerHeight - 96))}px` }
  186. profileMenuOpen.value = false
  187. createMenuOpen.value = true
  188. await nextTick()
  189. createMenu.value?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus()
  190. }
  191. function chooseCreate(kind: 'agent' | 'group') {
  192. closeCreateMenu()
  193. mobileDrawerOpen.value = false
  194. if (kind === 'agent') emit('createAgent')
  195. else emit('createGroup')
  196. }
  197. function actionMenuKeydown(event: KeyboardEvent) {
  198. if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
  199. event.preventDefault()
  200. const buttons = [...(event.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)')]
  201. const index = buttons.indexOf(document.activeElement as HTMLElement)
  202. buttons[event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowUp' ? -1 : 1) + buttons.length) % buttons.length]?.focus()
  203. }
  204. function closeSettingsMenu() {
  205. settingsMenuOpen.value = false
  206. void nextTick(() => { if (settingsTrigger?.isConnected) settingsTrigger.focus() })
  207. }
  208. async function openSettingsMenu(event: MouseEvent) {
  209. if (settingsMenuOpen.value) { closeSettingsMenu(); return }
  210. settingsTrigger = event.currentTarget as HTMLButtonElement
  211. const rect = settingsTrigger.getBoundingClientRect()
  212. const height = ((props.isAdmin ? 4 : 3) + Number(canCheckUpdates.value)) * (window.innerWidth < 768 ? 44 : 36) + 18
  213. settingsMenuPosition.value = { left: `${Math.max(8, Math.min(rect.right - 180, window.innerWidth - 188))}px`, top: `${Math.max(8, rect.top - height)}px` }
  214. profileMenuOpen.value = false
  215. createMenuOpen.value = false
  216. toolsMenuOpen.value = false
  217. updateError.value = ''
  218. settingsMenuOpen.value = true
  219. await nextTick()
  220. settingsMenu.value?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus()
  221. }
  222. async function checkForUpdates() {
  223. if (openingUpdate.value || !canCheckUpdates.value) return
  224. updateError.value = ''
  225. if (window.yaoyaoDesktop?.openUpdates) {
  226. openingUpdate.value = true
  227. try {
  228. await window.yaoyaoDesktop.openUpdates()
  229. closeSettingsMenu()
  230. } catch (cause) {
  231. updateError.value = cause instanceof Error ? cause.message : '无法打开更新窗口,请重试'
  232. } finally { openingUpdate.value = false }
  233. return
  234. }
  235. standaloneReturnFocus.value = settingsTrigger?.closest('.mobile-drawer')
  236. ? mobileNavigationTrigger.value : settingsTrigger ?? undefined
  237. settingsMenuOpen.value = false
  238. mobileDrawerOpen.value = false
  239. standalone.value = 'update'
  240. }
  241. function openUpdateSettings() {
  242. settingsReturnFocus.value = standaloneReturnFocus.value as HTMLButtonElement | undefined
  243. standalone.value = null
  244. openSettings('system-update')
  245. }
  246. function chooseSettingsAction(action: 'settings' | 'bots' | 'about') {
  247. settingsMenuOpen.value = false
  248. if (action === 'bots') { switchInterfaceMode(); return }
  249. settingsReturnFocus.value = settingsTrigger?.closest('.mobile-drawer')
  250. ? mobileNavigationTrigger.value : settingsTrigger ?? undefined
  251. if (action === 'about') {
  252. standaloneReturnFocus.value = settingsReturnFocus.value
  253. mobileDrawerOpen.value = false
  254. standalone.value = 'about'
  255. return
  256. }
  257. openSettings(applicationWorkspace.value ? 'account-profile' : 'agent-identity')
  258. }
  259. function closeStandalone() { standalone.value = null; void nextTick(() => standaloneReturnFocus.value?.isConnected && standaloneReturnFocus.value.focus()) }
  260. function closeToolsMenu() { toolsMenuOpen.value = false; void nextTick(() => toolsTrigger?.isConnected && toolsTrigger.focus()) }
  261. async function openToolsMenu(event: MouseEvent) {
  262. if (!applicationWorkspace.value) return
  263. if (toolsMenuOpen.value) { closeToolsMenu(); return }
  264. toolsTrigger = event.currentTarget as HTMLButtonElement
  265. const rect = toolsTrigger.getBoundingClientRect()
  266. const height = 3 * (window.innerWidth < 768 ? 44 : 36) + 18
  267. toolsPosition.value = { left: `${Math.max(8, Math.min(rect.left, window.innerWidth - 208))}px`, top: `${Math.max(8, rect.top - height)}px` }
  268. settingsMenuOpen.value = false; createMenuOpen.value = false; toolsMenuOpen.value = true
  269. await nextTick(); toolsMenu.value?.querySelector<HTMLElement>('[role="menuitem"]')?.focus()
  270. }
  271. function chooseTool(page: 'apps' | 'automations' | 'bot-settings') {
  272. toolsMenuOpen.value = false
  273. if (page === 'automations') { void navigate('/conversations/automations?from=' + encodeURIComponent(route.fullPath)); return }
  274. standaloneReturnFocus.value = toolsTrigger?.closest('.mobile-drawer') ? mobileNavigationTrigger.value : toolsTrigger ?? undefined
  275. mobileDrawerOpen.value = false
  276. botSettingsPage.value = 'projects'
  277. standalone.value = page === 'bot-settings' ? 'bot-settings' : 'plugins'
  278. }
  279. function switchInterfaceMode() {
  280. if (!props.isAdmin) return
  281. settingsOpen.value = false
  282. void navigate(applicationWorkspace.value ? '/chat' : '/conversations')
  283. }
  284. function workspaceKeydown(event: KeyboardEvent) {
  285. if (!applicationWorkspace.value || !(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'k' || settingsOpen.value || document.querySelector('dialog[open]')) return
  286. event.preventDefault()
  287. if (sidebarSearchOpen.value) document.dispatchEvent(new CustomEvent(SIDEBAR_SEARCH_CLOSE_EVENT))
  288. else void openSidebarSearch(null)
  289. }
  290. function toggleProfileMenu(event: MouseEvent) {
  291. const trigger = event.currentTarget
  292. if (trigger instanceof HTMLButtonElement) profileMenuTrigger.value = trigger
  293. profileMenuOpen.value = !profileMenuOpen.value
  294. if (!profileMenuOpen.value) return
  295. void nextTick(() => {
  296. trigger instanceof HTMLElement
  297. && trigger.closest('.sidebar-account-switcher')?.querySelector<HTMLButtonElement>('.profile-menu [role="option"][aria-selected="true"]')?.focus()
  298. })
  299. }
  300. function chooseProfile(name: string) {
  301. emit('selectProfile', name)
  302. profileMenuOpen.value = false
  303. void nextTick(() => profileMenuTrigger.value?.focus())
  304. }
  305. function handleProfileMenuKeydown(event: KeyboardEvent) {
  306. const options = [...(event.currentTarget as HTMLElement).querySelectorAll<HTMLButtonElement>('[role="option"]')]
  307. if (!options.length) return
  308. const current = Math.max(0, options.indexOf(document.activeElement as HTMLButtonElement))
  309. let next: number | undefined
  310. if (event.key === 'ArrowDown') next = (current + 1) % options.length
  311. else if (event.key === 'ArrowUp') next = (current - 1 + options.length) % options.length
  312. else if (event.key === 'Home') next = 0
  313. else if (event.key === 'End') next = options.length - 1
  314. else if (event.key === 'Escape') {
  315. event.preventDefault()
  316. profileMenuOpen.value = false
  317. void nextTick(() => profileMenuTrigger.value?.focus())
  318. return
  319. }
  320. if (next === undefined) return
  321. event.preventDefault()
  322. options[next]?.focus()
  323. }
  324. function openSettings(page: SettingsPage = 'agent-identity', event?: MouseEvent) {
  325. const trigger = event?.currentTarget
  326. if (trigger instanceof HTMLButtonElement) {
  327. settingsReturnFocus.value = trigger.closest('.mobile-drawer')
  328. ? mobileNavigationTrigger.value
  329. : trigger
  330. }
  331. profileMenuOpen.value = false
  332. toolsMenuOpen.value = false
  333. settingsMenuOpen.value = false
  334. mobileDrawerOpen.value = false
  335. settingsPage.value = page
  336. settingsOpen.value = true
  337. }
  338. function closeSettings() {
  339. settingsOpen.value = false
  340. void nextTick(() => settingsReturnFocus.value?.focus())
  341. }
  342. function toggleSidebar() {
  343. createMenuOpen.value = false
  344. settingsMenuOpen.value = false
  345. sidebarCollapsed.value = !sidebarCollapsed.value
  346. try { localStorage.setItem(SIDEBAR_COLLAPSED_KEY, sidebarCollapsed.value ? '1' : '0') } catch { /* optional persistence */ }
  347. }
  348. function navIcon(key: NavItem['key']): 'chat' | 'history' | 'folder' | 'people' | 'board' {
  349. return key === 'groups' ? 'people' : key === 'kanban' ? 'board' : key === 'files' ? 'folder' : key === 'history' ? 'history' : 'chat'
  350. }
  351. async function openSidebarSearch(host: HTMLElement | null) {
  352. if (applicationWorkspace.value) {
  353. createMenuOpen.value = false
  354. sidebarSearchOpen.value = true
  355. document.dispatchEvent(new CustomEvent(SIDEBAR_SEARCH_EVENT, { detail: { section: 'groups' } }))
  356. return
  357. }
  358. if (sidebarCollapsed.value) {
  359. sidebarCollapsed.value = false
  360. try { localStorage.setItem(SIDEBAR_COLLAPSED_KEY, '0') } catch { /* optional persistence */ }
  361. await nextTick()
  362. }
  363. sidebarSearchOpen.value = true
  364. document.dispatchEvent(new CustomEvent(SIDEBAR_SEARCH_EVENT, { detail: { section: activeNav.value.key } }))
  365. await nextTick()
  366. host?.querySelector<HTMLInputElement>('input[type="search"]')?.focus()
  367. }
  368. async function closeSidebarSearch(host: HTMLElement | null, clear = false, restoreFocus = false) {
  369. const input = host?.querySelector<HTMLInputElement>('input[type="search"]')
  370. if (clear && input?.value) {
  371. input.value = ''
  372. input.dispatchEvent(new Event('input', { bubbles: true }))
  373. }
  374. sidebarSearchOpen.value = false
  375. document.dispatchEvent(new CustomEvent(SIDEBAR_SEARCH_CLOSE_EVENT))
  376. input?.blur()
  377. if (restoreFocus) {
  378. await nextTick()
  379. host?.closest('aside')?.querySelector<HTMLButtonElement>('.sidebar-search-trigger')?.focus()
  380. }
  381. }
  382. function handleSidebarInput(event: Event) {
  383. const input = event.target as HTMLInputElement
  384. if (input.matches('input[type="search"]') && !input.value) void closeSidebarSearch(input.closest('.sidebar-context'))
  385. }
  386. function handleSidebarFocusout(event: FocusEvent) {
  387. const input = event.target as HTMLInputElement
  388. if (input.matches('input[type="search"]') && !input.value) void closeSidebarSearch(input.closest('.sidebar-context'))
  389. }
  390. function handleSidebarSearchClosed() { sidebarSearchOpen.value = false }
  391. watch(() => route.fullPath, () => { mobileDrawerOpen.value = false; createMenuOpen.value = false; settingsMenuOpen.value = false; toolsMenuOpen.value = false; standalone.value = null })
  392. watch(() => activeNav.value.key, () => {
  393. sidebarSearchOpen.value = false
  394. profileMenuOpen.value = false
  395. })
  396. function showLocalVmSettings(){
  397. if(!props.isAdmin)return
  398. standaloneReturnFocus.value = document.activeElement instanceof HTMLElement ? document.activeElement : undefined
  399. settingsOpen.value = false; settingsMenuOpen.value = false; toolsMenuOpen.value = false; mobileDrawerOpen.value = false
  400. botSettingsPage.value = 'vm'; standalone.value = 'bot-settings'
  401. }
  402. onMounted(() => {
  403. window.addEventListener('yaoyao:local-vm-settings',showLocalVmSettings)
  404. try { sidebarCollapsed.value = localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1' } catch { /* optional persistence */ }
  405. document.addEventListener('mousedown', closeMenus)
  406. document.addEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSidebarSearchClosed)
  407. document.addEventListener('keydown', workspaceKeydown)
  408. })
  409. onBeforeUnmount(() => {
  410. window.removeEventListener('yaoyao:local-vm-settings',showLocalVmSettings)
  411. document.removeEventListener('mousedown', closeMenus)
  412. document.removeEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSidebarSearchClosed)
  413. document.removeEventListener('keydown', workspaceKeydown)
  414. })
  415. defineExpose({openLocalVm:showLocalVmSettings})
  416. </script>
  417. <template>
  418. <div class="workspace-shell" :class="{ 'workspace-shell--collapsed': sidebarCollapsed && !applicationWorkspace, 'workspace-shell--sidebar-focused': sidebarFocusMode, 'workspace-shell--conversations': applicationWorkspace, 'workspace-shell--conversation-open': applicationWorkspace && !!route.params.id }" :inert="unreadOpen || settingsOpen || !!standalone || (applicationWorkspace && sidebarSearchOpen)">
  419. <header class="mobile-header" :inert="mobileDrawerOpen">
  420. <button ref="mobileNavigationTrigger" class="icon-button" type="button" aria-label="打开导航" @click="openMobileDrawer">
  421. <AppIcon name="menu" :size="20" />
  422. </button>
  423. <button class="mobile-brand" type="button" aria-label="返回聊天" @click="navigate(applicationWorkspace ? '/conversations' : '/chat')">
  424. <BrandMark :size="28" compact />
  425. </button>
  426. <button class="icon-button" type="button" :aria-label="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
  427. <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" />
  428. </button>
  429. </header>
  430. <aside
  431. class="desktop-sidebar rail"
  432. :class="{ 'desktop-sidebar--collapsed': sidebarCollapsed && !applicationWorkspace }"
  433. aria-label="主导航"
  434. :inert="mobileDrawerOpen"
  435. >
  436. <div v-if="applicationWorkspace" class="bot-list-header">
  437. <button class="bot-logo-trigger sidebar-brand" type="button" aria-label="返回 Bot 列表" title="夭夭 AI" @click="navigate('/conversations')"><BrandMark :size="32" :label="false" compact bare /></button>
  438. <span class="bot-list-header__spacer" />
  439. <button class="bot-list-toolbar-button sidebar-search-trigger" type="button" aria-label="搜索" :aria-expanded="sidebarSearchOpen" @click="openSidebarSearch(desktopSidebarContext)"><AppIcon name="search" :size="21" /></button>
  440. <button class="bot-list-toolbar-button sidebar-create-trigger" type="button" aria-label="新建" aria-haspopup="menu" :aria-expanded="createMenuOpen" @click="openCreateMenu"><AppIcon name="plus" :size="23" /></button>
  441. </div>
  442. <div v-else class="sidebar-brand-row">
  443. <button class="rail__brand sidebar-brand" type="button" aria-label="返回聊天" title="夭夭 AI" @click="navigate(applicationWorkspace ? '/conversations' : '/chat')">
  444. <BrandMark :size="sidebarCollapsed ? 26 : 32" :label="false" compact bare />
  445. </button>
  446. </div>
  447. <button
  448. v-if="!applicationWorkspace"
  449. class="sidebar-collapse"
  450. :class="{ 'sidebar-collapse--collapsed': sidebarCollapsed }"
  451. type="button"
  452. :aria-label="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
  453. :title="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
  454. @click="toggleSidebar"
  455. >
  456. <AppIcon name="chevron-left" :size="18" />
  457. </button>
  458. <button
  459. v-if="!applicationWorkspace && !['files', 'kanban'].includes(activeNav.key)"
  460. class="sidebar-search-trigger"
  461. :class="{ 'sidebar-search-trigger--searching': sidebarSearchOpen && !applicationWorkspace }"
  462. type="button"
  463. aria-label="搜索"
  464. title="搜索"
  465. :aria-expanded="sidebarSearchOpen"
  466. :aria-hidden="sidebarSearchOpen && !applicationWorkspace ? 'true' : undefined"
  467. :tabindex="sidebarSearchOpen && !applicationWorkspace ? -1 : 0"
  468. @click="openSidebarSearch(desktopSidebarContext)"
  469. >
  470. <YaoYaoSidebarIcon name="search" />
  471. <span>搜索</span>
  472. </button>
  473. <div v-if="hasPrimaryAction && !applicationWorkspace" class="sidebar-primary-action">
  474. <slot name="sidebar-action" />
  475. </div>
  476. <nav v-if="!applicationWorkspace" class="sidebar-feature-nav" aria-label="功能入口">
  477. <button
  478. v-for="item in featureNavItems"
  479. :key="item.key"
  480. type="button"
  481. :class="{ active: activeNav.key === item.key }"
  482. :aria-current="activeNav.key === item.key ? 'page' : undefined"
  483. :title="item.label"
  484. @click="navigate(item.path)"
  485. >
  486. <YaoYaoSidebarIcon :name="navIcon(item.key)" />
  487. <span>{{ item.label }}</span>
  488. </button>
  489. </nav>
  490. <section
  491. ref="desktopSidebarContext"
  492. class="sidebar-context"
  493. :class="{ 'sidebar-context--searching': sidebarSearchOpen }"
  494. @keydown.esc.capture="closeSidebarSearch(desktopSidebarContext, true, true)"
  495. @input.capture="handleSidebarInput"
  496. @focusout.capture="handleSidebarFocusout"
  497. >
  498. <slot name="sidebar-before-heading" />
  499. <header v-if="!applicationWorkspace" class="sidebar-context__heading">
  500. <strong>{{ sidebarContextTitle || contextHeading }}</strong>
  501. <span v-if="sidebarSubtitle">{{ sidebarSubtitle }}</span>
  502. </header>
  503. <div class="sidebar-context__body">
  504. <slot name="sidebar" />
  505. </div>
  506. </section>
  507. <button class="unread-entry" type="button" :aria-label="`未读消息,${unread.total} 条`" title="未读消息" @click="unreadOpen=true"><AppIcon name="bell" :size="19" /><span v-if="!sidebarCollapsed || applicationWorkspace">未读消息</span><b v-if="unread.total">{{ unread.total>99?'99+':unread.total }}</b></button>
  508. <div class="sidebar-footer">
  509. <button v-if="applicationWorkspace" class="sidebar-tools-trigger" type="button" aria-haspopup="menu" :aria-expanded="toolsMenuOpen" @click="openToolsMenu"><YaoYaoSidebarIcon name="tools" :size="18" /><span>工具</span><AppIcon name="chevron-down" :size="15" /></button>
  510. <div class="sidebar-account-switcher">
  511. <button class="sidebar-account-switcher__main" type="button" :title="applicationWorkspace ? '当前账号' : `切换机器人:${profileTitle(activeProfile)}`" :aria-haspopup="applicationWorkspace ? 'menu' : 'listbox'" :aria-expanded="applicationWorkspace ? settingsMenuOpen : profileMenuOpen" @click="applicationWorkspace ? openSettingsMenu($event) : toggleProfileMenu($event)">
  512. <AccountInitialAvatar v-if="applicationWorkspace" :name="userName" :image-url="userAvatar" :size="30" />
  513. <AgentAvatar v-else :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="30" />
  514. <span class="account-copy">
  515. <strong>{{ applicationWorkspace ? userName : profileTitle(activeProfile) }}</strong>
  516. <span>{{ serverName || (applicationWorkspace ? '当前账号' : activeProfile?.name || userName || '未选择机器人') }}</span>
  517. </span>
  518. <AppIcon v-if="!applicationWorkspace" class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
  519. </button>
  520. <button v-if="!applicationWorkspace" class="sidebar-settings-trigger" type="button" title="设置与模式" aria-label="设置与模式" aria-haspopup="menu" :aria-expanded="settingsMenuOpen" @click="openSettingsMenu">
  521. <AppIcon name="settings" :size="17" />
  522. </button>
  523. <Transition name="menu-fade">
  524. <div v-if="profileMenuOpen" class="profile-menu" role="listbox" aria-label="切换机器人" @keydown="handleProfileMenuKeydown">
  525. <strong class="profile-menu__heading">切换机器人</strong>
  526. <button
  527. v-for="profile in profiles"
  528. :key="profile.name"
  529. type="button"
  530. role="option"
  531. :class="{ active: profile.name === activeProfile?.name }"
  532. :aria-selected="profile.name === activeProfile?.name"
  533. @click="chooseProfile(profile.name)"
  534. >
  535. <AgentAvatar :name="profileTitle(profile)" :avatar="profile.agentAvatar || ''" :size="24" />
  536. <strong>{{ profileTitle(profile) }}</strong>
  537. <AppIcon v-if="profile.name === activeProfile?.name" name="check" :size="15" />
  538. </button>
  539. </div>
  540. </Transition>
  541. </div>
  542. </div>
  543. </aside>
  544. <Transition name="drawer-fade">
  545. <button v-if="mobileDrawerOpen" class="drawer-scrim" type="button" aria-label="关闭导航" @click="closeMobileDrawer" />
  546. </Transition>
  547. <aside
  548. class="mobile-drawer"
  549. :class="{ 'mobile-drawer--open': mobileDrawerOpen, 'mobile-drawer--focused': sidebarFocusMode }"
  550. aria-label="移动导航"
  551. :aria-hidden="!mobileDrawerOpen"
  552. :inert="!mobileDrawerOpen"
  553. >
  554. <div class="mobile-drawer__header">
  555. <button class="sidebar-brand" type="button" aria-label="返回聊天" @click="navigate(applicationWorkspace ? '/conversations' : '/chat')">
  556. <BrandMark :size="32" compact />
  557. </button>
  558. <div class="mobile-drawer__actions"><button ref="mobileDrawerClose" class="icon-button" type="button" aria-label="关闭导航" @click="closeMobileDrawer">
  559. <AppIcon name="close" />
  560. </button><button v-if="applicationWorkspace" class="sidebar-create-trigger" type="button" aria-label="新建" title="新建" aria-haspopup="menu" :aria-expanded="createMenuOpen" @click="openCreateMenu"><AppIcon name="plus" :size="19" /></button></div>
  561. </div>
  562. <button
  563. v-if="!applicationWorkspace && !['files', 'kanban'].includes(activeNav.key)"
  564. class="sidebar-search-trigger"
  565. :class="{ 'sidebar-search-trigger--searching': sidebarSearchOpen && !applicationWorkspace }"
  566. type="button"
  567. aria-label="搜索"
  568. title="搜索"
  569. :aria-expanded="sidebarSearchOpen"
  570. :aria-hidden="sidebarSearchOpen && !applicationWorkspace ? 'true' : undefined"
  571. :tabindex="sidebarSearchOpen && !applicationWorkspace ? -1 : 0"
  572. @click="openSidebarSearch(mobileSidebarContext)"
  573. >
  574. <AppIcon name="search" :size="18" />
  575. <span>搜索</span>
  576. </button>
  577. <div v-if="hasPrimaryAction && !applicationWorkspace" class="sidebar-primary-action">
  578. <slot name="sidebar-action" />
  579. </div>
  580. <nav v-if="!applicationWorkspace" class="sidebar-feature-nav" aria-label="功能入口">
  581. <button
  582. v-for="item in featureNavItems"
  583. :key="item.key"
  584. type="button"
  585. :class="{ active: activeNav.key === item.key }"
  586. :aria-current="activeNav.key === item.key ? 'page' : undefined"
  587. @click="navigate(item.path)"
  588. >
  589. <AppIcon :name="item.icon" :size="18" />
  590. <span>{{ item.label }}</span>
  591. </button>
  592. </nav>
  593. <section
  594. ref="mobileSidebarContext"
  595. class="sidebar-context mobile-drawer__context"
  596. :class="{ 'sidebar-context--searching': sidebarSearchOpen }"
  597. @keydown.esc.capture="closeSidebarSearch(mobileSidebarContext, true, true)"
  598. @input.capture="handleSidebarInput"
  599. @focusout.capture="handleSidebarFocusout"
  600. >
  601. <slot name="sidebar-before-heading" />
  602. <header v-if="!applicationWorkspace" class="sidebar-context__heading">
  603. <strong>{{ sidebarContextTitle || contextHeading }}</strong>
  604. <span v-if="sidebarSubtitle">{{ sidebarSubtitle }}</span>
  605. </header>
  606. <div class="sidebar-context__body">
  607. <slot name="mobile-sidebar" />
  608. </div>
  609. </section>
  610. <div class="sidebar-footer mobile-drawer__footer">
  611. <button v-if="applicationWorkspace" class="sidebar-tools-trigger" type="button" aria-haspopup="menu" :aria-expanded="toolsMenuOpen" @click="openToolsMenu"><YaoYaoSidebarIcon name="tools" :size="18" /><span>工具</span><AppIcon name="chevron-down" :size="15" /></button>
  612. <div class="sidebar-account-switcher">
  613. <button class="sidebar-account-switcher__main" type="button" :title="applicationWorkspace ? '当前账号' : `切换机器人:${profileTitle(activeProfile)}`" :aria-haspopup="applicationWorkspace ? 'menu' : 'listbox'" :aria-expanded="applicationWorkspace ? settingsMenuOpen : profileMenuOpen" @click="applicationWorkspace ? openSettingsMenu($event) : toggleProfileMenu($event)">
  614. <AccountInitialAvatar v-if="applicationWorkspace" :name="userName" :image-url="userAvatar" :size="30" />
  615. <AgentAvatar v-else :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="30" />
  616. <span class="account-copy">
  617. <strong>{{ applicationWorkspace ? userName : profileTitle(activeProfile) }}</strong>
  618. <span>{{ serverName || (applicationWorkspace ? '当前账号' : activeProfile?.name || userName || '未选择机器人') }}</span>
  619. </span>
  620. <AppIcon v-if="!applicationWorkspace" class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
  621. </button>
  622. <button v-if="!applicationWorkspace" class="sidebar-settings-trigger" type="button" title="设置与模式" aria-label="设置与模式" aria-haspopup="menu" :aria-expanded="settingsMenuOpen" @click="openSettingsMenu">
  623. <AppIcon name="settings" :size="17" />
  624. </button>
  625. <Transition name="menu-fade">
  626. <div v-if="profileMenuOpen" class="profile-menu" role="listbox" aria-label="切换机器人" @keydown="handleProfileMenuKeydown">
  627. <strong class="profile-menu__heading">切换机器人</strong>
  628. <button
  629. v-for="profile in profiles"
  630. :key="profile.name"
  631. type="button"
  632. role="option"
  633. :class="{ active: profile.name === activeProfile?.name }"
  634. :aria-selected="profile.name === activeProfile?.name"
  635. @click="chooseProfile(profile.name)"
  636. >
  637. <AgentAvatar :name="profileTitle(profile)" :avatar="profile.agentAvatar || ''" :size="24" />
  638. <strong>{{ profileTitle(profile) }}</strong>
  639. <AppIcon v-if="profile.name === activeProfile?.name" name="check" :size="15" />
  640. </button>
  641. </div>
  642. </Transition>
  643. </div>
  644. </div>
  645. </aside>
  646. <main class="workspace-main" :inert="mobileDrawerOpen">
  647. <slot />
  648. </main>
  649. <Transition name="inspector-slide">
  650. <aside v-if="inspectorOpen" class="workspace-inspector">
  651. <div class="workspace-inspector__close">
  652. <button class="icon-button" type="button" :aria-label="inspectorCloseLabel" :title="inspectorCloseLabel" @click="emit('closeInspector')">
  653. <AppIcon name="close" />
  654. </button>
  655. </div>
  656. <slot name="inspector" />
  657. </aside>
  658. </Transition>
  659. <AboutDialog v-if="standalone === 'about'" @close="closeStandalone" />
  660. <UpdateCheckDialog v-if="standalone === 'update' && isAdmin" @close="closeStandalone" @manage="openUpdateSettings" />
  661. <BotPluginsDialog v-if="standalone === 'plugins' && applicationWorkspace" @close="closeStandalone" />
  662. <BotSettingsDialog v-if="standalone === 'bot-settings'" :is-admin="isAdmin" :initial-page="botSettingsPage" @close="closeStandalone" @changed="emit('botSettingsChanged')" />
  663. <SettingsCenterDialog
  664. :open="settingsOpen"
  665. :bot-mode="applicationWorkspace"
  666. @switch-mode="switchInterfaceMode"
  667. :initial-page="settingsPage"
  668. :user-name="userName"
  669. :user-avatar="userAvatar"
  670. :pairing-user-name="pairingUserName"
  671. :active-profile="activeProfile"
  672. :profiles="profiles"
  673. :theme="theme"
  674. :theme-preference="themePreference"
  675. :insecure-transport="insecureTransport"
  676. :is-admin="isAdmin"
  677. :upstream-ready="upstreamReady"
  678. :upstream-error="upstreamError"
  679. :identity-busy="identityBusy"
  680. :identity-error="identityError"
  681. :identity-reset-version="identityResetVersion"
  682. @close="closeSettings"
  683. @logout="emit('logout')"
  684. @select-profile="emit('selectProfile', $event)"
  685. @save-identity="emit('saveIdentity', $event)"
  686. @set-theme="emit('setTheme', $event)"
  687. />
  688. <Teleport to="body">
  689. <div v-if="settingsMenuOpen" class="workspace-create-dismiss" @pointerdown.self="closeSettingsMenu" @keydown.esc.prevent.stop="closeSettingsMenu">
  690. <div ref="settingsMenu" class="workspace-create-menu workspace-settings-menu" :style="settingsMenuPosition" role="menu" aria-label="账号菜单" @keydown="actionMenuKeydown">
  691. <button type="button" role="menuitem" @click="chooseSettingsAction('settings')"><AppIcon name="settings" :size="17" />我的设置</button>
  692. <button type="button" role="menuitem" @click="chooseSettingsAction('about')"><AppIcon name="info" :size="17" />关于</button>
  693. <button v-if="canCheckUpdates" type="button" role="menuitem" :disabled="openingUpdate" :aria-busy="openingUpdate" @click="checkForUpdates"><AppIcon name="refresh" :size="17" />{{ openingUpdate ? '正在打开…' : '检测更新' }}</button>
  694. <p v-if="updateError" class="update-menu-error" role="alert">{{ updateError }}</p>
  695. <a role="menuitem" href="https://yaoyao.samien.cn" target="_blank" rel="noopener noreferrer" @click="closeSettingsMenu"><AppIcon name="external" :size="17" />帮助</a>
  696. <button v-if="isAdmin" type="button" role="menuitem" @click="chooseSettingsAction('bots')"><AppIcon :name="applicationWorkspace ? 'chat' : 'users'" :size="17" />{{ applicationWorkspace ? '进入聊天模式' : '进入 Bot 模式' }}</button>
  697. </div>
  698. </div>
  699. <div v-if="toolsMenuOpen && applicationWorkspace" class="workspace-create-dismiss" @pointerdown.self="closeToolsMenu" @keydown.esc.prevent.stop="closeToolsMenu"><div ref="toolsMenu" class="workspace-create-menu workspace-tools-menu" :style="toolsPosition" role="menu" aria-label="工具" @keydown="actionMenuKeydown"><button type="button" role="menuitem" @click="chooseTool('bot-settings')"><AppIcon name="settings" :size="17" />Bot 设置</button><button type="button" role="menuitem" @click="chooseTool('automations')"><AppIcon name="clock" :size="17" />自动化</button><button type="button" role="menuitem" @click="chooseTool('apps')"><AppIcon name="link" :size="17" />已连接应用</button></div></div>
  700. <div v-if="createMenuOpen" class="workspace-create-dismiss" @pointerdown.self="closeCreateMenu" @keydown.esc.prevent.stop="closeCreateMenu">
  701. <div ref="createMenu" class="workspace-create-menu" :style="createPosition" role="menu" aria-label="新建聊天" @keydown="actionMenuKeydown">
  702. <button type="button" role="menuitem" @click="chooseCreate('agent')"><AppIcon name="users" :size="17" />新建 Bot</button>
  703. <button type="button" role="menuitem" @click="chooseCreate('group')"><AppIcon name="groups" :size="17" />新建群聊</button>
  704. </div>
  705. </div>
  706. </Teleport>
  707. <UnreadPanel v-if="unreadOpen" :mode="applicationWorkspace?'bot':'chat'" @close="unreadOpen=false" />
  708. </div>
  709. </template>
  710. <style scoped>
  711. .update-menu-error{margin:4px 10px;color:var(--danger);font-size:12px;line-height:1.5;overflow-wrap:anywhere}.workspace-settings-menu button:disabled{opacity:.6;cursor:wait}
  712. .sidebar-tools-trigger{display:flex;width:100%;min-height:44px;align-items:center;gap:10px;padding:8px 12px;margin:0 0 8px;border:0;border-radius:9px;background:transparent;color:var(--text-primary);font:500 13px var(--font-ui);cursor:pointer}.sidebar-tools-trigger span{flex:1;text-align:left}.sidebar-tools-trigger:hover,.sidebar-tools-trigger[aria-expanded=true]{background:var(--surface-hover)}.sidebar-tools-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.workspace-settings-menu a{display:flex;align-items:center;gap:8px;min-height:36px;padding:8px 10px;box-sizing:border-box;border-radius:7px;color:var(--text-primary);text-decoration:none;font-size:13px}.workspace-settings-menu a:hover{background:var(--surface-hover)}.workspace-settings-menu a:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
  713. .workspace-shell {
  714. display: grid;
  715. grid-template-columns: 264px minmax(0, 1fr) auto;
  716. width: 100%;
  717. height: 100%;
  718. min-width: 0;
  719. overflow: hidden;
  720. background: var(--canvas);
  721. transition: grid-template-columns 180ms var(--ease-out);
  722. }
  723. .workspace-shell--conversations { grid-template-columns: 360px minmax(0, 1fr) auto; }
  724. .workspace-shell--collapsed { grid-template-columns: 68px minmax(0, 1fr) auto; }
  725. .mobile-header, .mobile-drawer, .drawer-scrim { display: none; }
  726. .desktop-sidebar {
  727. --sidebar-search-top: 58px;
  728. position: relative;
  729. z-index: 20;
  730. display: flex;
  731. min-width: 0;
  732. min-height: 0;
  733. flex-direction: column;
  734. overflow: hidden;
  735. border-right: 1px solid var(--line);
  736. background: var(--surface);
  737. color: var(--text-primary);
  738. }
  739. .sidebar-brand-row { display: flex; min-height: 58px; align-items: center; padding: 9px 20px 7px; }
  740. .sidebar-brand { display: flex; min-width: 0; align-items: center; padding: 0; border: 0; border-radius: 10px; background: transparent; cursor: pointer; }
  741. .sidebar-brand:hover { background: transparent; }
  742. .sidebar-collapse {
  743. display: grid;
  744. width: 34px;
  745. height: 34px;
  746. flex: 0 0 34px;
  747. place-items: center;
  748. padding: 0;
  749. border: 0;
  750. border-radius: 9px;
  751. background: transparent;
  752. color: var(--text-muted);
  753. cursor: pointer;
  754. }
  755. .sidebar-collapse:hover { background: var(--surface-hover); color: var(--text-primary); }
  756. .sidebar-collapse { position: absolute; top: 12px; right: 11px; transition: transform 180ms var(--ease-out), color 140ms ease, background-color 140ms ease; }
  757. .sidebar-collapse--collapsed { transform: rotate(180deg); }
  758. .sidebar-search-trigger,
  759. .sidebar-feature-nav button {
  760. display: flex;
  761. width: calc(100% - 20px);
  762. min-height: 36px;
  763. align-items: center;
  764. gap: 11px;
  765. margin-inline: 10px;
  766. padding: 0 11px;
  767. border: 0;
  768. border-radius: 10px;
  769. background: transparent;
  770. color: var(--text-primary);
  771. cursor: pointer;
  772. text-align: left;
  773. font-size: 14px;
  774. font-weight: 650;
  775. }
  776. .sidebar-search-trigger:hover,
  777. .sidebar-search-trigger[aria-expanded="true"],
  778. .sidebar-feature-nav button:hover,
  779. .sidebar-feature-nav button.active { background: var(--surface-soft); }
  780. .sidebar-search-trigger[aria-expanded="true"] { box-shadow: 0 0 0 3px var(--focus-ring); }
  781. .sidebar-search-trigger--searching { visibility: hidden; pointer-events: none; }
  782. .desktop-sidebar .sidebar-context--searching :deep(.sidebar-search),
  783. .desktop-sidebar .sidebar-context--searching :deep(.library-search),
  784. .mobile-drawer .sidebar-context--searching :deep(.sidebar-search),
  785. .mobile-drawer .sidebar-context--searching :deep(.library-search) {
  786. position: absolute;
  787. z-index: 40;
  788. top: var(--sidebar-search-top);
  789. left: 10px;
  790. width: calc(100% - 20px);
  791. min-height: 36px;
  792. margin: 0;
  793. background: var(--surface);
  794. }
  795. .sidebar-primary-action { margin: 3px 10px; }
  796. .sidebar-primary-action :deep(button) {
  797. display: flex;
  798. width: 100%;
  799. min-height: 36px;
  800. align-items: center;
  801. justify-content: flex-start;
  802. gap: 11px;
  803. padding: 0 11px;
  804. border: 0;
  805. border-radius: 10px;
  806. background: transparent;
  807. color: var(--text-primary);
  808. cursor: pointer;
  809. font-size: 14px;
  810. font-weight: 650;
  811. }
  812. .sidebar-primary-action :deep(button:hover),
  813. .sidebar-primary-action :deep(button:focus-visible) { background: var(--surface-soft); }
  814. .sidebar-primary-action :deep(button:focus-visible) { outline: 0; box-shadow: inset 0 0 0 1px var(--line-strong); }
  815. .sidebar-primary-action :deep(button:active) { background: var(--surface-hover); }
  816. .sidebar-primary-action :deep(button > span) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  817. .sidebar-feature-nav { display: flex; flex-direction: column; gap: 1px; padding: 0 0 9px; }
  818. .sidebar-feature-nav button { margin-block: 0; font-weight: 680; }
  819. .sidebar-feature-nav button.active { font-weight: 740; }
  820. .sidebar-context { display: flex; min-height: 0; flex: 1; flex-direction: column; overflow: hidden; }
  821. .sidebar-context__heading { display: flex; min-height: 35px; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 20px 5px 21px; }
  822. .sidebar-context__heading strong { font-size: 13px; font-weight: 680; }
  823. .sidebar-context__heading span { overflow: hidden; color: var(--text-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
  824. .sidebar-context__body { min-height: 0; flex: 1; overflow: hidden; }
  825. .sidebar-context:not(.sidebar-context--searching) :deep(.library-search) { display: none; }
  826. .sidebar-context__body :deep(.library-search) { margin-bottom: 10px; }
  827. .sidebar-context__body :deep(.sidebar-list) { padding-inline: 10px; }
  828. .sidebar-context__body :deep(.sidebar-item) { min-height: 42px; padding: 5px 9px; }
  829. .sidebar-context__body :deep(.sidebar-item.sidebar-item--single-line) { min-height: 31px; padding: 1px 7px; }
  830. .sidebar-context__body :deep(.sidebar-item--single-line .sidebar-item__row strong) { font-size: 14px; font-weight: 400; }
  831. .sidebar-context__body :deep(.sidebar-item__icon) { width: 25px; height: 25px; flex-basis: 25px; border: 0; border-radius: 7px; background: transparent; }
  832. .sidebar-context__body :deep(.sidebar-item__icon--avatar) { background: transparent; color: var(--text-secondary); }
  833. .sidebar-context__body :deep(.sidebar-item__row strong) { font-size: 13px; }
  834. .sidebar-context__body :deep(.sidebar-item.sidebar-item--topic .sidebar-item__row strong) { font-size: 14px; font-weight: 450; }
  835. .sidebar-context__body :deep(.library-sidebar) { padding-top: 1px; }
  836. .sidebar-context__body :deep(.library-sidebar section) { margin-top: 2px; }
  837. .workspace-shell--sidebar-focused .desktop-sidebar .sidebar-search-trigger,
  838. .workspace-shell--sidebar-focused .desktop-sidebar > .sidebar-primary-action,
  839. .workspace-shell--sidebar-focused .desktop-sidebar .sidebar-feature-nav,
  840. .workspace-shell--sidebar-focused .desktop-sidebar .sidebar-footer { display: none; }
  841. .workspace-shell--sidebar-focused { grid-template-columns: 264px minmax(0, 1fr) auto; }
  842. .workspace-shell--sidebar-focused .desktop-sidebar .sidebar-context,
  843. .workspace-shell--sidebar-focused .desktop-sidebar--collapsed .sidebar-context { display: flex; flex: 1; }
  844. .sidebar-footer { flex: 0 0 auto; padding: 7px 10px 10px; background: var(--surface); }
  845. .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); }
  846. .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; }
  847. .sidebar-account-switcher__main:hover { background: var(--surface-soft); }
  848. .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; }
  849. .sidebar-settings-trigger:hover { background: var(--surface-soft); color: var(--text-primary); }
  850. .sidebar-settings-trigger:focus-visible { outline: 0; box-shadow: inset 0 0 0 1px var(--line-strong), 0 0 0 3px var(--focus-ring); }
  851. .sidebar-account__avatar { display: grid; width: 30px; height: 30px; flex: 0 0 30px; place-items: center; border-radius: 50%; background: transparent; color: var(--text-secondary); font-size: 13px; font-weight: 700; }
  852. .sidebar-account-switcher__chevron { flex: 0 0 auto; color: var(--text-muted); }
  853. .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); }
  854. .profile-menu__heading { display: block; padding: 8px 8px 5px; color: var(--text-muted); font-size: 11px; font-weight: 650; }
  855. .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; }
  856. .profile-menu button:hover, .profile-menu button.active { background: var(--surface-hover); }
  857. .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; }
  858. .profile-menu button strong { flex: 1; overflow: hidden; font-size: 13px; text-overflow: ellipsis; }
  859. .account-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; }
  860. .account-copy strong, .account-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  861. .account-copy strong { font-size: 13px; font-weight: 650; }
  862. .account-copy span { color: var(--text-muted); font-size: 11px; }
  863. .desktop-sidebar--collapsed .sidebar-brand-row { padding-inline: 21px; }
  864. .desktop-sidebar--collapsed .sidebar-brand { width: 26px; flex: 0 0 26px; overflow: hidden; }
  865. .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; }
  866. .desktop-sidebar--collapsed .sidebar-context { display: none; }
  867. .desktop-sidebar--collapsed .sidebar-search-trigger { width: 42px; justify-content: center; margin-inline: auto; padding: 0; }
  868. .desktop-sidebar--collapsed .sidebar-search-trigger span { display: none; }
  869. .desktop-sidebar--collapsed .sidebar-primary-action { width: 42px; margin-inline: auto; }
  870. .desktop-sidebar--collapsed .sidebar-primary-action :deep(button) { width: 42px; justify-content: center; padding: 0; }
  871. .desktop-sidebar--collapsed .sidebar-primary-action :deep(button > span) { display: none; }
  872. .desktop-sidebar--collapsed .sidebar-feature-nav { align-items: center; padding: 8px 0; }
  873. .desktop-sidebar--collapsed .sidebar-feature-nav button { width: 42px; justify-content: center; margin-inline: 0; padding: 0; }
  874. .desktop-sidebar--collapsed .sidebar-feature-nav button span,
  875. .desktop-sidebar--collapsed .sidebar-account-switcher__chevron,
  876. .desktop-sidebar--collapsed .account-copy { display: none; }
  877. .desktop-sidebar--collapsed .sidebar-footer { display: flex; order: 2; margin-top: 0; flex-direction: column; align-items: center; padding: 0 8px 8px; }
  878. .desktop-sidebar--collapsed .sidebar-account-switcher { width: 42px; flex-direction: column; gap: 3px; padding: 0; border-top: 0; }
  879. .desktop-sidebar--collapsed .sidebar-account-switcher__main { width: 42px; min-height: 42px; flex: 0 0 42px; justify-content: center; padding: 0; }
  880. .desktop-sidebar--collapsed .sidebar-settings-trigger { width: 42px; min-width: 42px; height: 42px; padding: 0; }
  881. .desktop-sidebar--collapsed .profile-menu { right: auto; bottom: 0; left: calc(100% + 8px); width: 220px; }
  882. .workspace-main { position: relative; display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; background: var(--canvas); }
  883. .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); }
  884. .workspace-inspector__close { position: absolute; z-index: 5; top: 10px; right: 10px; }
  885. .menu-fade-enter-active, .menu-fade-leave-active { transition: opacity 120ms ease, transform 120ms var(--ease-out); }
  886. .menu-fade-enter-from, .menu-fade-leave-to { opacity: 0; transform: translateY(4px); }
  887. .inspector-slide-enter-active, .inspector-slide-leave-active { overflow: hidden; transition: width 180ms var(--ease-out), opacity 150ms ease; }
  888. .inspector-slide-enter-from, .inspector-slide-leave-to { width: 0; min-width: 0; opacity: 0; }
  889. @media (max-width: 900px) {
  890. .workspace-shell, .workspace-shell--collapsed { grid-template-columns: minmax(0, 1fr); grid-template-rows: 52px minmax(0, 1fr); }
  891. .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); }
  892. .mobile-brand { padding: 0; border: 0; background: transparent; cursor: pointer; }
  893. .desktop-sidebar { display: none; }
  894. .workspace-main { grid-row: 2; }
  895. .drawer-scrim { display: block; position: fixed; z-index: 50; inset: 0; padding: 0; border: 0; background: var(--scrim); backdrop-filter: blur(2px); }
  896. .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); }
  897. .mobile-drawer--open { transform: translateX(0); }
  898. .mobile-drawer__header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; padding: 0 14px 5px 12px; }
  899. .mobile-drawer__context { margin-top: 2px; }
  900. .mobile-drawer--focused { padding-block: max(9px, env(safe-area-inset-top)) max(9px, env(safe-area-inset-bottom)); }
  901. .mobile-drawer--focused > .sidebar-search-trigger,
  902. .mobile-drawer--focused > .sidebar-primary-action,
  903. .mobile-drawer--focused > .sidebar-feature-nav,
  904. .mobile-drawer--focused > .sidebar-footer { display: none; }
  905. .mobile-drawer--focused .mobile-drawer__context { margin-top: 0; flex: 1; }
  906. .mobile-drawer__context .sidebar-context__body :deep(.resource-sidebar) { min-height: 0; flex: 1; }
  907. .mobile-drawer__footer { padding-inline: 10px; }
  908. .workspace-inspector { position: fixed; z-index: 45; inset: 52px 0 0 auto; width: min(440px, 100vw); min-width: 0; box-shadow: var(--shadow-float); }
  909. .inspector-slide-enter-from, .inspector-slide-leave-to { width: min(440px, 100vw); transform: translateX(100%); }
  910. }
  911. .drawer-fade-enter-active, .drawer-fade-leave-active { transition: opacity 160ms ease; }
  912. .drawer-fade-enter-from, .drawer-fade-leave-to { opacity: 0; }
  913. .sidebar-create-trigger{display:grid;width:34px;height:34px;flex:0 0 34px;place-items:center;padding:0;border:0;border-radius:9px;background:transparent;color:var(--text-muted);cursor:pointer}
  914. .sidebar-create-trigger:hover,.sidebar-create-trigger[aria-expanded="true"]{background:var(--surface-hover);color:var(--text-primary)}
  915. .workspace-shell--conversations .desktop-sidebar:not(.desktop-sidebar--collapsed) .sidebar-collapse{right:49px}
  916. .desktop-sidebar > .sidebar-create-trigger{position:absolute;top:12px;right:11px}
  917. .desktop-sidebar--collapsed > .sidebar-create-trigger{position:static;width:42px;height:36px;flex-basis:36px;align-self:center;margin:0 auto 4px}
  918. .mobile-drawer__actions{display:flex;align-items:center;gap:4px}
  919. .workspace-create-dismiss{position:fixed;inset:0;z-index:80}
  920. .workspace-create-menu{position:absolute;width:180px;padding:5px;border:1px solid var(--line);border-radius:11px;background:var(--surface-raised);box-shadow:var(--shadow-float)}
  921. .workspace-create-menu button{display:flex;width:100%;min-height:36px;align-items:center;gap:9px;padding:7px 10px;border:0;border-radius:7px;background:transparent;color:var(--text-primary);font:13px var(--font-ui);text-align:left;cursor:pointer}
  922. .workspace-create-menu button:hover,.workspace-create-menu button:focus-visible{outline:0;background:var(--surface-hover)}
  923. @media(max-width:767px){.workspace-tools-menu button,.workspace-settings-menu button,.workspace-settings-menu a{min-height:44px}}
  924. .bot-list-header{display:flex;align-items:center;gap:10px;padding:20px 20px 12px;min-height:74px}
  925. .bot-list-header__spacer{flex:1}
  926. .bot-logo-trigger{display:grid;place-items:center;padding:0;border:0;background:transparent;cursor:pointer;border-radius:50%}
  927. .bot-list-header .bot-list-toolbar-button{position:static;display:grid;place-items:center;width:42px;height:42px;min-height:42px;flex:0 0 42px;margin:0;padding:0;border:1px solid var(--line);border-radius:50%;background:var(--surface);color:var(--text-primary)}
  928. .workspace-shell--conversations .sidebar-context{margin-top:0}
  929. @media(max-width:900px){
  930. .workspace-shell--conversations{grid-template-rows:minmax(0,1fr);grid-template-columns:minmax(0,1fr)}
  931. .workspace-shell--conversations>.mobile-header,.workspace-shell--conversations>.mobile-drawer{display:none}
  932. .workspace-shell--conversations>.desktop-sidebar{display:flex;grid-row:1;border:0;padding-top:env(safe-area-inset-top)}
  933. .workspace-shell--conversations>.workspace-main{display:none;grid-row:1}
  934. .workspace-shell--conversation-open>.desktop-sidebar{display:none}
  935. .workspace-shell--conversation-open>.workspace-main{display:flex}
  936. }
  937. </style>
  938. <style scoped>
  939. .unread-entry{display:flex;align-items:center;gap:10px;margin:8px 12px;padding:10px 12px;border:0;border-radius:10px;background:transparent;color:inherit;text-align:left;cursor:pointer;min-height:44px}.unread-entry:hover{background:var(--surface-hover,rgba(128,128,128,.1))}.unread-entry span{flex:1}.unread-entry b{border-radius:20px;background:var(--accent,#796bcd);color:white;min-width:22px;padding:2px 6px;font-size:12px;white-space:nowrap}.desktop-sidebar--collapsed .unread-entry{position:relative;margin:8px;padding:10px}.desktop-sidebar--collapsed .unread-entry b{position:absolute;top:0;right:0;font-size:10px}@media(max-width:767px){.unread-entry{display:none}}
  940. </style>