ソースを参照

将桌面更新图标放到铃铛旁并在无更新时隐藏

Samien 1 ヶ月 前
親
コミット
41784a8720

+ 13 - 1
desktop/inline-update.css

@@ -4,6 +4,8 @@
 #desktop-update-entry button:focus-visible{outline:2px solid var(--accent,var(--success,#377a54));outline-offset:2px}
 #desktop-update-label{flex:1;text-align:left}
 #desktop-update-ring{width:20px;height:20px;flex:0 0 20px;transform:rotate(-90deg);color:var(--accent,var(--success,#377a54))}
+#desktop-update-icon{width:20px;height:20px;flex:0 0 20px;fill:none;stroke:currentColor;stroke-width:1.75;stroke-linecap:round;stroke-linejoin:round}
+#desktop-update-ring[hidden],#desktop-update-icon[hidden]{display:none}
 #desktop-update-ring circle{fill:none;stroke:currentColor;stroke-width:2.5;stroke-linecap:round}.update-track{opacity:.18}
 #desktop-update-dot{width:6px;height:6px;border-radius:50%;background:var(--accent,var(--success,#377a54))}
 #desktop-update-error{font-size:12px;color:var(--danger,#b33d33);overflow-wrap:anywhere}
@@ -11,4 +13,14 @@
 @keyframes desktop-update-spin{to{transform:rotate(270deg)}}
 @media(prefers-reduced-motion:reduce){.update-spinning{animation:none}}
 #desktop-update-entry.desktop-update-fallback{position:fixed;left:12px;bottom:12px;z-index:40;width:224px;max-width:calc(100vw - 24px);margin:0;padding:0}
