dsh-brief-sidebar 0.9.2 → 0.9.4

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.
@@ -0,0 +1,211 @@
1
+ /**
2
+ * The references section: 技能与记忆并列成两个可左右切换的页签。
3
+ *
4
+ * Why a tab strip instead of three stacked sections: the two lists answer the
5
+ * same question — "what entered this session's context" — from two different
6
+ * providers (this plugin's own skill fold, and prime-memory's recall/slots).
7
+ * Stacked, they pushed the board and the produced files off screen on a narrow
8
+ * sidebar; side by side, each gets the full width when the reader actually
9
+ * wants it.
10
+ *
11
+ * Two decisions worth knowing when editing this file:
12
+ *
13
+ * 1. **The inactive pane stays mounted and is hidden with `display:none`.**
14
+ * The recall pane carries real interaction state (selected row, pending
15
+ * rating, the expanded history group); unmounting it would silently reset
16
+ * what the human just did every time they glanced at the skills tab. The
17
+ * cost is that a hidden pane keeps re-rendering on projection frames —
18
+ * bounded, because the whole tab still renders nothing while `visible` is
19
+ * false (see `SummaryTab`).
20
+ * 2. **The parent repeats its children's gates.** A section that hides itself
21
+ * leaves the tab strip pointing at an empty pane, so the availability
22
+ * question is answered here with the SAME readers and resolvers the children
23
+ * use. `tests/reference-section.spec.tsx` pins both sides against the same
24
+ * fixtures — if a child gate ever drifts from this one, that test goes red
25
+ * rather than the UI growing a dead tab.
26
+ *
27
+ * Colours come from `--dsw-alias-*` tokens only (skin contract).
28
+ */
29
+ import { createElement } from 'react'
30
+ import { useCallback, useState } from 'react'
31
+ import type { CSSProperties, KeyboardEvent, ReactNode } from 'react'
32
+ import type { Context } from '@deepseek-ai/cordis'
33
+ import { SKILL_USAGE_KEY } from '../../projection/keys'
34
+ import { useProjectionValue } from '../use-projection'
35
+ import { readSkillUsage } from './skill-usage'
36
+ import { SEARCH_REFS_KEY, readSearchRefs } from './search-refs'
37
+ import { MEMORY_RECALL_KEY, readRecall } from './memory-recall'
38
+ import { MEMORY_SLOTS_KEY, readSlots } from './memory-slots'
39
+ import { resolveShowMemoryRecall, resolveShowMemorySlots } from '../scope-face'
40
+ import { useConfigFlag } from './use-config-flag'
41
+ import { SkillUsageSection } from './SkillUsageSection'
42
+ import { SearchRefSection } from './SearchRefSection'
43
+ import { MemoryRecallSection } from './MemoryRecallSection'
44
+ import { MemorySlotsSection } from './MemorySlotsSection'
45
+ import type { FeedbackFetch } from './recall-feedback'
46
+
47
+ export interface ReferenceSectionProps {
48
+ /** The DSH locale lookup, passed down from `apply`. */
49
+ t: (key: string) => string
50
+ /** The client root context (the tab body's only way to reach services). */
51
+ ctx?: Context
52
+ /** The session this tab is scoped to. */
53
+ scope?: { readonly sessionId?: string }
54
+ /** The rating transport, handed straight to the recall pane. */
55
+ fetchImpl?: FeedbackFetch
56
+ }
57
+
58
+ /** Which pane is showing. Three values, no fourth tab by design. */
59
+ export type ReferenceTab = 'skills' | 'memory' | 'search'
60
+
61
+ const SECTION_STYLE: CSSProperties = {
62
+ flexShrink: 0,
63
+ borderBottom: '1px solid var(--dsw-alias-border-secondary)',
64
+ }
65
+
66
+ const HEADER_STYLE: CSSProperties = {
67
+ padding: '10px 12px 0',
68
+ color: 'var(--dsw-alias-label-secondary)',
69
+ fontSize: 12,
70
+ fontWeight: 600,
71
+ }
72
+
73
+ /** The tab strip: flush-left, same gutter as section headers. */
74
+ const TABS_STYLE: CSSProperties = {
75
+ display: 'flex',
76
+ gap: 4,
77
+ padding: '6px 12px 0',
78
+ }
79
+
80
+ /** One tab. Selected gets a hairline of secondary label; never a colour literal. */
81
+ const TAB_STYLE: CSSProperties = {
82
+ background: 'none',
83
+ border: 'none',
84
+ borderBottom: '1px solid transparent',
85
+ font: 'inherit',
86
+ fontSize: 12,
87
+ color: 'var(--dsw-alias-label-tertiary)',
88
+ cursor: 'pointer',
89
+ padding: '4px 8px 6px',
90
+ }
91
+
92
+ const TAB_ACTIVE_STYLE: CSSProperties = {
93
+ ...TAB_STYLE,
94
+ borderBottom: '1px solid var(--dsw-alias-label-secondary)',
95
+ color: 'var(--dsw-alias-label-primary)',
96
+ fontWeight: 600,
97
+ }
98
+
99
+ const ORDER: readonly ReferenceTab[] = ['skills', 'memory', 'search']
100
+
101
+ /**
102
+ * The references section. All hooks run before any early return, so the tab
103
+ * strip can disappear (no capability at all) without ever changing hook order.
104
+ *
105
+ * @param props - translation, client context, session scope, rating transport.
106
+ */
107
+ export function ReferenceSection(props: ReferenceSectionProps): ReactNode {
108
+ const { t, ctx, scope, fetchImpl } = props
109
+ const [tab, setTab] = useState<ReferenceTab>('skills')
110
+
111
+ // The same three reads the children make, used only to decide whether the
112
+ // strip has anything to point at.
113
+ const skillsLive = readSkillUsage(useProjectionValue(ctx, scope?.sessionId, SKILL_USAGE_KEY)) !== undefined
114
+ const searchLive = readSearchRefs(useProjectionValue(ctx, scope?.sessionId, SEARCH_REFS_KEY)) !== undefined
115
+ const recallFrame = readRecall(useProjectionValue(ctx, scope?.sessionId, MEMORY_RECALL_KEY))
116
+ const slotsFrame = readSlots(useProjectionValue(ctx, scope?.sessionId, MEMORY_SLOTS_KEY))
117
+ const recallOn = useConfigFlag(ctx, resolveShowMemoryRecall) !== false
118
+ const slotsOn = useConfigFlag(ctx, resolveShowMemorySlots) === true
119
+ const memoryLive = (recallFrame !== undefined && recallOn) || (slotsFrame !== undefined && slotsOn)
120
+ const live: Record<ReferenceTab, boolean> = { skills: skillsLive, memory: memoryLive, search: searchLive }
121
+ // 记住的选择可能因能力变化而消失(例如关掉记忆开关):退到第一个可用页签。
122
+ const active: ReferenceTab = live[tab] ? tab : ORDER.find(id => live[id]) ?? 'skills'
123
+
124
+ const onTabsKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>): void => {
125
+ // 左右方向键在“可用页签”里循环:能力缺席的页签不该被键盘走到。
126
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
127
+ event.preventDefault()
128
+ const shown = ORDER.filter(id => live[id])
129
+ if (shown.length < 2) return
130
+ const at = shown.indexOf(active)
131
+ const step = event.key === 'ArrowRight' ? 1 : shown.length - 1
132
+ setTab(shown[(at + step) % shown.length])
133
+ }, [live, active])
134
+
135
+ // Nothing to reference — no provider in the composition, or both switched off.
136
+ if (!skillsLive && !memoryLive && !searchLive) return null
137
+
138
+ const tabButton = (id: ReferenceTab, label: string): ReactNode => createElement(
139
+ 'button',
140
+ {
141
+ key: id,
142
+ type: 'button',
143
+ role: 'tab',
144
+ id: `dsh-brief-reference-tab-${id}`,
145
+ 'aria-selected': active === id,
146
+ 'aria-controls': `dsh-brief-reference-pane-${id}`,
147
+ style: active === id ? TAB_ACTIVE_STYLE : TAB_STYLE,
148
+ 'data-dsh-brief-sidebar': `reference-tab-${id}`,
149
+ onClick: () => { setTab(id) },
150
+ },
151
+ label,
152
+ )
153
+
154
+ return createElement(
155
+ 'div',
156
+ { style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'references-section' },
157
+ createElement('div', { style: HEADER_STYLE }, t('section.references')),
158
+ createElement(
159
+ 'div',
160
+ {
161
+ role: 'tablist',
162
+ 'aria-label': t('section.references'),
163
+ style: TABS_STYLE,
164
+ onKeyDown: onTabsKeyDown,
165
+ 'data-dsh-brief-sidebar': 'reference-tabs',
166
+ },
167
+ skillsLive && tabButton('skills', t('references.tabSkills')),
168
+ // 每个页签各自条件化:只有一侧能力时不该留个点了就空的页签。
169
+ memoryLive && tabButton('memory', t('references.tabMemory')),
170
+ searchLive && tabButton('search', t('references.tabSearch')),
171
+ ),
172
+ skillsLive && createElement(
173
+ 'div',
174
+ {
175
+ role: 'tabpanel',
176
+ id: 'dsh-brief-reference-pane-skills',
177
+ 'aria-labelledby': 'dsh-brief-reference-tab-skills',
178
+ hidden: active !== 'skills',
179
+ style: active === 'skills' ? undefined : { display: 'none' },
180
+ 'data-dsh-brief-sidebar': 'reference-pane-skills',
181
+ },
182
+ createElement(SkillUsageSection, { t, ctx, scope, hideHeader: true }),
183
+ ),
184
+ memoryLive && createElement(
185
+ 'div',
186
+ {
187
+ role: 'tabpanel',
188
+ id: 'dsh-brief-reference-pane-memory',
189
+ 'aria-labelledby': 'dsh-brief-reference-tab-memory',
190
+ hidden: active !== 'memory',
191
+ style: active === 'memory' ? undefined : { display: 'none' },
192
+ 'data-dsh-brief-sidebar': 'reference-pane-memory',
193
+ },
194
+ // 两条记忆链各有自己的标题:召回与常驻槽位是不同事实,合成一节会糊掉。
195
+ createElement(MemoryRecallSection, { t, ctx, scope, fetchImpl }),
196
+ createElement(MemorySlotsSection, { t, ctx, scope }),
197
+ ),
198
+ searchLive && createElement(
199
+ 'div',
200
+ {
201
+ role: 'tabpanel',
202
+ id: 'dsh-brief-reference-pane-search',
203
+ 'aria-labelledby': 'dsh-brief-reference-tab-search',
204
+ hidden: active !== 'search',
205
+ style: active === 'search' ? undefined : { display: 'none' },
206
+ 'data-dsh-brief-sidebar': 'reference-pane-search',
207
+ },
208
+ createElement(SearchRefSection, { t, ctx, scope, hideHeader: true }),
209
+ ),
210
+ )
211
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The search-reference section: the `dshSummarySearchRefs` projection drawn as
3
+ * one tab of the references section.
4
+ *
5
+ * It answers "what did this session pull in from outside": the queries it ran
6
+ * through `advanced_search`, and the sources those searches returned. Two
7
+ * groups, because they are two different facts — a query says what was asked,
8
+ * a source says what came back, and an older `dsh-free-search` without
9
+ * `presentationMeta` produces queries with no sources at all. That degraded
10
+ * case is stated rather than hidden, because an empty source list would
11
+ * otherwise read as "the search found nothing".
12
+ *
13
+ * Optional on both sides: no registry, or a host half that never registered the
14
+ * unit, means the client reads capability absence and the section hides.
15
+ *
16
+ * Two contracts inherited from the recall section:
17
+ * - **no native `title` attribute** — the whole text rides the row or is left
18
+ * out; an unskinable tooltip would overlap the plugin's own surfaces;
19
+ * - **colours from `--dsw-alias-*` tokens only** (skin contract).
20
+ *
21
+ * Source links are untrusted external data, so only `http`/`https` become
22
+ * anchors (see `safeHref`) and they open with `rel="noreferrer noopener"`.
23
+ */
24
+ import { createElement } from 'react'
25
+ import type { CSSProperties, ReactNode } from 'react'
26
+ import type { Context } from '@deepseek-ai/cordis'
27
+ import { SEARCH_REFS_KEY } from '../../projection/keys'
28
+ import { useProjectionValue } from '../use-projection'
29
+ import { GroupHeader } from './FoldToggle'
30
+ import { expandCount, expandTotal } from '../locales'
31
+ import { isLatestSource, readSearchRefs, safeHref } from './search-refs'
32
+
33
+ export interface SearchRefSectionProps {
34
+ /** The DSH locale lookup, passed down from `apply`. */
35
+ t: (key: string) => string
36
+ /** The client root context (the tab body's only way to reach services). */
37
+ ctx?: Context
38
+ /** The session this tab is scoped to. */
39
+ scope?: { readonly sessionId?: string }
40
+ /** Drop the section header (the references tab already names it). */
41
+ hideHeader?: boolean
42
+ }
43
+
44
+ const SECTION_STYLE: CSSProperties = {
45
+ flexShrink: 0,
46
+ borderBottom: '1px solid var(--dsw-alias-border-secondary)',
47
+ }
48
+
49
+ const HEADER_STYLE: CSSProperties = {
50
+ padding: '10px 12px 0',
51
+ color: 'var(--dsw-alias-label-secondary)',
52
+ fontSize: 12,
53
+ fontWeight: 600,
54
+ }
55
+
56
+ const LIST_STYLE: CSSProperties = {
57
+ listStyle: 'none',
58
+ margin: 0,
59
+ padding: '6px 0',
60
+ display: 'flex',
61
+ flexDirection: 'column',
62
+ }
63
+
64
+ const ROW_STYLE: CSSProperties = {
65
+ display: 'flex',
66
+ alignItems: 'flex-start',
67
+ gap: 8,
68
+ padding: '6px 12px',
69
+ }
70
+
71
+ const QUERY_STYLE: CSSProperties = {
72
+ minWidth: 0,
73
+ flex: '1 1 auto',
74
+ overflowWrap: 'anywhere',
75
+ color: 'var(--dsw-alias-label-secondary)',
76
+ }
77
+
78
+ /** The source title, one line: the host and URL carry the identification. */
79
+ const TITLE_STYLE: CSSProperties = {
80
+ minWidth: 0,
81
+ flex: '1 1 auto',
82
+ display: '-webkit-box',
83
+ WebkitLineClamp: 2,
84
+ WebkitBoxOrient: 'vertical',
85
+ overflow: 'hidden',
86
+ color: 'var(--dsw-alias-label-secondary)',
87
+ }
88
+
89
+ const SNIPPET_STYLE: CSSProperties = {
90
+ margin: 0,
91
+ minWidth: 0,
92
+ overflowWrap: 'anywhere',
93
+ color: 'var(--dsw-alias-label-tertiary)',
94
+ fontSize: 11,
95
+ lineHeight: '16px',
96
+ }
97
+
98
+ const META_STYLE: CSSProperties = {
99
+ flexShrink: 0,
100
+ color: 'var(--dsw-alias-label-tertiary)',
101
+ fontSize: 11,
102
+ lineHeight: '18px',
103
+ whiteSpace: 'nowrap',
104
+ }
105
+
106
+ /** The "本轮" pill: same form as the skill tag, tertiary emphasis. */
107
+ const TAG_STYLE: CSSProperties = {
108
+ flexShrink: 0,
109
+ color: 'var(--dsw-alias-label-tertiary)',
110
+ border: '1px solid var(--dsw-alias-label-tertiary)',
111
+ borderRadius: 999,
112
+ padding: '0 6px',
113
+ fontSize: 11,
114
+ lineHeight: '16px',
115
+ whiteSpace: 'nowrap',
116
+ }
117
+
118
+ const FOOTER_STYLE: CSSProperties = {
119
+ padding: '0 12px 10px',
120
+ color: 'var(--dsw-alias-label-tertiary)',
121
+ fontSize: 11,
122
+ }
123
+
124
+ const EMPTY_STYLE: CSSProperties = {
125
+ margin: '0 auto',
126
+ padding: 24,
127
+ maxWidth: 460,
128
+ textAlign: 'center',
129
+ color: 'var(--dsw-alias-label-tertiary)',
130
+ lineHeight: 1.7,
131
+ fontSize: 12,
132
+ }
133
+
134
+ /**
135
+ * The search-reference section. The subscription is unconditional (a plain
136
+ * listener, not IO); the view decides whether anything is drawn.
137
+ *
138
+ * @param props - translation, client context, session scope, header switch.
139
+ */
140
+ export function SearchRefSection(props: SearchRefSectionProps): ReactNode {
141
+ const { t, ctx, scope, hideHeader = false } = props
142
+
143
+ const view = readSearchRefs(useProjectionValue(ctx, scope?.sessionId, SEARCH_REFS_KEY))
144
+
145
+ // Capability absent — the optional unit never registered. Hide, don't nag.
146
+ if (view === undefined) return null
147
+
148
+ if (view.queries.length === 0 && view.sources.length === 0) {
149
+ return createElement(
150
+ 'div',
151
+ { style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'search-refs-section' },
152
+ !hideHeader && createElement('div', { style: HEADER_STYLE }, t('section.searchRefs')),
153
+ createElement(
154
+ 'div',
155
+ { style: EMPTY_STYLE },
156
+ createElement('div', { style: { marginBottom: 6, color: 'var(--dsw-alias-label-secondary)' } }, t('searchRefs.empty')),
157
+ createElement('div', null, t('searchRefs.emptyHint')),
158
+ ),
159
+ )
160
+ }
161
+
162
+ const body: ReactNode[] = []
163
+
164
+ if (view.queries.length > 0) {
165
+ body.push(createElement(GroupHeader, {
166
+ key: 'qh',
167
+ text: expandCount(t('searchRefs.queryGroup'), view.queries.length),
168
+ marker: 'search-refs-query-group',
169
+ }))
170
+ body.push(createElement(
171
+ 'ul',
172
+ { key: 'ql', style: LIST_STYLE },
173
+ view.queries.map(row => createElement(
174
+ 'li',
175
+ { key: `${row.turn}:${row.query}`, style: ROW_STYLE, 'data-dsh-brief-sidebar': 'search-refs-query' },
176
+ createElement('span', { style: QUERY_STYLE }, row.query),
177
+ createElement('span', { style: META_STYLE }, expandCount(t('searchRefs.hits'), row.hits)),
178
+ )),
179
+ ))
180
+ }
181
+
182
+ if (view.sources.length > 0) {
183
+ body.push(createElement(GroupHeader, {
184
+ key: 'sh',
185
+ text: expandCount(t('searchRefs.sourceGroup'), view.sources.length),
186
+ marker: 'search-refs-source-group',
187
+ }))
188
+ body.push(createElement(
189
+ 'ul',
190
+ { key: 'sl', style: LIST_STYLE },
191
+ view.sources.map(item => {
192
+ const href = safeHref(item.url)
193
+ const label = item.title ?? item.url
194
+ const link = href === undefined
195
+ // A non-web scheme (or a malformed URL) from a provider stays text:
196
+ // it must not become a clickable thing in the user's sidebar.
197
+ ? createElement('span', { style: TITLE_STYLE }, label)
198
+ : createElement(
199
+ 'a',
200
+ { style: TITLE_STYLE, href, target: '_blank', rel: 'noreferrer noopener' },
201
+ label,
202
+ )
203
+ return createElement(
204
+ 'li',
205
+ { key: item.url, style: ROW_STYLE, 'data-dsh-brief-sidebar': 'search-refs-source' },
206
+ createElement(
207
+ 'div',
208
+ { style: { minWidth: 0, flex: '1 1 auto', display: 'flex', flexDirection: 'column', gap: 2 } },
209
+ link,
210
+ createElement('span', { style: META_STYLE }, item.host),
211
+ item.snippet !== undefined && createElement('p', { style: SNIPPET_STYLE }, item.snippet),
212
+ ),
213
+ isLatestSource(view, item.url) && createElement('span', { style: TAG_STYLE }, t('searchRefs.latestTag')),
214
+ )
215
+ }),
216
+ ))
217
+ } else {
218
+ // 有查询无来源:老版 dsh-free-search 不发 meta。说清楚,别让人以为搜了没结果。
219
+ body.push(createElement(
220
+ 'div',
221
+ { key: 'ns', style: FOOTER_STYLE, 'data-dsh-brief-sidebar': 'search-refs-no-sources' },
222
+ t('searchRefs.noSourcesHint'),
223
+ ))
224
+ }
225
+
226
+ // 列表有上限,页脚说真数:只有真数超过列表长度时才出现,免得两个数字打架。
227
+ if (view.queryTotal > view.queries.length || view.sourceTotal > view.sources.length) {
228
+ body.push(createElement(
229
+ 'div',
230
+ { key: 'cap', style: FOOTER_STYLE, 'data-dsh-brief-sidebar': 'search-refs-capped' },
231
+ expandTotal(t('searchRefs.capped'), Math.max(view.queryTotal, view.sourceTotal)),
232
+ ))
233
+ }
234
+
235
+ return createElement(
236
+ 'div',
237
+ { style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'search-refs-section' },
238
+ !hideHeader && createElement('div', { style: HEADER_STYLE }, t('section.searchRefs')),
239
+ ...body,
240
+ )
241
+ }
@@ -43,6 +43,12 @@ export interface SkillUsageSectionProps {
43
43
  ctx?: Context
44
44
  /** The session this tab is scoped to. */
45
45
  scope?: { readonly sessionId?: string }
46
+ /**
47
+ * Drop the section header. The references tab already says “技能”, so
48
+ * repeating it as a heading inside the pane is noise; standalone renders
49
+ * (and every existing test) keep the default `false`.
50
+ */
51
+ hideHeader?: boolean
46
52
  }
