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(), '') + }) +})