diff --git a/src/client/components/overviewPanel.tsx b/src/client/components/overviewPanel.tsx
index eac4ece..8902c05 100644
--- a/src/client/components/overviewPanel.tsx
+++ b/src/client/components/overviewPanel.tsx
@@ -42,6 +42,7 @@ import { makeOverviewUsage } from './overviewUsage'
import { makeStatsTiming } from './statsTiming'
import { makeOverviewCard } from './overviewCard'
import { makeOverviewSkills } from './overviewSkills'
+import { makeUpdatePill } from './updatePill'
import { IconSettings } from '../primitives'
export interface OverviewPanelProps {
@@ -64,6 +65,7 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
const StatsTiming = makeStatsTiming(kit, Donut)
const OverviewUsage = makeOverviewUsage(kit)
const OverviewSkills = makeOverviewSkills(kit)
+ const UpdatePill = makeUpdatePill(kit)
const ErrorBoundary = makeErrorBoundary(t)
// Read per render: the slot outlet re-renders on a locale switch.
@@ -175,6 +177,7 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
{/* Renders nothing until a live figure lands, so the heading row never reflows for it. */}
+
{/* One wrapper so the head's right cluster pushes once; a bare `.lc-gran` sibling would split the free space. */}
ReactElement {
)
return function PluginInfo(): ReactElement {
- const [latest, setLatest] = useState(null)
- useEffect(() => {
- if (PLUGIN_VERSION.includes('-dev')) return
- let on = true
- // fetchLatestVersion never rejects (every failure narrows to null); the `on` flag drops late results.
- void fetchLatestVersion().then((v) => { if (on && v) setLatest(v) })
- return () => { on = false }
- }, [])
- const update = latest !== null && isNewerVersion(latest, PLUGIN_VERSION) ? latest : null
+ const update = useNewerVersion()
const nameText = PLUGIN_NAME + ' (v' + PLUGIN_VERSION + ')'
const nameValue: ReactNode[] = [nameText]
if (update) nameValue.push({'↑ v' + update})
diff --git a/src/client/components/updatePill.tsx b/src/client/components/updatePill.tsx
new file mode 100644
index 0000000..bdf947e
--- /dev/null
+++ b/src/client/components/updatePill.tsx
@@ -0,0 +1,33 @@
+/** The Context Insights page's update capsule — the newer registry version, linking its release notes.
+ * Renders NOTHING without one, so the heading row never reflows for it. */
+
+import type { ReactElement } from 'react'
+import { useNewerVersion } from '../latestVersion'
+import { PLUGIN_REPO } from '../meta'
+import type { ViewKit } from '../viewkit'
+
+/** The release notes behind the capsule's click. */
+const RELEASES_URL = PLUGIN_REPO + '/releases/latest'
+
+export function makeUpdatePill(kit: ViewKit): () => ReactElement | null {
+ const { t } = kit
+ return function UpdatePill(): ReactElement | null {
+ const update = useNewerVersion()
+ if (update === null) return null
+ const tip = t('update.tip', { version: 'v' + update })
+ return (
+
+ {t('update.label')}
+ ↑ v{update}
+
+ )
+ }
+}
diff --git a/src/client/i18nEn.ts b/src/client/i18nEn.ts
index a3fe9c7..647075a 100644
--- a/src/client/i18nEn.ts
+++ b/src/client/i18nEn.ts
@@ -480,6 +480,8 @@ export const DICT_EN = {
'balance.title': 'DeepSeek balance',
'balance.tip.granted': 'Granted balance',
'balance.tip.toppedUp': 'Topped-up balance',
+ 'update.label': 'Update',
+ 'update.tip': 'Update {version} is available — click for the release notes',
'ov.heat.title': 'Activity',
'ov.heat.sub': 'Filter by date',
'ov.heat.empty': 'No activity yet — daily usage appears once sessions make model requests',
diff --git a/src/client/i18nZh.ts b/src/client/i18nZh.ts
index f0fceb9..d3f63a4 100644
--- a/src/client/i18nZh.ts
+++ b/src/client/i18nZh.ts
@@ -479,6 +479,8 @@ export const DICT_ZH = {
'balance.title': 'DeepSeek 余额',
'balance.tip.granted': '赠送余额',
'balance.tip.toppedUp': '充值余额',
+ 'update.label': '新版本',
+ 'update.tip': '新版本 {version} 已发布,点击查看更新说明',
'ov.heat.title': '活跃度',
'ov.heat.sub': '按日期筛选',
'ov.heat.empty': '暂无活跃数据 — 会话产生模型请求后,这里会显示每日用量',
diff --git a/src/client/latestVersion.ts b/src/client/latestVersion.ts
index 4712aa2..3879ceb 100644
--- a/src/client/latestVersion.ts
+++ b/src/client/latestVersion.ts
@@ -1,7 +1,8 @@
-/** Lazy 1h TTL check for the Plugin-info card: npm first, npmmirror as fallback, one at a time; all
+/** Lazy 1h TTL check for the update hints: npm first, npmmirror as fallback, one at a time; all
* sources failing resolves null. */
-import { PLUGIN_NAME } from './meta'
+import { useEffect, useState } from 'react'
+import { PLUGIN_NAME, PLUGIN_VERSION } from './meta'
const REGISTRY_HOSTS = [
'https://registry.npmjs.org',
@@ -48,3 +49,17 @@ export function isNewerVersion(latest: string, current: string): boolean {
}
return false
}
+
+/** The newer registry version for this build, or null — the read behind every surface's update hint. A dev
+ * build never asks; the 1h TTL cache answers every later mount from the first fetch. */
+export function useNewerVersion(): string | null {
+ const [latest, setLatest] = useState(null)
+ useEffect(() => {
+ if (PLUGIN_VERSION.includes('-dev')) return
+ let on = true
+ // fetchLatestVersion never rejects (every failure narrows to null); the `on` flag drops late results.
+ void fetchLatestVersion().then((v) => { if (on && v) setLatest(v) })
+ return () => { on = false }
+ }, [])
+ return latest !== null && isNewerVersion(latest, PLUGIN_VERSION) ? latest : null
+}
diff --git a/src/client/styles/overview.css b/src/client/styles/overview.css
index ecefec1..dad50a7 100644
--- a/src/client/styles/overview.css
+++ b/src/client/styles/overview.css
@@ -3,18 +3,22 @@
.lc-ov-pagescroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; scrollbar-gutter: stable; }
/* No width cap: the shipped first-level pages frame at 960px, this page adapts to the pane. */
.lc-ov-pagecontent { padding: 0 clamp(24px, 4vw, 48px) 48px; }
-.lc-ov-pagehead { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding-top: 28px; margin-bottom: 24px; }
+.lc-ov-pagehead { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding-top: 28px; margin-bottom: 24px; }
/* macOS hiddenInset titlebar clearance, so first-level pages align with the frame. */
[data-platform='darwin'] .lc-ov-pagehead { padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px)); }
-.lc-ov-pagetitle { flex: 0 1 auto; min-width: 0; margin: 0; font-size: 20px; line-height: 28px; font-weight: 500; }
+/* The capsules cluster at the head's 8px; only the title keeps the wider 16px lead-in. */
+.lc-ov-pagetitle { flex: 0 1 auto; min-width: 0; margin: 0 8px 0 0; font-size: 20px; line-height: 28px; font-weight: 500; }
.lc-ov-settings { flex: none; display: inline-flex; align-items: center; gap: 6px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); border-radius: 999px; padding: 3px 12px; font: inherit; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); white-space: nowrap; cursor: pointer; transition: border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
/* One wrapper around the picker and range group, so the head keeps a single auto-margin push. */
.lc-ov-headctl { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.lc-ov-settings:hover { border-color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-primary); }
-.lc-ov-balance { flex: none; display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 2px 10px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); font-size: 12px; line-height: 18px; text-decoration: none; color: inherit; }
+/* The head's two capsules share one chrome; only the update hint's version wears the warn token. */
+.lc-ov-balance, .lc-ov-update { flex: none; display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 2px 10px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); font-size: 12px; line-height: 18px; text-decoration: none; color: inherit; white-space: nowrap; }
.lc-ov-balance:hover .lc-ov-balance-value { text-decoration: underline; }
-.lc-ov-balance-label { color: var(--dsw-alias-label-secondary); white-space: nowrap; }
-.lc-ov-balance-value { font-variant-numeric: tabular-nums; font-weight: 600; white-space: nowrap; }
+.lc-ov-balance-label, .lc-ov-update-label { color: var(--dsw-alias-label-secondary); }
+.lc-ov-balance-value, .lc-ov-update-version { font-variant-numeric: tabular-nums; font-weight: 600; }
+.lc-ov-update:hover { border-color: var(--dsw-alias-state-warn-primary); }
+.lc-ov-update-version { color: var(--dsw-alias-state-warn-primary); }
.lc-hovertip { position: fixed; z-index: 300; max-width: min(320px, 50vw); white-space: pre-line; }
diff --git a/tests/client/components/pluginInfo.spec.ts b/tests/client/components/pluginInfo.spec.ts
index df97067..333e14f 100644
--- a/tests/client/components/pluginInfo.spec.ts
+++ b/tests/client/components/pluginInfo.spec.ts
@@ -6,9 +6,7 @@ import { flush, makeKit, mount, query, queryAll, text } from '../helpers/kit'
const kit = makeKit()
-type FetchBody = { version?: unknown }
-
-function stubFetchOk(body: FetchBody) {
+function stubFetchOk(body: { version?: unknown }) {
const fetchMock = vi.fn((_input: unknown) => Promise.resolve({ ok: true, json: () => Promise.resolve(body) }))
vi.stubGlobal('fetch', fetchMock)
return fetchMock
@@ -85,45 +83,4 @@ describe('PluginInfo', () => {
assert.equal(queryAll(m.container, '.lc-pi-update').length, 0)
await m.unmount()
})
-
- test('every registry failing narrows to null and renders no chip', async () => {
- const fetchMock = vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve(null) }))
- vi.stubGlobal('fetch', fetchMock)
- const PluginInfo = await loadPluginInfo('1.0.0')
- const m = await mount(h(PluginInfo, {}))
- await flush()
- // Both registries (official npm then npmmirror) are tried before giving up.
- assert.equal(fetchMock.mock.calls.length, 2)
- assert.equal(queryAll(m.container, '.lc-pi-update').length, 0)
- await m.unmount()
- })
-
- test('falls back to npmmirror when the official registry fails', async () => {
- const fetchMock = vi.fn((input: unknown) => {
- const url = String(input)
- if (url.startsWith('https://registry.npmjs.org/')) {
- return Promise.resolve({ ok: false, json: () => Promise.resolve(null) })
- }
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ version: '1.1.0' }) })
- })
- vi.stubGlobal('fetch', fetchMock)
- const PluginInfo = await loadPluginInfo('1.0.0')
- const m = await mount(h(PluginInfo, {}))
- await flush()
- assert.equal(fetchMock.mock.calls.length, 2)
- assert.equal(query(m.container, '.lc-pi-update').textContent, '↑ v1.1.0')
- await m.unmount()
- })
-
- test('unmounting before the answer lands drops the late result', async () => {
- let resolveFetch: ((res: { ok: boolean; json: () => Promise }) => void) | null = null
- vi.stubGlobal('fetch', vi.fn(() => new Promise((resolve) => { resolveFetch = resolve })))
- const PluginInfo = await loadPluginInfo('1.0.0')
- const m = await mount(h(PluginInfo, {}))
- await m.unmount()
- resolveFetch!({ ok: true, json: () => Promise.resolve({ version: '2.0.0' }) })
- await flush()
- // No state update after unmount, no act warning, no chip anywhere.
- assert.equal(queryAll(document.body, '.lc-pi-update').length, 0)
- })
})
diff --git a/tests/client/components/updatePill.spec.ts b/tests/client/components/updatePill.spec.ts
new file mode 100644
index 0000000..95d859b
--- /dev/null
+++ b/tests/client/components/updatePill.spec.ts
@@ -0,0 +1,68 @@
+// UpdatePill (src/client/components/updatePill.tsx): the Insights head's newer-version capsule. What it
+// renders and where it links; the check itself (dev skip, not-newer, registry failure, late result) is
+// useNewerVersion's, covered once in tests/client/latestVersion.spec.ts.
+
+import { createElement as h } from 'react'
+import assert from 'node:assert/strict'
+import { afterEach, describe, test, vi } from 'vitest'
+import type { makeUpdatePill as makeUpdatePillFn } from '../../../src/client/components/updatePill'
+import { flush, makeKit, mount, query, queryAll } from '../helpers/kit'
+
+/** Fresh latestVersion + updatePill modules — resetModules so the baked-in version define and the fetch
+ * TTL cache start clean; `version` emulates the tsdown define. */
+async function loadUpdatePill(version: string, active: 'en' | 'zh' = 'en'): Promise> {
+ vi.resetModules()
+ ;(globalThis as Record).__DSH_CTX_VERSION__ = version
+ await import('../../../src/client/latestVersion')
+ const mod = await import('../../../src/client/components/updatePill')
+ return mod.makeUpdatePill(makeKit(active))
+}
+
+/** A registry one minor ahead of the running build. */
+function stubRegistry(): void {
+ vi.stubGlobal('fetch', async () => ({ ok: true, json: async () => ({ version: '1.1.0' }) }))
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ delete (globalThis as Record).__DSH_CTX_VERSION__
+})
+
+describe('UpdatePill', () => {
+ test('a newer version paints the capsule linking the release notes', async () => {
+ stubRegistry()
+ const UpdatePill = await loadUpdatePill('1.0.0')
+ const m = await mount(h(UpdatePill, {}))
+ await flush()
+ const pill = query(m.container, '.lc-ov-update')
+ assert.equal(pill.getAttribute('href'), 'https://github.com/bowenliang123/dsh-context/releases/latest')
+ assert.equal(pill.getAttribute('target'), '_blank')
+ assert.equal(pill.getAttribute('rel'), 'noreferrer')
+ assert.equal(query(pill, '.lc-ov-update-label').textContent, 'Update')
+ assert.equal(query(pill, '.lc-ov-update-version').textContent, '↑ v1.1.0')
+ // The tip rides the page's portaled hover bubble, below the pill in the head.
+ assert.equal(pill.getAttribute('data-lc-tip'), 'Update v1.1.0 is available — click for the release notes')
+ assert.equal(pill.getAttribute('data-lc-tip-side'), 'bottom')
+ assert.equal(pill.getAttribute('aria-label'), pill.getAttribute('data-lc-tip'))
+ await m.unmount()
+ })
+
+ test('the Chinese dictionary rides the same capsule', async () => {
+ stubRegistry()
+ const UpdatePill = await loadUpdatePill('1.0.0', 'zh')
+ const m = await mount(h(UpdatePill, {}))
+ await flush()
+ assert.equal(query(m.container, '.lc-ov-update-label').textContent, '新版本')
+ assert.equal(query(m.container, '.lc-ov-update-version').textContent, '↑ v1.1.0')
+ await m.unmount()
+ })
+
+ test('the build being current leaves the head unpainted', async () => {
+ stubRegistry()
+ const UpdatePill = await loadUpdatePill('1.1.0')
+ const m = await mount(h(UpdatePill, {}))
+ await flush()
+ assert.equal(queryAll(m.container, '.lc-ov-update').length, 0)
+ await m.unmount()
+ })
+})
\ No newline at end of file
diff --git a/tests/client/latestVersion.spec.ts b/tests/client/latestVersion.spec.ts
index 820d5bd..91d447d 100644
--- a/tests/client/latestVersion.spec.ts
+++ b/tests/client/latestVersion.spec.ts
@@ -1,10 +1,13 @@
-// Latest-version check (src/client/latestVersion.ts): registry fetch with a 1h TTL and the semver comparator.
+// Latest-version check (src/client/latestVersion.ts): registry fetch with a 1h TTL, the semver comparator,
+// and the useNewerVersion hook every surface's update hint reads.
// The fetch is stubbed per test — the suite never hits the network — and each fetch test re-imports the module
// (vi.resetModules) to reset the module-level cache. Two registries are tried in order (official npm, then npmmirror).
+import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { afterEach, describe, test, vi } from 'vitest'
import { isNewerVersion } from '../../src/client/latestVersion'
+import { flush, mount } from './helpers/kit'
const HOUR_MS = 60 * 60 * 1000
@@ -12,6 +15,7 @@ afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
vi.resetModules()
+ delete (globalThis as Record).__DSH_CTX_VERSION__
})
async function freshModule(): Promise {
@@ -154,3 +158,63 @@ describe('isNewerVersion', () => {
assert.equal(isNewerVersion('x.y', '0.0.0'), false)
})
})
+
+/** Mount the hook through a throwaway component; `version` emulates the tsdown define. The settled value is
+ * what the hook last reported — an empty string stands for null, and a late setState still re-renders. */
+async function renderUseNewerVersion(version?: string): Promise<{ settled: () => string; unmount: () => Promise }> {
+ vi.resetModules()
+ if (version === undefined) delete (globalThis as Record).__DSH_CTX_VERSION__
+ else (globalThis as Record).__DSH_CTX_VERSION__ = version
+ const { useNewerVersion } = await import('../../src/client/latestVersion')
+ const seen: string[] = []
+ function Probe(): null {
+ seen.push(useNewerVersion() ?? '')
+ return null
+ }
+ const m = await mount(h(Probe, {}))
+ await flush()
+ return { settled: () => seen[seen.length - 1], unmount: () => m.unmount() }
+}
+
+describe('useNewerVersion', () => {
+ test('a newer registry version is served to the caller', async () => {
+ vi.stubGlobal('fetch', async () => ({ ok: true, json: async () => ({ version: '1.1.0' }) }))
+ const { settled, unmount } = await renderUseNewerVersion('1.0.0')
+ assert.equal(settled(), '1.1.0')
+ await unmount()
+ })
+
+ test('a dev build never checks the registry', async () => {
+ const fetchMock = vi.fn()
+ vi.stubGlobal('fetch', fetchMock)
+ const { settled, unmount } = await renderUseNewerVersion() // PLUGIN_VERSION = 0.0.0-dev
+ assert.equal(fetchMock.mock.calls.length, 0)
+ assert.equal(settled(), '')
+ await unmount()
+ })
+
+ test('a version that is not newer stays null', async () => {
+ vi.stubGlobal('fetch', async () => ({ ok: true, json: async () => ({ version: '1.0.0' }) }))
+ const { settled, unmount } = await renderUseNewerVersion('1.0.0')
+ assert.equal(settled(), '')
+ await unmount()
+ })
+
+ test('every registry failing stays null', async () => {
+ vi.stubGlobal('fetch', async () => ({ ok: false, json: async () => ({}) }))
+ const { settled, unmount } = await renderUseNewerVersion('1.0.0')
+ assert.equal(settled(), '')
+ await unmount()
+ })
+
+ test('unmounting before the answer lands drops the late result', async () => {
+ let resolveFetch: ((res: { ok: boolean; json: () => Promise<{ version: string }> }) => void) | null = null
+ vi.stubGlobal('fetch', vi.fn(() => new Promise((resolve) => { resolveFetch = resolve })))
+ const { settled, unmount } = await renderUseNewerVersion('1.0.0')
+ await unmount()
+ resolveFetch!({ ok: true, json: () => Promise.resolve({ version: '2.0.0' }) })
+ await flush()
+ // No state update after unmount, no act warning, and the value never lands.
+ assert.equal(settled(), '')
+ })
+})