-#desktop-update-entry.desktop-update-sidebar{max-width:none;margin:0 0 8px;padding:0}
+#desktop-update-entry.desktop-update-toolbar{position:relative;flex:0 0 36px;width:36px;height:36px;max-width:none;margin:0;padding:0;color:var(--accent)}
+#desktop-update-entry.desktop-update-toolbar button{justify-content:center;height:100%;min-height:0;padding:0;border-color:transparent;border-radius:50%;background:transparent}
+#desktop-update-entry.desktop-update-toolbar button:hover{background:var(--surface-hover)}
+#desktop-update-entry.desktop-update-toolbar.desktop-update-ready button{background:rgba(var(--accent-rgb),.10)}
+#desktop-update-entry.desktop-update-toolbar #desktop-update-label,#desktop-update-entry.desktop-update-toolbar #desktop-update-error{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
+#desktop-update-entry.desktop-update-toolbar #desktop-update-dot{position:absolute;top:3px;right:3px}
+.bot-list-header #desktop-update-entry.desktop-update-toolbar{flex-basis:42px;width:42px;height:42px}
+.bot-list-header #desktop-update-entry.desktop-update-toolbar button{border-color:var(--line);background:var(--surface)}
+.sidebar-brand-row #desktop-update-entry.desktop-update-toolbar{position:absolute;top:9px;right:89px}
+.desktop-sidebar--collapsed .sidebar-brand-row #desktop-update-entry.desktop-update-toolbar{position:relative;top:auto;right:auto}
+@media(max-width:767px){#desktop-update-entry.desktop-update-toolbar{display:none}}

+ 37 - 9
desktop/inline-update.js

@@ -18,15 +18,27 @@
   const button = document.getElementById('desktop-update-action')
   const label = document.getElementById('desktop-update-label')
   const ring = document.getElementById('desktop-update-ring')
+  const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+  icon.id = 'desktop-update-icon'
+  icon.setAttribute('viewBox', '0 0 24 24')
+  icon.setAttribute('aria-hidden', 'true')
+  button.prepend(icon)
   const value = document.getElementById('desktop-update-value')
   const dot = document.getElementById('desktop-update-dot')
   const error = document.getElementById('desktop-update-error')
   let state, pending = false, closed = false, requested = false, reading = false, localError = ''
+  let observer
+  function stop() {
+    closed = true; clearInterval(timer); detach(); observer?.disconnect()
+    if (fallback) entry.remove()
+  }
   function render() {
     if (!state || closed) return
     const busy = pending || ['checking', 'downloading', 'verifying', 'preparing', 'installing'].includes(state.phase)
     const percent = state.phase === 'downloading' && state.total > 0 ? Math.max(0, Math.min(100, Math.floor(state.received / state.total * 100))) : undefined
-    entry.hidden = !requested && !localError && !state.available && !['downloading', 'verifying', 'preparing', 'ready', 'installing', 'failed'].includes(state.phase)
+    const hasUpdate = state.available || ['downloading', 'verifying', 'preparing', 'ready', 'installing'].includes(state.phase) || (state.phase === 'failed' && state.latestVersion)
+    entry.hidden = entry.classList.contains('desktop-update-toolbar') ? !hasUpdate
+      : !hasUpdate && !requested && !localError && state.phase !== 'failed'
     label.textContent = state.phase === 'ready' ? state.installMode === 'restart' ? '重新启动' : '打开安装包'
       : state.phase === 'installing' ? '正在重启…'
       : state.phase === 'downloading' ? percent === undefined ? '正在下载…' : `正在下载 · ${percent}%`
@@ -37,7 +49,16 @@
     button.disabled = busy || state.retryable === false
     button.setAttribute('aria-label', label.textContent)
     button.setAttribute('aria-busy', String(busy))
-    button.title = localError || state.error || state.message
+    button.title = localError || state.error || (state.phase === 'ready'
+      ? state.installMode === 'restart' ? '更新已下载,点击重新启动完成安装' : '更新已下载,点击打开安装包'
+      : busy ? label.textContent : state.available ? `App ${state.latestVersion || ''} 可更新,点击下载` : state.message)
+    entry.title = button.title
+    entry.classList.toggle('desktop-update-ready', state.phase === 'ready')
+    icon.toggleAttribute('hidden', busy)
+    icon.innerHTML = state.phase === 'ready' && state.installMode === 'restart'
+      ? '<path d="M20 7v5h-5M4 17v-5h5M5.1 9a8 8 0 0 1 13-3l2 2M18.9 15a8 8 0 0 1-13 3l-2-2"/>'
+      : '<path d="M12 3v12m-5-5 5 5 5-5M5 21h14"/>'
+    ring.toggleAttribute('hidden', !busy)
     ring.classList.toggle('update-spinning', busy && percent === undefined)
     ring.setAttribute('role', busy ? 'progressbar' : 'img')
     ring.setAttribute('aria-label', label.textContent)
@@ -47,20 +68,21 @@
     if (percent === undefined) ring.removeAttribute('aria-valuenow')
     else ring.setAttribute('aria-valuenow', String(percent))
     value.setAttribute('stroke-dasharray', `${busy ? percent ?? 25 : 100} 100`)
-    dot.hidden = !state.available || busy
+    dot.hidden = !(state.available || state.phase === 'ready') || busy
     error.textContent = localError || state.error || ''; error.hidden = !error.textContent
   }
   async function refresh() {
     if (reading || closed) return
     if (fallback && document.querySelector('[data-desktop-update-inline]')) {
-      closed = true; clearInterval(timer); detach(); entry.remove()
+      stop()
       return
     }
     if (fallback) {
-      const sidebar = document.querySelector('.desktop-sidebar .sidebar-footer')
-      if (sidebar && entry.parentElement !== sidebar) {
-        sidebar.prepend(entry)
-        entry.classList.replace('desktop-update-fallback', 'desktop-update-sidebar')
+      const bell = document.querySelector('.desktop-sidebar .unread-entry')
+      if (bell && bell.nextElementSibling !== entry) {
+        bell.after(entry)
+        entry.classList.remove('desktop-update-fallback')
+        entry.classList.add('desktop-update-toolbar')
       }
     }
     reading = true
@@ -81,6 +103,12 @@
   })
   const detach = desktop.onUpdateRequested(() => { requested = true; void refresh() })
   const timer = setInterval(() => void refresh(), 500)
-  window.addEventListener('unload', () => { closed = true; clearInterval(timer); detach() })
+  if (fallback) {
+    observer = new MutationObserver(() => {
+      if (document.querySelector('[data-desktop-update-inline]')) stop()
+    })
+    observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-desktop-update-inline'] })
+  }
+  window.addEventListener('unload', stop)
   void refresh()
 })()

+ 44 - 17
desktop/inline-update.test.mjs

@@ -15,7 +15,7 @@ async function freePort() {
   return port
 }
 
