From 018e92964f7d31bb1d40ffead72821d2dcfa2490 Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Mon, 12 Oct 2026 13:24:35 +0800 Subject: [PATCH] feat(client): add an update capsule to the Insights page head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Context tab's Plugin-info card already hinted at a newer npm release; the Context Insights page head had no such hint. Add one beside the balance capsule, in that page's own capsule idiom rather than the card's inline chip: label plus the version in the warn token, linking the release notes through the page's portaled hover bubble. The registry check moves out of pluginInfo into a useNewerVersion hook both surfaces read, so the second hint costs no new fetch path, state, or mechanism — one TTL-cached read still answers both. Its own coverage moves with it into latestVersion.spec, leaving each surface's spec asserting only what that surface renders. The two head capsules share one CSS chrome instead of a copy, and the head's gap drops to 8px so the capsule cluster tightens; the title keeps its wider 16px lead-in, and the fold at narrow widths leaves every wrapped row on the same left edge with no overflow. --- src/client/components/overviewPanel.tsx | 3 + src/client/components/pluginInfo.tsx | 14 +---- src/client/components/updatePill.tsx | 33 +++++++++++ src/client/i18nEn.ts | 2 + src/client/i18nZh.ts | 2 + src/client/latestVersion.ts | 19 +++++- src/client/styles/overview.css | 14 +++-- tests/client/components/pluginInfo.spec.ts | 45 +------------- tests/client/components/updatePill.spec.ts | 68 ++++++++++++++++++++++ tests/client/latestVersion.spec.ts | 66 ++++++++++++++++++++- 10 files changed, 203 insertions(+), 63 deletions(-) create mode 100644 src/client/components/updatePill.tsx create mode 100644 tests/client/components/updatePill.spec.ts 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(), '') + }) +})