47
53
 
48
54
  const SECTION_STYLE: CSSProperties = {
@@ -121,7 +127,7 @@ const EMPTY_STYLE: CSSProperties = {
121
127
  * @param props - translation, client context, session scope.
122
128
  */
123
129
  export function SkillUsageSection(props: SkillUsageSectionProps): ReactNode {
124
- const { t, ctx, scope } = props
130
+ const { t, ctx, scope, hideHeader = false } = props
125
131
  const [availableOpen, setAvailableOpen] = useState(false)
126
132
 
127
133
  const view = readSkillUsage(useProjectionValue(ctx, scope?.sessionId, SKILL_USAGE_KEY))
@@ -137,7 +143,7 @@ export function SkillUsageSection(props: SkillUsageSectionProps): ReactNode {
137
143
  return createElement(
138
144
  'div',
139
145
  { style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'skill-usage-section' },
140
- createElement('div', { style: HEADER_STYLE }, t('section.skillUsage')),
146
+ !hideHeader && createElement('div', { style: HEADER_STYLE }, t('section.skillUsage')),
141
147
  view.session.length === 0 && createElement(
142
148
  'div',
143
149
  { style: EMPTY_STYLE },
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The `dshSummarySearchRefs` projection — reading it.
3
+ *
4
+ * The unit is registered by THIS plugin's host half (key `dshSummarySearchRefs`):
5
+ * the queries this session ran through `advanced_search`, and the sources those
6
+ * searches brought back. It answers "what did the session pull in from outside"
7
+ * — where the skill tab says which instruction packs were loaded and the memory
8
+ * tab says which of the user's own knowledge was injected.
9
+ *
10
+ * Reading discipline, same as every other brief section:
11
+ * - `undefined` = the capability is ABSENT (no projection registry, or a frame
12
+ * drifted past repair) → the tab hides;
13
+ * - empty lists = the honest empty state (the session has not searched yet);
14
+ * - a malformed row is dropped, never thrown.
15
+ *
16
+ * The module is deliberately PURE — no React, no context, no IO.
17
+ */
18
+ import { SEARCH_REFS_KEY } from '../../projection/keys'
19
+ import type { SearchRefItemView, SearchRefView, SearchQueryView } from '../../projection/keys'
20
+ import { urlKey } from '../../projection/search-ref-fold'
21
+
22
+ /** The key this plugin's client half subscribes to (re-exported for callers). */
23
+ export { SEARCH_REFS_KEY }
24
+
25
+ /** Whether a value is a non-negative safe integer (the view's counters). */
26
+ function isCounter(value: unknown): value is number {
27
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
28
+ }
29
+
30
+ /** Narrow one source row; `undefined` = malformed, dropped. */
31
+ function readItem(value: unknown): SearchRefItemView | undefined {
32
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
33
+ const raw = value as Record<string, unknown>
34
+ if (typeof raw['url'] !== 'string' || raw['url'].length === 0) return undefined
35
+ if (typeof raw['host'] !== 'string') return undefined
36
+ if (typeof raw['query'] !== 'string') return undefined
37
+ if (!isCounter(raw['count']) || !isCounter(raw['lastTurn'])) return undefined
38
+ const item: { -readonly [K in keyof SearchRefItemView]: SearchRefItemView[K] } = {
39
+ url: raw['url'],
40
+ host: raw['host'],
41
+ query: raw['query'],
42
+ count: raw['count'],
43
+ lastTurn: raw['lastTurn'],
44
+ }
45
+ if (typeof raw['title'] === 'string' && raw['title'].length > 0) item.title = raw['title']
46
+ if (typeof raw['snippet'] === 'string' && raw['snippet'].length > 0) item.snippet = raw['snippet']
47
+ return item
48
+ }
49
+
50
+ /** Narrow one query row; `undefined` = malformed, dropped. */
51
+ function readQuery(value: unknown): SearchQueryView | undefined {
52
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
53
+ const raw = value as Record<string, unknown>
54
+ if (typeof raw['query'] !== 'string' || raw['query'].length === 0) return undefined
55
+ if (!isCounter(raw['turn']) || !isCounter(raw['hits'])) return undefined
56
+ return { query: raw['query'], turn: raw['turn'], hits: raw['hits'] }
57
+ }
58
+
59
+ /** The latest-turn key set, or `null` when absent OR drifted (only a tag rides it). */
60
+ function readLatest(value: unknown): SearchRefView['latest'] {
61
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null
62
+ const raw = value as Record<string, unknown>
63
+ if (!isCounter(raw['turn']) || !Array.isArray(raw['urls'])) return null
64
+ return { turn: raw['turn'], urls: raw['urls'].filter((u): u is string => typeof u === 'string' && u.length > 0) }
65
+ }
66
+
67
+ /**
68
+ * Narrow a raw projection value into a search-reference view.
69
+ *
70
+ * @param value - the raw `unknown` snapshot of the `dshSummarySearchRefs` face.
71
+ * @returns the validated view, or `undefined` when the capability is absent.
72
+ */
73
+ export function readSearchRefs(value: unknown): SearchRefView | undefined {
74
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
75
+ const raw = value as Record<string, unknown>
76
+ if (!Array.isArray(raw['queries']) || !Array.isArray(raw['sources'])) return undefined
77
+ if (!isCounter(raw['queryTotal']) || !isCounter(raw['sourceTotal'])) return undefined
78
+ const queries: SearchQueryView[] = []
79
+ for (const entry of raw['queries']) {
80
+ const narrowed = readQuery(entry)
81
+ if (narrowed !== undefined) queries.push(narrowed)
82
+ }
83
+ const sources: SearchRefItemView[] = []
84
+ for (const entry of raw['sources']) {
85
+ const narrowed = readItem(entry)
86
+ if (narrowed !== undefined) sources.push(narrowed)
87
+ }
88
+ return {
89
+ latest: readLatest(raw['latest']),
90
+ queries,
91
+ sources,
92
+ queryTotal: raw['queryTotal'],
93
+ sourceTotal: raw['sourceTotal'],
94
+ }
95
+ }
96
+
97
+ /** Whether one source row was surfaced by the latest turn's searches. */
98
+ export function isLatestSource(view: SearchRefView, url: string): boolean {
99
+ return view.latest?.urls.includes(urlKey(url)) === true
100
+ }
101
+
102
+ /**
103
+ * The href to render for a source URL, or `undefined` when it must not be a link.
104
+ *
105
+ * Search results are untrusted external data: a `javascript:` or `data:` URL in
106
+ * a provider response would otherwise become a clickable footgun in the user's
107
+ * own sidebar. Only web schemes get an anchor; everything else renders as text.
108
+ */
109
+ export function safeHref(url: string): string | undefined {
110
+ try {
111
+ const parsed = new URL(url)
112
+ return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : undefined
113
+ } catch {
114
+ return undefined
115
+ }
116
+ }
package/src/index.ts CHANGED
@@ -37,7 +37,7 @@
37
37
  */
38
38
  import type { Context } from '@deepseek-ai/cordis'
39
39
  import z from '@deepseek-ai/schemastery'
40
- import { registerDeliverablesProjection, registerSkillUsageProjection } from './projection/register'
40
+ import { registerDeliverablesProjection, registerSearchRefProjection, registerSkillUsageProjection } from './projection/register'
41
41
 
42
42
  /** Profile row identity. */
43
43
  export const name = 'dsh-brief-sidebar'
@@ -68,4 +68,7 @@ export const Config = z.object({
68
68
  export function apply(ctx: Context): void {
69
69
  registerDeliverablesProjection(ctx)
70
70
  registerSkillUsageProjection(ctx)
71
+ // 搜索引用:本会话用 `advanced_search` 查了什么、取回了哪些来源。与技能一样
72
+ // 是可选贡献:没有 sessionProjections 服务的组合不注册也不影响本行加载。
73
+ registerSearchRefProjection(ctx)
71
74
  }
@@ -22,6 +22,13 @@ export const DELIVERABLES_KEY = 'dshSummaryDeliverables'
22
22
  */
23
23
  export const SKILL_USAGE_KEY = 'dshSummarySkillUsage'
24
24
 
25
+ /**
26
+ * The search-reference key (queries and returned sources of this session's
27
+ * `advanced_search` calls). Same rule as above: the name is ours, so no other
28
+ * host-side unit can answer for it by accident.
29
+ */
30
+ export const SEARCH_REFS_KEY = 'dshSummarySearchRefs'
31
+
25
32
  /** Client view of the produced-file fold (JSON-safe). */
26
33
  export interface DeliverablesView {
27
34
  /** Paths produced in the latest turn, first-seen order (capped). */
@@ -63,3 +70,39 @@ export interface SkillUsageView {
63
70
  /** How many entries that frame published — may exceed `available.length`. */
64
71
  readonly availableCount: number
65
72
  }
73
+
74
+ /** One external source a search brought back (wire view). */
75
+ export interface SearchRefItemView {
76
+ /** The URL as published (host case and trailing slash normalised). */
77
+ readonly url: string
78
+ readonly host: string
79
+ readonly title?: string
80
+ /** Provider snippet, capped by the fold. */
81
+ readonly snippet?: string
82
+ /** The query that most recently surfaced it. */
83
+ readonly query: string
84
+ /** Settled searches that surfaced this source. */
85
+ readonly count: number
86
+ readonly lastTurn: number
87
+ }
88
+
89
+ /** One query the session ran, with how many sources it returned. */
90
+ export interface SearchQueryView {
91
+ readonly query: string
92
+ readonly turn: number
93
+ readonly hits: number
94
+ }
95
+
96
+ /** Client view of the search-reference fold (JSON-safe). */
97
+ export interface SearchRefView {
98
+ /** Normalised URL keys surfaced in the latest turn; `null` before the first turn. */
99
+ readonly latest: { readonly turn: number; readonly urls: readonly string[] } | null
100
+ /** Distinct queries, most recent first (capped). */
101
+ readonly queries: readonly SearchQueryView[]
102
+ /** Distinct sources, most recent first (capped). */
103
+ readonly sources: readonly SearchRefItemView[]
104
+ /** Distinct queries session-wide — may exceed `queries.length`. */
105
+ readonly queryTotal: number
106
+ /** Distinct sources session-wide — may exceed `sources.length`. */
107
+ readonly sourceTotal: number
108
+ }