-test('the real desktop workspace downloads in place and waits for an explicit restart', { timeout: 60000 }, async () => {
+test('the desktop update icon appears beside the bell only for updates and waits for an explicit restart', { timeout: 60000 }, async () => {
   const root = resolve(import.meta.dirname, '..'), home = await mkdtemp(join(tmpdir(), 'yaoyao-inline-update-'))
   const port = await freePort(), upstream = await freePort(), origin = `http://127.0.0.1:${port}`
   await mkdir(join(home, 'server'))
@@ -32,8 +32,8 @@ test('the real desktop workspace downloads in place and waits for an explicit re
     await app.evaluate(({ app }, root) => {
       const { createRequire } = process.getBuiltinModule('node:module')
       const { autoUpdater } = createRequire(root + '/package.json')(root + '/.desktop-build-client/electron-updater.cjs')
-      globalThis.inlineFixture = { installs: 0 }
-      autoUpdater.checkForUpdates = async () => ({ isUpdateAvailable: true, updateInfo: { version: '99.0.0' } })
+      globalThis.inlineFixture = { installs: 0, available: false }
+      autoUpdater.checkForUpdates = async () => ({ isUpdateAvailable: inlineFixture.available, updateInfo: { version: '99.0.0' } })
       autoUpdater.downloadUpdate = () => new Promise(resolve => {
         inlineFixture.ready = () => resolve([])
         inlineFixture.stage = () => autoUpdater.emit('update-downloaded')
@@ -49,36 +49,63 @@ test('the real desktop workspace downloads in place and waits for an explicit re
     await page.waitForURL(origin + '/**')
     await page.goto(origin + '/conversations')
     await expect(page.locator('.desktop-sidebar .sidebar-account-switcher__main')).toBeVisible()
+    const entry = page.locator('.desktop-sidebar .desktop-update-entry')
+    await page.evaluate(() => window.yaoyaoDesktop.openUpdates())
+    await expect(entry).toHaveCount(0)
+    await expect(page.locator('#desktop-update-entry')).toHaveCount(0)
+    // Older servers receive the local updater script through the real main
+    // process. Its entry must follow the same bell placement and visibility.
+    await page.route(origin + '/legacy-update', route => route.fulfill({ contentType: 'text/html', body: '<aside class="desktop-sidebar"><div class="bot-list-header"><button class="unread-entry">铃铛</button><button>搜索</button></div></aside>' }))
+    await page.goto(origin + '/legacy-update')
+    await expect(page.locator('#desktop-update-entry')).toBeHidden()
+    await page.evaluate(() => window.yaoyaoDesktop.openUpdates())
+    await expect(page.locator('#desktop-update-entry')).toBeHidden()
+    await app.evaluate(() => { inlineFixture.available = true })
     await page.evaluate(() => window.yaoyaoDesktop.updateAction('check'))
-    const footer = page.locator('.desktop-sidebar .desktop-update-entry')
-    await expect(footer.getByRole('button', { name: '可更新', exact: true })).toBeVisible()
+    await expect(page.locator('.unread-entry + #desktop-update-entry').getByRole('button', { name: '可更新', exact: true })).toBeVisible()
+    await page.goto(origin + '/conversations')
+    await expect(entry.getByRole('button', { name: '可更新', exact: true })).toBeVisible()
     await expect(page.locator('#desktop-update-entry')).toHaveCount(0)
+    assert.equal(await entry.evaluate(element => element.previousElementSibling.classList.contains('unread-entry')), true)
+    await expect(page.locator('.sidebar-footer .desktop-update-entry')).toHaveCount(0)
     assert.equal(app.windows().length, 1)
     const output = join(root, 'test-results/desktop/inline-workspace'); await mkdir(output, { recursive: true })
     await page.screenshot({ path: join(output, 'available.png') })
-    await footer.getByRole('button', { name: '可更新', exact: true }).click()
-    await expect(footer.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '37')
+    await entry.getByRole('button', { name: '可更新', exact: true }).click()
+    await expect(entry.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '37')
+    await expect(page.locator('#desktop-update-entry')).toHaveCount(0)
+    await page.screenshot({ path: join(output, 'downloading.png') })
     await page.locator('.desktop-sidebar .sidebar-account-switcher__main').click()
-    const menu = page.locator('.workspace-settings-menu .desktop-update-entry')
-    await expect(menu).toContainText('正在下载 · 37%')
-    await page.screenshot({ path: join(output, 'downloading-menu.png') })
+    await expect(page.locator('.workspace-settings-menu .desktop-update-entry')).toHaveCount(0)
+    await expect(page.locator('.desktop-update-check')).toBeDisabled()
     await page.keyboard.press('Escape')
-    await expect(menu).toHaveCount(0)
     await app.evaluate(() => inlineFixture.stage())
-    await expect(footer).toContainText('正在准备…')
+    await expect(entry).toContainText('正在准备…')
     await page.reload()
-    await expect(footer).toContainText('正在准备…')
-    await expect(footer.getByRole('button')).toBeDisabled()
+    await expect(entry).toContainText('正在准备…')
+    await expect(entry.getByRole('button')).toBeDisabled()
     await app.evaluate(() => inlineFixture.ready())
-    await expect(footer.getByRole('button', { name: '重新启动', exact: true })).toBeEnabled()
+    await expect(entry.getByRole('button', { name: '重新启动', exact: true })).toBeEnabled()
+    await expect(entry).toHaveClass(/desktop-update-entry--ready/)
     assert.equal(await app.evaluate(() => inlineFixture.installs), 0)
     assert.equal(app.windows().length, 1)
     await page.screenshot({ path: join(output, 'ready.png') })
     await page.evaluate(() => document.documentElement.classList.add('dark'))
     await page.screenshot({ path: join(output, 'ready-dark.png') })
-    await footer.getByRole('button', { name: '重新启动', exact: true }).click()
+    await page.goto(origin + '/legacy-update')
+    await expect(page.locator('.unread-entry + #desktop-update-entry').getByRole('button', { name: '重新启动', exact: true })).toBeVisible()
+    await expect(page.locator('#desktop-update-icon')).toBeVisible()
+    await expect(page.locator('#desktop-update-ring')).toBeHidden()
+    await page.goto(origin + '/chat')
+    await expect(page.locator('.sidebar-status-actions .desktop-update-entry')).toBeVisible()
+    await expect(page.locator('#desktop-update-entry')).toHaveCount(0)
+    await page.screenshot({ path: join(output, 'ready-chat.png') })
+    await page.getByRole('button', { name: '折叠侧边栏', exact: true }).click()
+    await expect(entry.getByRole('button', { name: '重新启动', exact: true })).toBeVisible()
+    await page.screenshot({ path: join(output, 'ready-collapsed.png') })
+    await entry.getByRole('button', { name: '重新启动', exact: true }).click()
     await expect.poll(() => app.evaluate(() => inlineFixture.installs)).toBe(1)
-    await expect(footer.getByRole('button', { name: '正在重启…', exact: true })).toBeDisabled()
+    await expect(entry.getByRole('button', { name: '正在重启…', exact: true })).toBeDisabled()
     assert.equal((await fetch(origin + '/healthz')).status, 200)
   } finally {
     await app?.close().catch(() => {})

+ 13 - 14
src/client/components/app/DesktopUpdateEntry.vue

@@ -3,7 +3,7 @@ import { computed } from 'vue'
 import type { DesktopUpdateState } from '@shared/desktopUpdate'
 import AppIcon from '@/components/common/AppIcon.vue'
 
-const props = defineProps<{ state?: DesktopUpdateState; pending?: boolean; error?: string; menu?: boolean; compact?: boolean }>()
+const props = defineProps<{ state?: DesktopUpdateState; pending?: boolean; error?: string }>()
 const emit = defineEmits<{ activate: [] }>()
 const busy = computed(() => Boolean(props.pending || ['checking', 'downloading', 'verifying', 'preparing', 'installing'].includes(props.state?.phase ?? '')))
 const progress = computed(() => props.state?.phase === 'downloading' && props.state.total > 0
@@ -22,39 +22,38 @@ const label = computed(() => {
   return '检测更新'
 })
 const detail = computed(() => props.error || props.state?.error || (props.state?.phase === 'ready'
-  ? '更新已下载,点击重新启动完成安装' : props.state?.available ? `App ${props.state.latestVersion ?? ''} 可更新,点击下载` : props.state?.message))
+  ? props.state.installMode === 'restart' ? '更新已下载,点击重新启动完成安装' : '更新已下载,点击打开安装包'
+  : busy.value ? label.value : props.state?.available ? `App ${props.state.latestVersion ?? ''} 可更新,点击下载` : props.state?.message))
 </script>
 
 <template>
-  <div class="desktop-update-entry" :class="{ 'desktop-update-entry--compact': compact }">
-    <button type="button" :role="menu ? 'menuitem' : undefined" :title="detail" :aria-label="label" :aria-busy="busy" :disabled="busy || state?.retryable === false" @click="emit('activate')">
+  <div class="desktop-update-entry" :class="{ 'desktop-update-entry--ready': state?.phase === 'ready' }" :title="detail">
+    <button type="button" :title="detail" :aria-label="label" :aria-busy="busy" :disabled="busy || state?.retryable === false" @click="emit('activate')">
       <span v-if="busy" class="desktop-update-ring" :class="{ 'desktop-update-ring--spinning': progress === undefined }" role="progressbar" :aria-label="label" :aria-valuemin="0" :aria-valuemax="100" :aria-valuenow="progress">
         <svg viewBox="0 0 24 24" aria-hidden="true"><circle class="desktop-update-ring__track" cx="12" cy="12" r="9" /><circle class="desktop-update-ring__value" cx="12" cy="12" r="9" pathLength="100" :stroke-dasharray="`${progress ?? 25} 100`" /></svg>
       </span>
-      <AppIcon v-else :name="state?.phase === 'ready' ? 'refresh' : state?.available ? 'download' : 'refresh'" :size="18" />
+      <AppIcon v-else :name="state?.phase === 'ready' ? state.installMode === 'restart' ? 'refresh' : 'download' : state?.retryable === false ? 'alert' : state?.available ? 'download' : 'refresh'" :size="20" />
       <span class="desktop-update-entry__label" aria-live="polite">{{ label }}</span>
-      <span v-if="state?.available && !busy" class="desktop-update-dot" aria-hidden="true" />
+      <span v-if="(state?.available || state?.phase === 'ready') && !busy" class="desktop-update-dot" aria-hidden="true" />
     </button>
     <p v-if="error || state?.error" class="desktop-update-error" role="alert">{{ error || state?.error }}</p>
   </div>
 </template>
 
 <style scoped>
-.desktop-update-entry{width:100%;min-width:0;color:var(--text-primary)}
-.desktop-update-entry button{display:flex;align-items:center;gap:9px;width:100%;min-height:36px;padding:7px 10px;border:0;border-radius:7px;background:transparent;color:inherit;font:13px var(--font-ui);text-align:left;cursor:pointer}
+.desktop-update-entry{position:relative;flex:0 0 auto;width:36px;height:36px;color:var(--accent)}
+.desktop-update-entry button{position:relative;display:flex;align-items:center;justify-content:center;width:100%;height:100%;padding:0;border:1px solid transparent;border-radius:50%;background:transparent;color:inherit;cursor:pointer}
 .desktop-update-entry button:hover{background:var(--surface-hover)}
 .desktop-update-entry button:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
 .desktop-update-entry button:disabled{cursor:default}
-.desktop-update-entry__label{flex:1;white-space:nowrap}
-.desktop-update-dot{width:6px;height:6px;border-radius:50%;background:var(--accent)}
-.desktop-update-ring{display:inline-flex;flex:0 0 18px;width:18px;height:18px;color:var(--accent)}
+.desktop-update-entry__label,.desktop-update-error{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
+.desktop-update-dot{position:absolute;top:3px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--accent)}
+.desktop-update-entry--ready button{background:rgba(var(--accent-rgb), .10)}
+.desktop-update-ring{display:inline-flex;flex:0 0 20px;width:20px;height:20px;color:var(--accent)}
 .desktop-update-ring svg{width:100%;height:100%;transform:rotate(-90deg)}
 .desktop-update-ring circle{fill:none;stroke:currentColor;stroke-width:2.5}
 .desktop-update-ring__track{opacity:.18}.desktop-update-ring__value{stroke-linecap:round}
 .desktop-update-ring--spinning svg{animation:desktop-update-spin 1s linear infinite}
-.desktop-update-error{margin:4px 10px;color:var(--danger);font-size:12px;line-height:1.5;overflow-wrap:anywhere}
-.desktop-update-entry--compact button{justify-content:center;padding:9px}
-.desktop-update-entry--compact .desktop-update-entry__label,.desktop-update-entry--compact .desktop-update-dot,.desktop-update-entry--compact .desktop-update-error{display:none}
 @keyframes desktop-update-spin{to{transform:rotate(270deg)}}
 @media(prefers-reduced-motion:reduce){.desktop-update-ring--spinning svg{animation:none}}
 </style>

+ 22 - 12
src/client/components/app/WorkspaceShell.vue

@@ -145,7 +145,10 @@ const createPosition = ref({ left: '8px', top: '58px' })
 const settingsMenuOpen = ref(false)
 const desktopUpdate = useDesktopUpdate()
 const { state: desktopUpdateState, error: desktopUpdateError, pending: desktopUpdatePending, visible: desktopUpdateVisible } = desktopUpdate
-const canCheckUpdates = computed(() => Boolean(window.yaoyaoDesktop?.openUpdates) || props.isAdmin)
+const canCheckUpdates = computed(() => desktopUpdate.supported || Boolean(window.yaoyaoDesktop?.openUpdates) || props.isAdmin)
+const desktopUpdateBusy = computed(() => desktopUpdatePending.value || ['checking', 'downloading', 'verifying', 'preparing', 'installing'].includes(desktopUpdateState.value?.phase ?? ''))
+const desktopUpdateCheckLabel = computed(() => desktopUpdateState.value?.phase === 'checking' ? '正在检测…'
+  : desktopUpdateState.value?.phase === 'checked' && !desktopUpdateState.value.available ? '已是最新版本' : '检测更新')
 const openingUpdate = ref(false)
 const updateError = ref('')
 const settingsMenu = ref<HTMLElement | null>(null)
@@ -263,7 +266,7 @@ async function openSettingsMenu(event: MouseEvent) {
 async function checkForUpdates() {
   if (openingUpdate.value || !canCheckUpdates.value) return
   updateError.value = ''
-  if (desktopUpdate.supported) { await desktopUpdate.run(); return }
+  if (desktopUpdate.supported) { await desktopUpdate.run('check'); return }
   if (window.yaoyaoDesktop?.openUpdates) {
     openingUpdate.value = true
     try {
@@ -498,6 +501,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
           <AppIcon name="bell" :size="20" />
           <b v-if="unread.total" class="unread-entry__count" aria-hidden="true">{{ unread.total > 99 ? '99+' : unread.total }}</b>
         </button>
+        <DesktopUpdateEntry v-if="desktopUpdate.supported && desktopUpdateVisible" :state="desktopUpdateState" :pending="desktopUpdatePending" :error="desktopUpdateError" @activate="desktopUpdate.run()" />
         <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>
         <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>
       </div>
@@ -505,10 +509,13 @@ defineExpose({openLocalVm:showLocalVmSettings})
         <button class="rail__brand sidebar-brand" type="button" aria-label="返回聊天" title="夭夭 AI" @click="navigate(applicationWorkspace ? '/conversations' : '/chat')">
           <BrandMark :size="sidebarCollapsed ? 26 : 32" :label="false" compact bare />
         </button>
-        <button class="unread-entry" type="button" :aria-label="`未读消息,${unread.total} 条`" aria-haspopup="dialog" aria-controls="unread-center" :aria-expanded="unreadOpen" title="未读消息" @click="openUnread">
-          <AppIcon name="bell" :size="20" />
-          <b v-if="unread.total" class="unread-entry__count" aria-hidden="true">{{ unread.total > 99 ? '99+' : unread.total }}</b>
-        </button>
+        <div class="sidebar-status-actions">
+          <button class="unread-entry" type="button" :aria-label="`未读消息,${unread.total} 条`" aria-haspopup="dialog" aria-controls="unread-center" :aria-expanded="unreadOpen" title="未读消息" @click="openUnread">
+            <AppIcon name="bell" :size="20" />
+            <b v-if="unread.total" class="unread-entry__count" aria-hidden="true">{{ unread.total > 99 ? '99+' : unread.total }}</b>
+          </button>
+          <DesktopUpdateEntry v-if="desktopUpdate.supported && desktopUpdateVisible" :state="desktopUpdateState" :pending="desktopUpdatePending" :error="desktopUpdateError" @activate="desktopUpdate.run()" />
+        </div>
       </div>
       <button
         v-if="!applicationWorkspace"
@@ -577,7 +584,6 @@ defineExpose({openLocalVm:showLocalVmSettings})
       </section>
 
       <div class="sidebar-footer">
-        <DesktopUpdateEntry v-if="desktopUpdate.supported && desktopUpdateVisible && !settingsMenuOpen" :state="desktopUpdateState" :pending="desktopUpdatePending" :error="desktopUpdateError" :compact="sidebarCollapsed && !applicationWorkspace" @activate="desktopUpdate.run()" />
         <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>
         <div class="sidebar-account-switcher">
           <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)">
@@ -628,7 +634,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
         <button class="sidebar-brand" type="button" aria-label="返回聊天" @click="navigate(applicationWorkspace ? '/conversations' : '/chat')">
           <BrandMark :size="32" compact />
         </button>
-        <div class="mobile-drawer__actions"><button ref="mobileDrawerClose" class="icon-button" type="button" aria-label="关闭导航" @click="closeMobileDrawer">
+        <div class="mobile-drawer__actions"><DesktopUpdateEntry v-if="desktopUpdate.supported && desktopUpdateVisible" :state="desktopUpdateState" :pending="desktopUpdatePending" :error="desktopUpdateError" @activate="desktopUpdate.run()" /><button ref="mobileDrawerClose" class="icon-button" type="button" aria-label="关闭导航" @click="closeMobileDrawer">
           <AppIcon name="close" />
         </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>
       </div>
@@ -686,7 +692,6 @@ defineExpose({openLocalVm:showLocalVmSettings})
       </section>
 
       <div class="sidebar-footer mobile-drawer__footer">
-        <DesktopUpdateEntry v-if="desktopUpdate.supported && desktopUpdateVisible && !settingsMenuOpen" :state="desktopUpdateState" :pending="desktopUpdatePending" :error="desktopUpdateError" @activate="desktopUpdate.run()" />
         <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>
         <div class="sidebar-account-switcher">
           <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)">
@@ -772,9 +777,10 @@ defineExpose({openLocalVm:showLocalVmSettings})
         <div ref="settingsMenu" class="workspace-create-menu workspace-settings-menu" :style="settingsMenuPosition" role="menu" aria-label="账号菜单" @keydown="actionMenuKeydown">
           <button type="button" role="menuitem" @click="chooseSettingsAction('settings')"><AppIcon name="settings" :size="17" />我的设置</button>
           <button type="button" role="menuitem" @click="chooseSettingsAction('about')"><AppIcon name="info" :size="17" />关于</button>
-          <DesktopUpdateEntry v-if="desktopUpdate.supported" :state="desktopUpdateState" :pending="desktopUpdatePending" :error="desktopUpdateError" menu @activate="desktopUpdate.run()" />
+          <button v-if="desktopUpdate.supported" class="desktop-update-check" type="button" role="menuitem" :disabled="desktopUpdateBusy || desktopUpdateState?.phase === 'ready'" :aria-busy="desktopUpdateBusy" @click="checkForUpdates"><AppIcon name="refresh" :size="17" />{{ desktopUpdateCheckLabel }}</button>
           <button v-else-if="canCheckUpdates" type="button" role="menuitem" :disabled="openingUpdate" :aria-busy="openingUpdate" @click="checkForUpdates"><AppIcon name="refresh" :size="17" />{{ openingUpdate ? '正在打开…' : '检测更新' }}</button>
           <p v-if="updateError" class="update-menu-error" role="alert">{{ updateError }}</p>
+          <p v-if="desktopUpdate.supported && (desktopUpdateError || desktopUpdateState?.error)" class="update-menu-error" role="alert">{{ desktopUpdateError || desktopUpdateState?.error }}</p>
           <a role="menuitem" href="https://yaoyao.samien.cn" target="_blank" rel="noopener noreferrer" @click="closeSettingsMenu"><AppIcon name="external" :size="17" />帮助</a>
           <button v-if="isAdmin" type="button" role="menuitem" @click="chooseSettingsAction('bots')"><AppIcon :name="applicationWorkspace ? 'chat' : 'users'" :size="17" />{{ applicationWorkspace ? '进入聊天模式' : '进入 Bot 模式' }}</button>
         </div>
@@ -1054,12 +1060,16 @@ defineExpose({openLocalVm:showLocalVmSettings})
   cursor: pointer;
   transition: background-color 140ms ease, border-color 140ms ease;
 }
-.sidebar-brand-row .unread-entry { position: absolute; top: 9px; right: 49px; }
+.sidebar-status-actions { position: absolute; top: 9px; right: 49px; display: flex; align-items: center; gap: 4px; }
+.bot-list-header .desktop-update-entry { width: 42px; height: 42px; }
+.bot-list-header .desktop-update-entry :deep(button) { border-color: var(--line); background: var(--surface); }
+.bot-list-header .desktop-update-entry--ready :deep(button) { background: rgba(var(--accent-rgb), .10); }
 .bot-list-header .unread-entry { position: relative; color: var(--text-secondary); }
 .unread-entry:hover, .unread-entry[aria-expanded="true"], .bot-list-header .unread-entry:hover, .bot-list-header .unread-entry[aria-expanded="true"] { background: var(--surface-hover); color: var(--text-primary); }
 .unread-entry:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
 .unread-entry__count { position: absolute; top: -3px; right: -3px; display: grid; min-width: 18px; height: 18px; place-items: center; padding: 0 4px; border: 2px solid var(--surface); border-radius: 20px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; font-weight: 600; line-height: 14px; white-space: nowrap; font-variant-numeric: tabular-nums; }
 .desktop-sidebar--collapsed .sidebar-brand-row { flex-direction: column; gap: 8px; padding: 9px 13px; }
+.desktop-sidebar--collapsed .sidebar-status-actions { position: static; flex-direction: column; }
 .desktop-sidebar--collapsed .unread-entry { position: relative; top: auto; right: auto; }
-@media (max-width: 767px) { .unread-entry, .bot-list-header .unread-entry { display: none; } }
+@media (max-width: 767px) { .sidebar-status-actions, .unread-entry, .bot-list-header .unread-entry { display: none; } }
 </style>

+ 4 - 4
src/client/composables/useDesktopUpdate.ts

@@ -7,9 +7,9 @@ export function useDesktopUpdate() {
   const state = ref<DesktopUpdateState>()
   const error = ref('')
   const pending = ref(false)
-  const requested = ref(false)
-  const visible = computed(() => Boolean(requested.value || pending.value || state.value?.available || error.value
-    || ['downloading', 'verifying', 'preparing', 'ready', 'installing', 'failed'].includes(state.value?.phase ?? '')))
+  const visible = computed(() => Boolean(state.value?.available
+    || ['downloading', 'verifying', 'preparing', 'ready', 'installing'].includes(state.value?.phase ?? '')
+    || (state.value?.phase === 'failed' && state.value.latestVersion)))
   let stopped = false, reading = false
   let timer: ReturnType<typeof setInterval> | undefined
   let detach: (() => void) | undefined
@@ -48,7 +48,7 @@ export function useDesktopUpdate() {
     if (!supported) return
     void refresh()
     timer = setInterval(() => void refresh(), 500)
-    detach = desktop?.onUpdateRequested?.(() => { requested.value = true; void refresh() })
+    detach = desktop?.onUpdateRequested?.(() => { void refresh() })
   })
   onBeforeUnmount(() => { stopped = true; clearInterval(timer); detach?.() })
   return { supported, state, error, pending, visible, refresh, run }

+ 27 - 8
tests/client/workspaceShell.test.ts

@@ -290,7 +290,7 @@ it('opens the native updater, prevents duplicate clicks and lets a failed openin
   wrapper.unmount()
 })
 
-it('keeps desktop downloads and restart in the same entry, including after closing the menu', async () => {
+it('keeps desktop downloads and restart beside the bell in both workspaces', async () => {
   vi.useFakeTimers()
   let state: DesktopUpdateState = { phase: 'checked', installMode: 'restart', currentVersion: '0.4.80', latestVersion: '0.4.81', available: true, received: 0, total: 100, message: '发现更新' }
   let finish!: (state: DesktopUpdateState) => void
@@ -310,21 +310,30 @@ it('keeps desktop downloads and restart in the same entry, including after closi
     await flushPromises()
     const entry = () => wrapper.get('.desktop-sidebar .desktop-update-entry')
     expect(entry().text()).toBe('可更新')
+    expect(entry().element.previousElementSibling?.classList.contains('unread-entry')).toBe(true)
+    expect(wrapper.find('.sidebar-footer .desktop-update-entry').exists()).toBe(false)
     await entry().get('button').trigger('click')
     await entry().get('button').trigger('click')
     await flushPromises()
     expect(updateAction).toHaveBeenCalledExactlyOnceWith('download')
     expect(entry().get('[role="progressbar"]').attributes('aria-valuenow')).toBe('42')
     await wrapper.get('.desktop-sidebar .sidebar-account-switcher__main').trigger('click')
-    expect(document.querySelector('.workspace-settings-menu .desktop-update-entry')?.textContent).toContain('正在下载 · 42%')
+    expect(document.querySelector('.workspace-settings-menu .desktop-update-entry')).toBeNull()
+    expect(document.querySelector('.desktop-update-check')?.hasAttribute('disabled')).toBe(true)
+    expect(entry().get('[role="progressbar"]').attributes('aria-valuenow')).toBe('42')
     state = { ...state, phase: 'preparing', received: 100 }
     await vi.advanceTimersByTimeAsync(500)
-    expect(document.querySelector('.workspace-settings-menu .desktop-update-entry')?.textContent).toContain('正在准备…')
-    expect(document.querySelector('.workspace-settings-menu .desktop-update-entry button')?.hasAttribute('disabled')).toBe(true)
+    expect(entry().text()).toContain('正在准备…')
+    expect(entry().get('button').attributes()).toHaveProperty('disabled')
     await wrapper.get('.desktop-sidebar .sidebar-account-switcher__main').trigger('click')
     state = { ...state, phase: 'ready', available: false }
     finish(state); await flushPromises()
     expect(entry().text()).toBe('重新启动')
+    expect(entry().classes()).toContain('desktop-update-entry--ready')
+    expect(entry().get('button').attributes('title')).toContain('点击重新启动')
+    await wrapper.vm.$router.push('/chat'); await flushPromises()
+    expect(wrapper.find('.sidebar-status-actions .desktop-update-entry').exists()).toBe(true)
+    expect(entry().element.previousElementSibling?.classList.contains('unread-entry')).toBe(true)
     expect(updateAction).toHaveBeenCalledTimes(1)
     await entry().get('button').trigger('click'); await flushPromises()
     expect(updateAction).toHaveBeenLastCalledWith('install')
@@ -333,23 +342,33 @@ it('keeps desktop downloads and restart in the same entry, including after closi
   } finally { wrapper.unmount(); vi.useRealTimers() }
 })
 
-it('shows desktop check errors in place and allows retry without opening a window', async () => {
+it('hides the toolbar update icon when no update exists, including manual checks and errors', async () => {
+  vi.useFakeTimers()
   const checked: DesktopUpdateState = { phase: 'checked', installMode: 'restart', currentVersion: '0.4.80', available: false, received: 0, total: 0, message: '已是最新版本' }
+  let state: DesktopUpdateState = { ...checked, phase: 'idle' }
+  let requested!: () => void
   const updateAction = vi.fn().mockRejectedValueOnce(new Error('连接失败')).mockResolvedValueOnce(checked)
-  window.yaoyaoDesktop = { updateState: vi.fn(async () => ({ ...checked, phase: 'idle' as const })), updateAction, openComputer: vi.fn(), computerClosed: vi.fn(), onComputerClose: vi.fn() }
+  window.yaoyaoDesktop = { updateState: vi.fn(async () => state), updateAction, onUpdateRequested: callback => { requested = callback; return () => {} }, openComputer: vi.fn(), computerClosed: vi.fn(), onComputerClose: vi.fn() }
   const wrapper = await mountShell('/conversations')
   try {
     await flushPromises()
+    expect(wrapper.find('.desktop-update-entry').exists()).toBe(false)
+    state = { ...checked, phase: 'checking' }; requested(); await flushPromises()
+    expect(wrapper.find('.desktop-update-entry').exists()).toBe(false)
+    state = checked; await vi.advanceTimersByTimeAsync(500)
+    expect(wrapper.find('.desktop-update-entry').exists()).toBe(false)
     await wrapper.get('.desktop-sidebar .sidebar-account-switcher__main').trigger('click')
-    const button = () => document.querySelector<HTMLButtonElement>('.workspace-settings-menu .desktop-update-entry button')!
+    const button = () => document.querySelector<HTMLButtonElement>('.desktop-update-check')!
     button().click(); button().click(); await flushPromises()
     expect(updateAction).toHaveBeenCalledExactlyOnceWith('check')
     expect(document.querySelector('.workspace-settings-menu [role="alert"]')?.textContent).toBe('连接失败')
+    expect(wrapper.find('.desktop-update-entry').exists()).toBe(false)
     button().click(); await flushPromises()
     expect(button().textContent).toContain('已是最新版本')
     expect(document.querySelector('.workspace-settings-menu')).not.toBeNull()
     expect(document.querySelector('.workspace-settings-menu [role="alert"]')).toBeNull()
-  } finally { wrapper.unmount() }
+    expect(wrapper.find('.desktop-update-entry').exists()).toBe(false)
+  } finally { wrapper.unmount(); vi.useRealTimers() }
 })
 
 it('does not expose server update controls to a non-admin browser account', async () => {