feat(insights): promote the dashboard to a first-level sidebar panel page

The Context Insights surface moves from a sidebar-foot button opening a
frame-wide overlay to a first-level panel, a sibling of the shipped
Plugins and Automation tasks pages: the page registers on the layout's
keyed `main` slot and the entry on `sidebar.panellist` under one id
(`dsh-context`), so the shell owns the entry row and panel switching —
mounting the page IS opening it (no plugin-side open state, the footer
entry, the overlay seat, and the overviewStore all go away). The
`insightsEntry` preference now mounts/unwinds the registration pair.
Both seats ship on every supported line (0.1.5-rc.1+), pinned per
baseline by the new insightPage seam probe; runtime-verified on a
disposable 0.1.5-rc.1 profile.

The chrome mirrors the shipped first-level pages: one scroll region
with the 960px centered column, the 20px page heading beside the range
group and the balance capsule, and the page element as the `lc-ov`
query container. Inline-size containment makes the page the containing
block for fixed descendants, so the heatmap/usage/balance tooltips can
no longer ride the harness Tooltip (its portal prop is 0.1.7+): they
now carry `data-lc-tip` served by the page's own hover tip — one fixed
bubble portaled to <body>, delegated off the page root (the image
lightbox's precedent), correct on every supported line.

Session card jumps need no close (openSession's own navigation returns
to the conversation); the settings row jump unmounts the page by
selecting the Plugins panel. Verified across 1440-390px in both
locales, and the /context modal is untouched.
This commit is contained in:
bowenliang123 committed 2026-10-05 11:20:59 +08:00
1 parent 84fe1d7038
commit 96c8b92caa
26 files changed
+1091 -711

No files matched your search

+5 -5
View File
@@ -9,7 +9,7 @@
**The best [DeepSeek Harness plugin](https://www.deepseek.com/harness/) for Agent's context insights and management.**
[`dsh-context`](https://www.npmjs.com/package/dsh-context) provides full context lifecycle management features.
- **Context Dashboard** — the cross-session overview above Settings on the sidebar foot: KPI band with a 7-day token/cost usage chart, activity heatmap, aggregate composition ring, and filterable session cards that jump straight into any session.
- **Context Insights page** — the cross-session overview as a first-level sidebar panel (beside Plugins and Automation tasks): KPI band with a 7-day token/cost usage chart, activity heatmap, aggregate composition ring, and filterable session cards that jump straight into any session.
- **Context tab** — an UI context dashboard for DeepSeek Harness's context stats, composition, trend, events, and messages.
- **Context panel** — the same dashboard as a right-sidebar tab (dsh 0.1.5-rc.1+): pick **Context** on the sidebar's guide page and the panel opens beside the chat.
- **`/context` command** — the slash command shows the context model for current context composition and recent context evolution.
@@ -44,16 +44,16 @@ Four surfaces, one story — what your agent is carrying, how it got there, and
| Where | What you get |
| --- | --- |
| **Context Dashboard** | Every session at a glance: usage, cost, cache hit, daily activity, and per-session context profiles — filtered by range, day, group, or search, one click to jump in. |
| **Context Insights page** | Every session at a glance: usage, cost, cache hit, daily activity, and per-session context profiles — filtered by range, day, group, or search, one click to jump in. |
| **Context tab** | The full dashboard: stats, composition, per-request trend, events, file activity, and the agent network — in every session. |
| **`/context` command** | A centered modal with the same composition and context browser, without leaving the chat. |
| **Preferences card** | Per-user defaults: view placement, trend granularity & mode, File Activity sort, and more. |
## 🗂️ The Context Dashboard
## 🗂️ The Context Insights page
Click **Context Dashboard / 上下文仪表盘** at the bottom-left of the sidebar, right above **Settings**:
Click **Context Insights / 上下文洞察** in the sidebar's panel list — the same row level as **Plugins** and **Automation tasks** — and the page opens as a full center-column panel:
![Context Dashboard](https://raw.githubusercontent.com/bowenliang123/dsh-context/main/docs/context-dashboard.png)
![The Context Insights page](https://raw.githubusercontent.com/bowenliang123/dsh-context/main/docs/context-dashboard.png)
| Section | The question it answers |
| --- | --- |
+11 -10
View File
@@ -81,7 +81,8 @@ The browser half rides generation-specific seats, each reached through an option
| Seam | V3 (`0.1.5-rc.1`) | V4 (`0.1.7-rc.2`, `0.2.0-rc.2`) |
| --- | --- | --- |
| Conversation panel root | keyed `main` panel's `main.conversation` (the plugin's `conversation.view` seat hangs under it) | same as V3 |
| Right Sidebar tab + guide entry | `sidebarRightTabs` registry, `sidebar.right.pane.tab`(+`.title`) seats, guide capsule with `order`/`title`/`description`/`icon` | same as V3 (`0.1.7-rc.2`+ adds an optional `commandId` to guide entries and `bindCommands` to tab actions — additive, unused by the plugin) |
| Insight page (panel + entry) | the layout's keyed `main` slot under the `dsh-context` panel key, with the sidebar entry on the `sidebar.panellist` list under the same id — the shell owns the entry row and panel switching; both spellings probed per baseline (the `insightPage` probe) | same as V3 |
| Right Sidebar tab + guide entry | `sidebarRightTabs` registry, `sidebar.right.pane.tab`(`.title`) seats, guide capsule with `order`/`title`/`description`/`icon` | same as V3 (`0.1.7-rc.2`+ adds an optional `commandId` to guide entries and `bindCommands` to tab actions — additive, unused by the plugin) |
| Preferences card seat | `settings.plugin.item` over the `settingsScope` transport | `plugins.bundle.config` over the `configForms` transport |
| Session jump | `uiWorkspace.openSession` — the sidebar row click's own verb; the retired `sessions.open(id)` spelling is still served as a fallback | `uiWorkspace.openSession(target: SessionTarget)` — same verb, parameter re-spelled by the 0.1.6 selection refactor; the sessions service carries no selection verb any more |
@@ -89,9 +90,9 @@ The guide capsule carries `order`, `title`, `description`, and `icon`; `0.1.5-rc
The File Activity card's file-name affordance is an optional-column story: on a line with the right Sidebar it opens the file's preview tab through `ctx.sidebarRight.openResource` (the built-in Files sidebar's own path), building the `dsh-resource://file/…` address with the harness's browser-safe `fileAddressFor` (inlined by the client bundle, exactly as the Sidebar's own file types inline it). Where the preview face is absent (or refuses the address), the name keeps its original system-open behavior; the `sidebar.nav` probe pins the navigation face's spelling.
The session jump — a Context Dashboard session card or an Agent network node bringing its session forward — rides the harness's own selection verb (`openSessionVia` in `src/client/services.ts`): `uiWorkspace.openSession`, the verb the built-in sidebar's row click uses on every supported line, with the sessions service's retired `open(id)` spelling kept as the degradation path for a composition without the workspace module. Both faces are re-proved per call and a deployment serving neither (or a hostile face) swallows silently — the panel still closes, the jump just does not land. Issue #90 was exactly the retired-spelling trap: the plugin called `sessions.open` alone, which the 0.1.6 selection refactor removed from the service (the click then did nothing on `0.1.7-rc.2`), so the jump now follows the view owner first and the per-baseline `sessionNav` probe pins both spellings presence-as-declared.
The session jump — a Context Insights session card or an Agent network node bringing its session forward — rides the harness's own selection verb (`openSessionVia` in `src/client/services.ts`): `uiWorkspace.openSession`, the verb the built-in sidebar's row click uses on every supported line, with the sessions service's retired `open(id)` spelling kept as the degradation path for a composition without the workspace module. Both faces are re-proved per call and a deployment serving neither (or a hostile face) swallows silently — the verb's own navigation returns the center column to the conversation (`selectPanel(null)`), so a dead jump simply stays on the page. Issue #90 was exactly the retired-spelling trap: the plugin called `sessions.open` alone, which the 0.1.6 selection refactor removed from the service (the click then did nothing on `0.1.7-rc.2`), so the jump now follows the view owner first and the per-baseline `sessionNav` probe pins both spellings presence-as-declared.
The Context Dashboard's DeepSeek balance capsule is an all-optional stack, so it arms nowhere it cannot: the host reaches the `settings` and `credentials` services only inside a deferred `ctx.inject` (and re-reads them per request, so a key added while the host runs is picked up on the next open), mounts `/api/dsh-context/balance` through the same Connection exact-route registry as the detail and backfill routes, and the outbound `/user/balance` read carries the key only inside the host process. The provider's settings row is read through whichever face the running line serves: the `get(ns)` registered-section read through V3 (the `llm-deepseek` namespace), the Config-form `describe()` projection on V4+ — there the row is the entry whose served value declares the top-level `apiKeyEnv` credential ref, preferred under the settings ids the generations have served it as (`llm-deepseek` on product profiles, `llm-deepseek-api-key` elsewhere). The capsule appears only on a live, well-formed answer; a deployment whose DeepSeek API-key provider is not composed (no settings row on either face), whose credentials service cannot resolve the key, whose connection service lacks the exact-route registry, or whose platform read fails (network, non-2xx, malformed payload, a third-party endpoint that does not serve the balance path) answers a typed `null` and the browser renders nothing — no pill, no spinner, no error state. The `balance` probe pins the settings face and the provider row's sources per baseline.
The Context Insights page's DeepSeek balance capsule is an all-optional stack, so it arms nowhere it cannot: the host reaches the `settings` and `credentials` services only inside a deferred `ctx.inject` (and re-reads them per request, so a key added while the host runs is picked up on the next open), mounts `/api/dsh-context/balance` through the same Connection exact-route registry as the detail and backfill routes, and the outbound `/user/balance` read carries the key only inside the host process. The provider's settings row is read through whichever face the running line serves: the `get(ns)` registered-section read through V3 (the `llm-deepseek` namespace), the Config-form `describe()` projection on V4+ — there the row is the entry whose served value declares the top-level `apiKeyEnv` credential ref, preferred under the settings ids the generations have served it as (`llm-deepseek` on product profiles, `llm-deepseek-api-key` elsewhere). The capsule appears only on a live, well-formed answer; a deployment whose DeepSeek API-key provider is not composed (no settings row on either face), whose credentials service cannot resolve the key, whose connection service lacks the exact-route registry, or whose platform read fails (network, non-2xx, malformed payload, a third-party endpoint that does not serve the balance path) answers a typed `null` and the browser renders nothing — no pill, no spinner, no error state. The `balance` probe pins the settings face and the provider row's sources per baseline.
## What each check means
@@ -112,19 +113,19 @@ The skill-injection category (issue #66) bumps the timeline projection's `stateV
The Agent network card covers the orphaned relatives without waiting for a visit: a session listed without a `contextTimeline` row (composition absent, occupancy only) fetches its slim head from the plugin's `/api/dsh-context/detail` fetch route (mounted through Connection's exact-route registry, behind the same authenticated `/api` fence), which folds the durable log on demand — the node's ring renders the full composition the first time the card opens. The same backfill serves sessions whose cache predates the plugin's installation entirely. Where the route is unavailable (the baseline gate, a connection service without the exact-route registry), the card degrades to the pressure-only occupancy ring as before.
The session cards' last-user-message preview (`lastUser` on the `contextTimeline` head) bumps the timeline projection's `stateVersion` (19 → 20): a stale row can never gain the field while its session is idle (it only lands when a user message folds), so on upgrade every cached timeline row is invalidated and refolded from the durable log. To keep the bump from orphaning idle sessions' rows, the warm-up (`src/host/backfill.ts`) probes BOTH dashboard rows — `contextActivity` AND `contextTimeline` — and cold-refolds every session whose either row is missing or version-stale, so previews (and every other head figure) are exact for the whole session list the first time the dashboard opens after the upgrade. A deployment without the cold-path services — or whose client cannot mount the warm-up's trigger route — degrades to the per-session refold on the session's next activity, the same as pre-warm-up upgrades.
The session cards' last-user-message preview (`lastUser` on the `contextTimeline` head) bumps the timeline projection's `stateVersion` (19 → 20): a stale row can never gain the field while its session is idle (it only lands when a user message folds), so on upgrade every cached timeline row is invalidated and refolded from the durable log. To keep the bump from orphaning idle sessions' rows, the warm-up (`src/host/backfill.ts`) probes BOTH insight rows — `contextActivity` AND `contextTimeline` — and cold-refolds every session whose either row is missing or version-stale, so previews (and every other head figure) are exact for the whole session list the first time the page opens after the upgrade. A deployment without the cold-path services — or whose client cannot mount the warm-up's trigger route — degrades to the per-session refold on the session's next activity, the same as pre-warm-up upgrades.
The Context Dashboard (the sidebar-foot panel) reads its `contextActivity` projection — a per-day ledger of billed tokens and completed requests, `stateVersion` 1 — off every session-list row's projection column. A new key is purely additive: no existing cached row is invalidated, but sessions folded before the unit existed (and sessions that predate the plugin entirely, whose `contextTimeline`/`contextHeaders` rows are missing too) have nothing to serve until they next go live. The plugin therefore runs a one-pass warm-up on demand (`src/host/backfill.ts`): the dashboard is the rows' only reader, so its panel POSTs the plugin's `/api/dsh-context/backfill` fetch route (the same authenticated `/api` fence as the detail route) the first time it opens, and only then does the host walk the corpus — for every stored session missing the row it cold-reads the durable log once through the projection cache's own cold-read ladder (`coldSnapshot`), which seeds each unit from its cached rows, folds the remainder, and writes the refreshed checkpoint back. The pass runs once per host process (later opens and later POSTs are no-ops); live sessions are skipped (they fold for themselves); a legacy log the running harness refuses to migrate is permanent — its per-session detail logs at debug and the pass ends with one summary info line, so an unmigratable corpus costs one line per dashboard-open process, not one warn per session per boot; and a deployment whose client never opens the dashboard never cold-reads a thing. A deployment without the sessionQuery/sessionProjectionCache/sessionPersistence/sessions services — or whose connection service lacks the exact-route registry — simply never arms the pass (such a client could not open the dashboard to read the rows anyway).
The Context Insights page (the sidebar panel list's first-level `main` panel) reads its `contextActivity` projection — a per-day ledger of billed tokens and completed requests, `stateVersion` 1 — off every session-list row's projection column. A new key is purely additive: no existing cached row is invalidated, but sessions folded before the unit existed (and sessions that predate the plugin entirely, whose `contextTimeline`/`contextHeaders` rows are missing too) have nothing to serve until they next go live. The plugin therefore runs a one-pass warm-up on demand (`src/host/backfill.ts`): the page is the rows' only reader, so it POSTs the plugin's `/api/dsh-context/backfill` fetch route (the same authenticated `/api` fence as the detail route) the first time it mounts, and only then does the host walk the corpus — for every stored session missing the row it cold-reads the durable log once through the projection cache's own cold-read ladder (`coldSnapshot`), which seeds each unit from its cached rows, folds the remainder, and writes the refreshed checkpoint back. The pass runs once per host process (later opens and later POSTs are no-ops); live sessions are skipped (they fold for themselves); a legacy log the running harness refuses to migrate is permanent — its per-session detail logs at debug and the pass ends with one summary info line, so an unmigratable corpus costs one line per page-open process, not one warn per session per boot; and a deployment whose client never opens the page never cold-reads a thing. A deployment without the sessionQuery/sessionProjectionCache/sessionPersistence/sessions services — or whose connection service lacks the exact-route registry — simply never arms the pass (such a client could not open the page to read the rows anyway).
The dashboard's per-day usage chart (the first row's right half) extends the `contextActivity` ledger with per-day pricing records — each day's billed buckets keyed provider → model → pricing period, the same `SessionCostUsage` raw material the timeline's session-cost totals carry, booked at each turn's initiation instant and priced client-side by the same book and estimator as the KPI band. Booking fees per (provider, model, period) changes the fold's state shape, so the unit's `stateVersion` bumps (1 → 2): on upgrade every cached `contextActivity` row reads version-stale, and the dashboard's existing warm-up (above) cold-refolds the corpus from the durable logs, rebuilding every day's tokens AND fees exactly the first time the panel opens. On the wire the field is additive-optional: a day folded before it existed (or whose settlements carried no model header or no usage) serves without it, and the chart degrades to tokens-only bars for those days — the same sanitizer contract every other projection value rides.
The page's per-day usage chart (the first row's right half) extends the `contextActivity` ledger with per-day pricing records — each day's billed buckets keyed provider → model → pricing period, the same `SessionCostUsage` raw material the timeline's session-cost totals carry, booked at each turn's initiation instant and priced client-side by the same book and estimator as the KPI band. Booking fees per (provider, model, period) changes the fold's state shape, so the unit's `stateVersion` bumps (1 → 2): on upgrade every cached `contextActivity` row reads version-stale, and the page's existing warm-up (above) cold-refolds the corpus from the durable logs, rebuilding every day's tokens AND fees exactly the first time the panel opens. On the wire the field is additive-optional: a day folded before it existed (or whose settlements carried no model header or no usage) serves without it, and the chart degrades to tokens-only bars for those days — the same sanitizer contract every other projection value rides.
The timing card's session-time strip (the card's bottom band: every completed step's TTFT wait, decode blocks in stream order, tool-run windows, and in-step residue packed gapless in occurrence order — each slice's width is its share of the session's cumulative active time, with ticks at zero, the quartiles, and the full span) bumps the timeline projection's `stateVersion` (22 → 23): the strip's painted spans are a per-step collection the fold flushes at `step/end`, which later events cannot backfill for steps already folded (the timing totals' own v11 precedent), so on upgrade every cached timeline row is invalidated and refolded from the durable log, which rebuilds the spans for sessions started under older plugin builds; the v20 warm-up rebuilds idle sessions' rows the first time the dashboard opens. On the wire the collection is additive-optional — it rides the detail channel (and the inline value on channel-less hosts), never the slim head — so a payload folded before it existed serves without it, and the timing card shows no strip then. The spans are bounded to the newest 2,000 records (a plain count cap), so an outsize session's strip paints its recent window; untrusted spans are re-proved per item at the client's render boundary, the same sanitizer contract every collection rides.
The timing card's session-time strip (the card's bottom band: every completed step's TTFT wait, decode blocks in stream order, tool-run windows, and in-step residue packed gapless in occurrence order — each slice's width is its share of the session's cumulative active time, with ticks at zero, the quartiles, and the full span) bumps the timeline projection's `stateVersion` (22 → 23): the strip's painted spans are a per-step collection the fold flushes at `step/end`, which later events cannot backfill for steps already folded (the timing totals' own v11 precedent), so on upgrade every cached timeline row is invalidated and refolded from the durable log, which rebuilds the spans for sessions started under older plugin builds; the v20 warm-up rebuilds idle sessions' rows the first time the page opens. On the wire the collection is additive-optional — it rides the detail channel (and the inline value on channel-less hosts), never the slim head — so a payload folded before it existed serves without it, and the timing card shows no strip then. The spans are bounded to the newest 2,000 records (a plain count cap), so an outsize session's strip paints its recent window; untrusted spans are re-proved per item at the client's render boundary, the same sanitizer contract every collection rides.
The timing totals' wait/generation boundary moving from the first token to the first observable instant (above — an earlier `block-start` marker wins when the token packing could not stamp it, the same boundary the strip's spans already paint) bumps the timeline projection's `stateVersion` (23 → 24): rows folded under 23 charged the marker-tiled decode window to BOTH the wait and the generation split, so their legend rows sum past 100%, and the mis-split totals cannot be repaired by later events, so on upgrade every cached timeline row is invalidated and refolded from the durable log; the v20 warm-up rebuilds idle sessions' rows the first time the dashboard opens. The wire shape is unchanged — the same `ttftMs`/`genMs` fields carry the corrected sums — and the TPS chip keeps the harness-parity first-token anchor, so only the card's wait/generation shares move.
The timing totals' wait/generation boundary moving from the first token to the first observable instant (above — an earlier `block-start` marker wins when the token packing could not stamp it, the same boundary the strip's spans already paint) bumps the timeline projection's `stateVersion` (23 → 24): rows folded under 23 charged the marker-tiled decode window to BOTH the wait and the generation split, so their legend rows sum past 100%, and the mis-split totals cannot be repaired by later events, so on upgrade every cached timeline row is invalidated and refolded from the durable log; the v20 warm-up rebuilds idle sessions' rows the first time the page opens. The wire shape is unchanged — the same `ttftMs`/`genMs` fields carry the corrected sums — and the TPS chip keeps the harness-parity first-token anchor, so only the card's wait/generation shares move.
Accounting for `developer/message` tool-registry changes bumps the timeline projection's `stateVersion` (24 → 25): earlier rows omitted these model-visible messages from their surface, request composition, and archives. On upgrade the derived rows refold from the durable log; the existing dashboard warm-up also rebuilds idle sessions. The wire shape stays unchanged.
Accounting for `developer/message` tool-registry changes bumps the timeline projection's `stateVersion` (24 → 25): earlier rows omitted these model-visible messages from their surface, request composition, and archives. On upgrade the derived rows refold from the durable log; the existing page warm-up also rebuilds idle sessions. The wire shape stays unchanged.
The trend chart's duration overlay (the card's Duration toggle: each request's step active time drawn as a curve over the bars, read off the right-hand quartile axis) bumps the timeline projection's `stateVersion` (25 → 26): the per-request `activeMs` is stamped at `step/end` fold time — the whole step window (the model call plus its tool runs) minus the step's user waits, those being approval decision intervals (`approval/asked` → `approval/decided`, a crash-dangling pair closing at the step's own end) and `ask_user_question` answer windows (the tool's body is the wait itself, in both the blocking and the timed schema's foreground phase) — so requests already folded can never gain it later (the v9 `cacheRead` precedent), and on upgrade every cached timeline row is invalidated and refolded from the durable log, while the v20 warm-up rebuilds idle sessions' rows the first time the dashboard opens. On the wire the field is additive-optional: a request whose step is still open (the live tail) or that folded without one (an unpaired/hostile log) serves without it, and the overlay's curve simply breaks at that bar instead of faking a point — the same sanitizer contract every collection rides.
The trend chart's duration overlay (the card's Duration toggle: each request's step active time drawn as a curve over the bars, read off the right-hand quartile axis) bumps the timeline projection's `stateVersion` (25 → 26): the per-request `activeMs` is stamped at `step/end` fold time — the whole step window (the model call plus its tool runs) minus the step's user waits, those being approval decision intervals (`approval/asked` → `approval/decided`, a crash-dangling pair closing at the step's own end) and `ask_user_question` answer windows (the tool's body is the wait itself, in both the blocking and the timed schema's foreground phase) — so requests already folded can never gain it later (the v9 `cacheRead` precedent), and on upgrade every cached timeline row is invalidated and refolded from the durable log, while the v20 warm-up rebuilds idle sessions' rows the first time the page opens. On the wire the field is additive-optional: a request whose step is still open (the live tail) or that folded without one (an unpaired/hostile log) serves without it, and the overlay's curve simply breaks at that bar instead of faking a point — the same sanitizer contract every collection rides.
To force a full refold of an existing session sooner, delete its cached projection row — it is a derived cache and is rebuilt from the durable log:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 565 KiB

