WorkspaceShell.vue 46 KB

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