@huaqiu/dsh-eda-selection 0.5.9 → 0.5.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -76,11 +76,38 @@ window.__ModuleLoader__.load({
76
76
  *
77
77
  * @module
78
78
  */
79
+ /**
80
+ * The dock seat — the geometry that makes the chip line up with the input
81
+ * card.
82
+ *
83
+ * DSH renders every `conversation.input.dock` entry as a full-bleed child of
84
+ * `.composerStack`, while the input card itself is inset by the shared
85
+ * composer clearance and capped at the card max width (InputBar `.root` pads
86
+ * `--dsh-composer-side-clearance` on both sides; `.card` caps at
87
+ * `--dsh-composer-card-max-width`). A bare `alignSelf: flex-start` child
88
+ * therefore hugs the COLUMN edge — visually `side-clearance` (16px) left of
89
+ * the input card it describes.
90
+ *
91
+ * This seat reproduces the card's own box: same clearance subtraction, same
92
+ * max-width cap, centered — the exact geometry the shipped dock entries
93
+ * (QueueDock `.dock`, TodoPanel `.root`) derive from the same variables. The
94
+ * fallbacks mirror DSH's defaults so a foreign host gets a sane inset instead
95
+ * of an unstyled flush-left pill.
96
+ */
97
+ const DOCK_SEAT = {
98
+ boxSizing: "border-box",
99
+ flex: "none",
100
+ display: "flex",
101
+ justifyContent: "flex-start",
102
+ minWidth: 0,
103
+ width: "calc(100% - var(--dsh-composer-side-clearance, 16px) * 2)",
104
+ maxWidth: "var(--dsh-composer-card-max-width, 100%)",
105
+ margin: "0 auto"
106
+ };
79
107
  const ROOT = {
80
108
  display: "inline-flex",
81
109
  alignItems: "center",
82
110
  gap: 6,
83
- alignSelf: "flex-start",
84
111
  maxWidth: "100%",
85
112
  padding: "3px 10px",
86
113
  borderRadius: 999,
@@ -115,6 +142,9 @@ window.__ModuleLoader__.load({
115
142
  if (text.length === 0) return null;
116
143
  const aria = selectionChipAriaLabel(snapshot, t);
117
144
  return (0, react.createElement)("div", {
145
+ style: DOCK_SEAT,
146
+ "data-dsh-part": "selection-chip-dock"
147
+ }, (0, react.createElement)("div", {
118
148
  style: ROOT,
119
149
  "data-dsh-plugin": "huaqiu-eda-selection",
120
150
  "data-dsh-part": "selection-chip",
@@ -125,7 +155,7 @@ window.__ModuleLoader__.load({
125
155
  }, (0, react.createElement)("span", {
126
156
  style: DOT,
127
157
  "aria-hidden": true
128
- }), (0, react.createElement)("span", { style: TEXT }, text));
158
+ }), (0, react.createElement)("span", { style: TEXT }, text)));
129
159
  }
130
160
  //#endregion
131
161
  //#region src/client/locales.ts
@@ -272,6 +302,32 @@ window.__ModuleLoader__.load({
272
302
  return;
273
303
  }
274
304
  }
305
+ /** Browser-console tag — greppable so the sync loop can be traced end to end. */
306
+ const TRACE = "[dsh-eda-selection]";
307
+ /**
308
+ * Emit one line describing a snapshot the dock received.
309
+ *
310
+ * The chip is the last hop of a four-stage chain (KiCad selection tool → gRPC
311
+ * `SubscribeSelection` → hq-edge `SelectionWatcher` → `/runtime/dsh` poll →
312
+ * this component), and a blank chip can originate at any of them. Logging
313
+ * what arrived here is what separates "the host never published" from "the
314
+ * state arrived but was not renderable".
315
+ *
316
+ * Never throws and never blocks render: a host console that is not a real
317
+ * console (or one that rejects the object) must not take the composer down.
318
+ */
319
+ function traceSnapshot(where, snapshot) {
320
+ try {
321
+ console.debug(TRACE, where, snapshot === null ? "unknown" : {
322
+ revision: snapshot.revision,
323
+ editor: snapshot.editor,
324
+ hasSelection: snapshot.hasSelection,
325
+ itemCount: snapshot.itemCount,
326
+ labels: snapshot.labels,
327
+ renderable: snapshot.hasSelection && snapshot.itemCount > 0
328
+ });
329
+ } catch {}
330
+ }
275
331
  /**
276
332
  * Read and normalize the current snapshot from the bridge.
277
333
  *
@@ -297,14 +353,19 @@ window.__ModuleLoader__.load({
297
353
  (0, react.useEffect)(() => {
298
354
  const edge = getEdge();
299
355
  if (!edge || typeof edge.selection?.onChanged !== "function") {
356
+ traceSnapshot("mount: no bridge selection face — nothing will render", null);
300
357
  setSnapshot(null);
301
358
  return;
302
359
  }
303
- setSnapshot(readSnapshot(edge));
360
+ const initial = readSnapshot(edge);
361
+ traceSnapshot("mount", initial);
362
+ setSnapshot(initial);
304
363
  let alive = true;
305
364
  const dispose = edge.selection.onChanged((next) => {
306
365
  if (!alive) return;
307
- setSnapshot(normalizeSelectionSnapshot(next));
366
+ const normalized = normalizeSelectionSnapshot(next);
367
+ traceSnapshot("onChanged", normalized);
368
+ setSnapshot(normalized);
308
369
  });
309
370
  return () => {
310
371
  alive = false;
package/lib/client.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","names":["createElement","useState","createElement"],"sources":["../src/client/chip-text.ts","../src/client/SelectionChip.tsx","../src/client/locales.ts","../src/client/hq-edge.ts","../src/client/index.ts"],"sourcesContent":["/**\n * Pure label formatting for the ambient `Current selection` chip.\n *\n * Deliberately free of React and of the cordis context so the text the user\n * reads is unit-testable on its own — the same call\n * `@huaqiu/dsh-tool-symbol-footprint` makes for its sidebar copy.\n *\n * ── Grounding rule ──────────────────────────────────────────────────────────\n * The chip may only state what the host actually reported:\n *\n * - the item COUNT (`item_count`) — never inferred from `labels.length`,\n * because the host truncates the label list to a small bound;\n * - the editor identity (`editor`), and only when it is one we model;\n * - a BOUNDED label list, explicitly introduced as a partial view.\n *\n * Anything else — \"the selected footprint\", \"net N$12\", a guessed type — would\n * be a claim the summary does not support, and the agent has the real entity\n * ids one tool call away.\n *\n * @module\n */\nimport type { SelectionEditorName, SelectionSnapshot } from './hq-edge.js'\n\n/** Translation function shape (the locale service's `bind` result). */\nexport type Translate = (key: string, params?: Record<string, unknown>) => string\n\n/** Fallback translator: the key itself, so a missing dictionary is never blank. */\nexport const identityTranslate: Translate = (key) => key\n\n/** Editor display name for one editor identity. */\nexport function editorLabel(\n editor: SelectionEditorName,\n t: Translate = identityTranslate,\n): string {\n switch (editor) {\n case 'PCB':\n return t('editor.pcb')\n case 'SCHEMATIC':\n return t('editor.schematic')\n default:\n return t('editor.unspecified')\n }\n}\n\n/**\n * `\"{count} items\"` with the singular form honoured for one item.\n *\n * Both languages ship a dedicated singular key, so English reads \"1 item\"\n * rather than \"1 items\".\n */\nexport function itemCountLabel(count: number, t: Translate = identityTranslate): string {\n return count === 1 ? t('chip.item') : t('chip.items', { count })\n}\n\n/**\n * How many labels the chip may append after the count.\n *\n * The host already bounds the list (8 entries × 64 chars), but the chip is one\n * line of ambient UI, so it shows fewer than the host is willing to send.\n */\nexport const MAX_CHIP_LABELS = 3\n\n/**\n * The bounded, comma-joined label suffix, or `''` when there is nothing to show.\n *\n * The separator is a plain comma plus a space rather than a locale-specific\n * list separator: the chip is a compact ambient indicator, and the host's\n * labels are identifiers (R1, U3, …) that read the same in both languages.\n */\nexport function labelSuffix(labels: readonly string[]): string {\n return labels.slice(0, MAX_CHIP_LABELS).join(', ')\n}\n\n/**\n * The full chip text, e.g. `Current selection · 3 items (PCB): R1, R2, R3`.\n *\n * Returns `''` for anything that must NOT be rendered — an unknown state\n * (`null`) or a known-empty selection. One gate for both, so the chip and the\n * prompt section can never disagree about when the selection exists.\n */\nexport function formatSelectionChip(\n snapshot: SelectionSnapshot | null,\n t: Translate = identityTranslate,\n): string {\n if (snapshot === null || !snapshot.hasSelection || snapshot.itemCount <= 0) return ''\n\n const parts: string[] = [t('chip.label'), t('chip.sep'), itemCountLabel(snapshot.itemCount, t)]\n\n // The editor is parenthesised only when it is one we model; UNSPECIFIED\n // means the host has not identified a focused editor, and \"(editor)\" would\n // be noise.\n if (snapshot.editor !== 'UNSPECIFIED') {\n parts.push(`(${editorLabel(snapshot.editor, t)})`)\n }\n\n const labels = labelSuffix(snapshot.labels)\n\n // Appended WITHOUT a joining space: the separator lives inside the localized\n // string (`': {labels}'`), and a space before a colon reads wrong in both\n // languages.\n return labels.length > 0 ? `${parts.join(' ')}${t('chip.labels', { labels })}` : parts.join(' ')\n}\n\n/** Screen-reader label for the chip. */\nexport function selectionChipAriaLabel(\n snapshot: SelectionSnapshot | null,\n t: Translate = identityTranslate,\n): string {\n if (snapshot === null || !snapshot.hasSelection || snapshot.itemCount <= 0) return ''\n return t('chip.aria', { count: snapshot.itemCount })\n}\n","/**\n * The ambient `Current selection` chip — the browser half's only visible\n * surface.\n *\n * It is a passive indicator: it renders what the host published and nothing\n * else. There is no click action (the agent reaches the real selection through\n * the existing EDA tools), no expand affordance, and no per-item type claim.\n *\n * ── When it renders ─────────────────────────────────────────────────────────\n * Only for a snapshot with `hasSelection && itemCount > 0`. Both UNKNOWN\n * (`null` — nothing published yet, or the sync failed) and KNOWN-EMPTY\n * (`hasSelection: false` with a real revision) render NOTHING. Rendering\n * either of them would put a chip on screen that the host does not back.\n *\n * @module\n */\nimport { createElement, type CSSProperties, type ReactElement } from 'react'\nimport type { SelectionSnapshot } from './hq-edge.js'\nimport {\n formatSelectionChip,\n selectionChipAriaLabel,\n type Translate,\n} from './chip-text.js'\n\nconst ROOT: CSSProperties = {\n display: 'inline-flex',\n alignItems: 'center',\n gap: 6,\n // The dock is full-width; the chip itself is only as wide as its text so it\n // reads as an ambient tag rather than a toolbar.\n alignSelf: 'flex-start',\n maxWidth: '100%',\n padding: '3px 10px',\n borderRadius: 999,\n boxSizing: 'border-box',\n fontSize: 12,\n lineHeight: '18px',\n color: 'var(--dsw-alias-label-secondary)',\n background: 'color-mix(in srgb, var(--dsw-alias-label-primary) 5%, transparent)',\n border: '1px solid color-mix(in srgb, var(--dsw-alias-label-primary) 14%, transparent)',\n}\n\n/** The leading dot — the only ornament, and it carries no meaning. */\nconst DOT: CSSProperties = {\n width: 6,\n height: 6,\n borderRadius: '50%',\n flex: '0 0 auto',\n background: 'var(--dsw-alias-state-success, #34c759)',\n}\n\nconst TEXT: CSSProperties = {\n // One line, ellipsised. The label list is host-bounded and already short.\n overflow: 'hidden',\n textOverflow: 'ellipsis',\n whiteSpace: 'nowrap',\n}\n\nexport interface SelectionChipProps {\n /** The latest snapshot, or `null` when the state is unknown. */\n snapshot: SelectionSnapshot | null\n /** Bound dictionary (the entry's locale seat). */\n t: Translate\n}\n\n/**\n * Render the chip, or `null` when there is nothing grounded to show.\n *\n * Returning `null` (rather than an empty fragment) lets the dock skip the row\n * entirely, so an absent selection costs no vertical space in the composer.\n */\nexport function SelectionChip({ snapshot, t }: SelectionChipProps): ReactElement | null {\n const text = formatSelectionChip(snapshot, t)\n\n if (text.length === 0) return null\n\n const aria = selectionChipAriaLabel(snapshot, t)\n\n return createElement(\n 'div',\n {\n style: ROOT,\n // `data-*` markers so the entry can be identified in tests and by the\n // host's own diagnostics without depending on the copy.\n 'data-dsh-plugin': 'huaqiu-eda-selection',\n 'data-dsh-part': 'selection-chip',\n 'data-editor': snapshot?.editor ?? 'UNSPECIFIED',\n 'data-revision': snapshot?.revision ?? '',\n role: 'status',\n 'aria-label': aria.length > 0 ? aria : undefined,\n },\n createElement('span', { style: DOT, 'aria-hidden': true }),\n createElement('span', { style: TEXT }, text),\n )\n}\n","/**\n * zh / en copy for the ambient `Current selection` dock entry.\n *\n * Chinese is the source of truth: `EdaSelectionKey` is derived from it and\n * `en` is typed against it, so a key added to one language without the other\n * is a COMPILE error rather than a silent Chinese fallback at runtime.\n *\n * Kept in its own module (rather than inlined in the component) so the copy is\n * reviewable on its own and the test file can assert bilingual balance\n * directly, mirroring `@huaqiu/dsh-auth` / `@huaqiu/dsh-tool-schematic-gen`.\n */\n\nexport const zh = {\n /** Dock-entry label shown wherever the owner projects one. */\n 'dock.label': '当前选中',\n /** The chip itself: \"Current selection · 3 items (PCB)\". */\n 'chip.label': '当前选中',\n 'chip.sep': '·',\n /** Editor names, spelled for a human reader. */\n 'editor.pcb': 'PCB',\n 'editor.schematic': '原理图',\n 'editor.unspecified': '编辑器',\n /** \"{count} items\" / \"{count} 项\". */\n 'chip.items': '{count} 项',\n /** One item — English needs the singular form. */\n 'chip.item': '1 项',\n /** Screen-reader label for the chip. */\n 'chip.aria': '当前编辑器选中 {count} 项',\n /** Bounded label list, shown after the count when the host supplied any. */\n 'chip.labels': ':{labels}',\n} as const\n\nexport type EdaSelectionKey = keyof typeof zh\n\nexport const en: Record<EdaSelectionKey, string> = {\n 'dock.label': 'Current selection',\n 'chip.label': 'Current selection',\n 'chip.sep': '·',\n 'editor.pcb': 'PCB',\n 'editor.schematic': 'Schematic',\n 'editor.unspecified': 'editor',\n 'chip.items': '{count} items',\n 'chip.item': '1 item',\n 'chip.aria': 'Current editor selection: {count} items',\n 'chip.labels': ': {labels}',\n}\n","/**\n * Structural view of the `hqEdge` host service's `selection` face.\n *\n * Declared structurally and NEVER imported from `@hqedge/dsh-edge-bridge`:\n * that package is a DSH host plugin (its root export is a cordis entry), so\n * importing it here would pull a plugin into a plugin. The contract is instead\n * pinned by the edge-bridge's own unit tests, which assert the exact same\n * field names and semantics this interface describes.\n *\n * Both halves of the bridge publish this shape, and both normalise it through\n * the same `normalizeSelectionSnapshot` rules — so a browser-side chip and a\n * node-side prompt provider reading the same snapshot always agree.\n *\n * @module\n */\n\n/** Editor identity, as an enum NAME (never the wire number). */\nexport type SelectionEditorName = 'UNSPECIFIED' | 'SCHEMATIC' | 'PCB'\n\n/**\n * One selection summary.\n *\n * `revision` is a STRING: protobuf uint64 crosses the HTTP boundary as bigint,\n * and `JSON.stringify` throws on bigint, so hq-edge serialises it as a string.\n */\nexport interface SelectionSnapshot {\n /** Whether the FOCUSED editor currently has a selection. */\n hasSelection: boolean\n /** Number of selected items. Zero when `hasSelection` is false. */\n itemCount: number\n /** Which editor the selection belongs to. */\n editor: SelectionEditorName\n /**\n * Bounded, display-only labels (reference designators, symbol names, …).\n *\n * Host-truncated to a small fixed count and per-label length. Treat as a\n * PARTIAL view: never infer `itemCount` from `labels.length`, and never\n * claim these are all the selected items.\n */\n labels: string[]\n /** Monotonic per-host revision. Empty before the first published summary. */\n revision: string\n}\n\n/** The `selection` face of the `hqEdge` host service. */\nexport interface HqEdgeSelection {\n /**\n * The latest known selection summary, or `null` when UNKNOWN.\n *\n * `null` means \"nothing has been published yet, or the sync failed\" — which\n * is deliberately NOT the same thing as a known-empty selection. A consumer\n * must render nothing for either, but must never treat `null` as\n * \"definitely nothing selected\".\n */\n getCurrent(): SelectionSnapshot | null\n /**\n * Subscribe to selection changes. Fires immediately with the current state\n * when one is already known.\n * @returns a disposer removing exactly this listener.\n */\n onChanged(listener: (selection: SelectionSnapshot | null) => void): () => void\n}\n\n/** The slice of the `hqEdge` service this plugin consumes. */\nexport interface HqEdgeLike {\n selection?: HqEdgeSelection\n}\n\n/**\n * Normalize one raw summary into {@link SelectionSnapshot}, or `null` when it\n * carries no usable state.\n *\n * The bridge already normalizes, but this plugin can be pointed at a host\n * service that predates the selection face (or at a hand-rolled test double),\n * so the same defensive rules are applied again here:\n *\n * - null / undefined / non-object → `null` (unknown)\n * - `hasSelection` not a boolean → `null` (cannot tell selected from not)\n * - unknown editor name → `UNSPECIFIED`\n * - non-string labels are dropped; the array is copied\n *\n * A well-formed `hasSelection: false` is KEPT: that is a known-empty state,\n * and dropping it would erase the distinction the whole design rests on.\n *\n * @param raw - value read from `hqEdge.selection.getCurrent()`.\n * @returns the normalized snapshot, or `null` for unknown/malformed.\n */\nexport function normalizeSelectionSnapshot(raw: unknown): SelectionSnapshot | null {\n if (raw === null || raw === undefined) return null\n if (typeof raw !== 'object' || Array.isArray(raw)) return null\n\n const record = raw as Record<string, unknown>\n\n if (typeof record.hasSelection !== 'boolean') return null\n\n const itemCount =\n typeof record.itemCount === 'number' && Number.isFinite(record.itemCount) && record.itemCount > 0\n ? Math.floor(record.itemCount)\n : 0\n\n const editor = record.editor\n const knownEditor: SelectionEditorName =\n editor === 'SCHEMATIC' || editor === 'PCB' ? editor : 'UNSPECIFIED'\n\n const labels = Array.isArray(record.labels)\n ? record.labels.filter((label): label is string => typeof label === 'string')\n : []\n\n const revision =\n typeof record.revision === 'string'\n ? record.revision\n : typeof record.revision === 'number' && Number.isFinite(record.revision)\n ? String(record.revision)\n : ''\n\n return { hasSelection: record.hasSelection, itemCount, editor: knownEditor, labels, revision }\n}\n\n/**\n * Whether a snapshot is worth surfacing at all.\n *\n * Both the chip and the prompt section use this single gate, so the two can\n * never disagree about when the selection \"exists\". Unknown (`null`) and\n * known-empty both answer \"no\" — but for different reasons, and the chip keeps\n * them apart internally so a later transition is not swallowed.\n */\nexport function hasVisibleSelection(snapshot: SelectionSnapshot | null): boolean {\n return snapshot !== null && snapshot.hasSelection && snapshot.itemCount > 0\n}\n","/**\n * `@huaqiu/dsh-eda-selection` — browser half.\n *\n * Contributes ONE entry to the public `conversation.input.dock` slot: an\n * ambient `Current selection · N items (editor)` chip that appears above the\n * composer whenever the FOCUSED KiCad editor has a selection, and disappears\n * the moment it does not.\n *\n * ── Where the state comes from ──────────────────────────────────────────────\n * The `hqEdge` host service provided by `@hqedge/dsh-edge-bridge`. Its\n * `selection` face is fed by the bridge's OWN `/runtime/dsh` poll, so this\n * plugin adds NO second polling loop and never talks to HQ Edge itself.\n *\n * `inject` declares `hqEdge`, so DSH holds this plugin's activation until the\n * bridge has provided it — `ctx.get('hqEdge')` inside `apply()` therefore\n * resolves. The accessor is still built lazily (a closure, not a captured\n * value) because the service object is created once but its `selection` face\n * is read on every subscription.\n *\n * The bridge's `onChanged` replays the current state immediately when one is\n * already known, so a chip mounted after the first summary does not wait for a\n * change; and it returns a disposer, which is what the slot's own lifecycle\n * expects.\n *\n * ── Unknown vs known-empty ──────────────────────────────────────────────────\n * `hqEdge.selection.getCurrent()` returns `null` when the state is UNKNOWN\n * (nothing published yet, or the sync failed). That is NOT a selection, and\n * the chip renders nothing for it — a chip must never appear on screen that\n * the host does not currently back.\n *\n * ── No second composer ──────────────────────────────────────────────────────\n * This entry never touches the draft, the input state, or the reference\n * pipeline. It is a read-only projection of host state; the agent reaches the\n * real selection through the existing EDA selection tools.\n *\n * @module @huaqiu/dsh-eda-selection/client\n */\nimport { createElement, useEffect, useState, type ReactElement } from 'react'\nimport type {} from '@deepseek-ai/dsh-client-ui-slots'\nimport { SelectionChip } from './SelectionChip.js'\nimport { en, zh, type EdaSelectionKey } from './locales.js'\nimport {\n normalizeSelectionSnapshot,\n type HqEdgeLike,\n type SelectionSnapshot,\n} from './hq-edge.js'\n\nexport const inject = ['slots', 'locale', 'hqEdge'] as const\n\n/** This plugin's dictionary namespace. */\nexport const EDA_SELECTION_NS = 'huaqiu:eda-selection'\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n 'huaqiu:eda-selection': EdaSelectionKey\n }\n}\n\n/**\n * Minimal structural client context (the DSH client runtime provides these\n * faces). Declared structurally, never imported, so the package stays\n * independently installable — the same pattern as\n * `@huaqiu/dsh-tool-schematic-gen` and `@huaqiu/dsh-huaqiu-about`.\n */\nexport interface ClientContext {\n slots?: {\n inject(key: string, callback: () => () => void): () => void\n register(\n spec: {\n name: string\n id?: string\n order?: number\n label?: () => string\n locale?: string\n },\n component: unknown,\n ): () => void\n }\n locale?: {\n register(namespace: string, dictionaries: unknown): unknown\n bind(namespace: string): (key: string, params?: Record<string, unknown>) => string\n }\n get<T = unknown>(name: string): T | undefined\n effect?(fn: () => (() => void) | void, label?: string): void\n}\n\n/** Translation function shape (the locale service's `bind` result). */\nexport type Translate = (key: string, params?: Record<string, unknown>) => string\n\n/**\n * Resolve the `hqEdge` service defensively.\n *\n * `inject` already guarantees it exists by `apply()` time; the try/catch and\n * the shape checks keep a standalone DSH (no edge-bridge) from taking the\n * plugin down. In that case the chip simply never renders.\n */\nfunction getHqEdge(ctx: ClientContext): HqEdgeLike | undefined {\n try {\n const edge = ctx.get<HqEdgeLike | undefined>('hqEdge')\n if (!edge || !edge.selection || typeof edge.selection.getCurrent !== 'function') return undefined\n return edge\n } catch {\n return undefined\n }\n}\n\n/**\n * Read and normalize the current snapshot from the bridge.\n *\n * The bridge already normalizes; re-normalizing here keeps this plugin honest\n * when pointed at an older bridge (or a test double) that does not.\n */\nfunction readSnapshot(edge: HqEdgeLike | undefined): SelectionSnapshot | null {\n if (!edge) return null\n try {\n return normalizeSelectionSnapshot(edge.selection?.getCurrent())\n } catch {\n return null\n }\n}\n\nexport interface SelectionDockProps {\n /** Bound dictionary, injected by the client entry. */\n t: Translate\n /** Lazy bridge accessor — a closure over `ctx`, never a captured value. */\n getEdge: () => HqEdgeLike | undefined\n}\n\n/**\n * The dock component: subscribes to the bridge and renders the chip.\n *\n * A hook rather than a plain read, because the snapshot arrives asynchronously\n * (the bridge's first poll lands after this component mounts).\n */\nexport function SelectionDock({ t, getEdge }: SelectionDockProps): ReactElement | null {\n const [snapshot, setSnapshot] = useState<SelectionSnapshot | null>(null)\n\n useEffect(() => {\n const edge = getEdge()\n\n // No bridge (standalone DSH) or a bridge without the selection face\n // (older edge-bridge): render nothing, permanently. Not an error.\n if (!edge || typeof edge.selection?.onChanged !== 'function') {\n setSnapshot(null)\n return\n }\n\n // `onChanged` replays the current state immediately when one is known, so\n // this read is only needed for the unknown-at-mount case.\n setSnapshot(readSnapshot(edge))\n\n let alive = true\n const dispose = edge.selection.onChanged((next) => {\n if (!alive) return\n // Normalize on the way in too: a listener must never be handed a shape\n // it did not expect, whatever the bridge did.\n setSnapshot(normalizeSelectionSnapshot(next))\n })\n\n return () => {\n alive = false\n dispose()\n }\n }, [getEdge])\n\n return createElement(SelectionChip, { snapshot, t })\n}\n\n/**\n * Install the dock entry on the running client context.\n *\n * Never throws: a deployment without the slot service, the locale service, or\n * the `hqEdge` bridge must still load — it simply shows no chip.\n */\nexport function apply(ctx: ClientContext): void {\n // Dictionaries first, so the first render already has localized text. The\n // untyped register form is used because this namespace is declared by this\n // package (not by DSH), which keeps DSH core untouched.\n const locale = ctx.locale\n if (locale && typeof locale.register === 'function') {\n try {\n locale.register(EDA_SELECTION_NS, { zh, en })\n } catch {\n // A duplicate registration (hot reload, double mount) is not fatal —\n // the first registration's dictionaries are already live.\n }\n }\n\n const t: Translate =\n locale && typeof locale.bind === 'function'\n ? locale.bind(EDA_SELECTION_NS)\n : (key: string) => key\n\n const getEdge = (): HqEdgeLike | undefined => getHqEdge(ctx)\n\n const slots = ctx.slots\n if (!slots || typeof slots.inject !== 'function' || typeof slots.register !== 'function') {\n return\n }\n\n ctx.effect?.(\n () =>\n slots.inject('conversation.input.dock', () =>\n slots.register(\n {\n name: 'conversation.input.dock',\n // Our own id: a fresh id is added BESIDE the shipped entries\n // (queue / todo / goal) instead of replacing one of them.\n id: 'eda-selection',\n // Ascending among the dock entries. `queue` is 20 and `todo` is 0,\n // so 5 puts the ambient chip near the top — closest to the composer\n // it describes — without displacing the todo dock at 0.\n order: 5,\n label: () => t('dock.label'),\n locale: EDA_SELECTION_NS,\n },\n () => createElement(SelectionDock, { t, getEdge }),\n ),\n ),\n 'huaqiu eda-selection: composer dock chip',\n )\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"client.js","names":["createElement","useState","createElement"],"sources":["../src/client/chip-text.ts","../src/client/SelectionChip.tsx","../src/client/locales.ts","../src/client/hq-edge.ts","../src/client/index.ts"],"sourcesContent":["/**\n * Pure label formatting for the ambient `Current selection` chip.\n *\n * Deliberately free of React and of the cordis context so the text the user\n * reads is unit-testable on its own — the same call\n * `@huaqiu/dsh-tool-symbol-footprint` makes for its sidebar copy.\n *\n * ── Grounding rule ──────────────────────────────────────────────────────────\n * The chip may only state what the host actually reported:\n *\n * - the item COUNT (`item_count`) — never inferred from `labels.length`,\n * because the host truncates the label list to a small bound;\n * - the editor identity (`editor`), and only when it is one we model;\n * - a BOUNDED label list, explicitly introduced as a partial view.\n *\n * Anything else — \"the selected footprint\", \"net N$12\", a guessed type — would\n * be a claim the summary does not support, and the agent has the real entity\n * ids one tool call away.\n *\n * @module\n */\nimport type { SelectionEditorName, SelectionSnapshot } from './hq-edge.js'\n\n/** Translation function shape (the locale service's `bind` result). */\nexport type Translate = (key: string, params?: Record<string, unknown>) => string\n\n/** Fallback translator: the key itself, so a missing dictionary is never blank. */\nexport const identityTranslate: Translate = (key) => key\n\n/** Editor display name for one editor identity. */\nexport function editorLabel(\n editor: SelectionEditorName,\n t: Translate = identityTranslate,\n): string {\n switch (editor) {\n case 'PCB':\n return t('editor.pcb')\n case 'SCHEMATIC':\n return t('editor.schematic')\n default:\n return t('editor.unspecified')\n }\n}\n\n/**\n * `\"{count} items\"` with the singular form honoured for one item.\n *\n * Both languages ship a dedicated singular key, so English reads \"1 item\"\n * rather than \"1 items\".\n */\nexport function itemCountLabel(count: number, t: Translate = identityTranslate): string {\n return count === 1 ? t('chip.item') : t('chip.items', { count })\n}\n\n/**\n * How many labels the chip may append after the count.\n *\n * The host already bounds the list (8 entries × 64 chars), but the chip is one\n * line of ambient UI, so it shows fewer than the host is willing to send.\n */\nexport const MAX_CHIP_LABELS = 3\n\n/**\n * The bounded, comma-joined label suffix, or `''` when there is nothing to show.\n *\n * The separator is a plain comma plus a space rather than a locale-specific\n * list separator: the chip is a compact ambient indicator, and the host's\n * labels are identifiers (R1, U3, …) that read the same in both languages.\n */\nexport function labelSuffix(labels: readonly string[]): string {\n return labels.slice(0, MAX_CHIP_LABELS).join(', ')\n}\n\n/**\n * The full chip text, e.g. `Current selection · 3 items (PCB): R1, R2, R3`.\n *\n * Returns `''` for anything that must NOT be rendered — an unknown state\n * (`null`) or a known-empty selection. One gate for both, so the chip and the\n * prompt section can never disagree about when the selection exists.\n */\nexport function formatSelectionChip(\n snapshot: SelectionSnapshot | null,\n t: Translate = identityTranslate,\n): string {\n if (snapshot === null || !snapshot.hasSelection || snapshot.itemCount <= 0) return ''\n\n const parts: string[] = [t('chip.label'), t('chip.sep'), itemCountLabel(snapshot.itemCount, t)]\n\n // The editor is parenthesised only when it is one we model; UNSPECIFIED\n // means the host has not identified a focused editor, and \"(editor)\" would\n // be noise.\n if (snapshot.editor !== 'UNSPECIFIED') {\n parts.push(`(${editorLabel(snapshot.editor, t)})`)\n }\n\n const labels = labelSuffix(snapshot.labels)\n\n // Appended WITHOUT a joining space: the separator lives inside the localized\n // string (`': {labels}'`), and a space before a colon reads wrong in both\n // languages.\n return labels.length > 0 ? `${parts.join(' ')}${t('chip.labels', { labels })}` : parts.join(' ')\n}\n\n/** Screen-reader label for the chip. */\nexport function selectionChipAriaLabel(\n snapshot: SelectionSnapshot | null,\n t: Translate = identityTranslate,\n): string {\n if (snapshot === null || !snapshot.hasSelection || snapshot.itemCount <= 0) return ''\n return t('chip.aria', { count: snapshot.itemCount })\n}\n","/**\n * The ambient `Current selection` chip — the browser half's only visible\n * surface.\n *\n * It is a passive indicator: it renders what the host published and nothing\n * else. There is no click action (the agent reaches the real selection through\n * the existing EDA tools), no expand affordance, and no per-item type claim.\n *\n * ── When it renders ─────────────────────────────────────────────────────────\n * Only for a snapshot with `hasSelection && itemCount > 0`. Both UNKNOWN\n * (`null` — nothing published yet, or the sync failed) and KNOWN-EMPTY\n * (`hasSelection: false` with a real revision) render NOTHING. Rendering\n * either of them would put a chip on screen that the host does not back.\n *\n * @module\n */\nimport { createElement, type CSSProperties, type ReactElement } from 'react'\nimport type { SelectionSnapshot } from './hq-edge.js'\nimport {\n formatSelectionChip,\n selectionChipAriaLabel,\n type Translate,\n} from './chip-text.js'\n\n/**\n * The dock seat — the geometry that makes the chip line up with the input\n * card.\n *\n * DSH renders every `conversation.input.dock` entry as a full-bleed child of\n * `.composerStack`, while the input card itself is inset by the shared\n * composer clearance and capped at the card max width (InputBar `.root` pads\n * `--dsh-composer-side-clearance` on both sides; `.card` caps at\n * `--dsh-composer-card-max-width`). A bare `alignSelf: flex-start` child\n * therefore hugs the COLUMN edge — visually `side-clearance` (16px) left of\n * the input card it describes.\n *\n * This seat reproduces the card's own box: same clearance subtraction, same\n * max-width cap, centered — the exact geometry the shipped dock entries\n * (QueueDock `.dock`, TodoPanel `.root`) derive from the same variables. The\n * fallbacks mirror DSH's defaults so a foreign host gets a sane inset instead\n * of an unstyled flush-left pill.\n */\nconst DOCK_SEAT: CSSProperties = {\n boxSizing: 'border-box',\n flex: 'none',\n display: 'flex',\n justifyContent: 'flex-start',\n minWidth: 0,\n width: 'calc(100% - var(--dsh-composer-side-clearance, 16px) * 2)',\n maxWidth: 'var(--dsh-composer-card-max-width, 100%)',\n margin: '0 auto',\n}\n\nconst ROOT: CSSProperties = {\n display: 'inline-flex',\n alignItems: 'center',\n gap: 6,\n // The seat owns the horizontal geometry; the chip is only as wide as its\n // text so it reads as an ambient tag rather than a toolbar.\n maxWidth: '100%',\n padding: '3px 10px',\n borderRadius: 999,\n boxSizing: 'border-box',\n fontSize: 12,\n lineHeight: '18px',\n color: 'var(--dsw-alias-label-secondary)',\n background: 'color-mix(in srgb, var(--dsw-alias-label-primary) 5%, transparent)',\n border: '1px solid color-mix(in srgb, var(--dsw-alias-label-primary) 14%, transparent)',\n}\n\n/** The leading dot — the only ornament, and it carries no meaning. */\nconst DOT: CSSProperties = {\n width: 6,\n height: 6,\n borderRadius: '50%',\n flex: '0 0 auto',\n background: 'var(--dsw-alias-state-success, #34c759)',\n}\n\nconst TEXT: CSSProperties = {\n // One line, ellipsised. The label list is host-bounded and already short.\n overflow: 'hidden',\n textOverflow: 'ellipsis',\n whiteSpace: 'nowrap',\n}\n\nexport interface SelectionChipProps {\n /** The latest snapshot, or `null` when the state is unknown. */\n snapshot: SelectionSnapshot | null\n /** Bound dictionary (the entry's locale seat). */\n t: Translate\n}\n\n/**\n * Render the chip, or `null` when there is nothing grounded to show.\n *\n * Returning `null` (rather than an empty fragment) lets the dock skip the row\n * entirely, so an absent selection costs no vertical space in the composer.\n */\nexport function SelectionChip({ snapshot, t }: SelectionChipProps): ReactElement | null {\n const text = formatSelectionChip(snapshot, t)\n\n if (text.length === 0) return null\n\n const aria = selectionChipAriaLabel(snapshot, t)\n\n return createElement(\n 'div',\n {\n style: DOCK_SEAT,\n 'data-dsh-part': 'selection-chip-dock',\n },\n createElement(\n 'div',\n {\n style: ROOT,\n // `data-*` markers so the entry can be identified in tests and by the\n // host's own diagnostics without depending on the copy.\n 'data-dsh-plugin': 'huaqiu-eda-selection',\n 'data-dsh-part': 'selection-chip',\n 'data-editor': snapshot?.editor ?? 'UNSPECIFIED',\n 'data-revision': snapshot?.revision ?? '',\n role: 'status',\n 'aria-label': aria.length > 0 ? aria : undefined,\n },\n createElement('span', { style: DOT, 'aria-hidden': true }),\n createElement('span', { style: TEXT }, text),\n ),\n )\n}\n","/**\n * zh / en copy for the ambient `Current selection` dock entry.\n *\n * Chinese is the source of truth: `EdaSelectionKey` is derived from it and\n * `en` is typed against it, so a key added to one language without the other\n * is a COMPILE error rather than a silent Chinese fallback at runtime.\n *\n * Kept in its own module (rather than inlined in the component) so the copy is\n * reviewable on its own and the test file can assert bilingual balance\n * directly, mirroring `@huaqiu/dsh-auth` / `@huaqiu/dsh-tool-schematic-gen`.\n */\n\nexport const zh = {\n /** Dock-entry label shown wherever the owner projects one. */\n 'dock.label': '当前选中',\n /** The chip itself: \"Current selection · 3 items (PCB)\". */\n 'chip.label': '当前选中',\n 'chip.sep': '·',\n /** Editor names, spelled for a human reader. */\n 'editor.pcb': 'PCB',\n 'editor.schematic': '原理图',\n 'editor.unspecified': '编辑器',\n /** \"{count} items\" / \"{count} 项\". */\n 'chip.items': '{count} 项',\n /** One item — English needs the singular form. */\n 'chip.item': '1 项',\n /** Screen-reader label for the chip. */\n 'chip.aria': '当前编辑器选中 {count} 项',\n /** Bounded label list, shown after the count when the host supplied any. */\n 'chip.labels': ':{labels}',\n} as const\n\nexport type EdaSelectionKey = keyof typeof zh\n\nexport const en: Record<EdaSelectionKey, string> = {\n 'dock.label': 'Current selection',\n 'chip.label': 'Current selection',\n 'chip.sep': '·',\n 'editor.pcb': 'PCB',\n 'editor.schematic': 'Schematic',\n 'editor.unspecified': 'editor',\n 'chip.items': '{count} items',\n 'chip.item': '1 item',\n 'chip.aria': 'Current editor selection: {count} items',\n 'chip.labels': ': {labels}',\n}\n","/**\n * Structural view of the `hqEdge` host service's `selection` face.\n *\n * Declared structurally and NEVER imported from `@hqedge/dsh-edge-bridge`:\n * that package is a DSH host plugin (its root export is a cordis entry), so\n * importing it here would pull a plugin into a plugin. The contract is instead\n * pinned by the edge-bridge's own unit tests, which assert the exact same\n * field names and semantics this interface describes.\n *\n * Both halves of the bridge publish this shape, and both normalise it through\n * the same `normalizeSelectionSnapshot` rules — so a browser-side chip and a\n * node-side prompt provider reading the same snapshot always agree.\n *\n * @module\n */\n\n/** Editor identity, as an enum NAME (never the wire number). */\nexport type SelectionEditorName = 'UNSPECIFIED' | 'SCHEMATIC' | 'PCB'\n\n/**\n * One selection summary.\n *\n * `revision` is a STRING: protobuf uint64 crosses the HTTP boundary as bigint,\n * and `JSON.stringify` throws on bigint, so hq-edge serialises it as a string.\n */\nexport interface SelectionSnapshot {\n /** Whether the FOCUSED editor currently has a selection. */\n hasSelection: boolean\n /** Number of selected items. Zero when `hasSelection` is false. */\n itemCount: number\n /** Which editor the selection belongs to. */\n editor: SelectionEditorName\n /**\n * Bounded, display-only labels (reference designators, symbol names, …).\n *\n * Host-truncated to a small fixed count and per-label length. Treat as a\n * PARTIAL view: never infer `itemCount` from `labels.length`, and never\n * claim these are all the selected items.\n */\n labels: string[]\n /** Monotonic per-host revision. Empty before the first published summary. */\n revision: string\n}\n\n/** The `selection` face of the `hqEdge` host service. */\nexport interface HqEdgeSelection {\n /**\n * The latest known selection summary, or `null` when UNKNOWN.\n *\n * `null` means \"nothing has been published yet, or the sync failed\" — which\n * is deliberately NOT the same thing as a known-empty selection. A consumer\n * must render nothing for either, but must never treat `null` as\n * \"definitely nothing selected\".\n */\n getCurrent(): SelectionSnapshot | null\n /**\n * Subscribe to selection changes. Fires immediately with the current state\n * when one is already known.\n * @returns a disposer removing exactly this listener.\n */\n onChanged(listener: (selection: SelectionSnapshot | null) => void): () => void\n}\n\n/** The slice of the `hqEdge` service this plugin consumes. */\nexport interface HqEdgeLike {\n selection?: HqEdgeSelection\n}\n\n/**\n * Normalize one raw summary into {@link SelectionSnapshot}, or `null` when it\n * carries no usable state.\n *\n * The bridge already normalizes, but this plugin can be pointed at a host\n * service that predates the selection face (or at a hand-rolled test double),\n * so the same defensive rules are applied again here:\n *\n * - null / undefined / non-object → `null` (unknown)\n * - `hasSelection` not a boolean → `null` (cannot tell selected from not)\n * - unknown editor name → `UNSPECIFIED`\n * - non-string labels are dropped; the array is copied\n *\n * A well-formed `hasSelection: false` is KEPT: that is a known-empty state,\n * and dropping it would erase the distinction the whole design rests on.\n *\n * @param raw - value read from `hqEdge.selection.getCurrent()`.\n * @returns the normalized snapshot, or `null` for unknown/malformed.\n */\nexport function normalizeSelectionSnapshot(raw: unknown): SelectionSnapshot | null {\n if (raw === null || raw === undefined) return null\n if (typeof raw !== 'object' || Array.isArray(raw)) return null\n\n const record = raw as Record<string, unknown>\n\n if (typeof record.hasSelection !== 'boolean') return null\n\n const itemCount =\n typeof record.itemCount === 'number' && Number.isFinite(record.itemCount) && record.itemCount > 0\n ? Math.floor(record.itemCount)\n : 0\n\n const editor = record.editor\n const knownEditor: SelectionEditorName =\n editor === 'SCHEMATIC' || editor === 'PCB' ? editor : 'UNSPECIFIED'\n\n const labels = Array.isArray(record.labels)\n ? record.labels.filter((label): label is string => typeof label === 'string')\n : []\n\n const revision =\n typeof record.revision === 'string'\n ? record.revision\n : typeof record.revision === 'number' && Number.isFinite(record.revision)\n ? String(record.revision)\n : ''\n\n return { hasSelection: record.hasSelection, itemCount, editor: knownEditor, labels, revision }\n}\n\n/**\n * Whether a snapshot is worth surfacing at all.\n *\n * Both the chip and the prompt section use this single gate, so the two can\n * never disagree about when the selection \"exists\". Unknown (`null`) and\n * known-empty both answer \"no\" — but for different reasons, and the chip keeps\n * them apart internally so a later transition is not swallowed.\n */\nexport function hasVisibleSelection(snapshot: SelectionSnapshot | null): boolean {\n return snapshot !== null && snapshot.hasSelection && snapshot.itemCount > 0\n}\n","/**\n * `@huaqiu/dsh-eda-selection` — browser half.\n *\n * Contributes ONE entry to the public `conversation.input.dock` slot: an\n * ambient `Current selection · N items (editor)` chip that appears above the\n * composer whenever the FOCUSED KiCad editor has a selection, and disappears\n * the moment it does not.\n *\n * ── Where the state comes from ──────────────────────────────────────────────\n * The `hqEdge` host service provided by `@hqedge/dsh-edge-bridge`. Its\n * `selection` face is fed by the bridge's OWN `/runtime/dsh` poll, so this\n * plugin adds NO second polling loop and never talks to HQ Edge itself.\n *\n * `inject` declares `hqEdge`, so DSH holds this plugin's activation until the\n * bridge has provided it — `ctx.get('hqEdge')` inside `apply()` therefore\n * resolves. The accessor is still built lazily (a closure, not a captured\n * value) because the service object is created once but its `selection` face\n * is read on every subscription.\n *\n * The bridge's `onChanged` replays the current state immediately when one is\n * already known, so a chip mounted after the first summary does not wait for a\n * change; and it returns a disposer, which is what the slot's own lifecycle\n * expects.\n *\n * ── Unknown vs known-empty ──────────────────────────────────────────────────\n * `hqEdge.selection.getCurrent()` returns `null` when the state is UNKNOWN\n * (nothing published yet, or the sync failed). That is NOT a selection, and\n * the chip renders nothing for it — a chip must never appear on screen that\n * the host does not currently back.\n *\n * ── No second composer ──────────────────────────────────────────────────────\n * This entry never touches the draft, the input state, or the reference\n * pipeline. It is a read-only projection of host state; the agent reaches the\n * real selection through the existing EDA selection tools.\n *\n * @module @huaqiu/dsh-eda-selection/client\n */\nimport { createElement, useEffect, useState, type ReactElement } from 'react'\nimport type {} from '@deepseek-ai/dsh-client-ui-slots'\nimport { SelectionChip } from './SelectionChip.js'\nimport { en, zh, type EdaSelectionKey } from './locales.js'\nimport {\n normalizeSelectionSnapshot,\n type HqEdgeLike,\n type SelectionSnapshot,\n} from './hq-edge.js'\n\nexport const inject = ['slots', 'locale', 'hqEdge'] as const\n\n/** This plugin's dictionary namespace. */\nexport const EDA_SELECTION_NS = 'huaqiu:eda-selection'\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n 'huaqiu:eda-selection': EdaSelectionKey\n }\n}\n\n/**\n * Minimal structural client context (the DSH client runtime provides these\n * faces). Declared structurally, never imported, so the package stays\n * independently installable — the same pattern as\n * `@huaqiu/dsh-tool-schematic-gen` and `@huaqiu/dsh-huaqiu-about`.\n */\nexport interface ClientContext {\n slots?: {\n inject(key: string, callback: () => () => void): () => void\n register(\n spec: {\n name: string\n id?: string\n order?: number\n label?: () => string\n locale?: string\n },\n component: unknown,\n ): () => void\n }\n locale?: {\n register(namespace: string, dictionaries: unknown): unknown\n bind(namespace: string): (key: string, params?: Record<string, unknown>) => string\n }\n get<T = unknown>(name: string): T | undefined\n effect?(fn: () => (() => void) | void, label?: string): void\n}\n\n/** Translation function shape (the locale service's `bind` result). */\nexport type Translate = (key: string, params?: Record<string, unknown>) => string\n\n/**\n * Resolve the `hqEdge` service defensively.\n *\n * `inject` already guarantees it exists by `apply()` time; the try/catch and\n * the shape checks keep a standalone DSH (no edge-bridge) from taking the\n * plugin down. In that case the chip simply never renders.\n */\nfunction getHqEdge(ctx: ClientContext): HqEdgeLike | undefined {\n try {\n const edge = ctx.get<HqEdgeLike | undefined>('hqEdge')\n if (!edge || !edge.selection || typeof edge.selection.getCurrent !== 'function') return undefined\n return edge\n } catch {\n return undefined\n }\n}\n\n/** Browser-console tag — greppable so the sync loop can be traced end to end. */\nconst TRACE = '[dsh-eda-selection]'\n\n/**\n * Emit one line describing a snapshot the dock received.\n *\n * The chip is the last hop of a four-stage chain (KiCad selection tool → gRPC\n * `SubscribeSelection` → hq-edge `SelectionWatcher` → `/runtime/dsh` poll →\n * this component), and a blank chip can originate at any of them. Logging\n * what arrived here is what separates \"the host never published\" from \"the\n * state arrived but was not renderable\".\n *\n * Never throws and never blocks render: a host console that is not a real\n * console (or one that rejects the object) must not take the composer down.\n */\nfunction traceSnapshot(where: string, snapshot: SelectionSnapshot | null): void {\n try {\n // eslint-disable-next-line no-console\n console.debug(\n TRACE,\n where,\n snapshot === null\n ? 'unknown'\n : {\n revision: snapshot.revision,\n editor: snapshot.editor,\n hasSelection: snapshot.hasSelection,\n itemCount: snapshot.itemCount,\n labels: snapshot.labels,\n // The gate the chip applies — `hasSelection && itemCount > 0`. A\n // state that arrives but fails this is the \"no chip, but not\n // because the sync broke\" case.\n renderable: snapshot.hasSelection && snapshot.itemCount > 0,\n },\n )\n } catch {\n /* a non-console host must never break the composer */\n }\n}\n\n/**\n * Read and normalize the current snapshot from the bridge.\n *\n * The bridge already normalizes; re-normalizing here keeps this plugin honest\n * when pointed at an older bridge (or a test double) that does not.\n */\nfunction readSnapshot(edge: HqEdgeLike | undefined): SelectionSnapshot | null {\n if (!edge) return null\n try {\n return normalizeSelectionSnapshot(edge.selection?.getCurrent())\n } catch {\n return null\n }\n}\n\nexport interface SelectionDockProps {\n /** Bound dictionary, injected by the client entry. */\n t: Translate\n /** Lazy bridge accessor — a closure over `ctx`, never a captured value. */\n getEdge: () => HqEdgeLike | undefined\n}\n\n/**\n * The dock component: subscribes to the bridge and renders the chip.\n *\n * A hook rather than a plain read, because the snapshot arrives asynchronously\n * (the bridge's first poll lands after this component mounts).\n */\nexport function SelectionDock({ t, getEdge }: SelectionDockProps): ReactElement | null {\n const [snapshot, setSnapshot] = useState<SelectionSnapshot | null>(null)\n\n useEffect(() => {\n const edge = getEdge()\n\n // No bridge (standalone DSH) or a bridge without the selection face\n // (older edge-bridge): render nothing, permanently. Not an error.\n if (!edge || typeof edge.selection?.onChanged !== 'function') {\n traceSnapshot('mount: no bridge selection face — nothing will render', null)\n setSnapshot(null)\n return\n }\n\n // `onChanged` replays the current state immediately when one is known, so\n // this read is only needed for the unknown-at-mount case.\n const initial = readSnapshot(edge)\n traceSnapshot('mount', initial)\n setSnapshot(initial)\n\n let alive = true\n const dispose = edge.selection.onChanged((next) => {\n if (!alive) return\n // Normalize on the way in too: a listener must never be handed a shape\n // it did not expect, whatever the bridge did.\n const normalized = normalizeSelectionSnapshot(next)\n traceSnapshot('onChanged', normalized)\n setSnapshot(normalized)\n })\n\n return () => {\n alive = false\n dispose()\n }\n }, [getEdge])\n\n return createElement(SelectionChip, { snapshot, t })\n}\n\n/**\n * Install the dock entry on the running client context.\n *\n * Never throws: a deployment without the slot service, the locale service, or\n * the `hqEdge` bridge must still load — it simply shows no chip.\n */\nexport function apply(ctx: ClientContext): void {\n // Dictionaries first, so the first render already has localized text. The\n // untyped register form is used because this namespace is declared by this\n // package (not by DSH), which keeps DSH core untouched.\n const locale = ctx.locale\n if (locale && typeof locale.register === 'function') {\n try {\n locale.register(EDA_SELECTION_NS, { zh, en })\n } catch {\n // A duplicate registration (hot reload, double mount) is not fatal —\n // the first registration's dictionaries are already live.\n }\n }\n\n const t: Translate =\n locale && typeof locale.bind === 'function'\n ? locale.bind(EDA_SELECTION_NS)\n : (key: string) => key\n\n const getEdge = (): HqEdgeLike | undefined => getHqEdge(ctx)\n\n const slots = ctx.slots\n if (!slots || typeof slots.inject !== 'function' || typeof slots.register !== 'function') {\n return\n }\n\n ctx.effect?.(\n () =>\n slots.inject('conversation.input.dock', () =>\n slots.register(\n {\n name: 'conversation.input.dock',\n // Our own id: a fresh id is added BESIDE the shipped entries\n // (queue / todo / goal) instead of replacing one of them.\n id: 'eda-selection',\n // Ascending among the dock entries. `queue` is 20 and `todo` is 0,\n // so 5 puts the ambient chip near the top — closest to the composer\n // it describes — without displacing the todo dock at 0.\n order: 5,\n label: () => t('dock.label'),\n locale: EDA_SELECTION_NS,\n },\n () => createElement(SelectionDock, { t, getEdge }),\n ),\n ),\n 'huaqiu eda-selection: composer dock chip',\n )\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huaqiu/dsh-eda-selection",
3
- "version": "0.5.9",
3
+ "version": "0.5.22",
4
4
  "description": "Ambient 'Current Selection' chip above the DSH composer plus a dynamic system-prompt section describing the focused EDA editor's selection.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.mjs",
@@ -41,7 +41,7 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@huaqiu/dsh-plugin-log": "0.5.9"
44
+ "@huaqiu/dsh-plugin-log": "0.5.22"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@deepseek-ai/cordis": "^4.0.2",
@@ -22,13 +22,41 @@ import {
22
22
  type Translate,
23
23
  } from './chip-text.js'
24
24
 
25
+ /**
26
+ * The dock seat — the geometry that makes the chip line up with the input
27
+ * card.
28
+ *
29
+ * DSH renders every `conversation.input.dock` entry as a full-bleed child of
30
+ * `.composerStack`, while the input card itself is inset by the shared
31
+ * composer clearance and capped at the card max width (InputBar `.root` pads
32
+ * `--dsh-composer-side-clearance` on both sides; `.card` caps at
33
+ * `--dsh-composer-card-max-width`). A bare `alignSelf: flex-start` child
34
+ * therefore hugs the COLUMN edge — visually `side-clearance` (16px) left of
35
+ * the input card it describes.
36
+ *
37
+ * This seat reproduces the card's own box: same clearance subtraction, same
38
+ * max-width cap, centered — the exact geometry the shipped dock entries
39
+ * (QueueDock `.dock`, TodoPanel `.root`) derive from the same variables. The
40
+ * fallbacks mirror DSH's defaults so a foreign host gets a sane inset instead
41
+ * of an unstyled flush-left pill.
42
+ */
43
+ const DOCK_SEAT: CSSProperties = {
44
+ boxSizing: 'border-box',
45
+ flex: 'none',
46
+ display: 'flex',
47
+ justifyContent: 'flex-start',
48
+ minWidth: 0,
49
+ width: 'calc(100% - var(--dsh-composer-side-clearance, 16px) * 2)',
50
+ maxWidth: 'var(--dsh-composer-card-max-width, 100%)',
51
+ margin: '0 auto',
52
+ }
53
+
25
54
  const ROOT: CSSProperties = {
26
55
  display: 'inline-flex',
27
56
  alignItems: 'center',
28
57
  gap: 6,
29
- // The dock is full-width; the chip itself is only as wide as its text so it
30
- // reads as an ambient tag rather than a toolbar.
31
- alignSelf: 'flex-start',
58
+ // The seat owns the horizontal geometry; the chip is only as wide as its
59
+ // text so it reads as an ambient tag rather than a toolbar.
32
60
  maxWidth: '100%',
33
61
  padding: '3px 10px',
34
62
  borderRadius: 999,
@@ -79,17 +107,24 @@ export function SelectionChip({ snapshot, t }: SelectionChipProps): ReactElement
79
107
  return createElement(
80
108
  'div',
81
109
  {
82
- style: ROOT,
83
- // `data-*` markers so the entry can be identified in tests and by the
84
- // host's own diagnostics without depending on the copy.
85
- 'data-dsh-plugin': 'huaqiu-eda-selection',
86
- 'data-dsh-part': 'selection-chip',
87
- 'data-editor': snapshot?.editor ?? 'UNSPECIFIED',
88
- 'data-revision': snapshot?.revision ?? '',
89
- role: 'status',
90
- 'aria-label': aria.length > 0 ? aria : undefined,
110
+ style: DOCK_SEAT,
111
+ 'data-dsh-part': 'selection-chip-dock',
91
112
  },
92
- createElement('span', { style: DOT, 'aria-hidden': true }),
93
- createElement('span', { style: TEXT }, text),
113
+ createElement(
114
+ 'div',
115
+ {
116
+ style: ROOT,
117
+ // `data-*` markers so the entry can be identified in tests and by the
118
+ // host's own diagnostics without depending on the copy.
119
+ 'data-dsh-plugin': 'huaqiu-eda-selection',
120
+ 'data-dsh-part': 'selection-chip',
121
+ 'data-editor': snapshot?.editor ?? 'UNSPECIFIED',
122
+ 'data-revision': snapshot?.revision ?? '',
123
+ role: 'status',
124
+ 'aria-label': aria.length > 0 ? aria : undefined,
125
+ },
126
+ createElement('span', { style: DOT, 'aria-hidden': true }),
127
+ createElement('span', { style: TEXT }, text),
128
+ ),
94
129
  )
95
130
  }
@@ -104,6 +104,46 @@ function getHqEdge(ctx: ClientContext): HqEdgeLike | undefined {
104
104
  }
105
105
  }
106
106
 
107
+ /** Browser-console tag — greppable so the sync loop can be traced end to end. */
108
+ const TRACE = '[dsh-eda-selection]'
109
+
110
+ /**
111
+ * Emit one line describing a snapshot the dock received.
112
+ *
113
+ * The chip is the last hop of a four-stage chain (KiCad selection tool → gRPC
114
+ * `SubscribeSelection` → hq-edge `SelectionWatcher` → `/runtime/dsh` poll →
115
+ * this component), and a blank chip can originate at any of them. Logging
116
+ * what arrived here is what separates "the host never published" from "the
117
+ * state arrived but was not renderable".
118
+ *
119
+ * Never throws and never blocks render: a host console that is not a real
120
+ * console (or one that rejects the object) must not take the composer down.
121
+ */
122
+ function traceSnapshot(where: string, snapshot: SelectionSnapshot | null): void {
123
+ try {
124
+ // eslint-disable-next-line no-console
125
+ console.debug(
126
+ TRACE,
127
+ where,
128
+ snapshot === null
129
+ ? 'unknown'
130
+ : {
131
+ revision: snapshot.revision,
132
+ editor: snapshot.editor,
133
+ hasSelection: snapshot.hasSelection,
134
+ itemCount: snapshot.itemCount,
135
+ labels: snapshot.labels,
136
+ // The gate the chip applies — `hasSelection && itemCount > 0`. A
137
+ // state that arrives but fails this is the "no chip, but not
138
+ // because the sync broke" case.
139
+ renderable: snapshot.hasSelection && snapshot.itemCount > 0,
140
+ },
141
+ )
142
+ } catch {
143
+ /* a non-console host must never break the composer */
144
+ }
145
+ }
146
+
107
147
  /**
108
148
  * Read and normalize the current snapshot from the bridge.
109
149
  *
@@ -141,20 +181,25 @@ export function SelectionDock({ t, getEdge }: SelectionDockProps): ReactElement
141
181
  // No bridge (standalone DSH) or a bridge without the selection face
142
182
  // (older edge-bridge): render nothing, permanently. Not an error.
143
183
  if (!edge || typeof edge.selection?.onChanged !== 'function') {
184
+ traceSnapshot('mount: no bridge selection face — nothing will render', null)
144
185
  setSnapshot(null)
145
186
  return
146
187
  }
147
188
 
148
189
  // `onChanged` replays the current state immediately when one is known, so
149
190
  // this read is only needed for the unknown-at-mount case.
150
- setSnapshot(readSnapshot(edge))
191
+ const initial = readSnapshot(edge)
192
+ traceSnapshot('mount', initial)
193
+ setSnapshot(initial)
151
194
 
152
195
  let alive = true
153
196
  const dispose = edge.selection.onChanged((next) => {
154
197
  if (!alive) return
155
198
  // Normalize on the way in too: a listener must never be handed a shape
156
199
  // it did not expect, whatever the bridge did.
157
- setSnapshot(normalizeSelectionSnapshot(next))
200
+ const normalized = normalizeSelectionSnapshot(next)
201
+ traceSnapshot('onChanged', normalized)
202
+ setSnapshot(normalized)
158
203
  })
159
204
 
160
205
  return () => {