After

Width:  |  Height:  |  Size: 441 KiB

+7 -9
View File
@@ -1,20 +1,20 @@
/**
* BalanceCapsule — the DeepSeek open-platform balance pill in the Context
* Dashboard's header (the plugin's one account-level figure, beside the
* Insights page's heading (the plugin's one account-level figure, beside the
* session-level ones). Clicking it opens the platform's usage console in a
* new tab. Mounted only by the dashboard header; it paints the figure the
* new tab. Mounted only by the page heading; it paints the figure the
* previous open remembered while client/balance.ts revalidates it in the
* background, and renders NOTHING when nothing is remembered and the route
* answers nothing — the pill exists only when there is a figure to show. The
* non-zero parts of the breakdown (topped-up / granted) ride the harness
* Tooltip primitive — immediate on hover, where a native `title` lags — in
* non-zero parts of the breakdown (topped-up / granted) ride the page's
* portaled hover tip (hoverTip.tsx, off the pill's `data-lc-tip`) —
* immediate on hover, where a native `title` lags — in
* the entry's own currency (the pill itself carries the total); the entry
* follows the active locale (zh → CNY) with the account's first currency as
* the fallback.
*/
import { useState, type ReactElement } from 'react'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import { balanceEntryOf, readPlatformBalance } from '../balance'
import type { PlatformBalance } from '../../shared/types'
import type { ClientCtx } from '../services'
@@ -48,10 +48,11 @@ export function makeBalanceCapsule(ctx: ClientCtx, kit: ViewKit): () => ReactEle
...(entry.toppedUp > 0 ? [t('balance.tip.toppedUp') + ': ' + money(entry.toppedUp)] : []),
...(entry.granted > 0 ? [t('balance.tip.granted') + ': ' + money(entry.granted)] : []),
]
const pill = (
return (
<a
className="lc-ov-balance"
aria-label={tipLines.length > 0 ? tipLines.join('\n') : undefined}
{...(tipLines.length > 0 ? { 'data-lc-tip': tipLines.join('\n'), 'data-lc-tip-side': 'bottom' } : {})}
href={USAGE_URL}
target="_blank"
rel="noreferrer"
@@ -60,8 +61,5 @@ export function makeBalanceCapsule(ctx: ClientCtx, kit: ViewKit): () => ReactEle
<span className="lc-ov-balance-value">{money(entry.total)}</span>
</a>
)
return tipLines.length > 0
? <Tooltip label={tipLines.join('\n')} side="bottom">{pill}</Tooltip>
: pill
}
}
+15 -19
View File
@@ -8,16 +8,16 @@
* can spread its steps thinly across many days while another day stacks a
* run, so each metric scales on its OWN maximum — in four steps; a day with
* data is a button whose click
* pins the session list to that day (click again to release). Cells tip
* through the harness's own Tooltip primitive (instant on hover; native
* `title` lags a second behind), the bubble carrying the date, the day's
* active sessions, and its steps. The grid is computed from the injected
* pins the session list to that day (click again to release). Cells carry
* their tip (the date, the day's active sessions, and its steps) in
* `data-lc-tip`, served by the page's portaled hover tip (hoverTip.tsx) —
* instant on hover, where a native `title` lags a second behind. The grid is
* computed from the injected
* `today` key, so
* the layout is deterministic in tests and follows the browser's local
* calendar at runtime.
*/
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ReactElement } from 'react'
import { dayKeyOf, shiftDayKey, sundayOfWeek } from '../../shared/days'
import { type DayTotals } from '../overview'
@@ -142,24 +142,20 @@ export function makeHeatmap(kit: ViewKit): (props: HeatmapProps) => ReactElement
if (cell.future) return <span key={cell.key} className="lc-heat-cell lc-heat-future" aria-hidden="true" />
const level = cell.entry === undefined ? 0 : levelOf(metricValueOf(cell.entry), max)
if (cell.entry === undefined) {
return (
<Tooltip key={cell.key} label={cell.key} side="top">
<span className="lc-heat-cell lc-heat-0" />
</Tooltip>
)
return <span key={cell.key} className="lc-heat-cell lc-heat-0" data-lc-tip={cell.key} />
}
const picked = props.selected === cell.key
const label = `${cell.key}\n${t('ov.heat.sessions', { n: cell.entry.sessions })}\n${t('ov.heat.steps', { n: cell.entry.requests })}`
return (
<Tooltip key={cell.key} label={label} side="top">
<button
type="button"
className={`lc-heat-cell lc-heat-${String(level)}${picked ? ' lc-heat-on' : ''}`}
aria-label={label}
aria-pressed={picked}
onClick={() => { if (props.onSelect !== undefined) props.onSelect(picked ? null : cell.key) }}
/>
</Tooltip>
<button
key={cell.key}
type="button"
className={`lc-heat-cell lc-heat-${String(level)}${picked ? ' lc-heat-on' : ''}`}
data-lc-tip={label}
aria-label={label}
aria-pressed={picked}
onClick={() => { if (props.onSelect !== undefined) props.onSelect(picked ? null : cell.key) }}
/>
)
})}
</div>
+172
View File
@@ -0,0 +1,172 @@
/**
* The dashboard's hover tips — one fixed bubble, portaled to <body>, driven
* by event delegation off the page root.
*
* Why not the harness Tooltip: the Insights page is the `lc-ov` query
* container, and inline-size containment makes the page the containing block
* for FIXED descendants — an in-tree fixed bubble (the harness Tooltip's
* unportaled spelling) would paint sidebar-width away from its anchor, while
* the primitive's `portal` prop exists only on 0.1.7+. The plugin already
* portals its own fixed overlays for exactly this trap (the image lightbox,
* base.css), so the page's tips follow that precedent and stay correct on
* every supported line.
*
* Anchors carry `data-lc-tip` (the label) and optional `data-lc-tip-side`
* ('top' default, 'bottom'); the zone delegates `mouseover`/`mouseout` and
* focus/blur capture, so anchors stay plain elements — no per-cell wrapper
* components. One bubble at a time: it re-anchors on anchor switch, hides on
* anchor leave, on any zone scroll, on a viewport resize (every anchor just
* moved), and on unmount; while shown it sets the anchor's
* `aria-describedby`.
*
* @module dsh-context/client/components/hoverTip
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react'
import { createPortal } from 'react-dom'
/** The bubble's identity for the anchor's `aria-describedby` (one live bubble at a time). */
const TIP_ID = 'lc-hovertip-bubble'
/** Anchor-to-bubble distance and the viewport margin the bubble clamps inside, in px. */
const GAP = 6
const VIEWPORT_MARGIN = 8
interface ActiveTip {
anchor: Element
label: string
side: 'top' | 'bottom'
rect: { left: number; top: number; width: number; height: number; bottom: number }
}
/** The bubble's measured spot for one shown tip. */
interface Measured {
tip: ActiveTip
left: number
top: number
}
/** The nearest tip anchor of an event target, if any. */
function anchorOf(target: EventTarget | null): Element | null {
return target instanceof Element ? target.closest('[data-lc-tip]') : null
}
function tipOf(anchor: Element): ActiveTip | null {
const label = anchor.getAttribute('data-lc-tip')
if (label === null || label === '') return null
const r = anchor.getBoundingClientRect()
return {
anchor,
label,
side: anchor.getAttribute('data-lc-tip-side') === 'bottom' ? 'bottom' : 'top',
rect: { left: r.left, top: r.top, width: r.width, height: r.height, bottom: r.bottom },
}
}
export interface HoverTipZone {
/** Spread onto the zone's root element (the page section). */
zoneProps: {
onMouseOver: (ev: React.MouseEvent) => void
onMouseOut: (ev: React.MouseEvent) => void
onFocusCapture: (ev: React.FocusEvent) => void
onBlurCapture: (ev: React.FocusEvent) => void
onScrollCapture: (ev: React.UIEvent) => void
}
/** The portaled bubble element, or null while nothing is tipped. */
bubble: ReactElement | null
}
/**
* The zone hook: stable delegated handlers plus the portaled bubble. The
* bubble renders hidden until the layout effect measures it, so it never
* flashes at an unmeasured spot.
*/
export function useHoverTip(): HoverTipZone {
const [tip, setTip] = useState<ActiveTip | null>(null)
const [measured, setMeasured] = useState<Measured | null>(null)
const bubbleRef = useRef<HTMLDivElement | null>(null)
const show = useCallback((anchor: Element): void => {
setTip((current) => {
// Same anchor (moving across its own subtree): keep tip and measure.
if (current?.anchor === anchor) return current
current?.anchor.removeAttribute('aria-describedby')
const next = tipOf(anchor)
if (next === null) return null
anchor.setAttribute('aria-describedby', TIP_ID)
return next
})
}, [])
const hide = useCallback((): void => {
setTip((current) => {
current?.anchor.removeAttribute('aria-describedby')
return null
})
}, [])
// Anchor switching never hides: leaving A for B skips the hide (B's
// mouseover re-anchors), and moving inside A's own subtree keeps it.
const onMouseOver = useCallback((ev: React.MouseEvent): void => {
const anchor = anchorOf(ev.target)
if (anchor !== null) show(anchor)
}, [show])
const onMouseOut = useCallback((ev: React.MouseEvent): void => {
if (anchorOf(ev.target) !== null && anchorOf(ev.relatedTarget) === null) hide()
}, [hide])
const onFocusCapture = useCallback((ev: React.FocusEvent): void => {
const anchor = anchorOf(ev.target)
if (anchor !== null) show(anchor)
}, [show])
const onBlurCapture = useCallback((ev: React.FocusEvent): void => {
if (anchorOf(ev.target) !== null && anchorOf(ev.relatedTarget) === null) hide()
}, [hide])
const onScrollCapture = useCallback((): void => { hide() }, [hide])
// A viewport resize moves every anchor (the pane re-solves, the content
// column re-centers): the zone's scroll rule extended — retract rather
// than float detached off the moved anchor.
useEffect(() => {
if (tip === null) return undefined
const retract = (): void => { hide() }
window.addEventListener('resize', retract)
return () => { window.removeEventListener('resize', retract) }
}, [tip, hide])
// Measure once per shown tip (the measured record keys on tip identity, so
// a same-anchor mousemove never re-hides the bubble): center on the anchor,
// clamp inside the viewport, flip sides when the preferred side overflows.
useLayoutEffect(() => {
if (tip === null || measured?.tip === tip) return
const el = bubbleRef.current
/* v8 ignore next -- the portal commits its ref before layout effects run,
* so a null ref here is unreachable; kept as a guard so a future portal
* change cannot crash the page. */
if (el === null) return
const w = el.offsetWidth
const h = el.offsetHeight
const { rect } = tip
const left = Math.max(VIEWPORT_MARGIN, Math.min(rect.left + rect.width / 2 - w / 2, window.innerWidth - w - VIEWPORT_MARGIN))
let top = tip.side === 'top' ? rect.top - h - GAP : rect.bottom + GAP
if (tip.side === 'top' && top < VIEWPORT_MARGIN) top = rect.bottom + GAP
else if (tip.side === 'bottom' && top + h > window.innerHeight - VIEWPORT_MARGIN) top = rect.top - h - GAP
setMeasured({ tip, left, top: Math.max(VIEWPORT_MARGIN, top) })
}, [tip, measured])
const shown = measured !== null && measured.tip === tip ? measured : null
return {
zoneProps: { onMouseOver, onMouseOut, onFocusCapture, onBlurCapture, onScrollCapture },
bubble: tip === null ? null : createPortal(
<div
ref={bubbleRef}
id={TIP_ID}
role="tooltip"
className="lc-tip lc-hovertip"
style={shown === null
? { left: 0, top: 0, visibility: 'hidden' }
: { left: shown.left, top: shown.top, opacity: 1 }}
>{tip.label}</div>,
document.body,
),
}
}
-56
View File
@@ -1,56 +0,0 @@
/**
* The Context Dashboard's sidebar entry: a footer action stacked directly
* above Settings (the harness's own foot layout: footer actions, then the
* settings row), mirroring the Settings trigger's geometry in both column
* widths. The button carries the plugin emblem — in the mono seat, so the
* glyph matches the harness's own footer rows rather than shouting over
* them — and, on the wide column, its label. Clicking opens the overview
* overlay through the shared module store (overviewStore.ts).
*
* The per-user `insightsEntry` preference takes the entry down (renders
* null) without unregistering the seat. The subscription fails open: an
* absent settings store, an unserved namespace, or a value the plugin
* cannot understand all leave the entry visible.
*/
import { useSyncExternalStore, type ReactElement } from 'react'
import { ContextIcon } from '../icon'
import { overviewStore } from '../overviewStore'
import type { ContextSettings, InsightsEntry } from '../settings'
import type { ViewKit } from '../viewkit'
export interface OverviewButtonProps {
/** The footer-action owner share: false on the collapsed 56px rail (icon only). */
wide?: boolean
}
/** Stable subscription faces for the settings-less degrade (no re-subscribes). */
const subscribeNever = (): (() => void) => () => {}
const entryShown = (): 'show' => 'show'
export function makeOverviewButton(kit: ViewKit, settings?: ContextSettings): (props: OverviewButtonProps) => ReactElement | null {
const { t } = kit
// Stable per-factory faces: useSyncExternalStore resubscribes when the
// subscribe identity changes, so both wrappers are made once, here.
const subscribeEntry = settings === undefined
? subscribeNever
: (listener: () => void): (() => void) => settings.store.subscribe(listener)
const getEntry = settings === undefined ? entryShown : (): InsightsEntry => settings.insightsEntry()
return function OverviewButton(props: OverviewButtonProps): ReactElement | null {
// The entry toggle: subscribed, so a preference flip takes effect live.
const entry = useSyncExternalStore(subscribeEntry, getEntry)
if (entry === 'hide') return null
return (
<button
type="button"
className={props.wide === true ? 'lc-ov-entry' : 'lc-ov-entry lc-ov-entry-rail'}
title={t('ov.entry')}
aria-label={t('ov.entry')}
onClick={() => { overviewStore.set(true) }}
>
<ContextIcon size={props.wide === true ? 16 : 18} className="lc-ov-entry-icon" mono />
{props.wide === true && <span className="lc-ov-entry-label">{t('ov.entry')}</span>}
</button>
)
}
}
+222 -218
View File
@@ -1,28 +1,38 @@
/**
* The Context Dashboard panel — the cross-session insight surface opened
* from the sidebar foot (overviewButton.tsx). Rendered from the frame-wide
* `shell.overlay` slot behind the module store's flag; data rides the
* root-scope `useSessions` standard kit (every list row's host-cached
* projection values), so the panel draws every session's insight without
* opening one log.
* The Context Insights page — the plugin's first-level panel, opened from
* the sidebar's panel list (insightPage.ts), a sibling of the shipped
* Plugins and Automation tasks pages. Registered on the layout's keyed
* `main` slot, so the shell's center column mounts it when the panel is
* selected and unmounts it on leave — the page's mount IS its open, and its
* data rides the root-scope `useSessions`/`useWorkspaces` standard kit
* (every list row's host-cached projection values), so the page draws every
* session's insight without opening one log.
*
* The panel's first row is a 1:1 column pair: the KPI metrics band (the
* The chrome mirrors the shipped first-level pages: one scroll region whose
* centered content column (the Plugins page's 960px frame) opens with the
* page heading — the title beside the range group (and the DeepSeek balance
* capsule when it serves) — and the page element is the `lc-ov` query
* container, so every fold keys off the pane's own width. Hover tips portal
* to <body> (hoverTip.tsx) because the container would otherwise capture
* their fixed positioning.
*
* The body's first row is a 1:1 column pair: the KPI metrics band (the
* range's six figures — sessions, billed tokens, cost, cache hit, tool
* calls, active time — three per row) beside the last-7-days usage chart
* (each day's billed tokens and estimated cost as a bar pair, folded off
* the merged daily ledger). Below it, the aggregate stats row folds the
* same range's
* sessions into the two donut cards the per-session Context tab opens with —
* Token Stats (the composition-split billed volume) and Timing Stats (the
* summed totals). The body is then a 3:7 column pair: the insight column (the
* activity heatmap, then the preferences entry row) beside the session column
* (search, group chips, and the card grid); the heatmap keeps its own fixed
* 8-week window and PINs the list to a picked day (the panel's drill-down
* gesture). A session card click jumps to that session through the harness's
* own selection verb (openSessionVia) and closes the panel.
* (each day's billed tokens and estimated cost as a bar pair, folded off the
* merged daily ledger). Below it, the aggregate stats row folds the same
* range's sessions into the two donut cards the per-session Context tab
* opens with — Token Stats (the composition-split billed volume) and Timing
* Stats (the summed totals). The body is then a 3:7 column pair: the
* insight column (the activity heatmap, then the preferences entry row)
* beside the session column (search, group chips, and the card grid); the
* heatmap keeps its own fixed 8-week window and PINs the list to a picked
* day (the page's drill-down gesture). A session card click jumps to that
* session through the harness's own selection verb (openSessionVia), whose
* navigation returns the center column to the conversation.
*/
import { useEffect, useMemo, useState, useSyncExternalStore, type ReactElement } from 'react'
import { useEffect, useMemo, useState, type ReactElement } from 'react'
import { estimateSessionCost, formatCost, type CostCurrency, type ModelBook } from '../cost'
import { fmt } from '../format'
import { useModelPrices } from '../modelPrices'
@@ -33,19 +43,17 @@ import {
UNGROUPED_KEY, workspacesSnapshotOf,
type OverviewRange, type OverviewRow, type OverviewSort,
} from '../overview'
import { overviewStore } from '../overviewStore'
import { openSessionVia, type ClientCtx } from '../services'
import { openPluginSettings } from '../settingsJump'
import type { ViewKit } from '../viewkit'
import { makeBalanceCapsule } from './balanceCapsule'
import { makeErrorBoundary } from './errorBoundary'
import { useEscapeClose } from './escapeClose'
import { makeHeatmap, todayKey, type HeatMetric } from './heatmap'
import { makeDonut } from './donut'
import { useHoverTip } from './hoverTip'
import { makeOverviewTokens } from './overviewTokens'
import { makeOverviewUsage } from './overviewUsage'
import { makeStatsTiming } from './statsTiming'
import { ContextIcon } from '../icon'
import { makeOverviewCard } from './overviewCard'
import { IconSettings } from '../primitives'
@@ -83,7 +91,6 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
}
function OverviewBody(props: OverviewPanelProps): ReactElement | null {
const open = useSyncExternalStore(overviewStore.subscribe, overviewStore.getSnapshot)
const { book } = useModelPrices()
// The hook-level standard-kit reads (unconditional; guarded inside).
const snapshot = sessionsSnapshotOf(props)
@@ -95,27 +102,22 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
const [sort, setSort] = useState<OverviewSort>('recent')
const [metric, setMetric] = useState<HeatMetric>('steps')
const [page, setPage] = useState(0)
const close = (): void => { overviewStore.set(false) }
useEscapeClose(open, close)
const { zoneProps, bubble } = useHoverTip()
const rows = useMemo(() => rowsOfSnapshot(snapshot, wsSnapshot), [snapshot, wsSnapshot])
const groups = useMemo(() => sessionGroupsOf(wsSnapshot), [wsSnapshot])
// On open, summon the host's projection warm-up (this panel is the
// rows' only reader — one pass per host process) and re-pull the list
// once, so backfilled rows reach a long-connected page.
// On mount (the panel's open): summon the host's projection warm-up (this
// page is the rows' only reader — one pass per host process) and re-pull
// the list once, so backfilled rows reach a long-connected page.
useEffect(() => {
if (open) {
requestActivityBackfill()
refreshSessions(ctx)
}
}, [open])
requestActivityBackfill()
refreshSessions(ctx)
}, [])
// Any filter change re-anchors the pager at the first page.
useEffect(() => { setPage(0) }, [range, day, query, group, sort])
if (!open) return null
const currency = activeCurrency()
const now = Date.now()
const allRows = rows ?? []
@@ -139,208 +141,210 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
const kpi = kpisOf(ranged, allRows.length, book, currency)
const days = aggregateDays(allRows, book, currency)
const openOne = (id: string): void => {
// The view owner's verb navigates the center column back to the
// conversation itself (selectPanel(null)), so the page needs no close.
openSessionVia(ctx, id)
overviewStore.set(false)
}
return (
<div className="lc-ov-backdrop" onClick={close}>
<div className="lc-ov-card" onClick={(ev) => { ev.stopPropagation() }}>
<div className="lc-ov-head">
<ContextIcon size={18} className="lc-ov-head-icon" />
<span className="lc-ov-title">{t('ov.title')}</span>
{/* The DeepSeek platform balance (client/balance.ts): renders nothing
until a live figure lands, so the header row never reflows for it. */}
<BalanceCapsule />
<div className="lc-gran lc-ov-range" role="group" aria-label={t('ov.range.label')}>
{RANGES.map(r => (
<button
key={r}
type="button"
className={'lc-gran-btn' + (range === r ? ' lc-gran-on' : '')}
onClick={() => { setRange(r) }}
>{t('ov.range.' + r)}</button>
))}
<section className="lc-ov-page" aria-label={t('ov.title')} {...zoneProps}>
{bubble}
{/* The page's one scroll region: the shipped first-level pages' own
discipline — heading, stats, and body scroll as one. */}
<div className="lc-ov-pagescroll">
<div className="lc-ov-pagecontent">
<div className="lc-ov-pagehead">
<h1 className="lc-ov-pagetitle">{t('ov.title')}</h1>
{/* The DeepSeek platform balance (client/balance.ts): renders nothing
until a live figure lands, so the heading row never reflows for it. */}
<BalanceCapsule />
<div className="lc-gran lc-ov-range" role="group" aria-label={t('ov.range.label')}>
{RANGES.map(r => (
<button
key={r}
type="button"
className={'lc-gran-btn' + (range === r ? ' lc-gran-on' : '')}
onClick={() => { setRange(r) }}
>{t('ov.range.' + r)}</button>
))}
</div>
</div>
<button type="button" className="lc-modal-close hover:text-(--dsw-alias-label-primary) hover:bg-(--dsw-alias-bg-layer-2)" aria-label={t('cmd.close')} onClick={close}>×</button>
</div>
{rows === null ? (
<div className="lc-empty">{t('ov.unavailable')}</div>
) : (
/* The panel's one scroll region: everything under the head — the
KPI band, the stats row, and the 3:7 body — scrolls as one. */
<div className="lc-ov-scroll">
{/* The panel's first row: a 1:1 column pair — the KPI metrics
band (the range's six figures, three per row) beside the
last-7-days usage chart (per-day tokens + cost bars off the
merged ledger; the range selector does not scope it). */}
<div className="lc-ov-first">
<div className="lc-ov-kpis">
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('ov.kpi.sessions')}</span>
<span className="lc-stat-value">{kpi.sessions}</span>
<span className="lc-stat-sub">{t('ov.kpi.ofTotal', { n: kpi.listed })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('ov.kpi.tokens')}</span>
<span className="lc-stat-value">{fmt(kpi.tokens)}</span>
<span className="lc-stat-sub">{t('stats.turns')} {fmt(kpi.turns)}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('stats.cost')}</span>
<span className="lc-stat-value">{kpi.cost === null ? '—' : formatCost(kpi.cost, currency)}</span>
<span className="lc-stat-sub">{t('ov.kpi.pricedSub', { n: kpi.costSessions, total: kpi.sessions })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('stats.cacheHit')}</span>
<span className="lc-stat-value">{kpi.cacheHit === null ? '—' : kpi.cacheHit + '%'}</span>
<span className="lc-stat-sub">{t('ov.kpi.sessionsSub', { n: kpi.usageSessions })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('stats.toolCalls')}</span>
<span className="lc-stat-value">{fmt(kpi.toolCalls)}</span>
<span className="lc-stat-sub">{t('ov.kpi.toolSub', { dur: fmtDuration(kpi.toolsMs) })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('timing.total')}</span>
<span className="lc-stat-value">{fmtDuration(kpi.wallMs)}</span>
<span className="lc-stat-sub">{t('ov.kpi.wallSub', { n: fmt(kpi.calls) })}</span>
{rows === null ? (
<div className="lc-empty">{t('ov.unavailable')}</div>
) : (
<>
{/* The first row: a 1:1 column pair — the KPI metrics band
(the range's six figures, three per row) beside the
last-7-days usage chart (per-day tokens + cost bars off
the merged ledger; the range selector does not scope it). */}
<div className="lc-ov-first">
<div className="lc-ov-kpis">
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('ov.kpi.sessions')}</span>
<span className="lc-stat-value">{kpi.sessions}</span>
<span className="lc-stat-sub">{t('ov.kpi.ofTotal', { n: kpi.listed })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('ov.kpi.tokens')}</span>
<span className="lc-stat-value">{fmt(kpi.tokens)}</span>
<span className="lc-stat-sub">{t('stats.turns')} {fmt(kpi.turns)}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('stats.cost')}</span>
<span className="lc-stat-value">{kpi.cost === null ? '—' : formatCost(kpi.cost, currency)}</span>
<span className="lc-stat-sub">{t('ov.kpi.pricedSub', { n: kpi.costSessions, total: kpi.sessions })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('stats.cacheHit')}</span>
<span className="lc-stat-value">{kpi.cacheHit === null ? '—' : kpi.cacheHit + '%'}</span>
<span className="lc-stat-sub">{t('ov.kpi.sessionsSub', { n: kpi.usageSessions })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('stats.toolCalls')}</span>
<span className="lc-stat-value">{fmt(kpi.toolCalls)}</span>
<span className="lc-stat-sub">{t('ov.kpi.toolSub', { dur: fmtDuration(kpi.toolsMs) })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('timing.total')}</span>
<span className="lc-stat-value">{fmtDuration(kpi.wallMs)}</span>
<span className="lc-stat-sub">{t('ov.kpi.wallSub', { n: fmt(kpi.calls) })}</span>
</div>
</div>
<OverviewUsage days={days} currency={currency} today={todayKey()} />
</div>
<OverviewUsage days={days} currency={currency} today={todayKey()} />
</div>
{/* The aggregate stats row: the range's Token Stats and Timing
Stats, folded over the KPI band's own scope (the composition
split and summed totals off kpisOf). */}
<div className="lc-ov-stats">
<OverviewTokens tokens={kpi.tokenParts} />
<StatsTiming timing={kpi.timing} />
</div>
<div className="lc-ov-body">
<div className="lc-ov-left">
<div className="lc-card lc-ov-heat-card">
<div className="lc-card-title">
<span className="lc-card-title-text">{t('ov.heat.title')}</span>
{/* One wrapper so the right side pushes with a single auto
margin (two bare auto-margin siblings would split the
free space and drift apart). */}
<span className="lc-heat-ctl">
<span className="lc-card-sub">{t('ov.heat.sub')}</span>
<div className="lc-gran" role="group" aria-label={t('ov.heat.metric')}>
{METRICS.map(m => (
<button
key={m}
type="button"
className={'lc-gran-btn' + (metric === m ? ' lc-gran-on' : '')}
onClick={() => { setMetric(m) }}
>{t('ov.heat.metric.' + m)}</button>
))}
</div>
</span>
</div>
<Heatmap days={days} metric={metric} selected={day} onSelect={setDay} today={todayKey()} />
</div>
{/* The settings entry: one quiet row under the activity card, the
same best-effort preferences jump the Context tab's plugin-info
row rides. The jump drives the shell chrome behind this
overlay, so the panel closes with it to leave the jump visible. */}
<button type="button" className="lc-ov-settings" onClick={() => { openPluginSettings(); close() }}>
<span className="lc-ov-settings-label"><IconSettings size={14} />{t('plugin.settings')}</span>
<span className="lc-ov-settings-hint">{t('plugin.settingsOpen')}</span>
</button>
{/* The aggregate stats row: the range's Token Stats and Timing
Stats, folded over the KPI band's own scope (the composition
split and summed totals off kpisOf). */}
<div className="lc-ov-stats">
<OverviewTokens tokens={kpi.tokenParts} />
<StatsTiming timing={kpi.timing} />
</div>
<div className="lc-ov-right">
<div className="lc-ov-list-head">
<span className="lc-ov-list-title">{t('ov.list.title')}</span>
<span className="lc-ov-list-count">{visible.length}</span>
{day !== null && (
<button type="button" className="lc-ov-day-chip" title={t('ov.list.dayClear')} onClick={() => { setDay(null) }}>
{t('ov.list.dayFilter', { day })} ×
</button>
)}
<input
className="lc-ov-search"
type="search"
value={query}
placeholder={t('ov.list.search')}
aria-label={t('ov.list.search')}
onChange={(ev) => { setQuery(ev.target.value) }}
/>
<div className="lc-gran" role="group" aria-label={t('ov.list.sortLabel')}>
{SORTS.map(s => (
<button
key={s}
type="button"
className={'lc-gran-btn' + (sort === s ? ' lc-gran-on' : '')}
onClick={() => { setSort(s) }}
>{t('ov.list.sort.' + s)}</button>
))}
<div className="lc-ov-body">
<div className="lc-ov-left">
<div className="lc-card lc-ov-heat-card">
<div className="lc-card-title">
<span className="lc-card-title-text">{t('ov.heat.title')}</span>
{/* One wrapper so the right side pushes with a single auto
margin (two bare auto-margin siblings would split the
free space and drift apart). */}
<span className="lc-heat-ctl">
<span className="lc-card-sub">{t('ov.heat.sub')}</span>
<div className="lc-gran" role="group" aria-label={t('ov.heat.metric')}>
{METRICS.map(m => (
<button
key={m}
type="button"
className={'lc-gran-btn' + (metric === m ? ' lc-gran-on' : '')}
onClick={() => { setMetric(m) }}
>{t('ov.heat.metric.' + m)}</button>
))}
</div>
</span>
</div>
<Heatmap days={days} metric={metric} selected={day} onSelect={setDay} today={todayKey()} />
</div>
{/* The settings entry: one quiet row under the activity card, the
same best-effort preferences jump the Context tab's plugin-info
row rides. The jump selects the Plugins panel, which unmounts
this page on its own. */}
<button type="button" className="lc-ov-settings" onClick={() => { openPluginSettings() }}>
<span className="lc-ov-settings-label"><IconSettings size={14} />{t('plugin.settings')}</span>
<span className="lc-ov-settings-hint">{t('plugin.settingsOpen')}</span>
</button>
</div>
{chips.length > 0 && (
<div className="lc-ov-groups" role="group" aria-label={t('ov.group.label')}>
<button
type="button"
className={'lc-ov-chip' + (group === null ? ' lc-ov-chip-on' : '')}
onClick={() => { setGroup(null) }}
>{t('ov.range.all')}<span className="lc-ov-chip-n">{scoped.length}</span></button>
{chips.map(c => (
<button
key={c.key}
type="button"
className={'lc-ov-chip' + (group === c.key ? ' lc-ov-chip-on' : '')}
onClick={() => { setGroup(group === c.key ? null : c.key) }}
>{c.key === UNGROUPED_KEY ? t('ov.group.ungrouped') : c.key}<span className="lc-ov-chip-n">{c.count}</span></button>
))}
</div>
)}
{visible.length === 0 ? (
<div className="lc-empty">{t(allRows.length === 0 ? 'ov.list.empty' : 'ov.list.noMatch')}</div>
) : (
<>
<div className="lc-ov-grid">
{paged.items.map(row => (
<OverviewCard
key={row.id}
row={row}
{...(groups?.[row.id] !== undefined ? { group: groups[row.id] } : {})}
costLabel={cardCostOf(row, book, currency)}
now={now}
onOpen={openOne}
/>
<div className="lc-ov-right">
<div className="lc-ov-list-head">
<span className="lc-ov-list-title">{t('ov.list.title')}</span>
<span className="lc-ov-list-count">{visible.length}</span>
{day !== null && (
<button type="button" className="lc-ov-day-chip" title={t('ov.list.dayClear')} onClick={() => { setDay(null) }}>
{t('ov.list.dayFilter', { day })} ×
</button>
)}
<input
className="lc-ov-search"
type="search"
value={query}
placeholder={t('ov.list.search')}
aria-label={t('ov.list.search')}
onChange={(ev) => { setQuery(ev.target.value) }}
/>
<div className="lc-gran" role="group" aria-label={t('ov.list.sortLabel')}>
{SORTS.map(s => (
<button
key={s}
type="button"
className={'lc-gran-btn' + (sort === s ? ' lc-gran-on' : '')}
onClick={() => { setSort(s) }}
>{t('ov.list.sort.' + s)}</button>
))}
</div>
{paged.count > 1 && (
<div className="lc-ov-pager" role="navigation" aria-label={t('ov.list.pager')}>
</div>
{chips.length > 0 && (
<div className="lc-ov-groups" role="group" aria-label={t('ov.group.label')}>
<button
type="button"
className={'lc-ov-chip' + (group === null ? ' lc-ov-chip-on' : '')}
onClick={() => { setGroup(null) }}
>{t('ov.range.all')}<span className="lc-ov-chip-n">{scoped.length}</span></button>
{chips.map(c => (
<button
key={c.key}
type="button"
className="lc-ov-pager-btn"
disabled={paged.index === 0}
aria-label={t('ov.list.prev')}
onClick={() => { setPage(paged.index - 1) }}
>‹</button>
<span className="lc-ov-pager-n">{t('ov.list.page', { n: paged.index + 1, total: paged.count })}</span>
<button
type="button"
className="lc-ov-pager-btn"
disabled={paged.index === paged.count - 1}
aria-label={t('ov.list.next')}
onClick={() => { setPage(paged.index + 1) }}
>›</button>
className={'lc-ov-chip' + (group === c.key ? ' lc-ov-chip-on' : '')}
onClick={() => { setGroup(group === c.key ? null : c.key) }}
>{c.key === UNGROUPED_KEY ? t('ov.group.ungrouped') : c.key}<span className="lc-ov-chip-n">{c.count}</span></button>
))}
</div>
)}
{visible.length === 0 ? (
<div className="lc-empty">{t(allRows.length === 0 ? 'ov.list.empty' : 'ov.list.noMatch')}</div>
) : (
<>
<div className="lc-ov-grid">
{paged.items.map(row => (
<OverviewCard
key={row.id}
row={row}
{...(groups?.[row.id] !== undefined ? { group: groups[row.id] } : {})}
costLabel={cardCostOf(row, book, currency)}
now={now}
onOpen={openOne}
/>
))}
</div>
)}
</>
)}
{paged.count > 1 && (
<div className="lc-ov-pager" role="navigation" aria-label={t('ov.list.pager')}>
<button
type="button"
className="lc-ov-pager-btn"
disabled={paged.index === 0}
aria-label={t('ov.list.prev')}
onClick={() => { setPage(paged.index - 1) }}
>‹</button>
<span className="lc-ov-pager-n">{t('ov.list.page', { n: paged.index + 1, total: paged.count })}</span>
<button
type="button"
className="lc-ov-pager-btn"
disabled={paged.index === paged.count - 1}
aria-label={t('ov.list.next')}
onClick={() => { setPage(paged.index + 1) }}
>›</button>
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
</section>
)
}
+13 -15
View File
@@ -8,7 +8,8 @@
* cell — never a whole session's spend dumped on the day it was last
* active. The two series scale on their OWN window maxima (a $2 week must
* not flatten under a 169M-token week), so each bar reads as a share of its
* series' peak; the tooltip carries the exact figures. The window is fixed
* series' peak; the tooltip (the page's portaled hover tip, off the column's
* `data-lc-tip`) carries the exact figures. The window is fixed
* to the last 7 days — the range selector above scopes the KPI figures, not
* this calendar.
*
@@ -19,7 +20,6 @@
* prices.
*/
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ReactElement } from 'react'
import { formatCost, type CostCurrency } from '../cost'
import { type DayTotals } from '../overview'
@@ -103,20 +103,18 @@ export function makeOverviewUsage(kit: ViewKit): (props: OverviewUsageProps) =>
// qualifier ("仅含支持计价映射的模型") stays off the bubble.
const label = `${c.key}\n${t('ov.usage.tokens')} ${fmt(tokens)}\n${t('ov.usage.costTip')} ${cost === null ? '—' : formatCost(cost, props.currency)}`
return (
<Tooltip key={c.key} label={label} side="top">
<div className="lc-ov-usage-col">
<div className="lc-ov-usage-bars">
<span
className="lc-ov-usage-bar lc-ov-usage-tokens"
style={{ height: heightOf(tokens, maxTokens) }}
/>
<span
className="lc-ov-usage-bar lc-ov-usage-cost"
style={{ height: cost === null ? '0%' : heightOf(cost, maxCost) }}
/>
</div>
<div key={c.key} className="lc-ov-usage-col" data-lc-tip={label}>
<div className="lc-ov-usage-bars">
<span
className="lc-ov-usage-bar lc-ov-usage-tokens"
style={{ height: heightOf(tokens, maxTokens) }}
/>
<span
className="lc-ov-usage-bar lc-ov-usage-cost"
style={{ height: cost === null ? '0%' : heightOf(cost, maxCost) }}
/>
</div>
</Tooltip>
</div>
)
})}
</div>
-2
View File
@@ -333,7 +333,6 @@ export const DICT_ZH: Record<string, string> = {
'attach.sent': '发送',
'attach.token': 'Token',
'attach.tokensTip': '按 DeepSeek 官方图片尺寸换算(单图上限 384 tokens)估算的 token 消耗',
'ov.entry': '上下文洞察',
'ov.title': '上下文洞察',
'ov.unavailable': '会话列表暂不可用',
'ov.range.label': '统计范围',
@@ -735,7 +734,6 @@ export const DICT_EN: Record<string, string> = {
'attach.sent': 'Sent',
'attach.token': 'Token',
'attach.tokensTip': 'Estimated token cost via DeepSeek\'s official image-size conversion (384-token cap per image)',
'ov.entry': 'Context Insights',
'ov.title': 'Context Insights',
'ov.unavailable': 'The session list is unavailable',
'ov.range.label': 'Range',
+16 -5
View File
@@ -7,11 +7,11 @@
* it to the Plugins page's package cards. The client bundle inlines the
* file's markup at build time (the `?raw` channel in tsdown.config.ts) and
* re-renders it at every requested size. The identity seats (tab chip,
* command, sidebar registration, panel head) keep the sheet's fixed fills —
* command, sidebar registration) keep the sheet's fixed fills —
* deliberately polychrome on both light and dark chrome — while the
* sidebar-foot entry seat (overviewButton.tsx) opts into `mono`, trading
* every fill for `currentColor` so the glyph sits quietly beside the
* harness's own footer rows (Settings and friends).
* sidebar's panel-list seat (insightPage.ts) opts into `mono`, trading
* every fill for `currentColor` so the glyph follows the shell-owned row's
* own state colors, exactly as the shipped panel glyphs do.
*/
import sheetMarkup from '../../icon.svg?raw'
@@ -33,7 +33,7 @@ export interface ContextIconProps {
size?: number
/** Extra class for layout placement. */
className?: string
/** Render in the surrounding text colour instead of the palette (the sidebar-foot entry seat). */
/** Render in the surrounding text colour instead of the palette (the sidebar panel-list seat). */
mono?: boolean
}
@@ -52,6 +52,17 @@ export function ContextIcon({ size = 20, className, mono = false }: ContextIconP
)
}
/**
* The sidebar panel-list glyph (`sidebar.panellist`): the emblem in `mono` at
* the size the shell asks for, so the shell-owned row's hover/active colors
* paint it like the shipped panel glyphs. The shell owns the row's label,
* and the row's selected state its own styling, so — as on the shipped
* glyphs — the owner props' `active` goes unread.
*/
export function InsightPanelIcon({ size = 18 }: { size?: number }): ReactElement {
return <ContextIcon size={size} mono />
}
/**
* The tab chip's title seat (`sidebar.right.pane.tab.title`): the emblem before
* the label, so the chip reads as the files chip does. The label comes from the
+16 -21
View File
@@ -26,9 +26,9 @@ import { createElement as h } from 'react'
import { DICT_EN, DICT_ZH } from './i18n'
import { registerContextCommand } from './command'
import { makeContextModal } from './components/contextModal'
import { makeOverviewButton } from './components/overviewButton'
import { makeOverviewPanel } from './components/overviewPanel'
import { makeSettingsCard, makePluginConfigCard } from './components/settingsCard'
import { watchInsightPage } from './insightPage'
import { modalStoreOf } from './modalStore'
import type { ClientCtx } from './services'
import { createContextSettings, type ConfigFormsFace, type SettingsField, type SettingsScopeBinderFace } from './settings'
@@ -131,27 +131,22 @@ function apply(ctx: ClientCtx): void {
)
})
// The Context Dashboard (see components/overviewPanel.tsx): the cross-session
// insight surface. The entry is a footer action — the harness stacks those
// directly above Settings on the sidebar foot; the overlay it opens renders
// from the frame-wide shell.overlay seat, and the module store
// (overviewStore.ts) carries the open flag between the two registrations.
// Both seats are root-scope list slots present since the supported baseline.
const OverviewButton = makeOverviewButton(kit, settings)
ctx.slots.inject('sidebar.footer.action', () => {
return ctx.slots.register(
{ name: 'sidebar.footer.action', id: 'context-overview', order: 10, locale: NS },
// Root-scope seats: the owner props (wide, the standard kit) arrive untyped.
props => h(OverviewButton, props as unknown as Parameters<typeof OverviewButton>[0]),
)
})
// The Context Insights page (see components/overviewPanel.tsx): the
// cross-session insight surface as a first-level panel, a sibling of the
// shipped Plugins and Automation tasks pages. The page registers on the
// layout's keyed `main` slot and the sidebar entry on `sidebar.panellist`,
// both under one id (insightPage.ts); the shell owns the entry row and the
// panel switching, so no plugin-side open state exists. The `insightsEntry`
// preference mounts or unwinds the pair.
const OverviewPanel = makeOverviewPanel(ctx, kit)
ctx.slots.inject('shell.overlay', () => {
return ctx.slots.register(
{ name: 'shell.overlay', id: 'context-overview', order: 10, locale: NS },
props => h(OverviewPanel, props as unknown as Parameters<typeof OverviewPanel>[0]),
)
})
ctx.effect(() => watchInsightPage(
ctx,
settings,
// Root-scope seats: the owner props (the standard kit) arrive untyped.
props => h(OverviewPanel, props as unknown as Parameters<typeof OverviewPanel>[0]),
t,
NS,
), 'dsh-context: insight page')
/** The injected face both preference cards ride: the settings store as the
* framework's hooks-compartment `useContextSettings` seat, plus the set verb. */
+102
View File
@@ -0,0 +1,102 @@
/**
* The Context Insights page — the plugin's first-level panel, a sibling of
* the shipped Plugins and Automation tasks pages.
*
* Two registrations, one identity:
* - the full page itself on the layout's root keyed `main` slot, under the
* panel key — the shell's center column renders it whenever the panel is
* selected (keyed entryKey switching mounts it on entry and unmounts it
* on leave, so the page's mount IS its open);
* - the sidebar entry on the root list `sidebar.panellist` under the same
* id — the shell owns the row (button, label, active state, and the
* click that selects the panel), the plugin contributes only the glyph
* and the localized label thunk.
*
* Both seats ship on every supported line (the `main` conversation panel and
* the panellist contract are present since 0.1.5-rc.1 — the compat matrix
* pins both spellings per baseline), so the registrations ride plain
* `slots.inject` declaration injects, like the conversation tab's.
*
* The per-user `insightsEntry` preference gates the pair: 'show' mounts both
* registrations, 'hide' unwinds them (a hidden entry leaves the page no
* opener). The watcher mirrors placement.ts's preference-glued mounting;
* each mount owns its disposers so a preference flip takes down exactly what
* it drops.
*
* @module dsh-context/client/insightPage
*/
import { createElement as h } from 'react'
import { InsightPanelIcon } from './icon'
import type { Translate } from './i18n'
import type { ContextSettings, InsightsEntry } from './settings'
import type { ClientCtx } from './services'
/**
* The id shared by the sidebar entry and the `main` panel it opens (the
* layout's MainPanelId domain) — namespaced, like the right-Sidebar kind, so
* no shipped or foreign panel collides with it.
*/
export const INSIGHT_PANEL_ID = 'dsh-context'
/** The sidebar row's position: after the shipped Plugins (0) and Automation tasks (10) entries. */
export const INSIGHT_PANEL_ORDER = 20
/**
* Mount the page and its sidebar entry while the preference shows them.
* @param ctx - client root context carrying `slots` and the locale service.
* @param settings - the preferences store; the gate re-reads on every publish.
* @param page - the Insights page component (the root-scope standard kit
* arrives on its props, as on every root seat).
* @param t - the plugin-namespace translate; the label thunk reads it at call
* time, so a language switch relabels the sidebar row.
* @param ns - the plugin's locale namespace, put on both registrations so the
* framework synthesizes the `t` seat for the page too.
* @returns the watcher's disposer (unsubscribes and unwinds any live mount).
*/
export function watchInsightPage(
ctx: ClientCtx,
settings: ContextSettings,
page: (props: { sessionId?: string } & Record<string, unknown>) => unknown,
t: Translate,
ns: string,
): () => void {
let mounted = false
const disposers: (() => void)[] = []
const own = (result: unknown): void => {
if (typeof result === 'function') disposers.push(result as () => void)
}
const mount = (): void => {
own(ctx.slots.inject('main', () => ctx.slots.register(
{ name: 'main', key: INSIGHT_PANEL_ID, locale: ns },
page,
)))
own(ctx.slots.inject('sidebar.panellist', () => ctx.slots.register(
{ name: 'sidebar.panellist', id: INSIGHT_PANEL_ID, order: INSIGHT_PANEL_ORDER, label: () => t('ov.title'), locale: ns },
// The owner share is the icon's size (and the row's active flag, unread);
// the registry's component typing widens it to the generic slot props.
props => h(InsightPanelIcon, { size: typeof props.size === 'number' ? props.size : undefined }),
)))
}
const unmount = (): void => {
while (disposers.length > 0) disposers.pop()?.()
}
const applyEntry = (entry: InsightsEntry): void => {
if (entry === 'show' && !mounted) {
mounted = true
mount()
} else if (entry === 'hide' && mounted) {
mounted = false
unmount()
}
}
applyEntry(settings.insightsEntry())
const unsubscribe = settings.store.subscribe(() => { applyEntry(settings.insightsEntry()) })
return () => {
unsubscribe()
if (mounted) {
mounted = false
unmount()
}
}
}
-34
View File
@@ -1,34 +0,0 @@
/**
* The Context Dashboard's open-state store. Module-level so the footer entry
* (sidebar.footer.action) and the overlay (shell.overlay) — two separate
* slot registrations — share the one flag: the button flips it, the panel
* subscribes and renders. Root-scoped and global (unlike the /context
* modal's per-session stores): the overview is a frame-wide surface, one
* instance across sessions.
*/
export interface OverviewStore {
subscribe: (listener: () => void) => () => void
getSnapshot: () => boolean
set: (open: boolean) => void
}
function createStore(): OverviewStore {
let open = false
const listeners = new Set<() => void>()
// Arrow properties: the useSyncExternalStore call passes them unbound.
return {
subscribe: (listener) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
},
getSnapshot: () => open,
set: (next) => {
if (next === open) return
open = next
for (const listener of listeners) listener()
},
}
}
export const overviewStore: OverviewStore = createStore()
+45 -48
View File
@@ -1,46 +1,44 @@
/*
* The Context Insights panel: the sidebar-foot entry button (stacked above
* Settings by the harness's own foot layout) and the frame-wide overlay
* panel it opens — the first row (a 1:1 pair: the KPI metrics band, the
* range's six figures three per row, beside the last-7-days usage chart's
* token/cost bar columns) over the aggregate stats row (Token Stats |
* Timing Stats, the range's sessions folded into the per-session tab's two
* donut cards) over a 3:7 body of insight column (the activity heatmap,
* then the preferences entry row) beside the session column (search, chips,
* card grid).
* The backdrop is the `lc-ov` query container (it spans the viewport, and
* the card sizes off the viewport too), so the column fold keys off the
* viewport width. Inline-size containment is safe here: the backdrop IS the
* viewport rectangle, so the heat cells' fixed-position Tooltip bubbles
* keep their viewport coordinates.
* The Context Insights page: the plugin's first-level panel (the sidebar
* panel-list entry opens it in the shell's center column), a sibling of the
* shipped Plugins and Automation tasks pages. The chrome mirrors those
* pages: one scroll region whose centered content column (the Plugins
* page's 960px frame) opens with the page heading — the title beside the
* range group and the balance capsule — then the first row (a 1:1 pair: the
* KPI metrics band, the range's six figures three per row, beside the
* last-7-days usage chart's token/cost bar columns) over the aggregate
* stats row (Token Stats | Timing Stats, the range's sessions folded into
* the per-session tab's two donut cards) over a 3:7 body of insight column
* (the activity heatmap, then the preferences entry row) beside the session
* column (search, chips, card grid).
*
* The page element is the `lc-ov` query container: every fold below keys
* off the pane's own width (the pane follows the shell's sidebar state, per
* the harness's container-query idiom). Inline-size containment makes the
* page the containing block for fixed descendants, so the page's hover tips
* CANNOT live in-tree — hoverTip.tsx portals the one bubble to <body> (the
* image lightbox's precedent in base.css).
*/
/* ---- the sidebar-foot entry ---- */
/* Geometry mirrors the harness's own Settings trigger (ui-settings-general's
.trigger): the 42px row, 12px radius, 14px/22px label, and its shared hover
fill, so the plugin entry reads as a native foot control, not a visitor. */
.lc-ov-entry { display: flex; align-items: center; gap: 8px; width: calc(100% + 4px); height: 42px; box-sizing: border-box; margin: 0 -2px; border: 0; padding: 0 10px 0 8px; overflow: hidden; background: transparent; border-radius: 12px; color: var(--dsw-alias-label-primary); font-family: inherit; font-size: 14px; line-height: 22px; cursor: pointer; }
.lc-ov-entry:hover { background: var(--dsw-alias-interactive-bg-hover); }
/* The collapsed rail takes the native foot controls' 36x36 circle, icon only. */
.lc-ov-entry-rail { flex: none; justify-content: center; gap: 0; width: 36px; height: 36px; margin: 0; padding: 0; border-radius: 50%; }
.lc-ov-entry-icon { flex: none; }
.lc-ov-entry-label { flex: 1 1 auto; min-width: 0; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ---- the overlay chrome ---- */
/* The backdrop carries the `lc-ov` container: it spans the viewport exactly,
so queries against it track the card's own sizing basis (the card is
min(1120px, 100vw - 32px)) without making the card unable to query itself. */
.lc-ov-backdrop { position: fixed; inset: 0; z-index: 200; background: var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, 0.45)); display: flex; align-items: center; justify-content: center; container: lc-ov / inline-size; }
/* The card is a flex column at a FIXED height: the viewport minus a 40px
band top and bottom (the width keeps its 16px side frame), so the panel's
footprint never wobbles with the content. The backdrop is a definite-
height flex box, so the percentage resolves without viewport units. The
head stays put; the scroll region fills the rest and is the panel's one
scroller — the KPI band, the stats row, and the body scroll as one. */
.lc-ov-card { display: flex; flex-direction: column; width: min(1120px, calc(100vw - 32px)); height: calc(100% - 80px); overflow: hidden; box-sizing: border-box; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0, 0, 0, 0.4)); padding: 16px 18px 18px; color: var(--dsw-alias-label-primary); font-size: 13px; }
.lc-ov-head { flex: none; display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.lc-ov-head-icon { flex: none; }
.lc-ov-title { font-weight: 600; font-size: 15px; white-space: nowrap; }
/* ---- the page chrome (the shipped first-level pages' own frame) ---- */
.lc-ov-page { display: flex; flex-direction: column; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; box-sizing: border-box; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-size: 13px; container: lc-ov / inline-size; }
/* The one scroller: everything — heading, stats, body — scrolls as one. */
.lc-ov-pagescroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; scrollbar-gutter: stable; }
/* The Plugins page's centered column: the same 960px cap and clamp(24-48px)
inline / 48px bottom inset, so sibling first-level pages share one content
frame at every width. */
.lc-ov-pagecontent { max-width: 960px; margin: 0 auto; padding: 0 clamp(24px, 4vw, 48px) 48px; }
/* The heading row: title beside the range group (and the balance capsule
when it serves), wrapping under width pressure. The 28px top inset
belongs to the row, so the whole strip above the heading is its own box. */
.lc-ov-pagehead { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding-top: 28px; margin-bottom: 24px; }
/* macOS hiddenInset titlebar: the shipped pages' heads carry the frame's top
clearance above their own 28px inset, so first-level pages align. */
[data-platform='darwin'] .lc-ov-pagehead { padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px)); }
/* The first-level page title on the shipped pages' own step (20px/28px/500);
the theme carries no size token for it, so every first-level page states
the same literal. */
.lc-ov-pagetitle { flex: 1 1 auto; min-width: 0; margin: 0; font-size: 20px; line-height: 28px; font-weight: 500; }
/* The DeepSeek balance pill (balanceCapsule.tsx): a quiet chip between the
title and the range group, one link to the platform's usage console —
flex: none keeps the figure intact and the head's own wrap carries it to
@@ -49,14 +47,13 @@
.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-head .lc-modal-close { margin-left: 0; flex: none; }
/* The panel's one scroller: everything under the head. It inherits the
scrollbar discipline the columns used to own — the harness 8px bar with a
stable gutter, so cards never reflow by the bar's width, and the right
padding keeps the thumb clear of the card edges instead of riding on
them. */
.lc-ov-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; scrollbar-gutter: stable; padding-right: 8px; }
/* ---- the page's hover tip (hoverTip.tsx): one fixed bubble portaled to
<body> — containment-safe by construction. The shared `.lc-tip` chrome
supplies the face; these rules supply the fixed seat, the pre-line wrap
(labels carry \n rows), and the width cap. Opacity stays inline (the
measure-then-show two-phase), so no reveal class exists here. ---- */
.lc-hovertip { position: fixed; z-index: 300; max-width: min(320px, 50vw); white-space: pre-line; }
/* ---- the panel's first row: a 1:1 column pair — the KPI metrics band
(the range's six figures, three per row — the row pair is the tallened
@@ -135,7 +132,7 @@
/* ---- the body: a 3:7 column pair — the insight column (the heat over the
preferences entry) beside the session column. Neither column scrolls on
its own — the panel's `.lc-ov-scroll` owns the one bar — and `align-items:
its own — the page's `.lc-ov-pagescroll` owns the one bar — and `align-items:
start` keeps each column at its natural height, so the heat card never
stretches to the paged grid's height. Under width pressure the pair folds
to one full-width column. ---- */
+34
View File
@@ -62,6 +62,22 @@ export interface ClientSeam {
sessionsFile: string
sessionsOpen: boolean
}
/**
* The Context Insights page's first-level panel seam: the layout's keyed
* `main` slot (the page's seat — also the conversation panel's own) and
* the sidebar's `sidebar.panellist` list (the entry's seat), addressed by
* one shared id. Both spellings shipped with the sidebar/layout split and
* are present on every supported line; probed presence-as-declared so a
* rename names the seam instead of silently orphaning the page.
*/
insightPage: {
/** The panellist contract's declaring source, plus the slot's spelling in it. */
panellistFile: string
panellistNeedle: string
/** The layout's main-slot declaring source, plus the keyed declaration's spelling. */
mainFile: string
mainNeedle: string
}
/**
* The right Sidebar's tab seam — every supported generation ships it
* (0.1.5-rc.1 introduced it). The plugin's registration stays an OPTIONAL
@@ -254,6 +270,12 @@ export const BASELINES: readonly Baseline[] = [
sessionsFile: 'packages/api/session-controller/src/client/contract/sessions.ts',
sessionsOpen: true,
},
insightPage: {
panellistFile: 'packages/client/ui-sidebar/src/client/contract/slots.ts',
panellistNeedle: "'sidebar.panellist'",
mainFile: 'packages/client/ui-layout/src/client/index.ts',
mainNeedle: "'main': { kind: 'keyed'",
},
},
settings: {
serviceFile: 'packages/settings/settings/src/index.ts',
@@ -354,6 +376,12 @@ export const BASELINES: readonly Baseline[] = [
sessionsFile: 'packages/api/session-controller/src/client/contract/sessions.ts',
sessionsOpen: false,
},
insightPage: {
panellistFile: 'packages/client/ui-sidebar/src/client/contract/slots.ts',
panellistNeedle: "'sidebar.panellist'",
mainFile: 'packages/client/ui-layout/src/client/index.ts',
mainNeedle: "'main': { kind: 'keyed'",
},
},
settings: {
serviceFile: 'packages/settings/settings/src/index.ts',
@@ -459,6 +487,12 @@ export const BASELINES: readonly Baseline[] = [
sessionsFile: 'packages/api/session-controller/src/client/contract/sessions.ts',
sessionsOpen: false,
},
insightPage: {
panellistFile: 'packages/client/ui-sidebar/src/client/contract/slots.ts',
panellistNeedle: "'sidebar.panellist'",
mainFile: 'packages/client/ui-layout/src/client/index.ts',
mainNeedle: "'main': { kind: 'keyed'",
},
},
settings: {
serviceFile: 'packages/settings/settings/src/index.ts',
+13 -22
View File
@@ -3,8 +3,9 @@
// remembered (and an absent or failed route) renders nothing, the locale's
// currency shows with the account's first currency as fallback, and the
// non-zero parts of the breakdown (topped-up / granted; the pill itself
// carries the total) ride the harness Tooltip's hover bubble. The route read
// is stubbed per test; memory and storage are reset between tests.
// carries the total) ride the page's hover tip via the pill's data
// attributes. The route read is stubbed per test; memory and storage are
// reset between tests.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
@@ -12,7 +13,7 @@ import { afterEach, describe, test, vi } from 'vitest'
import { makeBalanceCapsule } from '../../../src/client/components/balanceCapsule'
import { PLATFORM_BALANCE_STORAGE_KEY, resetPlatformBalance } from '../../../src/client/balance'
import { asClientCtx, TestClientCtx } from '../helpers/harness'
import { flush, hover, makeKit, mount, query, queryAll, text, unhover } from '../helpers/kit'
import { flush, makeKit, mount, query, text } from '../helpers/kit'
const kit = makeKit()
@@ -82,17 +83,13 @@ describe('BalanceCapsule', () => {
assert.equal(pill?.getAttribute('rel'), 'noreferrer')
assert.equal(query(m.container, '.lc-ov-balance-label')?.textContent, 'DeepSeek balance')
assert.equal(query(m.container, '.lc-ov-balance-value')?.textContent, '$12.50')
// The breakdown lives in the harness Tooltip's hover bubble (no native
// `title`): the pill already carries the total, so only the non-zero
// parts ride the bubble — the topped-up line leads the granted one.
// The breakdown lives in the pill's data attributes for the page's hover
// tip (no native `title`): the pill already carries the total, so only
// the non-zero parts ride the bubble — the topped-up line leads the
// granted one, and the tip drops below the pill.
assert.equal(pill?.getAttribute('title'), null)
await hover(pill)
assert.equal(
query(m.container, '[role="tooltip"]').textContent,
'Topped-up balance: $10.00\nGranted balance: $2.50',
)
await unhover(pill)
assert.equal(queryAll(m.container, '[role="tooltip"]').length, 0, 'the bubble drops when the pointer leaves')
assert.equal(pill?.getAttribute('data-lc-tip'), 'Topped-up balance: $10.00\nGranted balance: $2.50')
assert.equal(pill?.getAttribute('data-lc-tip-side'), 'bottom')
await m.unmount()
})
@@ -105,8 +102,7 @@ describe('BalanceCapsule', () => {
const m = await mount(h(Capsule, {}))
await flush()
const pill = query(m.container, '.lc-ov-balance')
await hover(pill)
assert.equal(query(m.container, '[role="tooltip"]').textContent, 'Topped-up balance: $9.00', 'the zero granted line is gone')
assert.equal(pill.getAttribute('data-lc-tip'), 'Topped-up balance: $9.00', 'the zero granted line is gone')
await m.unmount()
stubRoute({
@@ -117,8 +113,7 @@ describe('BalanceCapsule', () => {
const m2 = await mount(h(bare, {}))
await flush()
assert.equal(query(m2.container, '.lc-ov-balance-value')?.textContent, '$0.00', 'the pill still shows the figure')
await hover(query(m2.container, '.lc-ov-balance'))
assert.equal(queryAll(m2.container, '[role="tooltip"]').length, 0, 'nothing to break down — no bubble')
assert.equal(query(m2.container, '.lc-ov-balance').getAttribute('data-lc-tip'), null, 'nothing to break down — no tip')
await m2.unmount()
})
@@ -131,11 +126,7 @@ describe('BalanceCapsule', () => {
await flush()
assert.equal(query(m.container, '.lc-ov-balance-label')?.textContent, 'DeepSeek 余额')
assert.equal(query(m.container, '.lc-ov-balance-value')?.textContent, '¥110.00')
await hover(query(m.container, '.lc-ov-balance'))
assert.equal(
query(m.container, '[role="tooltip"]').textContent,
'充值余额: ¥100.00\n赠送余额: ¥10.00',
)
assert.equal(query(m.container, '.lc-ov-balance').getAttribute('data-lc-tip'), '充值余额: ¥100.00\n赠送余额: ¥10.00')
await m.unmount()
})
+7 -9
View File
@@ -6,7 +6,7 @@ import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { gridOf, makeHeatmap, todayKey } from '../../../src/client/components/heatmap'
import { click, hover, makeKit, mount, query, queryAll, text, unhover } from '../helpers/kit'
import { click, makeKit, mount, query, queryAll, text } from '../helpers/kit'
const kit = makeKit()
const Heatmap = makeHeatmap(kit)
@@ -135,20 +135,18 @@ describe('Heatmap', () => {
await m.unmount()
})
test('cells tip through the harness Tooltip: the bubble mounts on hover and drops on leave', async () => {
test('cells carry the page hover tip in data attributes (date, sessions, steps; an empty day the bare date)', async () => {
const m = await mount(h(Heatmap, {
days: { '2026-09-16': { tokens: 10, requests: 1, sessions: 3, cost: null } },
today: TODAY,
weeks: 2,
}))
const cell = query<HTMLButtonElement>(m.container, 'button.lc-heat-cell')
await hover(cell)
assert.equal(query(m.container, '[role="tooltip"]').textContent, '2026-09-16\n3 active sessions\n1 steps')
await unhover(cell)
assert.equal(queryAll(m.container, '[role="tooltip"]').length, 0, 'the bubble drops when the pointer leaves')
assert.equal(
query<HTMLButtonElement>(m.container, 'button.lc-heat-cell').getAttribute('data-lc-tip'),
'2026-09-16\n3 active sessions\n1 steps',
)
// An empty day tips too — the bare date, one line.
await hover(query(m.container, 'span.lc-heat-0'))
assert.match(query(m.container, '[role="tooltip"]').textContent ?? '', /^\d{4}-\d{2}-\d{2}$/)
assert.match(query(m.container, 'span.lc-heat-0').getAttribute('data-lc-tip') ?? '', /^\d{4}-\d{2}-\d{2}$/)
await m.unmount()
})
+185
View File
@@ -0,0 +1,185 @@
// The dashboard's hover tip (src/client/components/hoverTip.tsx): one fixed
// bubble portaled to <body>, driven by delegated zone events — show on
// anchor hover/focus, re-anchor on switch, hide on leave/blur/scroll/unmount,
// with viewport clamping and side flipping in the measure pass.
import { act, createElement as h, type ReactElement } from 'react'
import assert from 'node:assert/strict'
import { afterEach, describe, test } from 'vitest'
import { useHoverTip } from '../../../src/client/components/hoverTip'
import { flush, mount, query, type Mounted } from '../helpers/kit'
/** A tip zone with a pair of anchors (A carries a child span) and a plain non-anchor. */
function Zone(): ReactElement {
const { zoneProps, bubble } = useHoverTip()
return h(
'div',
zoneProps,
h('button', { id: 'a', 'data-lc-tip': 'tip A' }, h('span', { id: 'a-child' }, 'A')),
h('button', { id: 'b', 'data-lc-tip': 'tip B', 'data-lc-tip-side': 'bottom' }, 'B'),
h('span', { id: 'plain' }, 'plain'),
h('span', { id: 'empty', 'data-lc-tip': '' }, 'empty'),
bubble,
)
}
const bubble = (): HTMLElement | null => document.body.querySelector('#lc-hovertip-bubble')
async function mouseover(target: Element): Promise<void> {
await act(async () => { target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) })
}
async function mouseout(target: Element, relatedTarget: EventTarget | null): Promise<void> {
await act(async () => { target.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget })) })
}
// React implements onFocus/onBlur (and their captures) over the bubbling
// focusin/focusout pair, so the specs drive those natively.
async function focusIn(target: Element): Promise<void> {
await act(async () => { target.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) })
}
async function blurOut(target: Element, relatedTarget: EventTarget | null): Promise<void> {
await act(async () => { target.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget })) })
}
/** Pin an anchor's rect (jsdom's are all zero) so the measure pass exercises real geometry. */
function pinRect(anchor: Element, rect: { left: number; top: number; width: number; height: number }): void {
const { left, top, width, height } = rect
Object.defineProperty(anchor, 'getBoundingClientRect', {
configurable: true,
value: () => ({ left, top, width, height, bottom: top + height, right: left + width }),
})
}
afterEach(() => {
// A leaked portal from a failed test never reaches the next one.
bubble()?.remove()
})
describe('useHoverTip', () => {
test('hovering an anchor shows its portaled tip and described-by; leaving hides both', async () => {
const m = await mount(h(Zone))
const a = query(m.container, '#a')
await mouseover(a)
const el = bubble()
assert.ok(el !== null, 'the bubble portaled to <body>')
assert.equal(el.getAttribute('role'), 'tooltip')
assert.equal(el.textContent, 'tip A')
assert.ok(el.className.includes('lc-tip') && el.className.includes('lc-hovertip'))
assert.equal(el.style.opacity, '1', 'the measured pass revealed the bubble')
// Zero-rect jsdom geometry: side 'top' overflows (top < margin), so the
// bubble flips below and clamps to the viewport margin.
assert.equal(el.style.left, '8px')
assert.equal(el.style.top, '8px')
assert.equal(a.getAttribute('aria-describedby'), 'lc-hovertip-bubble')
await mouseout(a, m.container)
assert.equal(bubble(), null, 'leaving to a non-anchor hides the bubble')
assert.equal(a.getAttribute('aria-describedby'), null, 'the described-by goes with it')
await m.unmount()
})
test('moving across the anchor subtree keeps the tip; switching anchors re-anchors without hiding', async () => {
const m = await mount(h(Zone))
const a = query(m.container, '#a')
const b = query(m.container, '#b')
await mouseover(a)
const shown = bubble()?.style.cssText
// Moving from the anchor onto its own child, and child events bubbling
// up: the same anchor keeps tip and measure (no hidden re-flash).
await mouseover(query(m.container, '#a-child'))
assert.equal(bubble()?.style.cssText, shown, 'same anchor keeps the measured spot')
assert.equal(a.getAttribute('aria-describedby'), 'lc-hovertip-bubble')
// A → B: leaving A toward B does not hide; B's mouseover re-anchors.
await mouseout(a, b)
assert.ok(bubble() !== null, 'anchor-to-anchor movement never hides')
await mouseover(b)
assert.equal(bubble()?.textContent, 'tip B')
assert.equal(a.getAttribute('aria-describedby'), null, 'the old anchor released')
assert.equal(b.getAttribute('aria-describedby'), 'lc-hovertip-bubble')
await m.unmount()
})
test('focus and blur mirror hover; blur into another anchor keeps the tip', async () => {
const m = await mount(h(Zone))
const a = query(m.container, '#a')
const b = query(m.container, '#b')
await focusIn(a)
assert.equal(bubble()?.textContent, 'tip A')
await blurOut(a, b)
assert.ok(bubble() !== null, 'focus moving anchor-to-anchor never hides')
await focusIn(b)
assert.equal(bubble()?.textContent, 'tip B')
await blurOut(b, null)
assert.equal(bubble(), null, 'focus leaving the zone hides')
await m.unmount()
})
test('a zone scroll hides the tip; non-anchors and empty labels never show one', async () => {
const m = await mount(h(Zone))
const a = query(m.container, '#a')
await mouseover(query(m.container, '#plain'))
assert.equal(bubble(), null, 'a non-anchor target shows nothing')
await mouseover(query(m.container, '#empty'))
assert.equal(bubble(), null, 'an empty data-lc-tip shows nothing')
await mouseover(a)
assert.ok(bubble() !== null)
await act(async () => {
query(m.container, '#plain').dispatchEvent(new Event('scroll', { bubbles: false }))
})
assert.equal(bubble(), null, 'any zone scroll retracts the bubble')
assert.equal(a.getAttribute('aria-describedby'), null)
await m.unmount()
})
test('a viewport resize retracts the tip (every anchor just moved)', async () => {
const m = await mount(h(Zone))
const a = query(m.container, '#a')
await mouseover(a)
assert.ok(bubble() !== null)
await act(async () => {
window.dispatchEvent(new Event('resize'))
})
assert.equal(bubble(), null, 'the resize retracted the bubble')
assert.equal(a.getAttribute('aria-describedby'), null)
// The listener went with it: a second resize reaches nothing to hide.
await act(async () => {
window.dispatchEvent(new Event('resize'))
})
assert.equal(bubble(), null)
await m.unmount()
})
test('the measure pass centers on the anchor, clamps horizontally, and flips a bottom tip that overflows', async () => {
const m = await mount(h(Zone))
const b = query(m.container, '#b')
// Side 'bottom', no overflow: below the anchor, centered (jsdom bubble
// measures 0×0, so the center lands on the anchor's own center).
pinRect(b, { left: 100, top: 100, width: 20, height: 20 })
await mouseover(b)
assert.equal(bubble()?.style.left, '110px')
assert.equal(bubble()?.style.top, '126px', 'anchor bottom + gap')
// Re-pin beyond the right edge and below the viewport: the left clamp and
// the bottom flip both engage (jsdom viewport is 1024×768).
pinRect(b, { left: 2000, top: 740, width: 20, height: 20 })
await mouseout(b, m.container)
await mouseover(b)
assert.equal(bubble()?.style.left, '1016px', 'clamped to innerWidth - margin (width 0)')
assert.equal(bubble()?.style.top, '734px', 'flipped above the anchor')
await m.unmount()
})
test('unmounting the zone removes the portaled bubble', async () => {
const m: Mounted = await mount(h(Zone))
await mouseover(query(m.container, '#a'))
assert.ok(bubble() !== null)
await m.unmount()
await flush()
assert.equal(bubble(), null, 'the portal goes down with the zone')
})
})
@@ -1,90 +0,0 @@
// The Context Dashboard's sidebar-foot entry (src/client/components/
// overviewButton.tsx): wide/rail rendering, the store flip on click, and the
// per-user insights-entry toggle.
import { createElement as h, act } from 'react'
import assert from 'node:assert/strict'
import { afterEach, describe, test } from 'vitest'
import { makeOverviewButton } from '../../../src/client/components/overviewButton'
import { overviewStore } from '../../../src/client/overviewStore'
import { createContextSettings } from '../../../src/client/settings'
import { click, makeKit, mount, query, queryAll } from '../helpers/kit'
const kit = makeKit()
const Button = makeOverviewButton(kit)
afterEach(() => {
overviewStore.set(false)
})
describe('OverviewButton', () => {
test('the wide column renders icon and label; the rail renders the icon alone', async () => {
const wide = await mount(h(Button, { wide: true }))
assert.equal(query(wide.container, '.lc-ov-entry-label').textContent, 'Context Insights')
assert.equal(query(wide.container, 'button.lc-ov-entry').getAttribute('aria-label'), 'Context Insights')
assert.equal(queryAll(wide.container, '.lc-ov-entry-rail').length, 0)
await wide.unmount()
const rail = await mount(h(Button, { wide: false }))
assert.equal(queryAll(rail.container, '.lc-ov-entry-label').length, 0)
assert.ok(query(rail.container, '.lc-ov-entry-icon'))
assert.ok(query(rail.container, 'button.lc-ov-entry-rail'))
await rail.unmount()
// An absent wide flag (a foreign owner) keeps the label hidden.
const bare = await mount(h(Button, {}))
assert.equal(queryAll(bare.container, '.lc-ov-entry-label').length, 0)
assert.ok(query(bare.container, 'button.lc-ov-entry-rail'))
await bare.unmount()
})
test('clicking opens the overview through the shared store', async () => {
assert.equal(overviewStore.getSnapshot(), false)
const m = await mount(h(Button, { wide: true }))
await click(query(m.container, 'button.lc-ov-entry'))
assert.equal(overviewStore.getSnapshot(), true)
await m.unmount()
})
test('the zh locale renders the translated label', async () => {
const ZhButton = makeOverviewButton(makeKit('zh'))
const m = await mount(h(ZhButton, { wide: true }))
assert.equal(query(m.container, '.lc-ov-entry-label').textContent, '上下文洞察')
await m.unmount()
})
test('the insights-entry toggle takes the button down and back live', async () => {
// The real store: a local echo flips the rendering without a scope.
const settings = createContextSettings()
const Toggled = makeOverviewButton(kit, settings)
const m = await mount(h(Toggled, { wide: true }))
assert.ok(query(m.container, 'button.lc-ov-entry'), 'the default (show) renders the entry')
await act(async () => { settings.set('insightsEntry', 'hide') })
assert.equal(queryAll(m.container, 'button.lc-ov-entry').length, 0, 'hide renders nothing')
await act(async () => { settings.set('insightsEntry', 'show') })
assert.ok(query(m.container, 'button.lc-ov-entry'), 'flipping back restores the entry')
await m.unmount()
})
test('an unpersisted hide (a rejected write) rolls back to a visible entry', async () => {
// The fail-open contract, end to end: a settings store whose scope died
// before the write landed must not leave the entry hidden. The rejection
// is held back so the optimistic echo is observable on its own.
const settings = createContextSettings()
const Toggled = makeOverviewButton(kit, settings)
let rejectSet: ((err: unknown) => void) | undefined
settings.attach({
getSnapshot: () => ({ status: 'ready', value: {}, writable: true }),
subscribe: () => () => {},
set: () => new Promise((_, reject) => { rejectSet = reject }),
})
const m = await mount(h(Toggled, { wide: true }))
await act(async () => { settings.set('insightsEntry', 'hide') })
assert.equal(queryAll(m.container, 'button.lc-ov-entry').length, 0, 'the optimistic echo hides first')
await act(async () => { rejectSet?.(new Error('transport down')) })
assert.ok(query(m.container, 'button.lc-ov-entry'), 'the rollback restores the entry')
await m.unmount()
})
})
+42 -53
View File
@@ -1,17 +1,17 @@
// The Context Dashboard panel (src/client/components/overviewPanel.tsx) —
// The Context Insights page (src/client/components/overviewPanel.tsx) —
// full renders through the standard-kit seams: KPI band, heatmap day-pin,
// filters/sorts, pagination, session open + close paths, and the
// degraded states.
// filters/sorts, pagination, session open path, hover tips, and the
// degraded states. The page is the keyed `main` panel: mounting it IS
// opening it (the shell unmounts on panel switch), so no open flag exists.
import { act, createElement as h } from 'react'
import assert from 'node:assert/strict'
import { afterEach, beforeEach, describe, test, vi } from 'vitest'
import { makeOverviewPanel } from '../../../src/client/components/overviewPanel'
import { resetModelPrices, setModelPricesLoader } from '../../../src/client/modelPrices'
import { overviewStore } from '../../../src/client/overviewStore'
import { dayKeyOf } from '../../../src/shared/days'
import { TestClientCtx, asClientCtx } from '../helpers/harness'
import { click, flush, keydown, makeKit, mount, query, queryAll, text, until, type Mounted } from '../helpers/kit'
import { click, flush, makeKit, mount, query, queryAll, text, until, type Mounted } from '../helpers/kit'
const PROVIDERS = {
deepseek: { models: { 'deepseek-v4-flash': { cost: { input: 1, output: 2, cache_read: 0.1 } } } },
@@ -90,7 +90,6 @@ async function openPanel(
ctx: TestClientCtx,
props: Record<string, unknown> = {},
): Promise<{ m: Mounted; Panel: ReturnType<typeof makeOverviewPanel> }> {
overviewStore.set(false)
const Panel = makeOverviewPanel(asClientCtx(ctx), makeKit())
const allProps = {
useSessions: useHookOf(sessionsSnapshot()),
@@ -98,24 +97,20 @@ async function openPanel(
...props,
}
const m = await mount(h(Panel, allProps))
assert.equal(m.container.textContent, '', 'closed renders nothing')
await act(async () => {
overviewStore.set(true)
})
// The price book's first fetch resolves a microtask or two behind the store
// flip; a second act window keeps its notify inside act.
// The price book's first fetch resolves a microtask or two behind the
// mount; a second act window keeps its notify inside act.
await flush()
return { m, Panel }
}
/** The fetch calls the panel fired (the warm-up trigger POST), per test. */
/** The fetch calls the page fired (the warm-up trigger POST), per test. */
const backfillPosts: string[] = []
beforeEach(() => {
resetModelPrices()
setModelPricesLoader(() => Promise.resolve(PROVIDERS))
backfillPosts.length = 0
// The header's balance capsule POSTs its own route on open (client/balance.ts);
// The heading's balance capsule POSTs its own route on mount (client/balance.ts);
// only the warm-up trigger is this spec's subject.
vi.stubGlobal('fetch', async (url: string | URL) => {
const route = String(url)
@@ -125,27 +120,28 @@ beforeEach(() => {
})
afterEach(async () => {
overviewStore.set(false)
resetModelPrices()
vi.unstubAllGlobals()
await new Promise(resolve => setTimeout(resolve, 1))
})
describe('OverviewPanel', () => {
test('renders the KPI band, heatmap, and session cards; summons the warm-up and refreshes the list on open', async () => {
test('renders the KPI band, heatmap, and session cards; summons the warm-up and refreshes the list on mount', async () => {
let pulls = 0
const ctx = makeCtx({ refresh: () => { pulls++; return Promise.resolve() } })
const { m } = await openPanel(ctx)
assert.equal(pulls, 1, 'the baseline re-pull fires on open')
assert.deepEqual(backfillPosts, ['/api/dsh-context/backfill'], 'the warm-up trigger POST fires on open')
assert.equal(pulls, 1, 'the baseline re-pull fires on mount')
assert.deepEqual(backfillPosts, ['/api/dsh-context/backfill'], 'the warm-up trigger POST fires on mount')
assert.ok(text(m.container).includes('Context Insights'))
// The head stays fixed; the one scroll region under it carries the 1:1
// first row (the KPI band beside the last-7-days usage chart), the
// aggregate stats pair, and the insight/session body.
const cardRows = [...query(m.container, '.lc-ov-card').children].map(el => el.className.split(' ')[0])
assert.deepEqual(cardRows, ['lc-ov-head', 'lc-ov-scroll'])
const scrollRows = [...query(m.container, '.lc-ov-scroll').children].map(el => el.className.split(' ')[0])
assert.deepEqual(scrollRows, ['lc-ov-first', 'lc-ov-stats', 'lc-ov-body'])
// The page chrome: the section's one scroll region carries the centered
// content column — the page heading, then the 1:1 first row (the KPI
// band beside the last-7-days usage chart), the aggregate stats pair,
// and the insight/session body.
const page = query(m.container, 'section.lc-ov-page')
assert.equal(page.getAttribute('aria-label'), 'Context Insights')
const scrollRows = [...query(m.container, '.lc-ov-pagecontent').children].map(el => el.className.split(' ')[0])
assert.deepEqual(scrollRows, ['lc-ov-pagehead', 'lc-ov-first', 'lc-ov-stats', 'lc-ov-body'])
assert.equal(query(m.container, '.lc-ov-pagehead h1.lc-ov-pagetitle').textContent, 'Context Insights')
// The pair's halves: the KPI band's six cells, then the usage chart.
assert.equal(queryAll(m.container, '.lc-ov-first > .lc-ov-kpis .lc-stat').length, 6)
assert.ok(query(m.container, '.lc-ov-first > .lc-card.lc-ov-usage') !== null, 'the usage chart rides the row')
@@ -337,18 +333,18 @@ describe('OverviewPanel', () => {
await m.unmount()
})
test('a card click opens the session through the harness verb and closes the panel', async () => {
test('a card click opens the session through the harness verb', async () => {
const opened: string[] = []
const ctx = makeCtx({ open: (id: string) => { opened.push(id) } })
const { m } = await openPanel(ctx)
await click(query<HTMLButtonElement>(m.container, '.lc-ov-grid > .lc-ov-session'))
// The verb's own navigation returns the center column to the conversation
// (selectPanel(null)); the shell unmounts the page — nothing here closes.
assert.deepEqual(opened, ['a'])
assert.equal(overviewStore.getSnapshot(), false)
assert.equal(m.container.textContent, '', 'the panel unmounted')
await m.unmount()
})
test('the settings row under the activity card runs the preferences jump and closes the panel', async () => {
test('the settings row under the activity card runs the preferences jump', async () => {
// The harness chrome the jump drives (settingsJump.ts): the Plugins panel
// entry first, then the bundle card's open control (the one non-switch
// button inside the card), both click-tracked.
@@ -370,8 +366,6 @@ describe('OverviewPanel', () => {
const row = query<HTMLButtonElement>(m.container, '.lc-ov-settings')
assert.equal(row.textContent, 'SettingsOpen plugin settings')
await click(row)
assert.equal(overviewStore.getSnapshot(), false, 'the panel closed so the jump lands visible')
assert.equal(m.container.textContent, '', 'the panel unmounted')
assert.deepEqual(clicks, ['Plugins'], 'the jump clicked the Plugins panel entry synchronously')
await until(() => clicks.length > 1, 'the jump never reached the bundle card open control')
assert.equal(clicks[1], 'Context')
@@ -381,24 +375,25 @@ describe('OverviewPanel', () => {
}
})
test('Escape and the backdrop close; the card body swallows clicks', async () => {
test('hovering a heat cell portals its tip to <body>; leaving hides it', async () => {
const ctx = makeCtx()
const { m } = await openPanel(ctx)
await keydown('Escape')
assert.equal(overviewStore.getSnapshot(), false)
const cell = queryAll<HTMLButtonElement>(m.container, 'button.lc-heat-cell')
.find(c => c.getAttribute('aria-label')?.startsWith(TODAY))
assert.ok(cell !== undefined)
await act(async () => {
cell.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
})
const bubble = document.body.querySelector('#lc-hovertip-bubble')
assert.ok(bubble !== null, 'the portaled bubble mounted')
assert.ok(bubble.textContent.startsWith(TODAY), 'the cell label rides the bubble')
assert.equal(cell.getAttribute('aria-describedby'), 'lc-hovertip-bubble')
await act(async () => {
cell.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget: m.container }))
})
assert.equal(document.body.querySelector('#lc-hovertip-bubble'), null, 'leaving the anchor hides the bubble')
assert.equal(cell.getAttribute('aria-describedby'), null)
await m.unmount()
const second = await openPanel(ctx)
await click(query(second.m.container, '.lc-ov-card'))
assert.equal(overviewStore.getSnapshot(), true, 'a card-body click does not close')
await click(query(second.m.container, '.lc-ov-backdrop'))
assert.equal(overviewStore.getSnapshot(), false)
await second.m.unmount()
const third = await openPanel(ctx)
await click(query<HTMLButtonElement>(third.m.container, '.lc-modal-close'))
assert.equal(overviewStore.getSnapshot(), false)
await third.m.unmount()
})
test('degraded states: unavailable list, empty list, no activity', async () => {
@@ -445,9 +440,6 @@ describe('OverviewPanel', () => {
useSessions: useHookOf(sessionsSnapshot()),
useWorkspaces: useHookOf(workspacesSnapshotValue),
}))
await act(async () => {
overviewStore.set(true)
})
await flush()
assert.ok(text(m.container).includes('上下文洞察'))
assert.ok(text(m.container).includes('活跃会话'))
@@ -503,9 +495,6 @@ describe('OverviewPanel', () => {
useSessions: useHookOf(sessionsSnapshot()),
useWorkspaces: useHookOf(workspacesSnapshotValue),
}))
await act(async () => {
overviewStore.set(true)
})
await flush()
await flush() // the price book lands
const values = queryAll(m.container, '.lc-stat-value').map(el => el.textContent)
@@ -543,7 +532,7 @@ describe('OverviewPanel', () => {
await m.unmount()
})
test('typing keeps the search input focused (the escape hook never re-fires its focus restore)', async () => {
test('typing keeps the search input focused (re-renders never touch the active element)', async () => {
const ctx = makeCtx()
const { m } = await openPanel(ctx)
const input = query<HTMLInputElement>(m.container, 'input.lc-ov-search')
+43 -35
View File
@@ -10,7 +10,7 @@ import { DICT_EN, DICT_ZH } from '../../src/client/i18n'
import { modalStoreOf, type ModalStore } from '../../src/client/modalStore'
import type { SettingsField, SettingsScopeLike, SettingsState } from '../../src/client/settings'
import { TestClientCtx, TestSessions, asClientCtx } from './helpers/harness'
import { click, mount, query, queryAll } from './helpers/kit'
import { click, mount, query, queryAll, text } from './helpers/kit'
// The entry ships via `module.exports` (bundle handoff shape); its runtime
// exports are the plugin triple, opaque to the static import type.
@@ -198,50 +198,58 @@ describe('client entry: conversation.input.overlay slot', () => {
})
})
describe('client entry: Context Dashboard seats', () => {
test('registers the sidebar-foot entry and the frame overlay, both rendering', async () => {
describe('client entry: Context Insights page seats', () => {
test('registers the keyed main page and the sidebar panel-list entry under one id', async () => {
const ctx = new TestClientCtx()
applyTo(ctx)
const actions = ctx.slots.of('sidebar.footer.action')
assert.equal(actions.length, 1)
assert.equal(actions[0].registration.name, 'sidebar.footer.action')
assert.equal(actions[0].registration.id, 'context-overview')
assert.equal(actions[0].registration.locale, 'dsh-context')
const actionEl = actions[0].component({ wide: true }) as ReactElement
assert.equal((actionEl.type as { name: string }).name, 'OverviewButton')
const actionMount = await mount(actionEl)
assert.equal(query(actionMount.container, '.lc-ov-entry-label').textContent, 'Context Insights')
await actionMount.unmount()
const pages = ctx.slots.of('main')
assert.equal(pages.length, 1)
assert.equal(pages[0].registration.name, 'main')
assert.equal(pages[0].registration.key, 'dsh-context')
assert.equal(pages[0].registration.locale, 'dsh-context')
const pageEl = pages[0].component({}) as ReactElement
assert.equal((pageEl.type as { name: string }).name, 'OverviewPanel')
const pageMount = await mount(pageEl)
assert.ok(query(pageMount.container, 'section.lc-ov-page') !== null, 'the page renders on mount (mount IS the open)')
assert.ok(text(pageMount.container).includes('The session list is unavailable'), 'no standard kit degrades visibly')
await pageMount.unmount()
const overlays = ctx.slots.of('shell.overlay')
assert.equal(overlays.length, 1)
assert.equal(overlays[0].registration.name, 'shell.overlay')
assert.equal(overlays[0].registration.id, 'context-overview')
assert.equal(overlays[0].registration.locale, 'dsh-context')
const overlayEl = overlays[0].component({}) as ReactElement
assert.equal((overlayEl.type as { name: string }).name, 'OverviewPanel')
const overlayMount = await mount(overlayEl)
assert.equal(overlayMount.container.textContent, '', 'closed by default')
await overlayMount.unmount()
const entries = ctx.slots.of('sidebar.panellist')
assert.equal(entries.length, 1)
assert.equal(entries[0].registration.name, 'sidebar.panellist')
assert.equal(entries[0].registration.id, 'dsh-context', 'the entry id addresses the main panel')
assert.equal(entries[0].registration.order, 20, 'after the shipped Plugins (0) and Automation tasks (10)')
assert.equal(entries[0].registration.locale, 'dsh-context')
assert.equal(entries[0].registration.label?.(), 'Context Insights')
const iconMount = await mount(entries[0].component({ size: 16 }) as ReactElement)
assert.ok(query(iconMount.container, 'svg') !== null, 'the entry glyph renders')
await iconMount.unmount()
ctx.dispose()
})
test('the entry opens the overlay through the shared store', async () => {
test('the insightsEntry preference unwinds and remounts the pair', async () => {
const ctx = new TestClientCtx()
applyTo(ctx)
const actionEl = ctx.slots.of('sidebar.footer.action')[0].component({ wide: true }) as ReactElement
const actionMount = await mount(actionEl)
await click(query(actionMount.container, 'button.lc-ov-entry'))
const overlayEl = ctx.slots.of('shell.overlay')[0].component({}) as ReactElement
const overlayMount = await mount(overlayEl)
assert.ok(overlayMount.container.textContent!.includes('The session list is unavailable'))
await actionMount.unmount()
await overlayMount.unmount()
// Reset for other specs sharing the module store.
const { overviewStore } = await import('../../src/client/overviewStore')
overviewStore.set(false)
// The preference flips through the registered settings card's set verb
// (the configForms transport is absent here, so drive the store directly
// through the last registered settings card face).
const scope = makeScope({ status: 'ready', value: {}, writable: true })
ctx.setService('settingsScope', { bind: () => scope })
assert.equal(ctx.slots.of('settings.plugin.item').length, 1, 'the V3 card seat armed')
const face = ctx.slots.of('settings.plugin.item')[0].registration.inject?.() as {
hooks: { contextSettings: { getSnapshot(): SettingsState } }
set: (field: SettingsField, value: string) => void
}
face.set('insightsEntry', 'hide')
assert.equal(ctx.slots.of('main').length, 0, 'hiding the entry unwinds the page')
assert.equal(ctx.slots.of('sidebar.panellist').length, 0, 'hiding unwinds the entry')
face.set('insightsEntry', 'show')
assert.equal(ctx.slots.of('main').length, 1)
assert.equal(ctx.slots.of('sidebar.panellist').length, 1)
ctx.dispose()
assert.equal(ctx.slots.of('main').length, 0, 'the plugin dispose unwinds the pair')
assert.equal(ctx.slots.of('sidebar.panellist').length, 0)
})
})
+131
View File
@@ -0,0 +1,131 @@
// The Context Insights page's registrations (src/client/insightPage.ts):
// the keyed `main` panel and the `sidebar.panellist` entry under one id,
// mounted while the `insightsEntry` preference shows them and unwound on
// 'hide'; the watcher's own disposer unsubscribes and unwinds any live mount.
import assert from 'node:assert/strict'
import { createElement as h, type ReactElement } from 'react'
import { describe, test } from 'vitest'
import { INSIGHT_PANEL_ID, INSIGHT_PANEL_ORDER, watchInsightPage } from '../../src/client/insightPage'
import { createContextSettings } from '../../src/client/settings'
import { InsightPanelIcon } from '../../src/client/icon'
import { TestClientCtx, asClientCtx } from './helpers/harness'
import { makeKit, mount } from './helpers/kit'
const NS = 'dsh-context'
function page(_props: { sessionId?: string } & Record<string, unknown>): null {
return null
}
describe('watchInsightPage', () => {
test("the default 'show' mounts both registrations under one identity", () => {
const ctx = new TestClientCtx()
const settings = createContextSettings()
const { t } = makeKit()
const dispose = watchInsightPage(asClientCtx(ctx), settings, page, t, NS)
const main = ctx.slots.of('main')
assert.equal(main.length, 1, 'the page rides the keyed main seat')
assert.equal(main[0].registration.name, 'main')
assert.equal(main[0].registration.key, INSIGHT_PANEL_ID)
assert.equal(main[0].registration.locale, NS)
assert.equal(main[0].component, page, 'the page component registered verbatim')
const entries = ctx.slots.of('sidebar.panellist')
assert.equal(entries.length, 1, 'the entry rides the sidebar panel list')
assert.equal(entries[0].registration.name, 'sidebar.panellist')
assert.equal(entries[0].registration.id, INSIGHT_PANEL_ID, 'the entry id addresses the main panel')
assert.equal(entries[0].registration.order, INSIGHT_PANEL_ORDER)
assert.equal(entries[0].registration.locale, NS)
assert.equal(entries[0].registration.label?.(), 'Context Insights', 'the label thunk resolves the page title')
const iconEl = entries[0].component({ size: 16 }) as ReactElement
assert.equal((iconEl.type as { name: string }).name, 'InsightPanelIcon', 'the entry glyph component')
dispose()
})
test("'hide' unwinds both registrations; 'show' mounts them again", () => {
const ctx = new TestClientCtx()
const settings = createContextSettings()
const { t } = makeKit()
const dispose = watchInsightPage(asClientCtx(ctx), settings, page, t, NS)
assert.equal(ctx.slots.of('main').length, 1)
settings.set('insightsEntry', 'hide')
assert.equal(ctx.slots.of('main').length, 0, 'hiding the entry unwinds the page')
assert.equal(ctx.slots.of('sidebar.panellist').length, 0, 'hiding unwinds the entry')
settings.set('insightsEntry', 'show')
assert.equal(ctx.slots.of('main').length, 1, 'showing remounts the pair')
assert.equal(ctx.slots.of('sidebar.panellist').length, 1)
// Unrelated preference changes never churn the mounts.
settings.set('defaultGranularity', 'turn')
assert.equal(ctx.slots.of('main').length, 1)
assert.equal(ctx.slots.of('sidebar.panellist').length, 1)
dispose()
})
test("a 'hide' start mounts nothing; the watcher disposer unwinds a live mount exactly once", () => {
const hidden = new TestClientCtx()
const hiddenSettings = createContextSettings()
hiddenSettings.set('insightsEntry', 'hide')
const { t } = makeKit()
const disposeHidden = watchInsightPage(asClientCtx(hidden), hiddenSettings, page, t, NS)
assert.equal(hidden.slots.of('main').length, 0)
assert.equal(hidden.slots.of('sidebar.panellist').length, 0)
disposeHidden()
// A later flip reaches no mount: the subscription is gone.
hiddenSettings.set('insightsEntry', 'show')
assert.equal(hidden.slots.of('main').length, 0)
const ctx = new TestClientCtx()
const settings = createContextSettings()
const dispose = watchInsightPage(asClientCtx(ctx), settings, page, t, NS)
dispose()
assert.equal(ctx.slots.of('main').length, 0, 'the disposer unwinds the live mount')
assert.equal(ctx.slots.of('sidebar.panellist').length, 0)
assert.doesNotThrow(dispose, 'the disposer is safe to repeat')
})
test('the panel-list icon renders the mono emblem at the asked size', async () => {
const m = await mount(h(InsightPanelIcon, { size: 16 }))
const svg = m.container.querySelector('svg')
assert.ok(svg !== null)
assert.equal(svg.getAttribute('width'), '16')
assert.ok(svg.innerHTML.includes('currentColor'), 'the mono seat trades fills for the text colour')
await m.unmount()
})
test('the entry glyph wrapper passes a numeric owner size through and defaults otherwise', async () => {
const ctx = new TestClientCtx()
const settings = createContextSettings()
const { t } = makeKit()
const dispose = watchInsightPage(asClientCtx(ctx), settings, page, t, NS)
const icon = ctx.slots.of('sidebar.panellist')[0].component
const sized = await mount(icon({ size: 18 }) as ReactElement)
assert.equal(sized.container.querySelector('svg')?.getAttribute('width'), '18')
await sized.unmount()
const bare = await mount(icon({}) as ReactElement)
assert.equal(bare.container.querySelector('svg')?.getAttribute('width'), '18', 'a non-number owner share falls to the icon default')
await bare.unmount()
dispose()
})
test('a slots face returning no disposer is tolerated across flips and dispose', () => {
// A minimal face: inject/register land nothing unwindable (the harness
// contract allows either to return no handle).
const bare = {
slots: {
inject: (_name: string, cb: () => unknown) => { cb(); return undefined },
register: () => undefined,
},
}
const settings = createContextSettings()
const { t } = makeKit()
const dispose = watchInsightPage(bare as never, settings, page, t, NS)
assert.doesNotThrow(() => { settings.set('insightsEntry', 'hide') })
assert.doesNotThrow(() => { settings.set('insightsEntry', 'show') })
assert.doesNotThrow(dispose)
})
})
-55
View File
@@ -1,55 +0,0 @@
// The Context Dashboard's open-state store (src/client/overviewStore.ts):
// subscribe/notify semantics, idempotent sets, unsubscribe.
import assert from 'node:assert/strict'
import { afterEach, describe, test } from 'vitest'
import { overviewStore } from '../../src/client/overviewStore'
afterEach(() => {
overviewStore.set(false)
})
describe('overviewStore', () => {
test('closed by default; set flips and notifies', () => {
assert.equal(overviewStore.getSnapshot(), false)
const seen: boolean[] = []
const unsubscribe = overviewStore.subscribe(() => { seen.push(overviewStore.getSnapshot()) })
overviewStore.set(true)
assert.equal(overviewStore.getSnapshot(), true)
assert.deepEqual(seen, [true])
overviewStore.set(false)
assert.deepEqual(seen, [true, false])
unsubscribe()
})
test('an idempotent set notifies nobody', () => {
overviewStore.set(true)
let calls = 0
const unsubscribe = overviewStore.subscribe(() => { calls++ })
overviewStore.set(true)
assert.equal(calls, 0)
overviewStore.set(false)
assert.equal(calls, 1)
unsubscribe()
})
test('unsubscribe detaches the listener', () => {
let calls = 0
const unsubscribe = overviewStore.subscribe(() => { calls++ })
unsubscribe()
overviewStore.set(true)
assert.equal(calls, 0)
})
test('several listeners all hear a flip', () => {
let a = 0
let b = 0
const unA = overviewStore.subscribe(() => { a++ })
const unB = overviewStore.subscribe(() => { b++ })
overviewStore.set(true)
assert.equal(a, 1)
assert.equal(b, 1)
unA()
unB()
})
})
+6 -5
View File
@@ -130,7 +130,7 @@ describe.skipIf(staging.artifactsMissing())('bundle smoke — the built lib/clie
test('registrations: bilingual dictionaries, five slots, the /context trigger source', () => {
assert.ok(state.dicts.get('dsh-context')?.zh && state.dicts.get('dsh-context')?.en, 'bilingual dictionaries registered')
assert.equal(state.slots.length, 5, 'view tab + assistant action + input overlay + dashboard entry and overlay slots')
assert.equal(state.slots.length, 5, 'view tab + assistant action + input overlay + insight page and sidebar entry slots')
assert.equal(state.slots[0]?.[0], 'conversation.view')
assert.equal(state.slots[0]?.[1].order, 20)
assert.equal(state.slots[0]?.[1].label?.(), '上下文', 'tab label localized')
@@ -140,10 +140,11 @@ describe.skipIf(staging.artifactsMissing())('bundle smoke — the built lib/clie
const overlayInject = state.slots[2]?.[1].inject
const overlayHooks = overlayInject?.('s1') as { hooks: { contextModal: { getSnapshot: unknown } } } | undefined
assert.equal(typeof overlayHooks?.hooks.contextModal.getSnapshot, 'function', 'overlay hooks carry the modal store')
assert.equal(state.slots[3]?.[0], 'sidebar.footer.action', 'the dashboard entry above Settings')
assert.equal(state.slots[3]?.[1].id, 'context-overview')
assert.equal(state.slots[4]?.[0], 'shell.overlay', 'the dashboard panel rides the frame-wide overlay seat')
assert.equal(state.slots[4]?.[1].id, 'context-overview')
assert.equal(state.slots[3]?.[0], 'main', 'the insight page rides the layout\'s keyed main seat')
assert.equal(state.slots[3]?.[1].key, 'dsh-context', 'the page panel key is the plugin id')
assert.equal(state.slots[4]?.[0], 'sidebar.panellist', 'the insight entry rides the sidebar panel list')
assert.equal(state.slots[4]?.[1].id, 'dsh-context', 'the entry id addresses the main panel')
assert.equal(state.slots[4]?.[1].label?.(), '上下文洞察', 'the entry label localized')
assert.equal(state.sources.length, 1, '/context trigger source registered')
assert.equal(state.sources[0]?.trigger, '/', 'trigger is the slash')
})
+6
View File
@@ -237,6 +237,12 @@ describe.skipIf(reasons.length > 0)('compat matrix — real dsh sources per base
)
})
test('client: the first-level insight page seam (keyed main panel + sidebar panel list)', () => {
const page = baseline.client.insightPage
assert.equal(staging.dshHasString(baseline.tag, page.panellistNeedle, page.panellistFile), true, 'the sidebar panel-list seat')
assert.equal(staging.dshHasString(baseline.tag, page.mainNeedle, page.mainFile), true, 'the layout\'s keyed main panel seat')
})
test('client: MarkdownText chrome prop', () => {
assert.equal(staging.dshHasString(baseline.tag, baseline.client.markdownChrome, 'packages/client/ui-primitives/src/markdown/MarkdownText.tsx'), true)
})